马骏(宁莆)
引文
互联网电商公司每天都有各种各样的促销活动,需要大量的活动页面来承载。每个页面虽然长的不一样,但模块大同小异,无非是图片、导航、各种商品列表。如果这些页面都要开发工程师来写肯定不现实。淘宝京东这些大型电商网站都有自己的建站工具。运营模块化搭建页面,前端开发只需要维护模块就行了。
海拍客自研的建站平台叫YTMS,Y取自洋驼首字母,TMS意思为模板建站系统。从2018年上线至今,已经支持了公司的上千个促销活动和主频道活动。
在没有YTMS以前,我们只有一个模板活动工具,运营在上面新建一个活动产生一个活动id。前端通过这个活动id返回的楼层数据渲染页面。所有模块不管是否有用到,都会在用户的设备里运行。YTMS解决了这个问题,把每个页面按需打包发布到CDN,大幅提升内容的丰富程度。
作为开发方,本文将从多个角度,解析系统用到的各种底层技术。
架构
系统架构
YTMS服务端选用EGGJS开发框架,使用antd开发用户界面。部署在公司的内部k8s集群。
用YTMS生成的静态页面部署在阿里云OSS。
YTMS架构图
页面访问
在YTMS设计中,页面分为PC和H5。对应的模板/模块代码分别有2套,页面发布后在oss也会有2个文件。用户的访问经过Nginx的UserAgent判断,显示适配对应设备的内容。
页面访问逻辑流程
技术架构
在公司内部,YTMS通过一系列的工具调用公司内部Dubbo/RocketMQ服务和阿里云的公共服务。
技术架构图
渲染原理
作为一个建站系统,模块化搭建页面是最基础的功能。YTMS渲染层基于React设计。采用1容器+N模块的方式组合页面。容器提供React上下文,每一个模块就是一个独立的React Component。
在浏览器渲染阶段,页面先加载模块的代码,再根据楼层信息把用户填写的表单数据填充进楼层。
YTMS组合架构
模块代码
在YTMS中,每一个模块都是独立的,模块的名称根据规则生成对应的React Component和less样式。
例如一个名为 hello-world 的模块,他的jsx代码会是这样:
// 用户写的YTMS模块
function HelloWorld(props) {
console.log(props.data);
return (
<div className="hello-world">Hello World</div>
);
}
在渲染阶段,YTMS会给该模块包一层匿名函数,用于隔离一些公共变量。
上面的代码就会变成
// YTMS包装以后的模块
const HelloWorld = function() {
// 插入一些公共变量和方法, 例如:
// const { yyy } = requireScript('xxx');
function HelloWorld(props) {
console.log(props.data);
return (
<div className="hello-world">Hello World</div>
);
}
return HelloWorld;
}();
这样,在渲染楼层时候,直接调用React.createElement方法,就能生成楼层。
// 楼层容器
function loadPage() {
const containerElements = React.createElement(
React.Fragment,
null,
// 楼层1
React.createElement(HelloWorld, formData1),
// 楼层2
React.createElement(HelloWorld, formData2),
// 楼层3
React.createElement(HelloWorld, formData3),
// 楼层4
React.createElement(HelloWorld, formData4),
);
// 等同于
<>
<HelloWorld data={formData1} />
<HelloWorld data={formData2} />
<HelloWorld data={formData3} />
<HelloWorld data={formData4} />
</>
// 把楼层渲染在页面上
ReactDOM.render(containerElements, document.getElementById('J_container'));
}
楼层数据
楼层数据分为基础数据和表单数据两块。基础数据用于描述模块的名称信息,表单数据为用户输入的数据。
一个典型的YTMS楼层大致如下:
const storeyData1 = {
"sid": 123,
"suuid": "0c2433df",
"storeyId": 666,
"title": "我是一个楼层",
"name": "hello-world",
"moduleVersion": 1,
"useSplitLine": false,
"moduleData": {
// 用户表单内容
"foo": "bar",
"hello": "world",
// 楼层的基础字段
"$root": {
"taPageId": null,
"sid": "0c2433df",
"order": 0,
"pageAgentType": "ALL",
"storeyId": 999,
"spot": "6.9.123.999",
"useSplitLine": false
}
}
};
实例化楼层的时候,传入moduleData字段。其中$root字段为系统生成每个楼层都有,模块可以知道自己所在的楼层基础信息。foo: 'bar'和hello: 'world'为用户输入的表单数据。
<HelloWorld data={storeyData1.moduleData} />
服务端渲染
发布页面的时候,YTMS会将模块做一次服务端渲染,提升页面初次加载速度。
具体的做法是先把每个楼层做一次服务端渲染,把HTML DOM字符串插入容器document.getElementById('J_container'),保持结构与后续React浏览器端渲染的一致。
由于渲染需要在服务端把模块代码执行一遍,得用NodeJS的vm模块做一个沙箱。
在沙箱中还要传入一些全局变量,模拟浏览器的环境。
// NodeJS端
const _Window = require('window');
const React = require('react'); // React对象
const window = new _Window(); // 基础的Window对象
const { renderToString } = require('react-dom/server'); // ssr方法
// 沙箱全局变量
const sandboxContext = {
window,
React,
renderToString,
reactElementStr: null, // renderToString返回的结果会赋给这个变量
};
// 模块代码数组
const moduleJs: string[] = ctx.getModuleJS();
// 执行脚本
const vmScript = `${moduleJs.join('\r\n')}
reactElementStr = renderToString(${reactComponentCode});
`
// 构建沙箱环境
VM.createContext(sandboxContext);
// 执行沙箱代码
VM.runInContext(vmScript, sandboxContext);
try {
return sandbox.reactElementStr as string;
} catch (err) {
this.ctx.logger.error(err);
return `页面代码出错,请联系前端同学`;
}
代码合并
YTMS的模板和模块分为模板代码、样式、用户表单配置三个文件,通过编译和填充后在模板template.html文件里组合,最终生成完整的html页面。
模块编译结构 (2)
组件
YTMS的模块之间是平级的,一些公用函数如React、lodash、babelPolyfill、http请求等组件,都是将umd打包上传cdn,通过外链引入。这些代码无法被服务端渲染执行,因为沙箱里只有一个模拟的window全局对象。
YTMS提供了一个函数requireScript,他实现了CommonJS规范,能让从CDN获取的代码,参与服务端渲染。
组件模块有几个限制:
默认有全局函数React和ReactDOM
不能使用require再加载其他代码
实现方式
组件代码
假设有一个React Component,渲染出
<div>test success</div>
用webpack或者rollup编译成es5的CommonJS包,上传到CDN。
// 一个简单的组件
function RequireDemo() {
return React.createElement("div", null, "test success");
}
export {
RequireDemo,
}
模块代码
// 用户写的YTMS模块
const { RequireDemo } = requireScript('/asset/ninpu/ytms-require-demo/1.0.0/index.js');
function HelloWorld(props) {
return (
<div className="hello-world">
<RequireDemo />
</div>
);
}
在服务端和浏览器端,YTMS都实现了一个require方法。实现的思路是读取模块代码字符串,用new Function()讲字符串转换为js方法。
在服务端编译阶段,上面的模块代码会被转换成
// 经过YTMS包装过以后的实际模块
const HelloWorld = function(){
const { RequireDemo } = requireScript('/asset/ninpu/ytms-require-demo/1.0.0/index.js');
function HelloWorld(props) {
return (
<div className="hello-world">
<RequireDemo />
</div>
);
}
return HelloWorld;
}();
获取组件地址
加载组件得先获取组件地址,为了拿到它比较方便的一个方法就是把requireScript方法执行一遍。这里用vm搭建一个沙箱环境,在全局变量中加入假的requireScript方法,在执行的时候拿到入参返回给modulePath。
// 服务端
function getModuleRequireDependencies(inputCode: string) {
const _Window = require('window');
const React = require('react'); // React对象
const window = new _Window(); // 基础的Window对象
// 引入组件的CDN地址储存在这里
const modulePath: Array<string> = [];
// 代理requireScript
const requireScript = (id: string) => {
modulePath.push(id);
// 返回Proxy,防止运行的时候出错卡住
return new Proxy({}, {
get: () => {
// requireScript的返回值可能会被结构,所以得保证有个空函数
return () => {}
},
});
}
const sandboxContext = {
window,
React,
requireScript,
};
VM.createContext(sandboxContext);
VM.runInContext(inputCode, sandboxContext);
return modulePath;
}
加载组件代码
加载组件代码需要实现CommonJS的require方法。
首先把组件代码的文本实例化为Function。在YTMS里处理比较简单,把代码打印到页面上<script>
标签里就行了。
// 浏览器端
// 组件代码
var _DepsFun0 = (function (exports, module) {xxx});
var _DepsFun1 = (function (exports, module) {yyy});
// 代码方法和组件地址的对应
var $_ScriptMap = {
"/asset/ninpu/ytms-require-demo/1.0.0/index.js": $_DepsFun0,
"/asset/ninpu/another-component/1.0.0/index.js": $_DepsFun1,
};
// 简易的module对象
function $YtmsDepModule(id) {
this.id = id;
this.exports = {};
}
function requireScript(id){
var module = new $YtmsDepModule(id);
var fn = $_ScriptMap[module.id];
fn.call(this, module.exports, module)
return module.exports;
}
代码存储
YTMS模板/模块代码分为两层储存方式。主代码放使用git维护在公司的代码仓库。开发人员每次修改模块后从主干或分支生成代码快照存在YTMS数据库中。
YTMS用快照渲染页面,可加快编译速度,减少外部系统依赖。开发人员通过git管理代码保证代码安全,并用git做codereview和代码扫描。
模板_模块代码储存结构
性能优化
服务端性能优化
在用户的使用过程中我们发现,预览页面的时候内容返回的速度特别慢。原因是YTMS数据库中储存的的代码快照是编译前的版本,每次运营修改内容并预览页面的时候,模板和模块代码都要经过编译和渲染步骤。如果模块数量多处理的时间就非常的长。
然而在正式环境中,模板/模块的代码改动远没有用户数据改动频繁,所以节省编译开销的收益会比较高。我们选择用Redis缓存编译后的代码。
Redis缓存
我们把这个逻辑放在编译代码的方法里,先将代码哈希处理获得md5值,用作redis的key,给它设置一个30天的缓存。在key中设置一个major_version作为强制刷新缓存的手段。
const token = react_code_${code_md5}_${major_version};
redis.set(token, compiledCode, 'EX', 60 * 60 * 24 * 30);
// 服务端
import * as babel from '@babel/core';
const compile = async (code: string) => {
const hash = crypto.createHash('md5');
hash.update(code);
const token = `react_code_${hash.digest('hex')}_1`;
const redis = this.ctx.app.redis;
let cacheCode = await redis.get(token);
if (!cacheCode) {
const babelCode = babel.transform(code, {
filename: 'index.js',
presets: [
"@babel/preset-env",
"@babel/preset-react",
],
plugins: [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-strict-mode",
],
});
cacheCode = babelCode.code;
}
redis.set(token, cacheCode, 'EX', 60 * 60 * 24 * 30); // 缓存30天
return cacheCode;
}
总结
本文主要介绍了YTMS管理模块和加载代码的原理。YTMS在这几年不停迭代,为前端团队的NodeJS实践提供了丰富的案例,证明了NodeJS能很好的支持公司业务。下一步我们将探索更多的可能性。