JavaScript 遠程組件加載方案實踐

遠程組件定義

遠程組件,這裏指的是加載遠程 JS 資源並渲染成組件。

其整體流程應該是:

其中最核心的是第 5 點和第 6 點,加載遠程組件並渲染內容,本文也將圍繞如何加載提出一些解決方案供大家思考。

遠程組件應用場景

遠程組件的應用場景主要有以下兩個特點:

場景區分

其要和以下幾個概念要區分開:

關於第一個,它和動態組件很類似,但應用場景還是有很多區別的,總結如下:

KonJTQ

如果你僅有一個組件,那完全用不上動態組件,用普通 UMD 方案即可; 你有多個組件,但是提前知道功能和數量,也不用到動態組件,用普通 UMD 方案即可; 只有當你的數量和內容不確定的時候才需要。

低代碼

我們知道一個低代碼平臺組件越多代表其覆蓋的場景也越多,功能也越強大。但是隨着組件的增多也會帶來項目體積過大,加載慢等問題。面臨這種情況有處理方式:

其中按需加載,就比較適合我們上面說的動態組件場景。

而且我們再回想一下應用場景:

代碼嵌入

遠程代碼嵌入一個典型場景是擴展點能力。所謂的擴展點,是爲了滿足用戶個性化訴求或者擴展一些能力,在自家產品上運行第三方 JavaScript 代碼。例如:

用戶自定義的邏輯肯定無法提前知道的,也無法在項目打包的時候就引入,所以需要動態組件的能力。

我們再回想一下應用場景:

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

我們在頂部看到一個連續的三元運算符:

我們想要使用這個組件,可以創建 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變量,也有我們掛載的 ReactReactDOM以及 MyLib變量:

通過上面觀察,一個 UMD 格式的 JS 文件,如果以 script 標籤的方式使用,就是往 window上掛載全局變量,並且會從 window上讀取依賴。

加載方案講解

整個動態組件最核心的地方就是執行 JS 並獲取導出的內容,其目前識別到的有以下四種方案:

我們從以下三點評判方案的優劣勢:

c3VDB4

方案 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 對象,當增、刪時修改一個代理變量,當獲取時則讀取全局變量。

window.a = 'aaa'

const fn = new Function('console.log(a)') // 會正確讀取到當前作用域下的 a 變量,既 aaa

fn()

我們看到其效果和 eval 相同。

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 的屬性值。

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