React从CSR到SSR:第一篇

React从CSR到SSR:第一篇

首先要明白CSR和SSR

对于html的加载,以React为例,我们习惯的做法是加载js文件中的React代码,去生成页面渲染,同时,js也完成页面交互事件的绑定,这样的一个过程就是CSR(客户端渲染),如下:

但如果这个js文件比较大的话,加载起来就会比较慢,到达页面渲染的时间就会比较长,导致首屏白屏。这时候,SSR(服务端渲染)就出来了:由服务端直接生成html内容返回给浏览器渲染首屏内容,如下:

但是服务端渲染的页面交互能力有限,如果要实现复杂交互,还是要通过引入js文件来辅助实现,我们把页面的展示内容和交互写在一起,让代码执行两次,这种方式就叫同构


CSR和SSR的区别在于,最终的html代码是从客户端添加的还是从服务端,而服务端要添加html,就得从服务端引入之前写好的组件文件。然而之前的组件都是按照ES6规范来码的,node端并不完全支持,怎么办呢?

node引入你要渲染的页面

react-dom为我们提供了将jsx语法渲染成html代码的函数,我们可以将我们要渲染的页面直接引入,通过renderToString来转出 router.js

...
import Intro from './Intro';
import { renderToString } from 'react-dom/server';
...

router.get('/', function (req, res) {
  res.render('body', {
    htmlStr: renderToString(<Intro />)
  })
})

node支持babel编译

但这里就会涉及到在node运行ES6语法的问题,可以在server运行入口文件,引入babel server.js

'use strict';

import 'babel-polyfill';
import 'babel-register';
...

同时在配置里面.babelrc加上es2015presets .babelrc

{
  "presets": [ 'es2015' ]
}

server打包入口

我的做法是在渲染页面入口,新增一个server端打包文件 intro.ssr.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import Intro from './Intro';

// 导出渲染函数,以给采用 Node.js 编写的 HTTP 服务器代码调用
export function render() {
  // 把根组件渲染成 HTML 字符串
  return renderToString(<Intro />)
}

webpack打包server所需文件

同时新起一个webpack配置文件,来打包server端所需要的文件

webpack.server.js

const path = require('path');
const nodeExternals = require('webpack-node-externals');


module.exports = require('./webpack.base.server')({
  mode: 'none',

  entry: {
    intro: [
      path.join(process.cwd(), 'client/modules/site/pages/Index/index.ssr.js')
    ]
  },

  target: 'node',

  externals: [nodeExternals()],

  output: {
    filename: '[name].ssr.js',
    path: path.join(process.cwd(), './server/ssr'),
    libraryTarget: 'commonjs2'
  },

  plugins: [],

  devtool: 'source-map',

  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
        exclude: path.resolve(__dirname, 'node_modules')
      },
      {
        // 忽略掉 CSS 文件
        test: /\.(less|css|sass)$/,
        use: ['ignore-loader'],
      }
    ]
  },
})

server所需要的代码跟客户端的不同:

  • 需要commonjs规范的代码
  • 不需要将第三方库的代码打包进去,所以这里用到webpack-node-externals,来去除第三方库的代码 需要将样式文件去除,因为服务端无法识别对css、image资源后缀的模块引用,所以我们交给client打包处理(下面会讲到client打包怎么处理样式文件)
  • 配置好出口,尽量跟客户端打包文件区分开,方便node路由来引入

好,弄完这一步,我们可以在路由里面引入我们打包好的代码啦

router.js

...
var { render } = require('../../../ssr/intro.ssr');
...

router.get('/', function (req, res) {
  var indexStr = render();
  res.render('body', {
    htmlStr: indexStr
  });
})

跟上面直接引入方式对比,这样会有几个好处

  • 更工程化,打包一气呵成
  • 无需在node端处理es6的语法问题
  • 根据约定好的文件入口出口,client的代码和server互不影响

燃鹅,当你打包好,启动server的时候,可能会遇到这个问题

window/document is undefined

这个是因为,渲染内容里面,调用了window/document,而在DOM都没有的服务端肯定没有这些的啦

  • 对于一些全局变量的引用,如下方的window.copyright
