从零开始构建react应用(五)同构之服务端渲染

前言

上文讲到使用react进行客户端渲染页面,这次讲解在服务端利用前端react的代码来渲染页面并输出到客户端,即构建同构应用。

PS:同构,我是这样理解的,同一份代码可以同时运行在客户端和服务端。

利用ts实现纯脚本组件的同构

当我们的组件不包含样式,图片等服务端无法直接解析处理的时候,我们可以直接利用ts的tsc命令将组件编译成相应的js,服务端则可以直接运行该js得到渲染的结果,当然这种情况实际并不存在,这里只是作为例子来讲解。

服务端bundle.tsx

我们在server目录下新建bundle.tsx将其作为前端react组件的一个打包入口文件。我们通过将它打包,并在服务端执行得到我们需要的渲染结果。

// ./src/server/bundle.tsx

import * as React from 'react';

/* tslint:disable-next-line no-submodule-imports */
import { renderToString } from 'react-dom/server';

import App from '../client/component/app';

export default {
  render() {
    return renderToString(<App />);
  },
};

可以看到,我们直接将客户端的App组件引入,并输出一个拥有render方法的对象,在服务端入口文件中我们只需要引入该bundle对象,并调用其render方法就可以得到渲染出的html字符串了。

PS:tslint:disable类似于eslint的对应语法,用来使得相应的规则不生效

// ./src/server/index.tsx

...
router.get('/*', (ctx: Koa.Context, next) => { // 配置一个简单的get通配路由
  const html = bundle.render(); // 获得渲染出的html字符串
  ctx.type = 'html';
  ctx.body = `
    ...
        <div id="app">${html}</div>
    ...
  `;
  next();
});
...

PS:...代表代码省略

客户端/服务端渲染对比

在chrome中打开localhost:3344后可以看的页面上的hello world,我们右键页面选择View Page Source,可以看到两种方法渲染的不同:

客户端渲染:
从零开始构建react应用(五)同构之服务端渲染

服务端渲染:
从零开始构建react应用(五)同构之服务端渲染

显而易见,服务端渲染会直接输出组件渲染的内容,浏览器在接收到这些内容后就会直接绘制呈现给我们,而客户端渲染会在react框架初始化完毕之后再进行,所以对比两种情况,客户端渲染时白屏时间会更长一些,且刷新页面时会有闪烁的感觉。

利用webpack实现非纯脚本组件的同构

在我们实际开发环境中,必然存在组件里引用样式文件,引用图片的情况,这种情况下ts并不具备webpack相应的将这些资源转换为js可处理的功能,所以我们需要使用webpack来处理服务端的bundle.tsx文件,使得服务端可以运行打包后的js文件。

服务端bundle.tsx的webpack配置文件

在客户端,像react这样的库,webpack会把它打包到输出的js文件里,而在服务端我们并不需要这么做,所以配置文件和客户端有很大不同。

// ./src/webpack/server.ts

import * as path from 'path';

import * as webpack from 'webpack';

import * as nodeExternals from 'webpack-node-externals';

import { cloneDeep } from 'lodash'; // lodash提供的深度复制方法cloneDeep

// 客户端+服务端全环境公共配置baseConfig,项目根目录路径baseDir,获取tsRule的方法getTsRule
import baseConfig, { baseDir, getTsRule } from './base';

const serverBaseConfig: webpack.Configuration = cloneDeep(baseConfig); // 服务端全环境公共配置

serverBaseConfig.entry = { // 入口属性配置
  'server-bundle': [
    './src/server/bundle.tsx',
  ],
};
serverBaseConfig.externals = [nodeExternals()],
serverBaseConfig.node = {
  __dirname: true,
  __filename: true,
};
serverBaseConfig.target = 'node';
serverBaseConfig.output.libraryTarget = 'commonjs2';

const serverDevConfig: webpack.Configuration = cloneDeep(serverBaseConfig); // 服务端开发环境配置

