YTMS底层技术解析

2021年09月09日 914次浏览

马骏(宁莆)

引文

互联网电商公司每天都有各种各样的促销活动,需要大量的活动页面来承载。每个页面虽然长的不一样,但模块大同小异,无非是图片、导航、各种商品列表。如果这些页面都要开发工程师来写肯定不现实。淘宝京东这些大型电商网站都有自己的建站工具。运营模块化搭建页面,前端开发只需要维护模块就行了。

海拍客自研的建站平台叫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能很好的支持公司业务。下一步我们将探索更多的可能性。