CD's blog CD's blog
首页
  • HTMLCSS
  • JavaScript
  • Vue
  • TypeScript
  • React
  • Node
  • Webpack
  • Git
  • Nestjs
  • 小程序
  • 浏览器网络
  • 学习笔记

    • 《TypeScript 从零实现 axios》
    • Webpack笔记
  • JS/TS教程

    • 《现代JavaScript》教程
🔧工具方法
  • 网站
  • 资源
  • Vue资源
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

CD_wOw

内卷的行情,到不了的梦
首页
  • HTMLCSS
  • JavaScript
  • Vue
  • TypeScript
  • React
  • Node
  • Webpack
  • Git
  • Nestjs
  • 小程序
  • 浏览器网络
  • 学习笔记

    • 《TypeScript 从零实现 axios》
    • Webpack笔记
  • JS/TS教程

    • 《现代JavaScript》教程
🔧工具方法
  • 网站
  • 资源
  • Vue资源
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 基础原理及工具方法

  • Babel

  • WebComponent

    • JS沙箱隔离实现原理
    • 通过quark-design了解webComponent使用封装
    • 远程组件加载方案笔记
      • 远程组件定义
      • 代码嵌入
      • UMD 格式说明
      • 动态加载方案
        • 动态 script 方案
        • eval方案
        • new Function + 沙箱
        • 微组件
      • 额外: JS Entry vs HTML Entry
  • 专项知识汇总

  • JavaScript笔记
  • WebComponent
CD
2023-05-03
目录

远程组件加载方案笔记


提示

阅读该篇你将了解到:

  • 远程组件定义
  • 什么是UMD模块化
  • 远程加载组件方案实现思路

# 远程组件定义

所谓远程组件 , 这里指通过加载远程js资源并将其渲染成为组件,通常为常用的框架,vue/react组件

远程组件的加载流程通常包括:

  1. 开发人员编写组件,并打包导出成UMD格式(浏览器端)。
  2. 将打包好的文件上传至服务器端,并获取对应文件的网络路径。
  3. 通过获取的网络路径发起请求,并获取资源内容。
  4. 将组件利用 Vue 中的动态组件(或是vue.mount)或者 React 中 React.createElement 进行渲染。

对于远程组件而言,其具有动态性和不确定性。

动态性: 当组件需要更新时,可直接覆盖 JS 内容就可以实现动态更新 不确定性: 对于使用动态组件的主应用来说,它并不关心动态组件的内容长什么样子,它只需要将获取的组件资源渲染并传入自己配置好的属性即可。

# 代码嵌入

远程代码嵌入一个典型场景是扩展点能力。所谓的扩展点,是为了满足用户个性化诉求或者扩展一些能力,在自家产品上运行第三方 JavaScript 代码。例如:

● Figma 插件机制 (opens new window) ● 有赞扩展点 (opens new window)

# UMD 格式说明

UMD( Universal ModuleDefinition)作为一种同构( isomorphic)的模块化解决方案出现,它能够让我们只需要在一个地方定义模块内容,并同时兼容 AMD 和 Commons 语法。其原理其实就是根据 AMD 和 COMMONJS 模块化规范的特征,判断当前环境是符合哪种规范的特征,从而使用相应的语法来导出。

接下来我们借助vite来打包一个react组件生成umd模块文件,加深理解:

mkdir react-demo && cd react-demo
yarn init -y
yarn add vite -D
yarn add react
1
2
3
4

增加vite.config.js文件

const path = require("path");
const { defineConfig } = require("vite");