...
render() {
  return (
    <div className='intro-footer'>
      <div>希沃教学软件尽在希沃易+官网 <a href='https://e.seewo.com'>e.seewo.com</a></div>
      <div>Copyright © {window.copyright} GuangZhou Shirui. All Rights Reserved. 粤ICP备12092924号-1</div>
    </div>
  );
}

node端,我们可以通过global.copyright去替换 而对于document里面的原生属性及方法,只能借助第三方库的力量

var { JSDOM } = require('jsdom');
// 制造个document避免未定义
var dom = new JSDOM('<!DOCTYPE html><p></p>');
global.document = dom.window.document;

注意:如果使用global.window = window也能解决此问题,但对于一些判断user-agent的库会出问题,例如isMobile,看了一下其源码,其中有一段用到了window去判断是否处于浏览器端

...
// instantiate直接返回IsMobileClass的实例,没有参数的获取
var instantiate = function() {
  var IM = new IsMobileClass();
  IM.Class = IsMobileClass;
  return IM;
};

if (typeof module !== 'undefined' && module.exports && typeof window === 'undefined') {
  //node
  module.exports = IsMobileClass;
} else if (typeof module !== 'undefined' && module.exports && typeof window !== 'undefined') {
  //browserify
  module.exports = instantiate();
} 
...

如果是在浏览器端,就直接拿navigator里面的userAgent而不拿传入的参数,所以在node端会无法判断。

webpack抽离样式

上面说到要在client端处理样式文件,之前我们打包前端代码,习惯将js和样式文件合并到一个bunble里面,但ssr不处理样式的打包,所以我们有必要在client打包的时候,将服务端需要的样式独立出来

...
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const extractLESS = new ExtractTextPlugin('[name].css');
module.exports = {
  ...
  plugins: [ extractLESS ],
  rules: [
    ...
    {
      test: /\.less$/,
      include: path.join(process.cwd(), 'client/modules/site/styles/site.less'),
      use: ExtractTextPlugin.extract({
        use: ['css-loader', 'less-loader'],
        fallback: 'style-loader'
      })
    }
  ] 
}

通过node路由来传递调用模板变量,按需加载

...
res.render('body', { 
  chunks: ['analytics', 'site'], 
  cssChunks: ['site'], 
  htmlStr: indexStr
});

在我们的模板页加入css文件

<head>
    <title>希沃信鸽</title>
    <% if (typeof cssChunks !== 'undefined') { %>
    <% cssChunks.forEach(function(name){ %>
      <%- getChunkCss(name) %>
    <% }) %>
    <% } %>
</head>

getChunkCss是中间件配置的一个方法

...
res.locals.getChunkCss = function(chunkName){
  var chunkCss = _.get(assetData, ['chunks', chunkName, 'css'], []);

  var cssArr = [];

  chunkCss.map(function(item){
    cssArr.push('<link rel="stylesheet" href="/statics/' + item + '"></link>')
  })

  return cssArr.join('');
}
...

同构的体现

上面的实现结果基本把静态页面渲染出来了,但是对于一些js操作,如事件绑定,dom操作等,在服务端渲染的html文本无法执行,所以这些js逻辑必须是在浏览器端才能执行,这里我们将目标页面的代码,在浏览器进行二次渲染

hydrate(
  <Intro />,
  document.getElementById('root')
);

其中hydratereact-dom针对服务端渲染提供的方法,有别于renderhydrate 的用处是,ReactDOM 复用 ReactDOMServer 服务端渲染的内容时尽可能保留结构,并补充事件绑定等 Client 特有内容的过程

在服务端请求数据还是前端

如果是静态页面做ssr,那么到这里其实已经实现了,如果是需要数据交互的,还得考虑数据请求的方式

  • 涉及到数据的请求,如果数据请求只存在渲染前的内容获取,那建议在node端进行处理,这样的好处是,在node端直接请求后台数据有其服务器地理优势
  • 但如果是一些交互操作需要请求数据的,仍是需要在前端自行处理
  • client请求数据接口的方式跟server端不同,所以client请求数据接口的组件可只在client端代码引入,无需打包在server所需文件中。
编辑于 2020-07-06 20:06