# 正文
上一节,我们实现了一个react ssr版的hello world,让我们对服务端组件直出有了一个基本的了解。
单纯的ssr也没啥意义,也只能把组件当做一个模板来用,连个最基本的交互都没有。
比如:我想点击页面某个元素的时候给我一个反馈提示。

ok ,这不就是增加一个事件么,这个太简单了吧,代码信手拈来。
//组件
export default class Index extends React.Component {
constructor(props) {
super(props);
}
handlerClick(){
alert('一起来玩 react ssr 啊');
}
render() {
return <h1 onClick={this.handlerClick}>click here!</h1>
}
}
其实结果可想而知,这个事件根本不会执行的。
这是为什么呢?
我们都知道元素事件是基于浏览器执行的,只有在浏览器端执行了相应的 js 代码才能绑定事件。
在上一节我们实现的这是一个 ssr 直出效果,也就是说只是一个静态页面。
所以我们需要让代码在浏览器端也执行一次,组件在浏览器端挂载完后react会自动完成事件绑定。
浏览器也执行一次代码,组件不会重复渲染吗?
浏览器接管页面后,react-dom在渲染组件前会先和页面中的节点做对比,只有对比失败的时候才会采用客户端的内容进行渲染,且react会尽量多的复用已有的节点。
# 初识同构
那需要写两套代码?
既然客户端和服务端都要执行,那是不是就要写两份代码,供双端使用?
当然不需要,也完全不合理。
这正是我们本节的重点 - 同构。
基于同构,浏览器和服务端可以运行同一份代码,服务端直出组件后,浏览器接管页面,然后剩下的工作由浏览器来完成。
# 客户端代码执行
经过上面一些理论的分析,我们已经清楚的了解到我们应该做什么。
现在回到正题,来实现元素事件的绑定。
# 如何实现?
大家应该都做过react spa项目,大部分情况都是 请求页面后服务器返回了一个页面的基本框架,同时包括 js css 等静态资源。
所以这里我们第一步要先把js代码打包,在服务端ssr 时,同时将这个 js 资源输出就可以了。
如图

