Open pspgbhu opened 6 years ago
传统的 SPA 开发模式由于其页面渲染全部放在了客户端,从而导致了一些一直以来难以解决的痛点:首屏白屏时间较长、SEO 不友好等。
而服务端渲染则可以解决这些传统的痛点:
服务端渲染的好处上面已经描述清楚了,那么同构呢?
感谢 Nodejs 的出现让服务端也有了运行 JavaScript 的能力,这样我们原本在前端运行的 React 在后端也可以运行了。两端公用一套逻辑代码,减少了开发量,也避免了前后端页面渲染的不一致。
React 提供了四个 API 来将虚拟 DOM 输出为 HTML 文本。
前两个方法在浏览器和服务端都是可用的:
下面这两个方法会将文本以流的形式输出,因此只能在服务端运行。
renderToString 方法与 renderToStaticMarkup 的区别:
后者不会在创建出的 DOM 节点上添加任何的 React 属性,这也就意味着创建出的页面将不会具备响应式的特性,renderToStaticMarkup 方法适用于创建纯静态页。
renderToStaticMarkup
我这边需要在客户端继续使用 React 的响应式特性,因此我选用了 renderToString 方法。
renderToString
Node 环境下是不可以直接运行 JSX 的,但是我们可以借助于 babel-register 来在服务端支持 jsx 格式的文件。
在整个 node 程序的最最开始引入 babel-register:
// app.js require('babel-register')({ presets: [ 'es2015', 'react', 'stage-2', ], plugins: [ ['transform-runtime', { polyfill: false, regenerator: true, }], ], extensions: ['.jsx', '.js'], }); const Koa = require('koa') const app = new Koa(); //... //...
这样就能正常处理 .jsx 后缀的文件和 jsx 语法了。同时还支持了 import 等 ES6 语法。
实现服务端渲染的第一步先从搭建起一个后端项目开始。为了减少 Node 项目的搭建成本,这里我推荐使用 koa-generator。
在脚手架的基础上,再新建三个文件夹:
/build
/common
/client
├── app.js # 程序入口文件 ├── bin │ └── www # 程序启动脚本 ├── build # Webpack 配置,用于打包前端静态资源 ├── client # 只在浏览器端运行的代码 | └── index.jsx # 浏览器端的入口文件 ├── common # 客户端和服务端共享代码, React 同构代码 | └── App.jsx # React 根组件 ├── controllers # Controllers ├── public # 静态资源文件 ├── routes # Koa 路由 └── views # 页面模板文件 └── index.jsx # ejs 渲染模板
单单实现 React 服务端渲染非常简单:使用 React 的 renderToString API 将虚拟 DOM 输出为 HTML 字符串,然后当浏览器请求页面时返回给浏览器就 OK 了。
// Node 服务端 ./routes/index.jsx import Router from 'koa-router'; import { renderToString } from 'react-dom/server'; import App from '../common/App'; const router = Router(); /** * 为了配合浏览器端的 React-Router 中 BrowserRouter 路由 * 我们使用了 '*' 来匹配任何 URL,以返回同样的 HTML。 */ router.get('/', async (ctx) => { // 因为使用了 babel-register,因此我们可以直接使用 jsx 语法。 // 将根组件 App 输出为 html 字符串。 const content = renderToString(<App/>); await ctx.render('index', { html: content, // 将 html 字符串通过 ejs 模板渲染出来 }); }); export default router;
对应的 index.ejs 文件:
<!-- index.ejs --> <!DOCTYPE html> <html> <head> <title>React Isomorphic</title> <link rel='stylesheet' href='/css/style.css' /> </head> <body> <!-- koa-router 将 react 虚拟dom 输出为字符串,渲染在 html 变量中 --> <div id="app"><%- html %></div> <script src="/js/app.js"></script> </body> </html>
这样,在浏览器请求页面的时候,服务端就会返回具备完整 DOM 结构的 HTML 了。
服务端渲染只负责渲染出首屏的内容,往往在首屏渲染后页面上还是会有很多的交互和异步操作的,因此我们依然需要向之前前端开发一样,打包 JS 并在 HTML 中引入对应的 JS 文件。
/client/index.jsx 作为整个浏览器端的入口 JS 文件,内容大致如下:
/client/index.jsx
// ./client/index.ejs import React, { Component } from 'react'; import { hydrate } from 'react-dom'; import { BrowserRouter, withRouter } from 'react-router-dom'; // 用 hydrate 来替代 render hydrate( <App></App>, document.querySelector('#app'), );
React v16 提供了新的 render API 来专门为服务端渲染来做首屏渲染优化: ReactDOM.hydrate (如果一个节点上有服务端渲染的标记,则 React 会保留现有 DOM,只去绑定事件处理程序,从而达到一个最佳的首屏渲染表现)。 我们将使用 hydrate 来替代 render
至此,一个最简单的服务端渲染就完全构建完毕了。
浏览器端的 React Router 支持两种模式:
这里我选择了 Browser Router,它会让我在使用 React Router 跳转时有一种更真实的跳转的感觉。
这里我们需要对浏览器端和服务器端分别处理,对于浏览器端:
// ./client/index.ejs import React, { Component } from 'react'; import { hydrate } from 'react-dom'; import { BrowserRouter, withRouter } from 'react-router-dom'; // 用 hydrate 来替代 render hydrate( <BrowserRouter> <App></App> </BrowserRouter>, document.querySelector('#app'), );
React-Router 提供了专门的 StaticRouter 来进行服务端渲染。 我们将浏览器请求的页面路径作为参数传入到 StaticRouter 的 location 参数中,StaticRouter 就会帮助我们来返回不同的虚拟 dom 节点。然后我们再用 renderToString 方法来转成 html 字符串。
同时我们还需要把 router.get('/') 更改为 router.get('*')。任何路径的请求都将会进入到该路由进行处理。
router.get('/')
router.get('*')
// Node /routes/index.jsx import Router from 'koa-router'; import { renderToString } from 'react-dom/server'; import { StaticRouter } from 'react-router-dom' const router = Router(); /** * 为了配合浏览器端的 React-Router 中 BrowserRouter 路由 * 我们使用了 '*' 来匹配任何来自浏览器的请求,这样保证了任何路径的请求都将得到该路由的处理。 */ router.get('*', async (ctx) => { const context = {}; const content = ( <StaticRouter location={ctx.url} context={context}> <App/> </StaticRouter> ); await ctx.render('index', { html: content, }); }); export default router;
Redux 的服务端渲染实现思路也很清晰,就是在服务端初始化一个 Store 的同时把这个 Store 也传递到客户端。
扩充 Koa 的路由文件:
// server-side ./routes/index.jsx import Router from 'koa-router'; import { renderToString } from 'react-dom/server'; import { StaticRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import { createStore } from 'redux' const router = Router(); router.get('*', async (ctx) => { // 在服务端初始化 store 的数据 const store = createStore(state => state, { name: 'Pspgbhu', site: 'http://pspgbhu.me', }); const context = {}; const content = ( <StaticRouter location={ctx.url} context={context}> {/* 增加 Provider */} <Provider store={store}> <App/> </Provider> </StaticRouter> ); // 获取 store 数据对象 const preloadedState = store.getState(); await ctx.render('index', { html: content, state: preloadedState, // 将 store 数据传递给 ejs 模板引擎 }); }); export default router;
模板引擎将 koa router 传来的 store 数据赋值给 window.__INITIAL_STATE_ 对象下。
window.__INITIAL_STATE_
<!-- index.ejs --> <!DOCTYPE html> <html> <head> <title>React Isomorphic</title> <link rel='stylesheet' href='/css/style.css' /> </head> <body> <div id="app"><%- html %></div> <script> // 将服务端的 store 对象赋值给该变量 window.__INITIAL_STATE_ = <%- state %>; </script> <script src="/js/app.js"></script> </body> </html>
// client-side index.jsx import React from 'react'; import { hydrate } from 'react-dom'; import { BrowserRouter } from 'react-router-dom'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; import App from '../common/App'; // 通过服务端注入的全局变量得到初始的 state const preloadedState = window.__INITIAL_STATE_; const store = createStore(state => state, preloadedState); hydrate( <Provider store={store}> <BrowserRouter> <App></App> </BrowserRouter> </Provider>, document.querySelector('#app'), );
通常我们在前端开发时,会直接在 jsx 中引入 css, less 等样式文件。
// app.jsx import './style/app.less'; import './style/style.css';
但是服务端却不能正确的处理这些文件,会引起服务端报错。 渲染 HTML 文档本身不需要 CSS 样式的参与,因此我们想办法忽略这些文件就可以了。
我们可以通过 babel-register 的插件 babel-plugin-transform-require-ignore 来忽略一些固定后缀的文件。下面让我们来扩充 app.js 中的 bable-register 配置。
// app.js require('babel-register')({ presets: [ 'es2015', 'react', 'stage-2', ], plugins: [ ['transform-runtime', { polyfill: false, regenerator: true, }], // babel-plugin-transform-require-ignore 插件 // 可以使 node 忽略一些固定的后缀文件。 [ 'babel-plugin-transform-require-ignore', { extensions: ['.less', '.sass', '.css'], }, ], ], extensions: ['.jsx', '.js'], });
上面的内容都是在说服务端渲染的事情,当页面在浏览器端运行的时候还是需要在浏览器中加载 JS 静态资源的。这些静态资源我们通常使用 Webpack 进行打包。
生产环境下处理静态资源的打包很简单,直接使用 webpack 打包出对应的 JS 和 CSS,然后在 ejs 模板中直接引用对应的静态资源即可。
开发环境下,因为需要频繁的更改项目代码,所以很需要代码的热更新。 服务端 Node 代码的热更新通过 nodemon 就能轻松实现。
对于前端静态资源的热更新,我是通过 webpack 去 watch 相应的代码源码,每次检测到代码改动后 webpack 再自动去 rebuild 代码。
此外,在开发环境下,我会将静态资源全部都打包在 /.dev 目录下,而不是 /build 目录下。并且在 app.js 中进行如下设置
/.dev
/** * 非生产环境下引用 .dev 目录作为静态资源目录。 * .dev 目录下的资源会优先于 public 文件下的资源。 */ if (process.env.NODE_ENV !== 'production') { app.use(serve(path.join(__dirname, '.dev'))); } app.use(serve(path.join(__dirname, 'public')));
以此来区分不同环境下的静态资源引用。
在同构的基础上,Node 服务端和浏览器端虽然都公用了整个 /common 目录下的 React 组件,但是在具体运行中还是有些不同。
在 Node 服务端,组件的生命周期只走到了 componentWillMount()。
componentWillMount()
在浏览器端,组件拥有着完整的生命周期。
平时我们在做前端开发的时候或多或少都会用到 window 对象和其他一些浏览器端特有的全局对象(如 document 等), Node 环境下是没有这两个对象的,如果贸然的使用,肯定是会引起 Node 端的报错的。
那么在组件中是不是不能用这些对象了呢?当然不是。上面有提到 Node 环境下,组件的生命周期只能走到 componentWillMount(),再之后的生命周期函数就不会被调用了。因此我们就可以把这些浏览器端特有的对象和方法放在 componentWillMount() 之后的生命周期函数里使用,比如在 componentDidMount() 中使用 document.querySelector('#app') 方法。
componentDidMount()
document.querySelector('#app')
整的来说,由于 React 及其全家桶对服务端渲染的支持十分不错,因此在服务端渲染的实现上整体还是比较简单的。而且实现上也很灵活,肯定不止本文的这一种思路。
这里在推荐一下 React 服务端渲染脚手架 react-isomorphic,我的个人博客 便是基于该脚手架重构开发的。
传统的 SPA 开发模式由于其页面渲染全部放在了客户端,从而导致了一些一直以来难以解决的痛点:首屏白屏时间较长、SEO 不友好等。
而服务端渲染则可以解决这些传统的痛点:
服务端渲染的好处上面已经描述清楚了,那么同构呢?
感谢 Nodejs 的出现让服务端也有了运行 JavaScript 的能力,这样我们原本在前端运行的 React 在后端也可以运行了。两端公用一套逻辑代码,减少了开发量,也避免了前后端页面渲染的不一致。
React API 的支持
React 提供了四个 API 来将虚拟 DOM 输出为 HTML 文本。
前两个方法在浏览器和服务端都是可用的:
下面这两个方法会将文本以流的形式输出,因此只能在服务端运行。
renderToString 方法与 renderToStaticMarkup 的区别:
后者不会在创建出的 DOM 节点上添加任何的 React 属性,这也就意味着创建出的页面将不会具备响应式的特性,
renderToStaticMarkup
方法适用于创建纯静态页。我这边需要在客户端继续使用 React 的响应式特性,因此我选用了
renderToString
方法。让服务端支持 JSX
Node 环境下是不可以直接运行 JSX 的,但是我们可以借助于 babel-register 来在服务端支持 jsx 格式的文件。
在整个 node 程序的最最开始引入 babel-register:
这样就能正常处理 .jsx 后缀的文件和 jsx 语法了。同时还支持了 import 等 ES6 语法。
项目框架搭建
实现服务端渲染的第一步先从搭建起一个后端项目开始。为了减少 Node 项目的搭建成本,这里我推荐使用 koa-generator。
在脚手架的基础上,再新建三个文件夹:
/build
: Webpack 启动配置,主要用于打包浏览器端所需要的静态资源。/common
: 主要是 React 的组件和逻辑代码。前后端公用这一部分代码。通常我们将 React 的根组件作为整个 common 文件夹的入口暴露出去。/client
: 只在浏览器端运行的文件。实现服务端渲染
单单实现 React 服务端渲染非常简单:使用 React 的
renderToString
API 将虚拟 DOM 输出为 HTML 字符串,然后当浏览器请求页面时返回给浏览器就 OK 了。对应的 index.ejs 文件:
这样,在浏览器请求页面的时候,服务端就会返回具备完整 DOM 结构的 HTML 了。
服务端渲染只负责渲染出首屏的内容,往往在首屏渲染后页面上还是会有很多的交互和异步操作的,因此我们依然需要向之前前端开发一样,打包 JS 并在 HTML 中引入对应的 JS 文件。
/client/index.jsx
作为整个浏览器端的入口 JS 文件,内容大致如下:至此,一个最简单的服务端渲染就完全构建完毕了。
搭配 React Router 使用
浏览器端的 React Router 支持两种模式:
这里我选择了 Browser Router,它会让我在使用 React Router 跳转时有一种更真实的跳转的感觉。
这里我们需要对浏览器端和服务器端分别处理,对于浏览器端:
React-Router 提供了专门的 StaticRouter 来进行服务端渲染。 我们将浏览器请求的页面路径作为参数传入到 StaticRouter 的 location 参数中,StaticRouter 就会帮助我们来返回不同的虚拟 dom 节点。然后我们再用
renderToString
方法来转成 html 字符串。同时我们还需要把
router.get('/')
更改为router.get('*')
。任何路径的请求都将会进入到该路由进行处理。Redux 的服务端渲染
Redux 的服务端渲染实现思路也很清晰,就是在服务端初始化一个 Store 的同时把这个 Store 也传递到客户端。
第一步:在服务端构建初始 store
扩充 Koa 的路由文件:
第二步:模板引擎将初始的 store 渲染到页面中
模板引擎将 koa router 传来的 store 数据赋值给
window.__INITIAL_STATE_
对象下。第三步:客户端获取 Redux store 的初始值
样式文件的处理
通常我们在前端开发时,会直接在 jsx 中引入 css, less 等样式文件。
但是服务端却不能正确的处理这些文件,会引起服务端报错。 渲染 HTML 文档本身不需要 CSS 样式的参与,因此我们想办法忽略这些文件就可以了。
我们可以通过 babel-register 的插件 babel-plugin-transform-require-ignore 来忽略一些固定后缀的文件。下面让我们来扩充 app.js 中的 bable-register 配置。
开发环境和生产环境下的静态资源打包
上面的内容都是在说服务端渲染的事情,当页面在浏览器端运行的时候还是需要在浏览器中加载 JS 静态资源的。这些静态资源我们通常使用 Webpack 进行打包。
生产环境下的静态资源
生产环境下处理静态资源的打包很简单,直接使用 webpack 打包出对应的 JS 和 CSS,然后在 ejs 模板中直接引用对应的静态资源即可。
开发环境下的静态资源
开发环境下,因为需要频繁的更改项目代码,所以很需要代码的热更新。 服务端 Node 代码的热更新通过 nodemon 就能轻松实现。
对于前端静态资源的热更新,我是通过 webpack 去 watch 相应的代码源码,每次检测到代码改动后 webpack 再自动去 rebuild 代码。
此外,在开发环境下,我会将静态资源全部都打包在
/.dev
目录下,而不是/build
目录下。并且在 app.js 中进行如下设置以此来区分不同环境下的静态资源引用。
一些要注意的地方
1. 生命周期的不同
在同构的基础上,Node 服务端和浏览器端虽然都公用了整个
/common
目录下的 React 组件,但是在具体运行中还是有些不同。在 Node 服务端,组件的生命周期只走到了
componentWillMount()
。在浏览器端,组件拥有着完整的生命周期。
2. Node 环境下没有 window 对象等其他一些浏览器端特有的全局对象或方法。
平时我们在做前端开发的时候或多或少都会用到 window 对象和其他一些浏览器端特有的全局对象(如 document 等), Node 环境下是没有这两个对象的,如果贸然的使用,肯定是会引起 Node 端的报错的。
那么在组件中是不是不能用这些对象了呢?当然不是。上面有提到 Node 环境下,组件的生命周期只能走到
componentWillMount()
,再之后的生命周期函数就不会被调用了。因此我们就可以把这些浏览器端特有的对象和方法放在componentWillMount()
之后的生命周期函数里使用,比如在componentDidMount()
中使用document.querySelector('#app')
方法。写在最后
整的来说,由于 React 及其全家桶对服务端渲染的支持十分不错,因此在服务端渲染的实现上整体还是比较简单的。而且实现上也很灵活,肯定不止本文的这一种思路。
这里在推荐一下 React 服务端渲染脚手架 react-isomorphic,我的个人博客 便是基于该脚手架重构开发的。