同構是指同開發一個能夠跑在不一樣的平臺上的程序。例如開發一段 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 加到請求頭中去。