远程组件加载方案实践
看完本文你将学习到如下知识:
- 远程组件定义
- UMD 模块规范
- 远程组件加载方案实现思路和细节
- systemjs 中
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 代码。例如:
用户自定义的逻辑肯定无法提前知道的,也无法在项目打包的时候就引入,所以需要动态组件的能力。
我们再回想一下应用场景:
- 动态性(此场景需要,用户扩展点有更新,可直接覆盖 JS 内容就可以实现动态更新)
- 不确定性(对于主应用而言,其不知道用户会有多少扩展点,以及每个扩展点会渲染成什么样,它只管拿到链接后进行渲染即可)
UMD 模块规范
我们上面多次提到 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 规范 - 如果有
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
变量:
通过上面观察,一个 UMD 格式的 JS 文件,如果以
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
方案❌ ✅ ✅ 微组件 ✅ ✅ ❌
方案 1:动态 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 组件:
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,其 System.import
方法同 importScript
作用一致并且考虑的情况更加全面。
之前也已经说了,此方案会造成全局变量的污染。
方案 2: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
。
此方案仍然没有解决全局变量污染的问题。
方案 3:new Function
+ 沙箱
这里的沙箱既包含 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 .
然后将链接指向我们构建出来的结果:
至此我们已经说明了沙箱的能力,但目前社区还没有一个可独立运行的沙箱库,基本上我们只能从微前端代码中研究,希望有志者可以开源一个通用的前端沙箱库。
方案 4:微组件
微组件也是通过 url 加载组件,并且具有沙箱、CSS 隔离等功能,具体参见文章:[《微组件实践》](www.yuque.com/docs/share/… 《微组件实践》)。
总结
本文先讲解了远程组件的定义,并且给了两个应用场景,最后给了 4 个解决方案。
看完本文你是否对远程组件已经有一个大概的了解呢?是否有比上述 4 中解决方案更好的办法呢?请在评论区留言。
转载自:https://juejin.cn/post/7086791335688028196