likes
comments
collection
share

远程组件加载方案实践

作者站长头像
站长
· 阅读数 17

看完本文你将学习到如下知识:

  • 远程组件定义
  • UMD 模块规范
  • 远程组件加载方案实现思路和细节
  • systemjsSystem.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

远程组件加载方案实践 我们在顶部看到一个连续的三元运算符:

  • 如果有 exportmodule 变量,则表示在 nodejs 环境中,遵循 CommonJS 规范
  • 如果有 definedefine.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变量,也有我们挂载的 ReactReactDOM以及 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 中解决方案更好的办法呢?请在评论区留言。