serverDevConfig.cache = false; // 禁用缓存
serverDevConfig.output.filename = '[name].js'; // 使用源文件名作为打包后文件名
(serverDevConfig.module as webpack.NewModule).rules.push(
  getTsRule('./src/webpack/tsconfig.server.json'),
);
serverDevConfig.plugins.push(
  new webpack.NoEmitOnErrorsPlugin(), // 编译出错时跳过输出阶段,以保证输出的资源不包含错误。
);

const serverProdConfig: webpack.Configuration = cloneDeep(serverBaseConfig); // 服务端生产环境配置

// TODO 服务端生产环境配置暂不处理和使用

export default {
  development: serverDevConfig,
  production: serverProdConfig,
};

疑问一:webpack-node-externals是干啥用的?

答:该库的作用是让webpack忽略node_modules里的库,避免将他们打包到输出文件中去。

疑问二:target为何要设置为node?

答:这是为了让webpack打包时忽略node内建的库,比如fs。

疑问三:配置的node属性设置__dirname和__filename为true是什么意思?

答:这是为了让webpack使用真实的相对当前上下文的路径,可以避免打包出的文件里路径错误。简单点说就是在源文件里使用__dirname,在打包后这个__dirname会被替换为源文件的相对路径值,而不是打包输出的文件的相对路径值。

疑问四:libraryTarget设置为commonjs2是什么意思?

答:将入口起点的返回值将分配给 module.exports 对象,参见官方文档详解:output-librarytarget

服务端TypeScript配置文件

相较客户端配置,服务端需要多include一个入口文件即bundle.tsx

// ./src/webpack/tsconfig.server.json

{
  "compilerOptions": {
    "target": "es5",
    "jsx": "react"
  },
  "include": [
    "../../src/client/**/*",
    "../../src/server/bundle.tsx"
  ]
}

服务端webpack执行时机

目前我们准备好了服务端的webpack配置文件,现在要选择一个时机将其执行,那就在客户端webpack打包完毕之后吧,这样在一起有序的执行也好管理哈。

// ./src/webpack/webpack-dev-server.ts

...
import webpackServerConfig from './server';

export default (app: Koa, serverCompilerDone) => {
  const clientDevConfig = webpackClientConfig.development;
  const serverDevConfig = webpackServerConfig.development;
  const clientCompiler = webpack(clientDevConfig);
  clientCompiler.plugin('done', () => {
    const serverCompiler = webpack(serverDevConfig);
    serverCompiler.plugin('done', serverCompilerDone);
    serverCompiler.run((err, stats) => {
      if (err) {
        console.error(stats);
      }
    });
  });
  ...
};

我们通过complier.plugin方法,来实现打包完成后的回调操作,我们改造了webpack-dev-server.ts输出的函数,接收第二个参数作为服务端webpack打包完成后的回调函数。

引用服务端打包输出的bundle文件

// ./src/server/index.ts

...
let bundle;
const bundleFile = path.join(__dirname, '../../bundle/server-bundle.js');
...
if (isDev) {
  webpackDevServer(app, () => {
    delete require.cache[require.resolve(bundleFile)];
    bundle = require(bundleFile).default;
  }); // 仅在开发环境使用
}
...

我们定义bundle变量用于接收server-bundle.js的输出结果,也就是我们上面提到的拥有一个render方法的对象。由于node的require缓存机制,所以我们每次打包完server-bundle.js后都需要先删除缓存,再给bundle赋值。

疑问五:require.cache的键值为何要使用require.resolve包裹文件名?

答:require源码在进行缓存时以绝对路径(使用其内部resolve方法获得)为key,所以这里需要包裹一下以获得真实的key。

疑问六:bundle为何是require(bundleFile)的default值?

答:因为bundle.tsx输出的就是default,export default xxx相当于exports.default = xxx。

小结

虽然在上述第二种方法里,我们没有实际引入样式、图片等文件,但是这个操作我想应该不难,加一个对应的loader(file-loader, css-loader等)即可实现。在写这篇文章之前,上述第二种方法里关于bundle的动态更新方法我一直是参考使用vue里的create-bundle-runner(利用vm实现自己的require),写文章的时候发现其实我目前的应用场景并没有那么复杂,效率性能也没有那么高要求,所以就使用了原生的require方法来实现。
参见:vuejs:create-bundle-runner

Thanks

By devlee

相关推荐