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

前言

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

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

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

当咱们的组件不包含样式,图片等服务端没法直接解析处理的时候,咱们能够直接利用ts的tsc命令将组件编译成相应的js,服务端则能够直接运行该js获得渲染的结果,固然这种状况实际并不存在,这里只是做为例子来说解。前端

服务端bundle.tsx

咱们在server目录下新建bundle.tsx将其做为前端react组件的一个打包入口文件。咱们经过将它打包,并在服务端执行获得咱们须要的渲染结果。vue

// ./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字符串了。node

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

// ./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:...表明代码省略webpack

客户端/服务端渲染对比

在chrome中打开localhost:3344后能够看的页面上的hello world,咱们右键页面选择View Page Source,能够看到两种方法渲染的不一样:git

客户端渲染:
客户端渲染github

服务端渲染:
服务端渲染web

显而易见,服务端渲染会直接输出组件渲染的内容,浏览器在接收到这些内容后就会直接绘制呈现给咱们,而客户端渲染会在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

相关文章
相关标签/搜索