# 实现思路说明
为了方便开发我们从这里开始使用 koa2来构建 http 服务。
另外统一双端的模块化方式,在 node 端也使用 es6 module 方式进行模块的引入,但是在node 端不能运行,所以需要使用 babel 进行编译。
npx babel xxx.js
整体实现思路
- 使用
koa创建一个基础http服务,可以直出Index组件。 - 然后编写客户端代码,增加
Index组件的渲染入口,使用react-dom库渲染Index组件。 - 然后使用
webpack将js代码打包到一个文件内index.js内。 - 服务端直出的时候输出这个
js资源到浏览器。 - 在运行前,需要使用
webpack将客户端代码编译打包,使用babel cli打包编译服务端代码。
# 安装插件、工具、库
react react-dom //react 基础库
@babel/core @babel/cli //babel 基础库
@babel/preset-react //编译 react 代码
@babel/preset-env //配置 babel 编译的一些选项
babel-loader //编译 js 代码
webpack webpack-cli //webpack 两个核心库
koa2 //web 开发框架
koa-static //实现静态资源的访问
ps:@babel/preset-env 是一个预设集合,代替了以往的 stage-* babel-preset-es2015等包,可以根据开发者的配置,按需加载插件,还可以通过设置target属性对node 或者浏览器端进行编译输出设置。
# 具体实施
创建基础 http 服务
// /app.js
//web 服务启动入口文件
//这是一个中间件,它用于处理web 请求,实现react ssr,将组件转换为 html字符串
const reactSsr = require('./dist/src/server/middlewares/react-ssr').default;
const Koa = require('koa2');
const koaStatic =require('koa-static');
const path = require('path');
const app = new Koa();
//设置可访问的静态资源,我们把 webpack 打包后的代码放到/dist/static目录下
app.use(koaStatic(
path.join(__dirname, './dist/static')
));
//react ssr 中间件
app.use(reactSsr);
//启动服务
app.listen(9001);
console.log('server is start .9001');
react ssr 中间件
直出组件的同时, 将index.js代码资源直出到浏览器端。
<script type="text/javascript" src="index.js"></script>
// ./src/server/middlewares/react-ssr.js
//完成 react ssr 工作的中间件,组件在服务端渲染的逻辑都在这个文件内
//引入Index 组件
import React from 'react';
//引入index 组件
import Index from '../../client/pages/index';
import { renderToString} from 'react-dom/server';
export default (ctx,next)=>{
const html = renderToString(<Index/>);
ctx.body=`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>my react ssr</title>
</head>
<body>
<div id="root">
${html}
</div>
</body>
</html>
<script type="text/javascript" src="index.js"></script>//这里绑定了 index.js代码,浏览器会下载后执行
`;
return next();
}
Index组件定义
// /src/client/pages/index/index.js
//index 组件
import React from 'react';
//组件
export default class Index extends React.Component {
constructor(props) {
super(props);
}
handlerClick(){
alert('一起来玩 react ssr 呀。');
}
render() {
return <h1 onClick={this.handlerClick}>click here!</h1>
}
}
实现组件在浏览器端渲染和挂载
浏览器端执行组件渲染的入口文件,也是 webpack 进行资源构建的 entry 入口。
// ./src/client/app/index.js
import React from 'react';
import ReactDom from 'react-dom';
import Index from '../pages/index';
//渲染 index 组件 到页面
ReactDom.hydrate(<Index />, document.getElementById('root'))
webpack 配置
// ./webpack/webpack.dev.config.js
const path = require('path');
//定一个通用的路径转换方法
const resolvePath = (pathstr) => path.resolve(__dirname, pathstr);
module.exports = {
mode: 'development',
entry: resolvePath('../src/client/app/index.js'),//入口文件
output: {
filename: 'index.js', //设置打包后的文件名
path: resolvePath('../dist/static')//设置构建结果的输出目录
},
module: {
rules: [{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/
}
]
}
}
babel 配置
个人习惯喜欢把配置单独放在.babelrc里面,当然也可以放到 webpack 配置文件内。
{
"env": {
"development": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": [
">1%",
"last 2 versions",
"not ie <= 8"
]
}
}
],
"@babel/preset-react"
]
}
}
}
简单说下上面配置中env和development。
env 用于设置对应环境下的配置, 在编译的时候babel会根据当前环境变量的值来决定采用哪个配置。
env字段的值会从process.env.BABEL_ENV获取,如果BABEL_ENV不存在,则从process.env.NODE_ENV获取,如果NODE_ENV还不存在,则取默认值development,使用这样方式进行配置可以定义多个不同的配置项,同时可以通过环境变量来控制要读取的配置。
客户端代码打包
webapck构建,配置一个 npm script命令
"dev": "webpack --config ./webpack/webpack.dev.config.js",
服务端代码打包
node 端代码使用的是es6 module方式,所以需要编译一次。
node 端所需要的 react 组件代码需要使用 babel 进行编译。
babel除了可以编译单独的文件外,还可以直接编译整个目录。
这里我们也为其配一个 npm script 命令,并将代码打包到dist/src目录下
"babel-node": "babel src -d dist/src"
执行上面的两个命令
npm run dev
npm run babel-node
ok,到这里浏览器端和服务端所需的最终代码已转换完成。
http 服务启动
node ./app.js
元素事件已正常绑定上。

# 双端对比测试
到这里我们再回看一下,前面说 react ssr 原理的时候,有说到双端节点对比。
意思是浏览器端代码执行时生成的节点结构会和网页内已有的结构进行对比。如果对比失败,则采用浏览器端的结构。
这个对比过程由 react 完成。
现在我们就来测试一下,以便更具象的理解这个概念。
我们在react ssr 中间件内多增加一个标签。
<body>
<div id="root">
${html} <span>测试内容</span>//增加了span 标签
</div>
</body>
再次运行服务查看页面,span 标签内容会一闪而过。
因为节点对比失败,结果使用的是客户端的节点。
当然还有一个重点就是浏览器端的组件渲染和服务端渲染的差别,服务端只是生成-html 字符串,也只会执行组件的componentWillMount方法。
在浏览器端渲染同时会对比节点,进行节点重用,完成事件的绑定。
# 小结
这一节,我们对同构有了初步的了解和认识,然后基于同构的理念,一步一步的从零实现了一个组件的双端渲染,同时这个实践也是对之前理论进行验证的重要过程。
虽然这仍然是一个 demo,功能虽小,但是思想才是重要的,我们可以基于此举一反三。会让你对react ssr 的理解更深一步,当然对于构建完整的react ssr应用骨架这也是必经之路。
最后,你也可以试试给组件添加一些其他的交互或者数据,体验一下这个过程,毕竟实践出真知嘛。
本节完整代码: