JavaScript 遠程組件加載方案實踐
遠程組件定義
遠程組件,這裏指的是加載遠程 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 方案即可; 只有當你的數量和內容不確定的時候才需要。
低代碼
-
不處理:全部引入
-
靜態分析:對每個用戶的拖拽結果進行靜態分析,然後形成每個用戶自己的引入內容
-
按需加載:實現一套動態組件機制,僅當組件被使用到時再進行加載,加載後緩存
其中按需加載,就比較適合我們上面說的動態組件場景。
而且我們再回想一下應用場景:
-
動態性(當組件需要更新時,可直接覆蓋 JS 內容就可以實現動態更新)
-
不確定性(對於主應用而言,其不知道用戶會拖拽多少個組件以及每個組件長什麼樣,它只需要將用戶拖拽的 JSON 數組進行循環遍歷,並渲染,然後將配置的屬性傳遞過去就可以了,具體到每個組件具體是長什麼樣其不關心)
代碼嵌入
遠程代碼嵌入一個典型場景是擴展點能力。所謂的擴展點,是爲了滿足用戶個性化訴求或者擴展一些能力,在自家產品上運行第三方 JavaScript 代碼。例如:
-
Figma 插件機制 [2]
-
有贊擴展點 [3]
用戶自定義的邏輯肯定無法提前知道的,也無法在項目打包的時候就引入,所以需要動態組件的能力。
我們再回想一下應用場景:
-
動態性(此場景需要,用戶擴展點有更新,可直接覆蓋 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[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></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 隔離)
-
兼容性
方案 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[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
作用一致並且考慮的情況更加全面。
之前也已經說了,此方案會造成全局變量的污染。
方案 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
+ 沙箱
因爲在嚴格模式下不允許 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 .
然後將鏈接指向我們構建出來的結果:
至此我們已經說明了沙箱的能力,但目前社區還沒有一個可獨立運行的沙箱庫,基本上我們只能從微前端代碼中研究,希望有志者可以開源一個通用的前端沙箱庫。
方案 4:微組件
微組件也是通過 url 加載組件,並且具有沙箱、CSS 隔離等功能,具體參見文章:[《微組件實踐》](www.yuque.com/docs/share/…[8] 《微組件實踐》)。
總結
本文先講解了遠程組件的定義,並且給了兩個應用場景,最後給了 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?#
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/LIU4XPitAhY8omgHNbGvvA