Moon island architecture - 动态组件解决方案

622 阅读9分钟

背景

现在市面上有许多的低代码解决方案,但这些方案往往是偏向于构建一整个站点并且单一组件库的,没有从太多偏向于面向已有项目的多组件库的解决方案。

  • 面向已有站点的问题有的人会说将页面或者模块用 iframe 嵌入,但想想如果用这种方案,那假如页面需要登录状态,那控制登录的上下文就必须通过一些比较 hack 的手段在页面间传递。
  • 多组件库则需要通过不同的组件 adapter 模式实现

好巧不巧最近搭建这样一套低代码解决方案,设计了一套 DSL(Domain Specific Language),手写了一个渲染引擎,正准备将这个模块做成一个动态加载模块供给各项目使用的时候,想了想还差一个重要的部分 — 组件库。

当然我们可以将一整个组件库交给使用方全数导入给到渲染引擎用于 Map 出组件,但这是一个比较暴力的做法,也不利于数据源的一致性。

作为在一家非常极客的公司上班的员工,导入整个组件库对我而言绝对只能是一个临时方案,主要基于以下几点考虑:

  • 项目的性能(我们应该尽量控制客户端包的体积大小)
  • 渲染模块的易用性(使用方不需要过多的再对渲染模块进行特殊加载配置)
  • 编辑的页面/模块以及生成运行时的一致性

既然已经有了上述的背景以及想法,渲染引擎以及 DSL 的存储都是一件非常简单的事情,组件注册中心需要如何提供一个可以基于 DSL 使用的组件自动下发组件模块的能力就成为了这个问题的核心要点了。

下图为理想状态下的实现架构:

Untitled.png

技术要点

那么要做这个页面渲染模块(Page renderer)以及组件注册中心(Component registry center),我认为有如下问题需要解决:

  • 如何将原本静态的组件映射关系变成动态?
  • 动态的组件如何与已经 bundle 的项目共享一个 React.js 实例?
  • 如何快速的响应生成组件模块的构建?

解决方案

如何动态查找、映射组件

如果你对 Low-code engine 有所了解,那你应该会知道一个页面从一个组件名称被定义到展示成一个实际的组件的过程是怎么样的,举个🌰:

现在有一份这样的简版的 DSL 定义:

{
    "node": "Button",
    "type": "primary",
    "children": "This is a button"
}

定义了一个 Button 组件 ,并给他传递了 typechildren 两个属性。

没有魔法!

那对应到实际组件的映射的做法就是最普通的字典查找:

import {Button, List} from 'antd'import {render} from 'low-code-render-engine';

const componentMapping = {
    Button: Button,
    List: List
}

render(dsl, componentMapping);

如上所述,那我们的页面渲染就被划分为 定义组件、DSL,交给渲染引擎渲染 两个阶段。

而通常情况下 DSL 都是一个嵌套递归的明确结构型数据格式

那么我们要提取出所使用的组件列表也就变得非常简单!

没错!只需要在组件字典定义前分析一下 DSL,并把相关的组件下载回来就好了。

import {FC} from 'react'
import {Button, List} from 'antd'import {render} from 'low-code-render-engine';

const extractComponentsFromDSL = (dsl): string[] => {
	// ... implements
	return ['Button']
}

const downloadComponents = (components): Promise<string, FC<any>> => {
	// ... implements
	return {
		Button
	}
}

downloadComponents(extractComponentFromDSL(dsl)).then((componentMapping) => {
	render(dsl, componentMapping);
})

通过上面这段代码,我们将整个 DSL 进行解析,并提前下载组件库,即可完成将原本的同步组件变成动态下载的。

当然这个方案显然不是最佳的,因为低代码我们需要经过 页面编辑设计、渲染呈现 2 个阶段

那我们完全可以在第一个阶段编辑设计保存后即快速将组件从 DSL 中提取出来并且生成好一个组件包下载。

