前言
可能我们如果在公司不是基础架构方面工作,基本不会让我们去做react ssr
这些事儿,一般都是公司框架都已经搞好了。但是,作为往高级开发努力的人来说,react ssr
还是非常有必要去了解去实践的。
文章会贴一些代码,但是不会全贴(篇幅会太长),每一部分的完整代码,都可以到github
上看。
然后,本文用到的技术就是react
node
webpack
搭建开发环境
完成一个简单ssr
先初始化项目
$ npm init -y
安装一些依赖
$ npm i react react-dom express
$ npm i @babel/{cli,core,preset-env,preset-react} babel-loader webpack webpack-cli webpack-node-externals -D
当前目录结构:
├── dist // 打包生产目录
│ ├── client // 前端
│ | ├── index.js // 打包后的文件
│ ├── server // 服务端
│ | ├── index.js // node启动入口
├── src // 源文件
│ ├── client //前端文件夹
│ | ├── pages // 页面文件
| | ├── index.js // 前端入口
│ ├── server //服务端文件夹
| | ├── index.js // 服务端入口
客户端简单写一个组件
const Index = () => {
return (
<div>
i am fruit
</div>
)
};
ReactDOM.hydrate(
<Fruit />,
document.getElementById('root')
);
react16
提供了hydrate
,而render
和hydrate
的区别在于:
render
会遵守客户端渲染结果,即如果客户端和服务端渲染结果不一致,会覆盖服务端渲染结果,采用客户端渲染结果。hydrate
在服务端渲染的时候,会最大程度保留服务端渲染结果。
服务端便采用引入renderToString
将组件渲染成原始html
import { renderToString } from 'react-dom/server';
const app = express();
app.get('*', (req, res) => {
const reactStr = renderToString(<Fruit />);
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<div id="root">${reactStr}</div>
</body>
</html>`;
return res.send(html);
});
因为是自己初始化项目,所以还需要配置webpack
,对于客户端和服务端,分别配置两个webpack
。客户端,没有什么注意点,正常配置即可。服务端,需要注意配置target
和externals
,前者表示部署目标,webpack
会编译为用于node/web
环境;后者排除不需要打包的模块,减小打包体积。
服务端externals
采用的是引入webpack-node-externals
来排除不需要打包的模块。
因为目前,客户端和服务端的webpack
配置差不多,就只贴服务端配置,可去github
上看完整代码。
const WebpackNodeExternals = require('webpack-node-externals')
const { resolvePath } = require('./util');
// const resolvePath = pathStr => path.join(__dirname, pathStr);
module.exports = {
mode: 'development',
target: 'node', //node环境
entry: resolvePath('../src/server/index.js'),
output: {
filename: 'index.js',
path: resolvePath('../dist/server')
},
externals: [WebpackNodeExternals()], //排除不需要的打包模块
module: {
rules: [{
test: /.jsx?$/,
use: 'babel-loader',
exclude: /node_modules/
}]
}
};
最后就是babelrc
配置:
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
最后通过npm
命令去运行我们的项目,就实现了一个最基本的react ssr
。
{
"client:dev": "webpack --config ./webpack/webpack.client.dev.js",
"server:dev": "webpack --config ./webpack/webpack.server.dev.js",
"node:dev": "node ./dist/server/index.js"
}
当然目前是非常简陋的,关键没有对代码监听,每次修改,都要重复以上过程,简直难受。
完善开发体验
安装一些依赖
$ npm i nodemon webpack-merge clean-webpack-plugin -D
因为目前代码,前端和服务端的webpack
配置有很多重复,用webpack-merge
复用配置项。
module.exports = { //抽离相同部分
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [{
test: /.jsx?$/,
use: 'babel-loader',
exclude: /node_modules/
}]
},
plugins: [
new CleanWebpackPlugin()
]
}
const merge = require('webpack-merge');
const base = require('./webpack.base');
module.exports = merge(base, {
//...
});
对于前端&服务端的源代码,采用webpack --watch
去监听代码改动。对于服务端打包后的代码的运行,采用nodemon
去运行监听修改。
{
"client:dev": "webpack --watch --config ./webpack/webpack.client.dev.js",
"server:dev": "webpack --watch --config ./webpack/webpack.server.dev.js",
"node:dev": "nodemon ./dist/server/index.js -w ./dist",
"dev": "npm run client:dev & npm run server:dev & npm run node:dev"
}
但是这样还是会有个缺陷,当目录下,没有dist
时,即第一次运行npm run dev
,nodemon
会找不到./dist/server/index.js
文件,于是会变成nodemon __dirname/index.js
,发生错误。
解决办法:也比较简单,写一个node
脚本,判断是否已经有了这个文件,若没有,我们先新建文件夹&文件。
const judgeFolder = (pathStr) => { //判断是否存在文件夹,若无则建
if (!fs.existsSync(resolvePath(pathStr))) {
fs.mkdirSync(resolvePath(pathStr))
};
}
const buildFile = () => {
const aimPath = resolvePath('../../dist/server/index.js');
if (!fs.existsSync(aimPath)) { //判断是否存在文件
judgeFolder('../../dist');
judgeFolder('../../dist/server');
fs.writeFileSync(aimPath, "console.log('build done')");
}
};
buildFile();
为了防止该node
脚本与我们的npm run server:dev
发生异常,我让他们运行顺序改成,先运行node
脚本,再并行运行三个监听文件操作。
{
"pre:file": "node ./webpack/scripts/pre-file.js",
"dev": "npm run pre:file && npm run client:dev & npm run server:dev & npm run node:dev"
}
这里的&&
和&
的区别是:&&
是继发执行;&
并发执行。
目前虽然会自动编译,但是,浏览器端需要手动刷新,可以通过webpack-dev-server
和react-hot-loader
实现热更新。因为文章主要是react ssr
实践过程,就不对此方法再加以详述了。
同构
所谓同构,就是一套代码既可以在服务端运行又可以在客户端运行,也就是服务端直出和客户端渲染相结合,在服务端直出组件后,由浏览器接管页面。
那么基于以上,现在服务端直出组件后,需要让浏览器接管,需要将客户端打包的js
文件输出。
修改目录结构:
├── src // 源文件
│ ├── constant //放置一些常量
| | ├── index.js // 常量文件
│ ├── server //服务端文件夹
| | ├── middleware // 中间件文件夹
| | ├── util // 函数方法文件
| | ├── index.js // 服务端入口
通过express.static
托管静态文件,将服务器直出组件写出中间件
const app = express();
app.use(express.static('./dist/client')); // 托管静态文件
app.use(ssr);
export default (req, res, next) => { // ssr中间件
const { path, url } = req;
if (url.indexOf('.') > -1) { // 加个简单处理
return;
};
const reactStr = renderToString(<Fruit />);
const htmlInfo = {
reactStr,
};
const html = handleHtml(htmlInfo); // 就是之前的html拼接
res.send(html);
return next();
};
因为express.static('./dist/client')
托管了静态文件,而index.js
就在./dist/client/index.js
,所以html
拼接需要加上:
<script type="text/javascript" src="/index.js"></script>
路由同构
路由同构即,前后端采用同一套路由,前端还是和之前spa
一样写路由;服务端,则通过当前请求的path
查找到组件,然后输出。
安装一些依赖
$ npm i react-router react-router-dom
定义一个路由配置文件route.config.js
。
[{
component: Fruit,
path: '/',
exact: true,
name: 'Fruits'
},{...}]
前端入口,和之前写spa
一样的写法,没啥好讲的。
const App = () => {
return (
<Fragment>
<Header /> // 我定义的一个,<link>跳转组件
<Switch>
{
routeConfig.map(v => {
const { name, ...rest } = v;
return <Route key={v.path} {...v} />
})
}
</Switch>
</Fragment>
)
};
ReactDOM.hydrate(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
);
服务端则通过react-router4
提供的StaticRouter
完成路由查找,该组件主要接受两个参数: location
,传入请求的path完成路由查找context
,可以传入一些初始数据,同时如果内部路由有判定为Redirect
跳转等等,就会在其对象上增加url
字段,服务端可以通过这个判断404
302
/301
等等,后续会用到。
export default (req, res, next) => {
const { path, url } = req;
if (url.indexOf('.') > -1) {
return;
};
const reactStr = renderToString(
<StaticRouter location={path}>
<App />
</StaticRouter>
);
const htmlInfo = {
reactStr,
};
const html = handleHtml(htmlInfo);
res.send(html);
return next();
};
那么至此,路由同构便完成了。
数据同构
数据同构是非常重要的一环,即用同一套代码请求数据,用同一个数据去渲染。那么就牵扯到三个问题:
- 服务器直出组件的时候,需要完成请求,携带数据;
- 浏览器接管页面的时候,需要有这个数据,不至于重新请求或者就没数据;
- 如果是浏览器接管后,从其他路由跳转到该路由,那么需要浏览器发起请求;
安装依赖
$ npm i react-router-config
$ npm i @babel/plugin-transform-runtime -D
给.babelrc
配置增加plugins
,编译async await
{
...
"plugins": [
"@babel/plugin-transform-runtime"
]
}
自定义一个数据文件,模拟请求:
export const fruitData = { // src/client/pages/data/index.js
name: 'fruitData'
};
给fruit.js
模拟请求数据
Index.preFetch = async () => {
const fetchData = () => {
return new Promise(res => {
setTimeout(() => {
res({
data: fruitData
})
}, 300); // 300ms模拟请求数据延迟
});
};
const data = await fetchData();
return data;
};
react-router-config
提供matchRoutes
可以通过传入path
和路由配置数组,查找到组件。获取到组件后,判断组件是否存在静态方法preFetch
,若有则请求获取数据。在通过StaticRouter
的context
传递数据。那么,服务器就获取到了数据,同时服务器直出组件携带了数据。
服务器在直出组件的同时,还要将数据传递给客户端,这个过程称之为注水。
通过textarea
传递数据,当然如果不想明文,可进行加密处理。
export default async (req, res, next) => {
const { path, url } = req;
if (url.indexOf('.') > -1) { // 简单处理下
return;
};
const branch = matchRoutes(routeConfig, path)[0];
let component = {};
if (branch) {
component = branch.route.component;
};
let initialData = {}
if (component.preFetch) { // 判断组件有无请求
initialData = await component.preFetch();
};
const context = {
initialData
};
const reactStr = renderToString(
<StaticRouter location={path} context={context}> //context传递数据
<App />
</StaticRouter>
);
const htmlInfo = {
reactStr,
initialData: JSON.stringify(initialData)
};
const html = handleHtml(htmlInfo);
res.send(html);
return next();
};
对于html
拼接,也是加上这段,进行注水
<textarea id="textareaSsrData" style="display: none">${initialData}</textarea>
那么对于客户端而言,需要进行脱水操作,然后将脱水得到的数据,传递给相应组件。可以通过Route
的render
属性传递。
const pathname = document.location.pathname;
const initialData = JSON.parse(document.getElementById('textareaSsrData').value);
// 获取到当前path和数据,传递到App组件里
ReactDOM.hydrate(
<BrowserRouter>
<App pathname={pathname} initialData={initialData} />
</BrowserRouter>,
document.getElementById('root')
);
const App = ({ pathname, initialData }) => {
return (
<Fragment>
<Header />
<Switch>
{
routeConfig.map(v => {
const { name, ...rest } = v;
if (pathname === v.path) { // 判断路由
const { component: Component, ..._rest } = rest;
return <Route key={v.path} {..._rest} render={(props) => {
props.initialData = initialData; // 传递数据
return <Component {...props} />
}} />
} else {
return <Route key={v.path} {...rest} />
}
})
}
</Switch>
</Fragment>
)
};
那么至此,无论是服务端请求到的数据,还是脱水得到的数据,都已经传递给组件了。那么引出了新的问题,对于组件而言,他是不知道自己在服务端还是客户端,那么需要一个字段来表明,他现在在服务端还是客户端,然后去相应的地方获取数据。
可以通过webpack.definePlugin
定义一个全局常量,来表明现在是在什么环境。
plugins: [ // 配置webpack
new webpack.DefinePlugin({
'__isServer': true, // 服务端设置true,客户端设置false
}),
]
那么在src/client
下新建一个文件夹util
,保存通用方法:判断当前是什么环境,从而去哪里获取数据。
export const envInitialData = (props) => { // 参数传入props
let initialData;
if (__isServer) { // StaticRouter的context传入到了props.staticContext
initialData = props.staticContext.initialData;
} else { // Route的render (props) => ...传入到props.xxx
initialData = props.initialData;
};
return initialData || {};
};
那么相应组件就调用该方法,来获取初始值,从而使得两端渲染结果一致。
const Index = (props) => {
const [info, setInfo] = useState(envInitialData(props).data || {});
...
return (
<div onClick={click}>
page: Fruit
<span>I am {info.name}</span>
</div>
)
};
那么到现在为止,之前三个问题的前两个问题已经解决了。还剩下第三个问题:从其他路由跳转到这个路由,需要浏览器发起请求。这个可以加个判断,若没有数据,则发起请求。
useEffect(() => {
const getData = async () => {
const { data } = await Index.preFetch();
setInfo(data);
};
// void 0表示 undefined,一般来说,若无name,会设置成null,所以问题不大
if (info.name === void 0) {
getData();
}
}, []);
那么至此,数据同构算是完成了。
301/302 404等情况
之前提到,如果StaticRouter
发生了路由跳转,可以进行处理301
/302
/404
等等情况,服务端处理也比较简单。通过判断context.url
是否存在,然后再对url
进行判断即可。
if (context.url) { // 我这边就简单判断下,只要有,就进行302跳转
res.writeHead(302, {
location: context.url
});
res.end();
} else {
const html = handleHtml(htmlInfo);
res.send(html);
}
其他章节: