同构是指同开发一个能够跑在不一样的平台上的程序。例如开发一段 js 代码能够同时被基于 node.js 开发的 web server 和浏览器使用。本文中咱们就要聊聊这种场景下,为何以及怎么样开发一个同构的 web 应用。html
咱们不会无缘无故地作出任何决策,你们使用同构确定是由于同构可以带来一些好处:前端
SSRvue
SSR 固然不是必须经过同构来实现的,但使用同构来实现 SSR 能够减小大量重复的代码开发。node
减小由于先后端使用两份代码同时维护一份逻辑而出错的可能性react
我没有想到比同构能更好地解决这个问题的方案了。webpack
在 SSR 是必需的时候,我感受同构仍是有必要的。ios
所以我以为一个比较良好的方案是开发一个支持同构的应用,但不强制使用 SSR,由于 SSR 带来必定的性能浪费。git
例子代码仓库地址。
启动例子的同时阅读下面的段落体验会更好。例子中使用了 express 做为 web server 框架,所以读者如有一些 express 基础会更容易理解例子。github
最简单的方式就是经过 window 对象的存在与否来判断当前的代码执行环境,只有在浏览器执行环境 window 对象才存在web
const isBrowser = typeof window !== 'undefined';
复制代码
一个同构的组件,它的生命周期在服务端和客户端的执行状况是不一样的。在 mount 操做前的生命周期能够跑在服务端。
beforeMount 和 render 生命周期 在服务端和客户端都会被执行,所以这两个生命周期内也不该该写平台相关的代码。
下面是此次 demo 使用的同构组件基类:
// ./lib/Component.js
const {isBrowser} = require('../utils');
module.exports = class Component {
constructor (props = {}, options) {
this.props = props;
this.options = options;
this.beforeMount();
if (isBrowser) {
// 浏览器端才执行的生命周期
this.options.mount.innerHTML = this.render();
this.mounted();
this.bind();
}
}
// 生命周期
async preFetch() {}
// 生命周期
beforeMount() {}
// 生命周期
mounted() {}
// 绑定事件时使用
bind() {}
// 从新渲染时调用
setState() {
this.options.mount.innerHTML = this.render();
this.bind();
}
render() {
return '';
}
};
复制代码
全部的业务组件都继承这个基类,例如一个实际业务组件以下:
// ./pages/index.js
const Component = require('../lib/Component');
module.exports = class Index extends Component {
render() {
return `
<h1>我是首页</h1>
<a href="/list">列表页</a>
`;
}
}
复制代码
启动例子后能够访问 http://localhost:3000/ 来访问这个页面,能够观察一下 SSR 的状况。
一个简单的 ServerRenderer 实现以下:
// ./lib/ServerRenderer.js
const path = require('path');
const fs = require('fs');
module.exports = async (mod) => {
// 获取组件
const Component = require(path.resolve(__dirname, '../', mod));
// 获取页面模板
const template = fs.readFileSync(path.resolve(__dirname, '../index.html'), 'utf8');
// 初始化业务组件
const com = new Component()
// 数据预取
await com.preFetch();
// 将组件渲染的字符串输出到页面模板
return template.replace(
'<!-- ssr -->',
com.render() +
// 把后端获取的数据放到全局变量中供前端代码初始化
'<script>window.__initial_props__ = ' +
JSON.stringify(com.props) +
'</script>'
)
// 替换插入静态资源标签
.replace('${modName}', mod);
}
复制代码
此次 demo 的全部页面都使用同一份 html 模板:
<!-- ./index.html -->
<html>
<head>
<title>test</title>
</head>
<body>
<div id="app">
<!-- 下面是 ssr 渲染后内容填充的占位符 -->
<!-- ssr -->
</div>
<!-- 插入实际业务的前端 js 代码 -->
<script src="http://localhost:9000/build/${modName}"></script>
</body>
</html>
复制代码
这次的 demo 是基于 express 框架开发的,下面的代码使用 ServerRenderer 渲染同构的组件,而后输出 页面 html 给浏览器。
// ./routes/index.js
var express = require('express');
var router = express.Router();
var ServerRenderer = require('../lib/ServerRenderer');
router.get('/', function(req, res, next) {
ServerRenderer('pages/index.js').then((html) => {
res.send(html);
});
});
复制代码
demo 中的 ClientLoader 是一个 webpack loader,该 loader 代码以下:
// ./lib/ClientLoader.js
module.exports = function(source) {
return `
${source}
// 入口文件 export 的是主组件
const Com = module.exports;
// 获取后端渲染时使用的初始状态 window.__initial_props__,保证先后端渲染结果一致。
new Com(window.__initial_props__, {
mount: document.querySelector('#app')
});
`;
};
复制代码
在 webpack.config.js 中使用这个插件(仅做用于页面入口组件)
// webpack.config.js
module.exports = {
...,
module: {
rules: [
...,
{
test: /pages\/.+\.js$/,
use: [
{loader: path.resolve(__dirname, './lib/ClientLoader.js')}
]
}
],
}
};
复制代码
有了以上的基础之后,咱们能够轻易的写一个支持同构的组件。下面是一个列表页。
// ./routes/index.js
/* GET list page. */
router.get('/list', function(req, res, next) {
ServerRenderer('pages/list.js').then((html) => {
res.send(html);
});
});
// ./pages/list.js
const Component = require('../lib/Component');
const {
getList,
addToList
} = require('../api/list.api');
module.exports = class Index extends Component {
constructor (props, options) {
super(props, options);
}
// 服务端执行,预取列表数据
async preFetch() {
await this.getList();
}
async getList() {
const list = (await getList()).data;
this.props.list = list;
}
...,
render() {
return `
<h1>我是列表页</h1>
<button class="add-btn">add</button>
<button class="save-btn">save</button>
<ul>
${
this.props.list.length ?
this.props.list.map((val, index) => `
<li>
${val.name}
<button class="del-btn">删除</button>
</li>
`).join('') :
'列表为空'
}
</ul>
`;
}
}
复制代码
若已启动 demo 服务器,访问 http://localhost:3000/list 能够看到服务端的渲染结果以下。window.__initial_props__
的存在保证先后端渲染的结果一致。
<html>
<head>
<title>test</title>
</head>
<body>
<div id="app">
<h1>我是列表页</h1>
<button class="add-btn">add</button>
<button class="save-btn">save</button>
<ul>
<!-- 服务端渲染预取的列表数据 -->
<li>
1
<button class="del-btn">删除</button>
</li>
<li>
2
<button class="del-btn">删除</button>
</li>
<li>
3
<button class="del-btn">删除</button>
</li>
<li>
4
<button class="del-btn">删除</button>
</li>
</ul>
<script>
// 预取的列表数据, 用来客户端渲染
// 客户端第一次渲染和服务端渲染结果相同,所以用户看不到客户端渲染的效果。
window.__initial_props__ = {
"list": [{
"name": 1
}, {
"name": 2
}, {
"name": 3
}, {
"name": 4
}]
}
</script>
</div>
<script src="http://localhost:9000/build/pages/list.js"></script>
</body>
</html>
复制代码
比起多页面应用,单页面应用须要多同构前端路由的部分。
初始化组件时带上路由信息:
// ./lib/ServerRenderer.js
module.exports = async (mod, url) => {
...
// 初始化业务组件
const com = new Component({
url
});
...
}
复制代码
书写 controller 时把路由输入 ServerRenderer:
/* GET single page. */
router.get('/single/:type', function(req, res, next) {
ServerRender('pages/single.js', req.url).then((html) => {
res.send(html);
});
});
复制代码
下面是一个单页应用组件,点击切换按钮就能够纯前端的切换路由并改变视图:
// ./pages/single.js
const Component = require('../lib/Component');
module.exports = class Index extends Component {
switchUrl() {
const isYou = this.props.url === '/single/you';
const newUrl = `/single/${isYou ? 'me' : 'you'}`;
this.props.url = newUrl;
window.history.pushState({}, 'hahha', newUrl);
this.setState();
}
bind() {
this.options.mount.getElementsByClassName('switch-btn')[0].onclick = this.switchUrl.bind(this);
}
render() {
;
return `
<h1>${this.props.url}</h1>
<button class="switch-btn">切换</button>
`;
}
}
复制代码
访问 /single/you
服务端返回的内容为:
<html>
<head>
<title>test</title>
</head>
<body>
<div id="app">
<h1>/single/you</h1>
<button class="switch-btn">切换</button>
<script>
window.__initial_props__ = {
"url": "/single/you"
}
</script>
</div>
<script src="http://localhost:9000/build/pages/single.js"></script>
</body>
</html>
复制代码
公共状态管理的同构和组件的 props 同构其实很是相似,都须要把后端预取数据之后的整棵状态树渲染到页面上而后前端初始化状态管理器 store 的时候使用这棵树来作为初始状态,以此来保证前端渲染的结果和后端一致。
上面使用的 demo 中使用的 httpClient 是 axios,这个库自己就已经支持同构。但仍是有一个问题,这个问题以前也提到过。
当涉及到会话相关的请求时,通常状况下浏览器发送请求时会带上 cookie 信息,可是服务端发起的请求并不会。所以,服务端发起请求时,须要手动地把 cookie 加到请求头中去。