背景介紹
簽證工程原來(lái)使用的也是 node 服務(wù)端渲染的模式,只不過(guò)引用的前端資源在另一個(gè)工程里,node 工程和前端工程維護(hù)兩份代碼,node 工程需要進(jìn)行首屏渲染,給window.__INITIAL_STATE__
屬性賦值準(zhǔn)備好的初始 state宁否,加載前端資源時(shí),react 會(huì)拿 INITIAL_STATE 的數(shù)據(jù)生成虛擬 DOM缀遍,通過(guò) diff 算法判斷 DOM 結(jié)構(gòu)沒(méi)有變化的話即使用服務(wù)端的首屏渲染的頁(yè)面慕匠,并且將頁(yè)面的生命周期函數(shù)、DOM 節(jié)點(diǎn)的事件加入到服務(wù)端渲染的靜態(tài)頁(yè)面上域醇,也就是激活標(biāo)記台谊。
使用 egg 重構(gòu)簽證工程原理和上面的一致,只不過(guò)將原來(lái)的兩個(gè)工程用更合理的方式寫(xiě)在一起(visa_node 工程)譬挚。主要參考線上運(yùn)行的旅行星推官的代碼锅铅。最后實(shí)現(xiàn)了:
- 前后端使用同一份代碼,并且通過(guò)環(huán)境判斷處理了node 環(huán)境可能引用的瀏覽器環(huán)境的 window减宣、document 變量的文件(這些文件主要是頁(yè)面 require 進(jìn)的一些立即執(zhí)行的方法中包含了這些變量)盐须;
- 接口請(qǐng)求前后端統(tǒng)一使用 axios 庫(kù);
- 申請(qǐng)了 beta 機(jī)器和線上機(jī)器進(jìn)行部署漆腌。
下面會(huì)介紹一下服務(wù)端渲染贼邓、egg 框架阶冈、簽證 aggregate 頁(yè)面同構(gòu)核心方法。
ssr 介紹
什么是服務(wù)端渲染(SSR)塑径?
react女坑、vue 這些構(gòu)建客戶端應(yīng)用程序的框架,默認(rèn)情況下可以通過(guò) js 生成 DOM 并操作 DOM统舀。也可以將同一個(gè)組件在服務(wù)器端渲染為靜態(tài)的 HTML 字符串(比如
ReactDOMServer.renderToString
)匆骗,直接發(fā)送到瀏覽器,最后將這些靜態(tài)標(biāo)記“激活”為客戶端可交互的應(yīng)用程序誉简。這種服務(wù)端渲染的應(yīng)用程序也被稱為“同構(gòu)”绰筛,因?yàn)閼?yīng)用程序的大部分代碼都可以在服務(wù)器和客戶端上運(yùn)行。
為什么使用服務(wù)器端渲染 (SSR) 描融?
更好的 SEO、更快的首屏渲染衡蚂、便捷開(kāi)發(fā)(前端不需要配置 nginx窿克、代理,只需和后端定義好接口)
egg 框架介紹及簽證重構(gòu)
egg 是什么毛甲?
官網(wǎng)有詳細(xì)介紹年叮。
egg 框架是阿里開(kāi)源的一個(gè)服務(wù)于企業(yè)級(jí)的基礎(chǔ)框架,基于 koa 進(jìn)行二次開(kāi)發(fā)玻募,奉行「約定優(yōu)于配置」只损,即在 koa 框架的基礎(chǔ)上,基于一定的約定七咧,根據(jù)功能差異將代碼放到不同的目錄下管理跃惫,從而降低整體團(tuán)隊(duì)的溝通成本和開(kāi)發(fā)成本。
目錄結(jié)構(gòu)
visa_node
工程的主要的目錄結(jié)構(gòu):
以簽證工程的 aggregate 頁(yè)面為例艾栋,講一下 如何使一份代碼同時(shí)在服務(wù)端和前端運(yùn)行爆存。
router.js
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const {router, controller, middleware} = app;
router.get('/', controller.home.index);
router.get('/visanode/aggregate', controller.aggregate.index);
};
當(dāng)執(zhí)行 GET /
,controller 文件夾下的 home 文件里的 index 方法就會(huì)執(zhí)行蝗砾,url 匹配到 /visanode/aggregate
同理先较。支持目錄級(jí)聯(lián)訪問(wèn):${directoryName}.${fileName}.${functionName}
.
controller/aggregate.js
const Controller = require('egg').Controller;
// App 根節(jié)點(diǎn),Store 為最初始定義的那個(gè)悼粮,一般 state 為空對(duì)象
const {App, Store} = require('../../src/page/aggregate/index.js');
const {queryInit, fetchFilter} = require('../../src/page/aggregate/actions.js');
class aggregateController extends Controller {
async index() {
const {ctx} = this;
const {query} = ctx.request.query;
// 業(yè)務(wù)邏輯闲勺,Store dispatch action,準(zhǔn)備頁(yè)面首次渲染需要的數(shù)據(jù)
Store.dispatch(queryInit(query));
await Store.dispatch(fetchFilter(query));
// renderReactSSR 是在 helper 對(duì)象上擴(kuò)展的一個(gè)屬性扣猫,用于渲染頁(yè)面
await ctx.helper.renderReactSSR(
'aggregate.nj',
App,
Store,
`${query}簽證產(chǎn)品推薦`
);
}
}
module.exports = aggregateController;
app/extend/helper.js
/**
* React 服務(wù)端渲染
* @param {String} viewPath 視圖路徑
* @param {Object} component 組件
* @param {Object} store 數(shù)據(jù)源
* @param {String} title 標(biāo)題
* @param {Object} other 其他
* @return {Object} 視圖信息
*/
renderReactSSR(viewPath, component, store, title = '去哪兒網(wǎng)', other = {}) {
const reactDOM = ReactDOMServer.renderToString(
React.createElement(
Provider,
{store},
React.createElement(component)
)
);
return this.ctx.render(viewPath, {
title,
reactDOM,
initialState: JSON.stringify(store.getState()),
skString: global.skString,
...other
});
}
this.ctx.render(viewPath, option)
: 框架在 ctx 對(duì)象上提供了 render
方法菜循,返回值為 Promise ,render 方法會(huì)直接賦值給 ctx.body
申尤。 所以我們?cè)?controller 里渲染頁(yè)面的時(shí)候要這樣寫(xiě):
await ctx.helper.renderReactSSR(...);
view模板渲染
app/view/aggregate.nj
{% extends "./layout.html" %}
{% block header %}
<link rel="stylesheet" href="{{ ctx.loadManifest('aggregate.css') }}" />
{% endblock %}
{% block body %}
<div class="yo-root" id="app">{{ reactDOM | safe }}</div>
<script> window.__INITIAL_STATE__ = {{ initialState | safe }}; </script>
<script type="text/javascript" src="{{ ctx.loadManifest('vendor.js') }}"></script>
<script type="text/javascript" src="{{ ctx.loadManifest('aggregate.js') }}"></script>
{% endblock %}
ctx.loadManifest
加載的是前端使用 webpack 打包后的資源债朵。| safe
意思是將輸入到頁(yè)面的內(nèi)容通過(guò)一個(gè) safe 的過(guò)濾器轉(zhuǎn)譯一下子眶。window.__INITIAL_STATE__
存放的是 initialState
,前端渲染 DOM 的時(shí)候會(huì)使用這個(gè) state序芦。
src/page/aggregate/index.js
import Store from './store';
import hydrateToPage from 'util/hydrateToPage';
import React from 'react';
import Header from './components/header.js';
import List from './components/list.js';
import 'style/page/aggregate.scss';
require('./ui/immersive'); // 適配
const App = () => {
return (
<div className="g-wrap">
<Header />
<List />
</div>
);
};
export {App, Store}; // 導(dǎo)出的這兩個(gè)對(duì)象在 controller 里被引入臭杰,服務(wù)端渲染
hydrateToPage(App, Store); // 前端渲染方法
src/util/hydrateToPage.js
import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
import isNodeEnv from './isNodeEnv';
const thirdpartApp = isNodeEnv ? null : require('./thirdparty/thirdpartApp').default;
export default (Component, store) => {
if (isNodeEnv) {
return null; // 如果是 node 環(huán)境,運(yùn)行這個(gè)環(huán)境返回 null
}
// 有時(shí)候需要在所有頁(yè)面加額外的東西谚中,可以在這里加
if (thirdpartApp.isccb) {
document.body.className += ' ccb-bg';
}
ReactDOM.render(
<Provider store={store}>
<Component />
</Provider>,
document.getElementById('app')
);
};
webpack 里添加頁(yè)面入口文件:
entry: {
aggregate: './src/page/aggregate/index.js',
}
最后會(huì)在 prd
目錄下的 manifest.json
文件中生成下面的資源映射渴杆,view 模板渲染中引的便是這里的前端資源:
"aggregate.css": "http://q.dev.qunarzz.com:7013/prd/aggregate@dev.css",
"aggregate.js": "http://q.dev.qunarzz.com:7013/prd/aggregate@dev.js",
"vendor.js": "http://q.dev.qunarzz.com:7013/prd/vendor.bundle.js"
egg 內(nèi)置對(duì)象
本地開(kāi)發(fā)
package.json
"scripts": {
"dev": "export VISA_PORT=7012 && egg-bin dev --port 7012",
"dev-js": "webpack-dev-server --config webpack.config.dev.js --port 7013",
}
本地開(kāi)發(fā)只需運(yùn)行這兩個(gè)命令:
-
npm run dev-js
: 運(yùn)行前端 -
npm run dev
: 運(yùn)行后端
最后
“ Egg.js 為企業(yè)級(jí)框架和應(yīng)用而生,我們希望由 Egg.js 孕育出更多上層框架宪塔,幫助開(kāi)發(fā)團(tuán)隊(duì)和開(kāi)發(fā)人員降低開(kāi)發(fā)和維護(hù)成本 ”