module.exports = defineConfig({
  build: {
    minify: false,
    lib: {
      formats: ["umd"],
      entry: path.resolve(__dirname, "index.jsx"),
      name: "MyComponent",
      fileName: (format) => `component.${format}.js`,
    },
    rollupOptions: {
      // 将不需要被打包的文件移除,改成采用主应用自带的依赖,可有效减小包体积
      external: ["react"],
      output: {
        // 在UMD构建模式下为外部化的依赖提供全局变量
        globals: {
          react: "React",
        },
      },
    },
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

增加index.jsx文件

import React from "react";

const Demo = () => {
  return <div>demo</div>;
};

export default Demo;
1
2
3
4
5
6
7

进行打包

yarn vite build
1

我们在dist文件夹则会生一个component.umd.js文件,我们将其格式化可以看到

(function (global, factory) {
  typeof exports === "object" && typeof module !== "undefined"
    ? (module.exports = factory(require("react")))
    : typeof define === "function" && define.amd
    ? define(["react"], factory)
    : ((global =
        typeof globalThis !== "undefined" ? globalThis : global || self),
      (global.MyComponent = factory(global.React)));
})(this, function (React) {
  "use strict";
  const Demo = () => {
    return /* @__PURE__ */ React.createElement("div", null, "demo...");
  };
  return Demo;
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

如果本地环境拥有exports或是module,则遵循commonjs模块化规范。如果有define.amd则使用amd模块化规范。否则判断是否有 globalThis 如果没有用 global或者 self,这里的 globalThis或者self 在浏览器环境下为 window。这里我们将MyComponent的组件函数挂载了global,也就是window下。

我们在根目录下再增加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="./dist/component.umd.js"></script>
</head>

<body>
  <div id="app"></div>
  <script>
    console.log(window)
    ReactDOM.render(React.createElement(window.MyComponent), document.getElementById('app'))
  </script>
</body>

</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

我们可以看到,组件成功渲染了。观察window变量,我们发现React对象、MyComponent函数都挂载在了window全局。我们可以得出结论 通过script标签注入的umd格式文件,会将内容挂载到window上,并从window上读取依赖

# 动态加载方案

动态加载js资源,通常有以下四种方式:

  • 动态script载入:即获取链接后,动态创建一个 script ,拿到变量后再删除此 script
  • eval方案:获取到需要运行的js字符串,将其转换成js并执行
  • new Function + 沙箱方案
  • 微组件

我们通常通过以下3点评判方案优劣:

简易轻便 沙箱能力(js\css隔离) 兼容性
动态script载入 ✅ ❌ ✅
eval方案 ✅ ❌ ✅
new Function + 沙箱方案 ❌ ✅ ✅
微组件 ✅ ✅ ❌

# 动态 script 方案

动态 script 的方案很简单,就是创建一个script标签加载,在不用的时候就移除。平日开发可以使用requirejs\systemjs直接引入资源链接。

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)
      })
    })
  }
})()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

这个时候,资源就加载并挂载在了window变量上。以react为例,这时我们可以为其创建一个自定义组件,来实现动态组件的渲染。

我们创建一个项目 (注意!项目基于React 17.0.1版本):

yarn create vite my-react-app --template react
1

创建utils/index.js文件,将上面importScript方法复制进来。

之前了解到umd.js会从全局window里获取依赖,这里我们在main.jsx中将react挂载在全局变量上以供动态组件使用

// main.jsx
import React from "react";
import ReactDOM from "react-dom";
import App from "./App.jsx";

window.React = React;
window.ReactDOM = ReactDOM;

ReactDOM.render(<App />, document.getElementById("root"));
1
2
3
4
5
6
7
8
9

我们增加一个自定义组件来渲染umdjs资源: 创建\componets\script-component.jsx文件

import { useState, useEffect } from "react";
import { importScript } from "../utils/index";

