从零开始构建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框架初始化完毕之后再进行,所以对比两种情况,客户端渲染时白屏时间会更长一些,且刷新页面时会有闪烁的感觉。
利用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