如果我们这么做,似乎已经连第三个 如何快速的响应生成组件模块的构建 都解决了? 显然是不可取的,假如以这样的方案实施,那么将面临如下两个问题:

  • 第一个面临的问题则是存储成本,每保存一个模块的时候那么不单是 DSL 被存储了,组件模块也需要同样被存储一份
  • 第二个问题是当我们已经被应用到大量模块定义中的组件被更新或者 bug 修复的时候,则需要对大量的模块进行重新构建

那我们可以提前去做的其实也就是 extractComponentsFromDSL 这个阶段的事情了。

如何共享一个 React.js 实例

OK 上文已经告诉大家如何将组件库动态化了,那还有一个非常难处理的问题就是 downloadComponents

一般情况下,前端项目构建(build time)、运行(runtime)两个阶段后者可以知道前者的内容,反之则不行

试想一下,经过我们这么一轮改造之后,你的项目其实一开始并不知道你会引入这些组件模块,例如 Button 模块,只有等到 DSL 被下载的时候才会知道你会下载一个 Button 模块,那么原本我们基于打包工具做的许多事情就都不可实施了。

例如大家所熟知的: webpack 是如何帮你避免重复引用多个相同模块并共享同一个实例的问题。

如果你读过源码或者相关文章应该了解到: webpack 会帮你把所有引用的文件生成一个 绝对路径 映射表,如果被访问的模块已经加载过,那么将会直接读取原有模块,也就是经常会看到的 __webpack__require(10) 类似这样的代码。 但很可惜,这个阶段都是在 build time 做的,运行时无法提前让构建提前知道他要什么。

这也是 webpack 5 提出的 module federation 所需要面临以及解决的问题:依赖前置, module federation 需要用户在指定动态模块的时候顺便定义一下 share ,提前将需要分享的模块全部提取到一个独立的可被标记的列表中,那么在引用这些模块的时候就将 require 直接指向 share 字典中获取模块。

Untitled (1).png

基于这个原理,那我们的共享问题是不是也可以按这种模式解决呢? 答案是肯定的!

通常情况我们现在在浏览器中运行着的模块多以 UMD,AMD,CMD 标准打包,这种打包模式都有一个特点:模块的导入导出都是以一个 scope function 并对内传递如 CMD 标准的: function (require, exports, module) ,既然如此,那只相当于我们持有 require 方法的句柄就可以为所欲为了

1662816642.gif

那么我们就只需要提前把 ReactReact-DOM 引用进来,不就可以实现共享了嘛,等不及了,快 上车 上代码!

import * as React from "react";
import * as ReactDOM from "react-dom";

type RequireFN = (moduleName: string) => any;

type CJSFN = (
  exports: Record<any, any>,
  require: RequireFN,
  module: Record<any, any>
) => void;

const moduleMap: Record<string, any> = {
  react: React,
  "react-dom": ReactDOM,
};

const requireDynamic = (components: string[]): Promise<CJSFN> => {
	// ... return module fn
}

const fakeRequire = (moduleName: string) => {
  if (!moduleMap[moduleName]) {
    throw new Error("Missing external module in mapping");
  }

  return moduleMap[moduleName];
};

requireDynamic(["Button"])
  .then((fn) => {
    const module = {
      exports: {},
    };

    const exports = {};

    fn(exports, fakeRequire, module);

    return module.exports;
  })
  .then((components: Record<string, React.FC<any>>) => {
    ReactDOM.render(
        React.createElement(components.Button, { type: "primary" }, "Test button"),
        document.getElementById('root')
    );
  });

ok,到这里为止,只要我们的服务返回一个基于 commonjs 构建的模块包,我们就已经可以完美解决组件动态的问题了。

如何快速的响应生成组件模块的构建

对了,这里面还差一个部分:组件的动态构建。

我们知道如果基于 Webpack 也好,直接 js 也好,即使把所有模块都提前加载在内存中,每次动态进行解析、构建都仍然需要花费大量时间进行。

那这个问题岂不是无解?非也!

