背景介绍
签证工程原来使用的也是 node 服务端渲染的模式,只不过引用的前端资源在另一个工程里,node 工程和前端工程维护两份代码,node 工程需要进行首屏渲染,给window.__INITIAL_STATE__
属性赋值准备好的初始 state,加载前端资源时,react 会拿 INITIAL_STATE 的数据生成虚拟 DOM,通过 diff 算法判断 DOM 结构没有变化的话即使用服务端的首屏渲染的页面,并且将页面的生命周期函数、DOM 节点的事件加入到服务端渲染的静态页面上,也就是激活标记。
使用 egg 重构签证工程原理和上面的一致,只不过将原来的两个工程用更合理的方式写在一起(visa_node 工程)。主要参考线上运行的旅行星推官的代码。最后实现了:
- 前后端使用同一份代码,并且通过环境判断处理了node 环境可能引用的浏览器环境的 window、document 变量的文件(这些文件主要是页面 require 进的一些立即执行的方法中包含了这些变量);
- 接口请求前后端统一使用 axios 库;
- 申请了 beta 机器和线上机器进行部署。
下面会介绍一下服务端渲染、egg 框架、签证 aggregate 页面同构核心方法。
ssr 介绍
什么是服务端渲染(SSR)?
react、vue 这些构建客户端应用程序的框架,默认情况下可以通过 js 生成 DOM 并操作 DOM。也可以将同一个组件在服务器端渲染为静态的 HTML 字符串(比如
ReactDOMServer.renderToString
),直接发送到浏览器,最后将这些静态标记“激活”为客户端可交互的应用程序。这种服务端渲染的应用程序也被称为“同构”,因为应用程序的大部分代码都可以在服务器和客户端上运行。
为什么使用服务器端渲染 (SSR) ?
更好的 SEO、更快的首屏渲染、便捷开发(前端不需要配置 nginx、代理,只需和后端定义好接口)
egg 框架介绍及签证重构
egg 是什么?
官网有详细介绍。
egg 框架是阿里开源的一个服务于企业级的基础框架,基于 koa 进行二次开发,奉行「约定优于配置」,即在 koa 框架的基础上,基于一定的约定,根据功能差异将代码放到不同的目录下管理,从而降低整体团队的沟通成本和开发成本。
目录结构
visa_node
工程的主要的目录结构:
以签证工程的 aggregate 页面为例,讲一下 如何使一份代码同时在服务端和前端运行。
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);
};
当执行 GET /
,controller 文件夹下的 home 文件里的 index 方法就会执行,url 匹配到 /visanode/aggregate
同理。支持目录级联访问:${directoryName}.${fileName}.${functionName}
.
controller/aggregate.js
const Controller = require('egg').Controller;
// App 根节点,Store 为最初始定义的那个,一般 state 为空对象
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;
// 业务逻辑,Store dispatch action,准备页面首次渲染需要的数据
Store.dispatch(queryInit(query));
await Store.dispatch(fetchFilter(query));
// renderReactSSR 是在 helper 对象上扩展的一个属性,用于渲染页面
await ctx.helper.renderReactSSR(
'aggregate.nj',
App,
Store,
`${query}签证产品推荐`
);
}
}
module.exports = aggregateController;
app/extend/helper.js
/**
* React 服务端渲染
* @param {String} viewPath 视图路径
* @param {Object} component 组件
* @param {Object} store 数据源
* @param {String} title 标题
* @param {Object} other 其他
* @return {Object} 视图信息
*/
renderReactSSR(viewPath, component, store, title = '去哪儿网', 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 对象上提供了 render
方法,返回值为 Promise ,render 方法会直接赋值给 ctx.body
。 所以我们在 controller 里渲染页面的时候要这样写:
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
意思是将输入到页面的内容通过一个 safe 的过滤器转译一下。window.__INITIAL_STATE__
存放的是 initialState
,前端渲染 DOM 的时候会使用这个 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}; // 导出的这两个对象在 controller 里被引入,服务端渲染
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 环境,运行这个环境返回 null
}
// 有时候需要在所有页面加额外的东西,可以在这里加
if (thirdpartApp.isccb) {
document.body.className += ' ccb-bg';
}
ReactDOM.render(
<Provider store={store}>
<Component />
</Provider>,
document.getElementById('app')
);
};
webpack 里添加页面入口文件:
entry: {
aggregate: './src/page/aggregate/index.js',
}
最后会在 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 内置对象
本地开发
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",
}
本地开发只需运行这两个命令:
-
npm run dev-js
: 运行前端 -
npm run dev
: 运行后端
最后
“ Egg.js 为企业级框架和应用而生,我们希望由 Egg.js 孕育出更多上层框架,帮助开发团队和开发人员降低开发和维护成本 ”
网友评论