看完本文你将学习到如下知识:
远程组件定义
UMD 模块规范
远程组件加载方案实现思路和细节
systemjs[1] 中
System.import
的丐版实现JS 沙箱能力丐版实现
远程组件,这里指的是加载远程 JS 资源并渲染成组件。
其整体流程应该是:
1、先有一个组件
2、将组件打包成 UMD
格式,可供浏览器使用(后面会介绍 UMD)
3、将其上传到某处
4、通过接口返回给客户端
5、客户端拿到链接后执行,获取导出内容(也就是 React、Vue 组件)
6、将组件利用 Vue 中的动态组件或者 React 中 React.createElement
进行渲染。
其中最核心的是第 5 点和第 6 点,加载远程组件并渲染内容,本文也将围绕如何加载提出一些解决方案供大家思考。
远程组件的应用场景主要有以下两个特点:
动态性(组件内容可动态更新)
不确定性(数量和单个组件具体内容是不确定的,而且主应用不关心)
其要和以下几个概念要区分开:
普通 UMD 方案:写在 index.html
中的通过 script
引入 UMD JS,类似 <script src='https://unpkg.com/antd@4.19.2/dist/antd.js'></script>
懒加载 import()
Vue 中的 <component is='xxx' />
动态组件
Webpack Module federation
关于第一个,它和动态组件很类似,但应用场景还是有很多区别的,总结如下:
普通 UMD 方案 | 动态组件 | |
---|---|---|
动态性 | ✅ | ✅ |
确定性 | 确定 | 不确定 |
一个链接导出单个/多个组件 | 多个组件 | 单个组件 |
如果你仅有一个组件,那完全用不上动态组件,用普通 UMD 方案即可; 你有多个组件,但是提前知道功能和数量,也不用到动态组件,用普通 UMD 方案即可; 只有当你的数量和内容不确定的时候才需要。
不处理:全部引入
静态分析:对每个用户的拖拽结果进行静态分析,然后形成每个用户自己的引入内容
按需加载:实现一套动态组件机制,仅当组件被使用到时再进行加载,加载后缓存
其中按需加载,就比较适合我们上面说的动态组件场景。
而且我们再回想一下应用场景:
动态性(当组件需要更新时,可直接覆盖 JS 内容就可以实现动态更新)
不确定性(对于主应用而言,其不知道用户会拖拽多少个组件以及每个组件长什么样,它只需要将用户拖拽的 JSON 数组进行循环遍历,并渲染,然后将配置的属性传递过去就可以了,具体到每个组件具体是长什么样其不关心)
远程代码嵌入一个典型场景是扩展点能力。所谓的扩展点,是为了满足用户个性化诉求或者扩展一些能力,在自家产品上运行第三方 JavaScript 代码。例如:
Figma 插件机制[2]
有赞扩展点[3]
用户自定义的逻辑肯定无法提前知道的,也无法在项目打包的时候就引入,所以需要动态组件的能力。
我们再回想一下应用场景:
动态性(此场景需要,用户扩展点有更新,可直接覆盖 JS 内容就可以实现动态更新)
不确定性(对于主应用而言,其不知道用户会有多少扩展点,以及每个扩展点会渲染成什么样,它只管拿到链接后进行渲染即可)
我们上面多次提到 UMD 模块规范,那什么是 UMD?如果对这个问题还不是很清楚,那你有必要了解一下,如果已十分清楚,可跳过。
UMD 模块规范是一种兼容浏览器全局变量、AMD 规范、CommonJS 规范的规范。
我们使用vite
将一个 React 组件打包为 UMD
格式来说明其运作方式。
mkdir react-demo && cd react-demo
yarn init -y
yarn add vite -D
yarn add react
增加 vite.config.js
文件,其内容如下:
react
排除,是因为每个 React 组件都需要这个包,如果都将其打进去就会导致包很大(也就是需要将公共依赖排除,并由主应用提供)。增加 index.jsx
,其内容如下:
import React from 'react'
const Demo = () => {
return <div>demo...</div>
}
export default Demo;
执行构建命令:
yarn vite build
如果有 export
和 module
变量,则表示在 nodejs 环境中,遵循 CommonJS[4] 规范
如果有 define
和 define.amd
变量,则表示用 [amd](https://github.com/amdjs/amdjs-api/wiki/AMD)
模块规范
否则判断是否有 [globalThis](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/globalThis)
如果没有用 global
或者 self
,这里的 globalThis
或者self
在浏览器环境下为 window
。
我们想要使用这个组件,可以创建 dist/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="./out.umd.js"></script>
</head>
<body>
<div id="app"></div>
<script>
console.log(window)
ReactDOM.render(React.createElement(window.MyLib), document.getElementById('app'))
</script>
</body>
</html>
我们看到界面已经可以正常渲染了:
然后我们观察 window
变量,也有我们挂载的 React
、ReactDOM
以及 MyLib
变量:
script
标签的方式使用,就是往 window
上挂载全局变量,并且会从 window
上读取依赖。整个动态组件最核心的地方就是执行 JS 并获取导出的内容,其目前识别到的有以下四种方案:
方案 1:动态 script
方案。即获取链接后,动态创建一个 script
,拿到变量后再删除此 script
方案 2:eval
方案。即通过链接获取到 JS 纯文本,然后再 eval
执行 JS
方案 3:new Function
+ sandbox
方案
方案 4:微组件
我们从以下三点评判方案的优劣势:
简单程度
运行时是否有沙箱能力(JS 沙箱和 CSS 隔离)
兼容性
足够简单 | 沙箱能力 | 兼容性 | |
---|---|---|---|
动态 script 方案 | ✅ | ❌ | ✅ |
eval 方案 | ✅ | ❌ | ✅ |
new Function + sandbox 方案 | ❌ | ✅ | ✅ |
微组件 | ✅ | ✅ | ❌ |
script
方案这个方案整体思路很简单,就是动态创建一个 script
,加载完成后再删掉(和 jsonp 类似)。
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)
})
})
}
})()
然后我们就可以用 React
或者 Vue
的动态组件进行渲染了。这里以 React
为例。
我们新建一个 React
项目:
yarn create vite my-react-app --template react
UMD
组件会从 window
上读取公共依赖,而我们将 React
作为了公共依赖,所以需要将其挂在到 window
上。然后我们需要增加 UmdComponent.jsx
,其逻辑为:
import { useState, useEffect } from 'react'
import { importScript } from './utils'
export const UmdComponent = ({ 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>
}
然后修改 App.jsx
,其主要是为了加载 react-draggable[5] 组件:
import { UmdComponent } from './UmdComponent'
const App = () => {
return <div>
<div>动态组件示例:</div>
<UmdComponent url='https://unpkg.com/react-draggable@4.4.4/build/web/react-draggable.min.js'
umdProps={{
onDrag(e) {
console.log(e)
}
}}>
<div style={{ width: 100, height: 100, backgroundColor: 'skyblue' }}></div>
</UmdComponent>
</div>
}
export default App;
其中 url
可从接口中获取,这里就不再演示。
上述 importScript
只是示例代码 ,不建议用到生产。如果确实有需求,建议使用 systemjs[6],其 System.import
方法同 importScript
作用一致并且考虑的情况更加全面。
之前也已经说了,此方案会造成全局变量的污染。
eval
方案eval
方案是指先获取 JS 链接的文本内容,然后通过 eval
的方式执行,并获取内容。
export const importScript = (() => {
// 自执行函数,创建一个闭包,保存 cache 结果(如果是用打包工具编写就大可不必这样,只需要在文件中定义一个 cache 变量即可)
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
})
}
})()
与方案 1 唯一的不同就是请求方式从 script
变成了 fetch
然后 eval
。
此方案仍然没有解决全局变量污染的问题。
new Function
+ 沙箱因为在严格模式下不允许 eval 函数,所以我们使用 new Function 函数,两个具体区别,可参考掘金文章[7]。
这里的沙箱既包含 JS 沙箱又包含 CSS 隔离,但我们这里仅仅为了说明问题,只写一个 JS 沙箱的丐版实现。
我们这里的沙箱的实现方式比较简单,就是通过 eval
+ with
+ proxy
,基本思路是通过代理远程 JS 中的 window 对象,当增、删时修改一个代理变量,当获取时则读取全局变量。
我们首先来 new Function
的用法:
window.a = 'aaa'
const fn = new Function('console.log(a)') // 会正确读取到当前作用域下的 a 变量,既 aaa
fn()
我们看到其效果和 eval
相同。
然后看一下 [with](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/with)
的用法:
const obj = { name: 'zhang' }
window.name = 'li'
with(obj) {
console.log(name) // 会先从 obj 上找 name 属性,所以会输出 zhang
}
with
通过包裹一个对象,增加一层作用域链,这样 name
变量在向上查找的过程中,发现 obj
里面有,就返回了 obj.name
的值。
如果我们把 obj
的 name 属性删除后,看看会发生什么?
输出结果变成了 li
,这说明,当在此对象上找不到时,会继续向上级作用域查找,因为上级是全局作用域,所以返回了 window.name
的属性值。
最后看一下 [Proxy](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy)
的用法:
const fakeWindow = {}
const proxyWindow = new Proxy(window, {
// 获取属性
get(target, key) {
return target[key] || fakeWindow[key]
},
// 设置属性
set(target, key, value) {
return fakeWindow[key] = value
}
})
那么我们看一下最终的解决方案:
function sandboxEval(code) {
const fakeWindow = {}
const proxyWindow = new Proxy(window, {
// 获取属性
get(target, key) {
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol/unscopables
if (key === Symbol.unscopables) return false
// 内部可能访问当这几个变量,都直接返回代理对象
if (['window', 'self', 'globalThis'].includes(key)) {
return proxyWindow
}
return target[key] || fakeWindow[key]
},
// 设置属性
set(target, key, value) {
return fakeWindow[key] = value
},
// 判断属性是否有
has(target, key) {
return key in target || key in fakeWindow
}
})
window.proxyWindow = proxyWindow
// 这是一个自执行函数
// 并且通过 `call` 调用,因为 code 可能通过 this 访问 window,所以通过 call 改变 this 指向
const codeBindScope = `
(function (window) {
with (window) {
${code}
}
}).call(window.proxyWindow, window.proxyWindow)
`
// 通过 new Function 的方式执行
const fn = new Function(codeBindScope)
fn()
// 获取最后的值
const lastKey = Object.keys(fakeWindow)[0]
return lastKey ? fakeWindow[lastKey] : undefined
}
然后我们替换 importScript
中的 eval
函数即可:
export const importScript = (() => {
// 自执行函数,创建一个闭包,保存 cache 结果(如果是用打包工具编写就大可不必这样,只需要在文件中定义一个 cache 变量即可)
const cache = {}
return (url) => {
// 如果有缓存,则直接返回缓存内容
if (cache[url]) return Promise.resolve(cache[url])
// 发起 get 请求
return fetch(url)
.then(response => response.text())
.then(text => {
// 沙箱执行
const res = sandboxEval(text)
const Com = res.default ? res.default : res
cache[url] = Com
return Com
})
}
})()
因为这个沙箱太弱鸡,以至于无法正常运行 react-draggable
, 所以我们使用讲解 UMD
时用到的 DEMO,将其改造为:
yarn vite build
yarn vite --port 8888 --cors --open .
然后将链接指向我们构建出来的结果:
至此我们已经说明了沙箱的能力,但目前社区还没有一个可独立运行的沙箱库,基本上我们只能从微前端代码中研究,希望有志者可以开源一个通用的前端沙箱库。
微组件也是通过 url 加载组件,并且具有沙箱、CSS 隔离等功能,具体参见文章:[《微组件实践》](www.yuque.com/docs/share/…[8] 《微组件实践》)。
本文先讲解了远程组件的定义,并且给了两个应用场景,最后给了 4 个解决方案。
看完本文你是否对远程组件已经有一个大概的了解呢?是否有比上述 4 中解决方案更好的办法呢?请在评论区留言。
[1]https://github.com/systemjs/systemjs: https://github.com/systemjs/systemjs
[2]https://www.infoq.cn/article/SaCHSl6KW7b7erkJHIiH: https://www.infoq.cn/article/SaCHSl6KW7b7erkJHIiH
[3]https://doc.youzanyun.com/resource/doc/3005: https://doc.youzanyun.com/resource/doc/3005
[4]http://wiki.commonjs.org/wiki/Modules/1.1: http://wiki.commonjs.org/wiki/Modules/1.1
[5]https://www.npmjs.com/package/react-draggable: https://www.npmjs.com/package/react-draggable
[6]https://github.com/systemjs/systemjs: https://github.com/systemjs/systemjs
[7]https://juejin.cn/post/6844903859274383373: https://juejin.cn/post/6844903859274383373
[8]https://www.yuque.com/docs/share/60069dca-a63f-4735-859c-01b3354fe924?#: https://www.yuque.com/docs/share/60069dca-a63f-4735-859c-01b3354fe924?#