这不是近年来大肆基于 Rust 做了非常多的 js 工具生态嘛~ ESbuild 这么好的东西怎么能不拿来用呢!来得早不如来得巧这不就解决了嘛! Untitled (2).png

上硬菜!嘿嘿 😋 你说巧不巧,这 API 简直就是为我的场景而生的

const imports = createImports(components || []);

const result = await esbuild.build({
  bundle: true,
  format: "cjs",
  minify: true,
  write: false,
  stdin: {
    contents: imports,
    loader: "js",
    resolveDir: __dirname,
  },
  external: ["react", "react-dom"],
});

const jsContent = generateCJSWithCode(result.outputFiles.map((file) => file.text).join("\n"));

首先我们起一个简单的服务,并且设计一个这样的 API:GET /components?names=Button

上面代码中的 components 就是从这么一个 query params 中取回来的,再看看 createImports 干了什么

const createImports = (components: string[]) => {
  return components
    .map((name) => {
      const lowName = name.toLowerCase();
      return `export * from 'antd/es/${lowName}/index.js';
              export {default as ${name}} from 'antd/es/${lowName}/index.js';
      `;
    })
    .join("\n");
};

没错,组装一个导入导出文件,例如上面的代码以 Button 为例,生成完则变成

export * from 'antd/es/button/index.js'
export {default as Button} from 'antd/es/button/index.js'

ESbuild 拿到上述代码后,将文件解析、构建、打包到一起,这时候,我们标记的 external 在构建后的代码则变成 __toESM(require("react")) ,由于我们标记的文件 format 为 cjs ,从 ESbuild 文档这段中 我们可以得知,他默认上下文中存在这 3 个变量,那就好办了,我模仿当年的 requireJS 就完了呀~

💡 It assumes the environment contains `exports`, `require`, and `module`.
const generateCJSWithCode = (code: string) => {
  return `function (require, exports, module) {
        ${code}
    }`;
}; 

嘿嘿,那到这里为止这个问题不就都解决完了嘛~

等等!还有个东西,前面 requireDynamic 方法还没实现。想必细心的大家应该都会发现,如果按照上面这段代码返回,实际上这个模块看似返回了,但其实没办法被真的拿到这个模块中来的。

这个好办呀!可还记得 jsonp 的实现原理不:加 callback name 呀

const requireDynamic = (
  moduleName: string,
  components: string[]
): Promise<CJSFN> => {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = `/components?moduleName=${moduleName}&names=${components.join(
      ","
    )}`;

    script.addEventListener("load", () => {
      resolve(window[moduleName]);
    });

    document.body.appendChild(script);
  });
};

没错,我们只需要加上一个拿到他的办法就好了,同样的 generateCJSWithCode 的实现上也加上这个方法名称

const generateCJSWithCode = (code: string,moduleNames: string) => {
  return `var ${moduleName} = function (require, exports, module) {
        ${code}
    }`;
}; 

至此,整个动态加载的问题也就全都被完整的解决了。

总结

最终,我把这个解决方案命名为 Moon island architecture,寓意着像地球与月亮一样相互需要又相互独立 我也将项目代码开源到 Github 中。

经过上文中的一系列折腾,我们数一数一共运用了哪些知识点:

  • JS CMDUMDAMD 等相关的包模块规范
  • Webpack 的模块生成原理
  • Module Federation 的原理
  • ESbuild
  • 数据 运行、存储 的权衡选择
  • Low-code engine 的设计与解析
  • 系统架构设计

上述的技术点,相信阅读本文的你花费一些时间,你也可以完全的去掌握,并设计出一个更加好的方案。更加说要解决一个这样的问题的方案成千上万,我这个也不是最好最完美的,但作为一个极客的人,在一家极客的公司,我希望用一种更加极客的思想去解决我遇到的问题。

感谢你花了这么长时间阅读,更希望我的这篇文章能够帮助到你。

如你发现文章中有错误之处、或你也想做一些非常极客非常酷的事情,欢迎随时与我联系。