// eslint-disable-next-line react/prop-types
const UmdScriptComponent = ({ 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>;
};
export default UmdScriptComponent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

接下来我们在main.jsx中引入并使用即可看到完整的项目实例。

import UmdScriptComponent from "./componets/UmdScriptComponent";

const App = () => {
  return (
    <div>
      <UmdScriptComponent
        url="https://unpkg.com/react-draggable@4.4.5/build/web/react-draggable.min.js"
        umdProps={{
          onDrag(e) {
            console.log(e);
          },
        }}
      >
        <div
          style={{ width: 100, height: 100, backgroundColor: "skyblue" }}
        ></div>
      </UmdScriptComponent>
    </div>
  );
};

export default App;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 小结

我们看到script渲染的方案可以完成一个组件的异步渲染。我们常用的webpack也是采用了类似这样的形式,在主文件内通过Promise.all 和 jsonp 创建了一个动态的script标签来完成组件的加载和渲染。当然以上的丐版代码还是存在缺陷,由于不是沙箱环境,会对主应用环境下的变量带来污染。

# eval方案

eval方案即拿到可执行的js文本内容(字符串),将其转换成函数并执行。eval在严格模式下是无法使用的,而且有性能上的问题。

export const importScript = (() => {
  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

该方案和script的区别在于这里通过异步请求获取资源后通过eval执行。同样的该方案有污染全局变量的风险。

# new Function + 沙箱

前面说到,严格模式不允许使用eval函数,在部分项目下存在风险,所以我们采用 new Function的形式进行加载。

eval与new Function的区别:

  • 对于eval:代码执行时的作用域为当前作用域。它可以访问到函数中的局部变量。
  • 对于new Function:代码执行时的作用域为全局作用域,不论它的在哪个地方调用的。所以它访问的是全局变量a。它根本无法访问b函数内的局部变量。

我们这里说的沙箱,只js、css隔离沙箱,具体的可看之前的js实现沙箱环境 (opens new window)一文,这里就不多赘述了。

// 通过new Function(/** js文本 */) 生成可执行函数
const fn = new Function(/** jsWenben */)

const fakeWindow = {}
const proxyWindow = new Proxy(window, {
   // 获取属性
   get(target, key) {
     return target[key] || fakeWindow[key]
   },
   // 设置属性
   set(target, key, value) {
      return fakeWindow[key] = value
   }
})

// with 通过包裹一个对象,增加一层作用域链,将with函数内的全局作用域代理到了fakeWindow上
with(fakeWindow) {
  fn()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

我们可以大胆得出结论,通过了沙箱的函数方法不会全局对象造成污染。

# 微组件

微组件方案,我的理解,即拥有微前端的能力,即js/css隔离,无技术栈限制,能够实现数据互通等能力。当然,我们这里并不打算采用微前端生态的HTML Entry的能力,而是将其打包成上述所说的umd包,通过导出的形式来获取微组件(或是函数模块)的内容,以满足主应用与微组件之间可独立部署更新的需要(松耦合)。

说到微前端,那么人们首先会想到的就是沙箱和跨技术栈的能力了。他们的核心则是前几篇当中的主人公: WebComponent。而市场上基于WebComponent构建的微前端框架目前也是百花齐放,各有各的特点。例如:

  • Single-spa (opens new window):最早的微前端框架,独立,跨技术栈,缺点是无通信,也无沙箱能力,无法预加载
  • 字节开源的magic-microservices (opens new window):一款基于 Web Components 的轻量级的微前端工厂函数, 优点是小巧轻便,抹平了框架差异,可通信,缺点是无沙箱能力 -micro-app (opens new window):与 single-spa 和 qiankun 不同,其借鉴了 Web Components 的思想,将微前端封装成一个 Web Components 组件,从而实现微前端的组件化渲染。缺点是无法加载umd文件。
  • EMP (opens new window): EMP是YY出品基于webpack5模块联邦实现各个项目中的模块相互异步引用。缺点也很明显,无法实现跨框架,各个项目之间必须统一webpack5,且依赖更新时有可能会对其他项目带来未知的影响。

为什么我们这里要举例这么多微前端框架呢?我们可以看到,我们想要的微组件的功能,其实微前端框架都能够做到,那么我们只需要去改造部分代码,即可完成我们想要的组件模块。

# 额外: JS Entry vs HTML Entry

在确定了运行时载入的方案后,另一个需要决策的点是,我们需要子应用提供什么形式的资源作为渲染入口?

  • JS Entry 的方式通常是子应用将资源打成一个 entry script,比如 single-spa 的 example 中的方式。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。

  • HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。想象一下这样一个场景:

<script src="//unpkg/antd.min.js"></script>
<body>
  <main id="root"></main>
</body>
// 子应用入口
ReactDOM.render(<App/>, document.getElementById('root'))
1
2
3
4
5
6

如果是 JS Entry 方案,主框架需要在子应用加载之前构建好相应的容器节点(比如这里的 "#root" 节点),不然子应用加载时会因为找不到 container 报错。但问题在于,主应用并不能保证子应用使用的容器节点为某一特定标记元素。而 HTML Entry 的方案则天然能解决这一问题,保留子应用完整的环境上下文,从而确保子应用有良好的开发体验。

HTML Entry 方案下,主框架注册子应用的方式则变成:

framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})
1

本质上这里 HTML 充当的是应用静态资源表的角色,在某些场景下,我们也可以将 HTML Entry 的方案优化成 Config Entry,从而减少一次请求,如:

framework.registerApp('subApp1', { html: '', scripts: ['//abc.alipay.com/index.js'], css: ['//abc.alipay.com/index.css']})
1
编辑 (opens new window)
#WebComponent#微前端
上次更新: 2023/05/03, 18:26:16
通过quark-design了解webComponent使用封装
图片类型、优化、处理知识点汇总

← 通过quark-design了解webComponent使用封装 图片类型、优化、处理知识点汇总→

最近更新
01
gsap动画库学习笔记 - 持续~
06-05
02
小程序使用笔记
03-29
03
Nestjs如何做登陆鉴权
03-27
更多文章>
Theme by Vdoing | Copyright © 2020-2023 CD | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式