远程组件加载方案笔记
提示
阅读该篇你将了解到:
- 远程组件定义
- 什么是UMD模块化
- 远程加载组件方案实现思路
# 远程组件定义
所谓远程组件 , 这里指通过加载远程js资源并将其渲染成为组件,通常为常用的框架,vue/react组件
远程组件的加载流程通常包括:
- 开发人员编写组件,并打包导出成UMD格式(浏览器端)。
- 将打包好的文件上传至服务器端,并获取对应文件的网络路径。
- 通过获取的网络路径发起请求,并获取资源内容。
- 将组件利用 Vue 中的动态组件(或是vue.mount)或者 React 中 React.createElement 进行渲染。
对于远程组件而言,其具有动态性和不确定性。
动态性: 当组件需要更新时,可直接覆盖 JS 内容就可以实现动态更新 不确定性: 对于使用动态组件的主应用来说,它并不关心动态组件的内容长什么样子,它只需要将获取的组件资源渲染并传入自己配置好的属性即可。
# 代码嵌入
远程代码嵌入一个典型场景是扩展点能力。所谓的扩展点,是为了满足用户个性化诉求或者扩展一些能力,在自家产品上运行第三方 JavaScript 代码。例如:
● Figma 插件机制 (opens new window) ● 有赞扩展点 (opens new window)
# UMD 格式说明
UMD( Universal ModuleDefinition)作为一种同构( isomorphic)的模块化解决方案出现,它能够让我们只需要在一个地方定义模块内容,并同时兼容 AMD 和 Commons 语法。其原理其实就是根据 AMD 和 COMMONJS 模块化规范的特征,判断当前环境是符合哪种规范的特征,从而使用相应的语法来导出。
接下来我们借助vite来打包一个react组件生成umd模块文件,加深理解:
mkdir react-demo && cd react-demo
yarn init -y
yarn add vite -D
yarn add react
2
3
4
增加vite.config.js文件
const path = require("path");
const { defineConfig } = require("vite");
module.exports = defineConfig({
build: {
minify: false,
lib: {
formats: ["umd"],
entry: path.resolve(__dirname, "index.jsx"),
name: "MyComponent",
fileName: (format) => `component.${format}.js`,
},
rollupOptions: {
// 将不需要被打包的文件移除,改成采用主应用自带的依赖,可有效减小包体积
external: ["react"],
output: {
// 在UMD构建模式下为外部化的依赖提供全局变量
globals: {
react: "React",
},
},
},
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
增加index.jsx文件
import React from "react";
const Demo = () => {
return <div>demo</div>;
};
export default Demo;
2
3
4
5
6
7
进行打包
yarn vite build
我们在dist文件夹则会生一个component.umd.js文件,我们将其格式化可以看到
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory(require("react")))
: typeof define === "function" && define.amd
? define(["react"], factory)
: ((global =
typeof globalThis !== "undefined" ? globalThis : global || self),
(global.MyComponent = factory(global.React)));
})(this, function (React) {
"use strict";
const Demo = () => {
return /* @__PURE__ */ React.createElement("div", null, "demo...");
};
return Demo;
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
如果本地环境拥有exports或是module,则遵循commonjs模块化规范。如果有define.amd则使用amd模块化规范。否则判断是否有 globalThis 如果没有用 global或者 self,这里的 globalThis或者self 在浏览器环境下为 window。这里我们将MyComponent的组件函数挂载了global,也就是window下。
我们在根目录下再增加index.html文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script>
<script src="./dist/component.umd.js"></script>
</head>
<body>
<div id="app"></div>
<script>
console.log(window)
ReactDOM.render(React.createElement(window.MyComponent), document.getElementById('app'))
</script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
我们可以看到,组件成功渲染了。观察window变量,我们发现React对象、MyComponent函数都挂载在了window全局。我们可以得出结论 通过script标签注入的umd格式文件,会将内容挂载到window上,并从window上读取依赖
# 动态加载方案
动态加载js资源,通常有以下四种方式:
- 动态script载入:即获取链接后,动态创建一个 script ,拿到变量后再删除此 script
- eval方案:获取到需要运行的js字符串,将其转换成js并执行
- new Function + 沙箱方案
- 微组件
我们通常通过以下3点评判方案优劣:
简易轻便 | 沙箱能力(js\css隔离) | 兼容性 | |
---|---|---|---|
动态script载入 | ✅ | ❌ | ✅ |
eval方案 | ✅ | ❌ | ✅ |
new Function + 沙箱方案 | ❌ | ✅ | ✅ |
微组件 | ✅ | ✅ | ❌ |
# 动态 script 方案
动态 script 的方案很简单,就是创建一个script标签加载,在不用的时候就移除。平日开发可以使用requirejs\systemjs直接引入资源链接。
const importScript = (() => {
// 自执行函数,创建一个闭包,保存 cache 结果
const cache = {}
return (url) => {
// 如果有缓存,则直接返回缓存内容
if (cache[url]) return Promise.resolve(cache[url])
return new Promise((resolve, reject) => {
// 保存最后一个 window 属性 key
const lastWindowKey = Object.keys(window).pop()
// 创建 script
const script = document.createElement('script')
script.setAttribute('src', url)
document.head.appendChild(script)
// 监听加载完成事件
script.addEventListener('load', () => {
document.head.removeChild(script)
// 最后一个新增的 key,就是 umd 挂载的,可自行验证
const newLastWindowKey = Object.keys(window).pop()
// 获取到导出的组件
const res = lastWindowKey !== newLastWindowKey ? (window[newLastWindowKey]) : ({})
const Com = res.default ? res.default : res
cache[url] = Com
resolve(Com)
})
// 监听加载失败情况
script.addEventListener('error', (error) => {
reject(error)
})
})
}
})()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
这个时候,资源就加载并挂载在了window变量上。以react为例,这时我们可以为其创建一个自定义组件,来实现动态组件的渲染。
我们创建一个项目 (注意!项目基于React 17.0.1版本):
yarn create vite my-react-app --template react
创建utils/index.js文件,将上面importScript方法复制进来。
之前了解到umd.js会从全局window里获取依赖,这里我们在main.jsx中将react挂载在全局变量上以供动态组件使用
// main.jsx
import React from "react";
import ReactDOM from "react-dom";
import App from "./App.jsx";
window.React = React;
window.ReactDOM = ReactDOM;
ReactDOM.render(<App />, document.getElementById("root"));
2
3
4
5
6
7
8
9
我们增加一个自定义组件来渲染umdjs资源: 创建\componets\script-component.jsx文件
import { useState, useEffect } from "react";
import { importScript } from "../utils/index";
// eslint-disable-next-line react/prop-types
const UmdScriptComponent = ({ url, children, umdProps = {} }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [UmdCom, setUmdCom] = useState(null);
useEffect(() => {
if (!url) return;
importScript(url)
.then((Com) => {
// 这里需要注意的是,res 因为是组件,所以类型是 function
// 而如果直接 setUmdCom 可以接受函数或者值,如果直接传递 setUmdCom(Com),则内部会先执行这个函数,则会报错
// 所以值为函数的场景下,必须是 如下写法
setUmdCom(() => Com);
})
.catch(setError)
.finally(() => {
setLoading(false);
});
}, [url]);
if (!url) return null;
if (error) return <div>error!!!</div>;
if (loading) return <div>loading...</div>;
if (!UmdCom) return <div>加载失败,请检查</div>;
return <UmdCom {...umdProps}>{children}</UmdCom>;
};
export default UmdScriptComponent
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
接下来我们在main.jsx中引入并使用即可看到完整的项目实例。
import UmdScriptComponent from "./componets/UmdScriptComponent";
const App = () => {
return (
<div>
<UmdScriptComponent
url="https://unpkg.com/react-draggable@4.4.5/build/web/react-draggable.min.js"
umdProps={{
onDrag(e) {
console.log(e);
},
}}
>
<div
style={{ width: 100, height: 100, backgroundColor: "skyblue" }}
></div>
</UmdScriptComponent>
</div>
);
};
export default App;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 小结
我们看到script渲染的方案可以完成一个组件的异步渲染。我们常用的webpack也是采用了类似这样的形式,在主文件内通过Promise.all 和 jsonp 创建了一个动态的script标签来完成组件的加载和渲染。当然以上的丐版代码还是存在缺陷,由于不是沙箱环境,会对主应用环境下的变量带来污染。
# eval方案
eval方案即拿到可执行的js文本内容(字符串),将其转换成函数并执行。eval在严格模式下是无法使用的,而且有性能上的问题。
export const importScript = (() => {
const cache = {}
return (url) => {
if (cache[url]) return Promise.resolve(cache[url])
// 发起 get 请求
return fetch(url)
.then(response => response.text())
.then(text => {
// 记录最后一个 window 的属性
const lastWindowKey = Object.keys(window).pop()
// eval 执行
eval(text)
// 获取最新 key
const newLastWindowKey = Object.keys(window).pop()
const res = lastWindowKey !== newLastWindowKey ? (window[newLastWindowKey]) : ({})
const Com = res.default ? res.default : res
cache[url] = Com
return Com
})
}
})()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
该方案和script的区别在于这里通过异步请求获取资源后通过eval执行。同样的该方案有污染全局变量的风险。
# new Function + 沙箱
前面说到,严格模式不允许使用eval函数,在部分项目下存在风险,所以我们采用 new Function的形式进行加载。
eval与new Function的区别:
- 对于eval:代码执行时的作用域为当前作用域。它可以访问到函数中的局部变量。
- 对于new Function:代码执行时的作用域为全局作用域,不论它的在哪个地方调用的。所以它访问的是全局变量a。它根本无法访问b函数内的局部变量。
我们这里说的沙箱,只js、css隔离沙箱,具体的可看之前的js实现沙箱环境 (opens new window)一文,这里就不多赘述了。
// 通过new Function(/** js文本 */) 生成可执行函数
const fn = new Function(/** jsWenben */)
const fakeWindow = {}
const proxyWindow = new Proxy(window, {
// 获取属性
get(target, key) {
return target[key] || fakeWindow[key]
},
// 设置属性
set(target, key, value) {
return fakeWindow[key] = value
}
})
// with 通过包裹一个对象,增加一层作用域链,将with函数内的全局作用域代理到了fakeWindow上
with(fakeWindow) {
fn()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
我们可以大胆得出结论,通过了沙箱的函数方法不会全局对象造成污染。
# 微组件
微组件方案,我的理解,即拥有微前端的能力,即js/css隔离,无技术栈限制,能够实现数据互通等能力。当然,我们这里并不打算采用微前端生态的HTML Entry的能力,而是将其打包成上述所说的umd包,通过导出的形式来获取微组件(或是函数模块)的内容,以满足主应用与微组件之间可独立部署更新的需要(松耦合)。
说到微前端,那么人们首先会想到的就是沙箱和跨技术栈的能力了。他们的核心则是前几篇当中的主人公: WebComponent。而市场上基于WebComponent构建的微前端框架目前也是百花齐放,各有各的特点。例如:
- Single-spa (opens new window):最早的微前端框架,独立,跨技术栈,缺点是无通信,也无沙箱能力,无法预加载
- 字节开源的magic-microservices (opens new window):一款基于 Web Components 的轻量级的微前端工厂函数, 优点是小巧轻便,抹平了框架差异,可通信,缺点是无沙箱能力 -micro-app (opens new window):与 single-spa 和 qiankun 不同,其借鉴了 Web Components 的思想,将微前端封装成一个 Web Components 组件,从而实现微前端的组件化渲染。缺点是无法加载umd文件。
- EMP (opens new window): EMP是YY出品基于webpack5模块联邦实现各个项目中的模块相互异步引用。缺点也很明显,无法实现跨框架,各个项目之间必须统一webpack5,且依赖更新时有可能会对其他项目带来未知的影响。
为什么我们这里要举例这么多微前端框架呢?我们可以看到,我们想要的微组件的功能,其实微前端框架都能够做到,那么我们只需要去改造部分代码,即可完成我们想要的组件模块。
# 额外: JS Entry vs HTML Entry
在确定了运行时载入的方案后,另一个需要决策的点是,我们需要子应用提供什么形式的资源作为渲染入口?
JS Entry 的方式通常是子应用将资源打成一个 entry script,比如 single-spa 的 example 中的方式。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。
HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。想象一下这样一个场景:
<script src="//unpkg/antd.min.js"></script>
<body>
<main id="root"></main>
</body>
// 子应用入口
ReactDOM.render(<App/>, document.getElementById('root'))
2
3
4
5
6
如果是 JS Entry 方案,主框架需要在子应用加载之前构建好相应的容器节点(比如这里的 "#root" 节点),不然子应用加载时会因为找不到 container 报错。但问题在于,主应用并不能保证子应用使用的容器节点为某一特定标记元素。而 HTML Entry 的方案则天然能解决这一问题,保留子应用完整的环境上下文,从而确保子应用有良好的开发体验。
HTML Entry 方案下,主框架注册子应用的方式则变成:
framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})
本质上这里 HTML 充当的是应用静态资源表的角色,在某些场景下,我们也可以将 HTML Entry 的方案优化成 Config Entry,从而减少一次请求,如:
framework.registerApp('subApp1', { html: '', scripts: ['//abc.alipay.com/index.js'], css: ['//abc.alipay.com/index.css']})