基础概念
SSR: 即服务端渲染 (Server Side Render) 传统的服务端渲染可以使用 Java,PHP 等开发语言来实现, 随着 Node.JS 和相关前端领域技术的不断进步, 前端同学也可以基于此完成独立的服务端渲染.
过程: 浏览器发送请求 -> 服务器运行 react 代码生成页面 -> 服务器返回页面 -> 浏览器下载 html 文档 -> 页面准备就绪 即: 当前页面的内容是服务器生成好给到浏览器的.
对应 CSR: 即客户端渲染 (Client Side Render) 过程: 浏览器发送请求 -> 服务器返回空白 HTML(HTML 里包含一个 root 节点和 JS 文件) -> 浏览器下载 JS 文件 -> 浏览器运行 react 代码 -> 页面准备就绪 即: 当前页面的内容是 JS 渲染出来
如何区分页面是否服务端渲染: 右键点击 -> 显示网页源代码, 如果页面上的内容在 HTML 文档里, 是服务端渲染, 否则就是客户端渲染.
对比
CSR: 首屏渲染时间长, react 代码运行在浏览器, 消耗的是浏览器的性能
SSR: 首屏渲染时间短, react 代码运行在服务器, 消耗的是服务器的性能
为什么要用服务端渲染
首屏加载时间优化, 由于 SSR 是直接返回生成好内容的 HTML, 而普通的 CSR 是先返回空白的 HTML, 再由浏览器动态加载 JavaScript 脚本并渲染好后页面才有内容; 所以 SSR 首屏加载更快, 减少白屏的时间, 用户体验更好.
SEO (搜索引擎优化), 搜索关键词的时候排名, 对大多数搜索引擎, 不识别 JavaScript 内容, 只识别 HTML 内容. (注: 原则上可以不用服务端渲染时最好不用, 所以如果只有 SEO 要求, 可以用预渲染等技术去替代)
构建一个服务端渲染的项目
(1) 使用 Node.JS 作为服务端和客户端的中间层, 承担 proxy 代理, 处理 cookie 等操作.
(2) hydrate 的使用: 在有服务端渲染情况下, 使用 hydrate 代替 render, 它的作用主要是将相关的事件注水进 HTML 页面中 (即: 让 React 组件的数据随着 HTML 文档一起传递给浏览器网页), 这样可以保持服务端数据和浏览器端一致, 避免闪屏, 使第一次加载体验更高效流畅.
ReactDom.hydrate(<App />, document.getElementById('root'));
(3) 服务端代码 webpack 编译: 通常会建一个 webpack.server.JS 文件, 除了常规的参数配置外, 还需要设置 target 参数为'node'.
- const serverConfig = {
- target: 'node',
- entry: './src/server/index.js',
- output: {
- filename: 'bundle.js',
- path: path.resolve(__dirname, '../dist')
- },
- externals: [nodeExternals()],
- module: {
- rules: [{
- test: /\.JS?$/,
- loader: 'babel-loader',
- exclude: [
- path.join(__dirname, './node_modules')
- ]
- }
- ...
- ]
- }
- (此处省略样式打包, 代码压缩, 运行坏境配置等等...)
- ...
- };
(4) 使用 react-dom/server 下的 renderToString 方法在服务器上把各种复杂的组件和代码转化成 HTML 字符串返回到浏览器, 并在初始请求时发送标记以加快页面加载速度, 并允许搜索引擎抓取页面以实现 SEO 目的.
- const render = (store, routes, req, context) => {
- const content = renderToString((
- <Provider store={store}>
- <StaticRouter location={req.path} context={context}>
- <div>
- {renderRoutes(routes)}
- </div>
- </StaticRouter>
- </Provider>
- ));
- return `
- <HTML>
- <head>
- <title>ssr</title>
- </head>
- <body>
- <div id='root'>${content}</div>
- <script src='/index.js'></script>
- </body>
- </HTML>
- `;
- }
- App.get('*', function (req, res) {
- ...
- const HTML = render(store, routes, req, context);
- res.send(HTML);
- });
与 renderToString 类似功能的还有: i. renderToStaticMarkup: 区别在于 renderToStaticMarkup 渲染出的是不带 data-reactid 的纯 HTML, 在 JavaScript 加载完成后因为不认识之前服务端渲染的内容导致重新渲染 (可能页面会闪一下).
ii. renderToNodeStream: 将 React 元素渲染为其初始 HTML, 返回一个输出 HTML 字符串的可读流.
iii. renderToStaticNodeStream: 与 renderToNodeStream 此类似, 除了这不会创建 React 在内部使用的额外 DOM 属性, 例如 data-reactroot.
(5) 使用 redux 承担数据准备, 状态维护的职责, 通常搭配 react-redux, redux-thunk(中间件: 发异步请求用到 action) 使用.(本猿目前使用比较多是就是 Redux 和 Mobx, 这里以 Redux 为例). A. 创建 store(服务器每次请求都要创建一次, 客户端只创建一次):
- const reducer = combineReducers({
- home: homeReducer,
- page1: page1Reducer,
- page2: page2Reducer
- });
- export const getStore = (req) => {
- return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios(req))));
- }
- export const getClientStore = () => {
- return createStore(reducer, Windows.STATE_FROM_SERVER, applyMiddleware(thunk.withExtraArgument(clientAxios)));
- }
B. action: 负责把数据从应用传到 store, 是 store 数据的唯一来源
- export const getData = () => {
- return (dispatch, getState, axiosInstance) => {
- return axiosInstance.get('interfaceUrl/xxx')
- .then((res) => {
- dispatch({
- type: 'HOME_LIST',
- list: res.list
- })
- });
- }
- }
C. reducer: 接收旧的 state 和 action, 返回新的 state, 响应 actions 并发送到 store.
- export default (state = { list: [] }, action) => {
- switch(action.type) {
- case 'HOME_LIST':
- return {
- ...state,
- list: action.list
- }
- default:
- return state;
- }
- }
- export default (state = { list: [] }, action) => {
- switch(action.type) {
- case 'HOME_LIST':
- return {
- ...state,
- list: action.list
- }
- default:
- return state;
- }
- }
D. 使用 react-redux 的 connect,Provider 把组件和 store 连接起来
Provider 将之前创建的 store 作为 prop 传给 Provider
- const content = renderToString((
- <Provider store={store}>
- <StaticRouter location={req.path} context={context}>
- <div>
- {renderRoutes(routes)}
- </div>
- </StaticRouter>
- </Provider>
- ));
connect([mapStateToProps],[mapDispatchToProps],[mergeProps], [options]) 接收四个参数 常用的是前两个属性 mapStateToProps 函数允许我们将 store 中的数据作为 props 绑定到组件上 mapDispatchToProps 将 action 作为 props 绑定到组件上
connect(mapStateToProps(),mapDispatchToProps())(MyComponent)
(6) 使用 react-router 承担路由职责 服务端路由不同于客户端, 它是无状态的. React 提供了一个无状态的组件 StaticRouter, 向 StaticRouter 传递当前 URL, 调用 ReactDOMServer.renderToString() 就能匹配到路由视图.
服务端
- import {
- StaticRouter
- } from 'react-router-dom';
- import {
- renderRoutes
- } from 'react-router-config'
- import routes from './router.js'
- <StaticRouter location={
- req.path
- } context={
- {
- context
- }
- }>
- {
- renderRoutes(routes)
- }
- </StaticRouter>
浏览器端
- import { BrowserRouter } from 'react-router-dom';
- import { renderRoutes } from 'react-router-config'
- import routes from './router.js'
- <BrowserRouter>
- {renderRoutes(routes)}
- </BrowserRouter>
当浏览器的地址栏发生变化的时候, 前端会去匹配路由视图, 同时由于 req.path 发生变化, 服务端匹配到路由视图, 这样保持了前后端路由视图的一致, 在页面刷新时, 仍然可以正常显示当前视图. 如果只有浏览器端路由, 而且是采用 BrowserRouter, 当页面地址发生变化后去刷新页面时, 由于没有对应的 HTML, 会导致页面找不到, 但是加了服务端路由后, 刷新发生时服务端会返回一个完整的 HTML 给客户端, 页面仍然正常显示. 推荐使用 react-router-config 插件, 然后如上代码在 StaticRouter 和 BrowserRouter 标签的子元素里加 renderRoutes(routes): 建一个 router.JS 文件
- const routes = [{ component: Root,
- routes: [
- { path: '/',
- exact: true,
- component: Home,
- loadData: Home.loadData
- },
- { path: '/child/:id',
- component: Child,
- loadData: Child.loadData
- routes: [
- path: '/child/:id/grand-child',
- component: GrandChild,
- loadData: GrandChild.loadData
- ]
- }
- ]
- }];
在浏览器端请求一个地址的时候, server.JS 里在实际渲染前可以通过 matchRouters 这种方式确定要渲染的内容, 调用 loaderData 函数进行 action 派发, 返回 promise->promiseAll->renderToString, 最终生成 HTML 文档返回.
- import { matchRoutes } from 'react-router-config'
- const loadBranchData = (location) => {
- const branch = matchRoutes(routes, location.pathname)
- const promises = branch.map(({ route, match }) => {
- return route.loadData
- ? route.loadData(match)
- : Promise.resolve(null)
- })
- return Promise.all(promises)
- }
(7) 写组件注意代码同构 (即: 一套 React 代码在服务端执行一次, 在客户端再执行一次) 由于服务器端绑定事件是无效的, 所以服务器返回的只有页面样式 (& 注水的数据), 同时返回 JavaScript 文件, 在浏览器上下载并执行 JavaScript 时才能把事件绑上, 而我们希望这个过程只需编写一次代码, 这个时候就会用到同构, 服务端渲染出样式, 在客户端执行时绑上事件.
优点: 共用前端代码, 节省开发时间 弊端: 由于服务器端和浏览器环境差异, 会带来一些问题, 如 document 等对象找不到, DOM 计算报错, 前端渲染和服务端渲染内容不一致等; 前端可以做非常复杂的请求合并和延迟处理, 但为了同构, 所有这些请求都在预先拿到结果才会渲染.
来源: https://juejin.im/post/5c90995a5188252d8d190351