从0开始微操SSR之nextjs项目实践
节目开始先放一张图片:
这是一个电商类的网站,对于这种电商类的网站,我们一般会想要的效果是:1、用户从百度或者一些搜索引擎直接搜索关键字,就能够导流到我的网站上,这就是SEO(Search Engine Optimization)。关于SEO百度百科的解释是:
SEO(Search Engine Optimization):汉译为搜索引擎优化。是一种方式:利用搜索引擎的规则提高网站在有关搜索引擎内的自然排名。目的是让其在行业内占据领先地位,获得品牌收益。很大程度上是网站经营者的一种商业行为,将自己或自己公司的排名前移。
2、首页打开的速度足够快,这里的足够快,指的是打开首页的时候页面上的数据已经从服务端获取,而不是还需要异步去请求接口。
以上的两点要求,就是SSR(Server Slider Rendering,即服务端渲染)主要解决的两个问题。而关于SSR更多的内容,网络上已经有太多的资源介绍了,本文不再赘述。本文主要介绍React技术栈中实现SSR架构的解决方案--nextjs,是怎么从0构建我们的网站的。关于nextjs的更多文档请看: www.nextjs.cn/
项目背景
本项目主要实现一个电商项目,主要包括的模块如下图所示:
一般用户可以浏览首页上商品和推荐的商户(见开始的图片),也可以点击商户或商品进入到详情里面,但是下单的时候要检查用户是否登录,如果没有登录则要必须登录才能下单。代码结构
1、目录结构
直接放截图吧:
2、简单说明
针对几个比较重要的文件夹和文件简单说明一下:
api ------------- 后端接口相关文件夹
build ----------- 编译出来的资源文件夹
components ------ 抽离的公用组件夹
pages ----------- 前端页面文件夹,根据pages文件结构,生成页面地址
static ---------- 前端图片资源存放文件夹
.babelrc -------- antd按需加载配置文件
next.config.js -- nextjs项目配置文件(项目中重要的配置文件)
复制代码
3、pages文件说明
对于next项目来说,有两种实现路由:
- 1、页面路由使用的是文件系统路由。这是默认提供,我认为也是最简单的方式。那就是把pages文件夹的结构就当做路由的配置,我这个项目也是基于此来搞的。什么意思呢?就是比如pages文件夹下有的index.js,默认就是项目的首页地址,对应'/',如果有一个about.js,那么其对应的地址就是:'/about',如果要把某些页面归总到pages下面的文件下,比如有个user,下面有login.js,那么login地址就是:'user/login',依次类推。反正新增一个页面,就在pages下或pages下的某个文件夹下新增一个js文件就好,简单粗暴。
- 2、除了页面路由之外,还提供了所谓的api路由,这种路有相当于写接口的maock数据测试用的,根据自己情况选择就好(我觉得可能一般用不上)具体的可以看看官网。
主要配置
1、从package.json文件说起
作为项目的最重要最基本的配置文件,我先放出配置,配置项后的注释是为了对配置做说明,实际运行的时候没有注释的:
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",//本地开发时候启动项目的命令,默认是电脑的3000端口
"build": "next build",//项目编译打包
"start": "next start", // 部署项目之后启动服务命令(其实就是启动个node服务器)
"start:dev": "APP_ENV='dev' next dev -p 8090", // 本地开发启动项目命令,电脑端口改为8090端口
"start:test": "APP_ENV='test' next start -p 8091",// 测试环境启动服务命令(如果有测试环境部署的话),端口为8091
"start:prod": "APP_ENV='prod' next start -p 8092"// 生产环境启动服务命令,端口为9092
},
"dependencies": {
"@ant-design/icons": "^4.1.0",
"@zeit/next-css": "^1.0.1",
"@zeit/next-less": "^1.0.1",
"add": "^2.0.6",
"antd": "^4.2.3",
"axios": "^0.19.2",
"babel-plugin-import": "^1.13.0",
"less": "^3.11.2",
"less-vars-to-js": "^1.3.0",
"lodash": "^4.17.15",
"next": "9.4.0",//最新版似乎Next 9.5 版本已正式发布
"next-antd-aza-less": "^1.0.2",
"next-cookies": "^2.0.3",
"next-size": "^2.1.0",
"path": "^0.12.7",
"react": "16.13.1",
"react-dom": "16.13.1",
"webpack-bundle-analyzer": "^3.7.0",
"webpack-filter-warnings-plugin": "^1.2.1",
"yarn": "^1.22.4"
},
"devDependencies": {
"next-compose-plugins": "^2.2.0"
}
}
复制代码
其他的不需要多说,但是针对以上的配置文件的scripts配置,说明一下:因为ssr项目其实相当于项目自带了一个node服务器,就说我们项目开发好后经过编译出来的文件,丢到正式服务器上后,项目要运行起来,需要启动这个node服务器,而此处的启动node服务器的命令就是start命令,如果指定端口,就是采用默认端口。而我们平时本地开发的时候,启动的服务器其实是dev命令,这里与一般的前后端分离的前端项目有所不同。简单的说,就是:
1、本地开发时的命令:
yarn dev
或者像我上面这儿想要修改一下端口的话
yarn start:dev
2、项目开发完成后:
第一步,编译出资源文件:
yarn build
第二步,启动node服务:
yarn start
或者像我上面这儿如果要部署到测试环境就:
yarn start:test
部署到生成环境
yarn start:prod
复制代码
而关于部署这块,除了直接丢资源文件到服务容器中,启动node服务这种默认的操作之外,还可以做一些加固或者其他操作(比如pm2进程守护等),此处我没有深入研究,不好扩展(主要是懒......)。当然,对于一般不是很大的项目来说,默认的这个操作也够用了。
2、next.config.js文件
const withLess = require("@zeit/next-less");
const withCss = require("@zeit/next-css");
const withPlugins = require("next-compose-plugins");
const cssLoaderGetLocalIdent = require("css-loader/lib/getLocalIdent.js");
const path = require('path');
const fs = require('fs');
const lessToJS = require('less-vars-to-js');
const FilterWarningsPlugin = require('webpack-filter-warnings-plugin');
module.exports = withPlugins([withLess,withCss], {
distDir: 'build',
publicRuntimeConfig: {APP_ENV: process.env.APP_ENV},
//1、集成antd插件
lessLoaderOptions : {//如果是antd就需要,antd-mobile不需要
javascriptEnabled : true,
modifyVars: lessToJS(
//2、修改antd的主题
fs.readFileSync(path.resolve(__dirname, './util/antd.less'), 'utf8')
)
},
//3、开启css模块化
cssModules: true,
cssLoaderOptions: {
camelCase: true,
localIdentName: "[local]___[hash:base64:5]",
getLocalIdent: (context, localIdentName, localName, options) => {
let hz = context.resourcePath.replace(context.rootContext, "");
if (/node_modules/.test(hz)) {
return localName;
} else {
return cssLoaderGetLocalIdent(
context,
localIdentName,
localName,
options
);
}
}
},
webpack(config,{isServer}){
// Fixes npm packages that depend on `fs` module
if (!isServer) {
config.node = {
fs: 'empty'
}
}
//4、设置不把antd打包进入项目中,减小项目的包大小
if(config.externals){
const includes = [/antd/];
config.externals = config.externals.map(external => {
if (typeof external !== 'function') return external;
return (ctx, req, cb) => {
return includes.find(include =>
req.startsWith('.')
? include.test(path.resolve(ctx, req))
: include.test(req)
)
? cb()
: external(ctx, req, cb);
};
});
}
config.plugins.push(
new FilterWarningsPlugin({
exclude: /mini-css-extract-plugin[^]*Conflicting order between:/
})
);i
复制代码
对于这个配置文件,可以说是next项目最重要的配置文件,因为我这个项目集成了antd,所以关键点已经注释出来,不再赘述,如要详细了解next.config.js配置,可到官网上查看: www.nextjs.cn/docs/api-re…
项目代码实现的问题分析
1、怎么算是SSR实现了?
关于这个问题,我们先看一张图:
其实,对于next项目的页面,如果你查看网页源代码,那么你会看到跟一般的没有ssr的前端页面有所不同,页面结构代码和数据是组装在一起的(图中圈起来的部分就是数据),这样的网页,就能SEO时候有较好的权重给予。也就是说,这是从感性上来看的,从理性上来说,要在react项目中做到这点,其实主要依赖于next框架中的一个非常重要的生命周期钩子函数:getInitialProps关于这个生命周期函数,文档上会说很多,但是其实总结起来就一句话:
把从componentDidMount中发出数据请求的操作,变成从getInitialProps中发出,就实现了SSR。
如果还要加一句就是:
这个getInitialProps只能在pages下的父页面中存在(文件系统路由中的文件),子组件中不能有这个操作。
具体看个例子:
import React from 'react';
import {
getStoreBannerApi,
getStoreGoodsListApi,
getStoreInfoApi
} from '../api/Api';
import HtmlHead from '../components/HtmlHead';
import MyCarousel from '../components/MyCarousel';
import SearchArea from '../components/SearchArea';
import StoreGoods from '../components/StoreDetailPage/StoreGoods';
import StoreLogo from '../components/StoreDetailPage/StoreLogo';
/**
* 商户详情页面(这必须是在pages文件夹下面的文件)
*/
const StoreDetail = ({
goodsList, //组件的这些属性都是从getInitialProps返回的对象中取出的
storeInfo,
banners
}) => {
return (
<>
{/*
*/}
{
banners.length ?
: null
}
{/*StoreGoods是子组件,其是没有getInitialProps生命周期的*/}
);
};
// 1、如果是函数组件,调用getInitialProps的方式是这样的
StoreDetail.getInitialProps = async (props) => {
try {
//2、从路由中传过来的参数获取
const {storeId} = props.query;
//3、发出异步请求
const res = await getStoreGoodsListApi({
page: 0,
size: 9999,
storeId: storeId
}).catch(e => ({}));
const res1 = await getStoreInfoApi(storeId).catch(e => ({}));
const banners = await getStoreBannerApi(storeId).catch(e => ({}));
// 4、每个getInitialProps必须返回key-value的对象,这个对象直接注入到组件的props中
return {
goodsList: res.code === 20000 ? res.data : [],
storeInfo: res1.code === 20000 ? res1.data : [],
storeId: storeId,
banners: banners.code === 20000 ? banners.data : [],
};
} catch (e) {
}
};
export default StoreDetail;
复制代码
上面代码中要注意第1点,函数组件对getInitialProps钩子函数的调用跟class组件写法上有所不同,class组件的写法如下:
import React from 'react'
class Page extends React.Component {
static async getInitialProps(ctx) {
const res = await fetch('https://api.github.com/repos/vercel/next.js')
const json = await res.json()
return { stars: json.stargazers_count }
}
render() {
return Next stars: {this.props.stars}
}
}
export default Page
复制代码
2、关于接口请求的问题?
在ssr项目中,首先要明确一点,一个数据的请求有可能发生在浏览器端,也有可能发生在服务端(node端,node在这儿其实是一个中间层,我们要数据还是从一个真正的后端服务器去要,比如java写的)。在浏览器端我们无需多说,直接ajax就好,但是node端有个问题,没有ajax对象,所以我们要使用一种既可以在浏览器,也可以在node端使用的规范,去发请求。在此我比较推荐的是axios。本项目中使用的axios实例封装代码如下:
import { message } from 'antd';
import axios from 'axios';
import Router from 'next/router';
import { clearLoginStorage, getToken } from './saveLogin';
//1、服务端调接口需要用户认证时的token,初始化渲染的时候给其赋值的
export let serverAuthorization = Object.create(null);
const request = axios.create({
baseURL: 'http://139.9.113.127:8080',
timeout: 30000,
});
// 拦截器
request.interceptors.response.use((response) => {
// console.log(response)
let resObj = {};
if (response.data && response.data.code === 20000) {
/*if (process.browser) {
resObj = {
code: response.data.code,
data: response.data.data
}
} else { //服务端,直接返回数据
resObj = {...response.data.data}
}*/
resObj = {
code: response.data.code,
data: response.data.data
};
} else {
resObj = {
code: response.data.code,
message: response.data.message,
data: null
};
/*if (process.browser) {
resObj = {
code: response.data.code,
message: response.data.message,
data: null
}
}*/
}
return resObj;
}, (error) => {
const res = error.response || {};
console.log(res.status);
if (process.browser && res.status === 401) { //客户端渲染的时候才需要,登录过期,跳转首页
// console.log('---->');
clearLoginStorage();
message.error('登录过期,请重新登录');
Router.replace('/user/login');
}
return Promise.reject(error);
});
request.interceptors.request.use((config) => {
//const token = process.browser //(getToken() || {}) : serverAuthorization;
// console.log(config.url);
let auth = '';
//2、区分是浏览器端发出的请求还是node端发出的请求
if (process.browser) {
auth = getToken()['access_token'] || '';
} else { // 3、服务端请求,从request中拿出token
auth = '';//request['access_token'] || '';
}
return {
...config,
headers: {
Authorization: `${auth ? 'Bearer ' + auth : ''}`,
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
},
//data: config.param ? JSON.stringify(config.param) : ''
};
}, (error) => {
return Promise.reject(error);
});
export default request;
复制代码
以上代码比较简单,就不多说了。
3、关于token同步的问题?
关于这个问题,其实跟第2点有点相似。其实上面我们说到的getInitialProps生命周期函数会在服务端渲染和非服务端渲染的时候都去执行,这样我们就不用专门去关注什么时候该SSR还是非SSR。但是这就造成了一个问题,因为我们的token是缓存在浏览器端的(此项目保存在session中),所以,当请求是node端发出的时候,我是根本拿不到浏览器缓存的token的,而如果此时这个由node发出的请求,又需要token的话,该怎么办呢?在本项目中,采用了一个比较简单粗暴的方式。在pages文件夹下的_app.js文件,这相当于是项目全局的入口配置文件:
import { ConfigProvider } from "antd";
// import zhCN from 'antd/es/locale/zh_CN';
import zhCN from 'antd/lib/locale/zh_CN';
import App from 'next/app';
import getCofnig from 'next/config';
import Router from 'next/router';
import React from 'react';
import LayoutBasic from '../components/Layout/BasicLayout';
import UserLayout from "../components/Layout/UserLayout";
import '../static/styles/base.less';
import request from "../util/request";
import { getToken } from "../util/saveLogin";
Router.events.on('routeChangeComplete', () => {
if (process.env.NODE_ENV !== 'production' && document) {
const els = document.querySelectorAll('link[href*="/_next/static/css/styles.chunk.css"]');
const timestamp = new Date().valueOf();
els[0].href = '/_next/static/css/styles.chunk.css?v=' + timestamp;
}
})
const {serverRuntimeConfig, publicRuntimeConfig} = getCofnig()
// console.log(serverRuntimeConfig, publicRuntimeConfig)
class NextApp extends App {
//服务端渲染调用的方法
static async getInitialProps({Component = {}, ctx}) {
const token = getToken(ctx) || {};
// 需要将token赋值给服务端请求
request['access_token'] = token.access_token;
let pageProps;
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx);
}
return {
pageProps,
};
}
render() {
const {Component, pageProps, store, router = {}} = this.props;
const {pathname} = router;
let LayOut = (
)
if (pathname.indexOf('/user/') === 0) { //用户登录模块
LayOut = (
)
}
return (
{LayOut}
)
}
}
export default NextApp;
复制代码
关键是看如下这句代码:
// 需要将token赋值给服务端请求
request['access_token'] = token.access_token;
复制代码
因为这个文件是全局入口文件,之前说过,getInitialProps生命周期函数可以在服务端和浏览器端执行,那么我在浏览器端执行的时候将浏览器缓存的token取出,放入到axios请求的实例对象的access_token中,因为node发的请求也要用这个实例,那么就可以从里面取出token放入node发出的请求参数中了,这样就达到了一个token在浏览器端和node端都同步的问题。。。
总结
以上总结一下:
- SSR对于页面加载(特别是首页加载)和SEO优化具有好处
- nextjs是react技术栈实现SSR方式的非常有名的框架,上手也比较简单,基本上跟着文档走就好
- nextjs实现SSR关键在于getInitialProps生命周期函数的使用,其他的跟编写一般前端应用没啥区别
当然,以上其实只是对nextjs的项目实践梳理了一些关键点,让项目能跑起来,能够上手。其实关于SSR这块的东西,大概看起来东西不多,但是真正实践起来其实有很多细节需要考虑的,比如加入express或koa包装中间层,比如token同步这块,感觉文中所述不是一个很好的方式,可能在node端做一些token缓存或者校验会更好等等,可能后续会做更多研究,也希望有大佬们多给予指教。文档写的有点累,如果对你有所启发,求鼓励三连
项目源码: github.com/phonet/next…