前言
上文讲到使用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
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。