同构应用是指写一份代码但可同时在浏览器和服务器中运行的应用。
认识同构应用
大多数单页应用的视图都是通过JavaScript代码在浏览器端渲染出来,但是在浏览器端渲染的坏处有:
搜索引起无法收录你的网页,因为展示的数据都是在浏览器端异步渲染出来的,大多数爬虫无法获取到这些数据。对于复杂的单页应用,渲染过程计算量大,对低端移动设备来说可能有性能问题,用户能明显感知首屏的渲染延迟。
同构应用运行原理的核心在于虚拟DOM, 虚拟DOM的优点在于:
- 因为操作 DOM 树是高耗时的操作,尽量减少 DOM 树操作能优化网页性能。而 DOM Diff 算法能找出2个不同 Object 的最小差异,得出最小 DOM 操作;
- 虚拟DOM的在渲染的时候不仅仅可以通过操作DOM树来表示结果,也能有其他的表示方法。例如虚拟DOM渲染成字符串(服务器渲染)等。
以react为例子,核心模块react负责管理react组件的生命周期,而具体的渲染工作可以交给react-dom模块来负责。
react-dom在渲染虚拟dom树时有2种方式可选:
- 通过render()函数去操作浏览器DOM树来展示出结果;
- 通过renderToString()计算出表示虚拟DOM的HTML形式的字符串;
构建同构应用的最终目的是从一份项目源码中构建出2份JavaScript代码。一份用于在node环境中运行渲染出HTML。其中用于在node环境中运行的JavaScript代码需要注意:
- 不能包含浏览器环境提供的API;
- 不能包含css代码,因为服务端渲染的目的是渲染html内容, 渲染出css代码会增加额外的计算量,影响服务端渲染;
- 不能像用于浏览器环境的输出代码那样把node_modules里的第三方模块和nodejs原生模块打包进去,而是需要通过commonjs规范去引入这些模块。
- 需要通过commonjs规范导出一个渲染函数,以用于在HTTP服务器中执行这个渲染函数,渲染出HTML内容返回。
解决方案
由于要从一份源码构建出2份不同的代码,需要2份webpack配置文件分别与之对应。构建用于浏览器环境的配置和前面讲的没有差别,主要侧重讲如何构建用于服务端渲染的代码。
创建一个用于构建服务端渲染代码的配置文件webpack_server.config.js内容如下:
const path = require("path");
const nodeExternals = require("webpack-node-externals");
module.exports = {
//js执行入口文件
entry: "./main_server.js",
//为了不把nodejs内置模块打包进输出文件中,例如: fs net模块等;
target: "node",
//为了不把node_modeuls目录下的第三方模块打包进输出文件中
externals: [nodeExternals()],
output: {
//为了以commonjs2规范导出渲染函数,以给采用nodejs编写的HTTP服务调用
libraryTarget: "commonjs2",
//把最终可在nodejs运行的代码输出到一个bundle_server.js文件中
filename: "bundle_server.js",
//输出文件都到dist目录下
path: path.resolve(__dirname, "./dist")
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
exclude: path.resolve(__dirname, 'node_modules')
},
{
//css代码不能被打包进用于服务端的代码中去,忽略掉css文件
test: /\.css/,
use: ["ignore-loader"]
}
]
},
devtool: 'source-map'
}
以上代码有几个关键的地方,分别是:
1. target: 'node' 由于输出代码的运行环境是node,源码中依赖的node原生模块没必要打包进去;
2. externals: [nodeExternals()] webpack-node-externals的目的是为了防止node_modules目录下的第三方模块被打包进去,因为nodejs默认会去node_modules目录下去寻找和使用第三方模块。
3. {{test: /\.css/, use: ['ignore-loader']}忽略掉依赖的css文件,css会影响服务端渲染性能,又是做服务端渲染不重要的部分;
4. libraryTarget: 'commonjs2'以commonjs2规范导出渲染函数,以供给采用nodejs编写的http服务器代码调用。
为了最大限度的服用代码,需要调整目下目录结构:
把页面的根组件放到一个单独的文件AppComponent.js,该文件只能包含根组件的代码,不能包含渲染入口的代码,而且需要导出根组件以供给渲染入口调用。
import React, { Component } from 'react';
import "./main.css"
export class AppComponent extends Component {
render() {
return <h1>hello webpack</h1>
}
}
分别为不同环境的渲染入口写两份不同的文件,分别是用于浏览器端渲染DOM的main_brwser.js和用于服务端渲染HTML字符串的main_server.js文件。
main_browser.js文件内容如下:
import React from 'react'
import { render } from 'react-dom'
import { AppComponent } from './AppComponent'
//把根组件渲染到DOM树上
render(<AppComponent />, window.document.getElementById('app'))
main_server.js文件内容如下:
import React from 'react'
import { renderToString } from 'react-dom/server'
import { AppComponent } from './AppComponent'
//导出渲染函数, 以采用nodejs编写http服务器代码调用
export function render() {
// 把根组件渲染成 HTML 字符串
return renderToString(<AppComponent/>)
}
为了能把渲染的完整html文件通过http服务返回给请求端,还需要通过node启动一个http服务器,用express来实现http_server.js
const express = require('express')
const {render} = require('./dist/bundle_server')
const app = express()
// 调用构建出的 bundle_server.js 中暴露出的渲染函数,再拼接下 HTML 模版,形成完整的 HTML 文件
app.get('/', function (req, res) {
res.send(`
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app">${render()}</div>
<!--导入 Webpack 输出的用于浏览器端渲染的 JS 文件-->
<script src="./dist/bundle_browser.js"></script>
</body>
</html>
`);
});
// 其它请求路径返回对应的本地文件
app.use(express.static('.'));
app.listen(3000, function () {
console.log('app listening on port 3000!')
});
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。