sedationh / blog

Blog space for a user who doesn't want to mess with blogs anymore:)
0 stars 0 forks source link

SDK 远程拉取组件组件进行加载 #83

Open sedationh opened 5 months ago

sedationh commented 5 months ago

本文来自于团队的分享,对外脱敏版本

下面来一步一步实现这个 SDK 的能力「演示版」

1. scirpt 直接挂进来

用 UMD 的方式将组件打包 生成 Comp1.umd.cjs

image

接下来把远程组件直接通过 script 的方式来引入看看

image

image

全局挂载正常,接着尝试进行渲染 Comp1

<!-- 引入 Comp1 -->
<script src="https://gw.alipayobjects.com/os/lib/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://gw.alipayobjects.com/os/lib/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<script src="/Comp1.umd.cjs"></script>
<h1>👇是 open 节点</h1>
<div id="open"></div>
<script>
  const vDom = React.createElement(Comp1);
  ReactDOM.render(vDom, document.getElementById('open'));
</script>

可以看到 Comp1 在页面上已经搞出来了

image

2. 上 SDK,动态引入 Comp1.umd.cjs

image

SDK 的用法大致如下

Sdk.init({/* 鉴权 */});
const ms = Sdk.create({ name: "Com1" });
ms.render(document.querySelector("#open"));

远程清单就是一个资源配置文件,出于演示目的,这里进行简化,直接固定从 pubilc 拿

import axios from "axios";

export class Sdk {
  static init() {}

  static create() {
    return new Comp({ js: "/Comp1.umd.cjs" });
  }
}

class Comp {
  js: string;

  constructor({ js }) {
    this.js = js;
  }

  async render(dom) {
    const { data: scriptCode } = await axios.get<string>(this.js, {
      responseType: "text",
    });

    eval(scriptCode);

    window.Comp1.render(dom);
  }
}
import { Sdk } from "./sdk/index.ts";

Sdk.init();
const ms = Sdk.create();
ms.render(document.getElementById("open"));

image

注意到这里用 eval 来执行源代码,让 Comp1 挂载到了全局上,从目前的效果上来看和动态挂个 scirpt 是一样的效果

3. 利用 Proxy Window 防止全局变量污染

如题,SDK 的设计不希望影响全局变量也不希望被全局变量环境干扰

这里技术实现前置依赖几个信息输入

  1. UMD 规范是怎么回事
(function (global, factory) {
  typeof exports === "object" && typeof module !== "undefined"
    ? (module.exports = factory())
    : typeof define === "function" && define.amd
    ? define(factory)
    : ((global =
        typeof globalThis !== "undefined" ? globalThis : global || self),
      (global.Comp1 = factory()));
})(this, function () {
  "use strict";
  function Comp1() {
    return /* @__PURE__ */ React.createElement("div", null, "Comp1");
  }
  return Comp1;
});

UMD (Universal Module Definition),就是一种 JavaScript 通用模块定义规范,让你的模块能在 JavaScript 所有运行环境中发挥作用

下面的 this 被映射为 global 入参,我们的文件被映射为 factory 入参

然后走判定逻辑

  1. exports;nodejs 环境

  2. define amd ;AMD 规范环境

  3. 兜底走 globalThis, this, self

  4. with 语句

with - JavaScript | MDN

window.a = 1;

const foo = {
  a: "foo",
};

with (foo) {
  console.log(a); // foo
}
console.log(a) // 1
  1. Function

Function - JavaScript | MDN

const fn = new Function("foo", `console.log(foo)`);
fn(66); // 66

前置理解说完了,接下来看核心实现

function simpleSandbox(code: string, globalThisCtx: any) {
  const withedCode = `with(ctx) { eval(${JSON.stringify(code)}) }`;
  withedCode;
  const fn = new Function("ctx", withedCode);
  fn.call(globalThisCtx, globalThisCtx);
}

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

    return true;
  },
});

const fakeWindow = {
  window: proxyWindow,
  globalThis: proxyWindow,
  self: proxyWindow,
};

修改下业务代码,扔进去一些对全局的污染

function Comp1() {
  window.a = 1;
  console.log(
    "%c seda [ a ]-7",
    "font-size:13px; background:pink; color:#bf2c9f;",
    window.a
  );
  return <div>Comp1</div>;
}

export default Comp1;

const render = (dom) => [ReactDOM.createRoot(dom!).render(<Comp1 />)];

export { render };

image

注意到全局 window 上没有被干扰,并且我们对全局的修改被扔到了 fakeWindow 上,依赖 window 上的一些方法通过 ProxyWindow 可以正常取到,并且页面渲染依然正常

4. 重复公用依赖问题

目前的打包是把 React 、ReactDOM 都扔进业务代码的,还需要业务代码暴露一个 render 的方法进行调用(1)

从我们的预期上,组件是有很多的,并且他们之间不是一个仓库,SDK 在引入的时候也只需要引入需要的文件

这个时候如果有多个业务模块同时引用,公共依赖就是重复的(2)

为了解决这个问题,还需要把 UMD 的依赖能力用起来,要注意 ⚠️,我们在 3 的时候搞了 Proxy Window

先把 业务打包的 external 打开,观察下打包的产物

(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.Comp1 = factory(global.React)));
})(this, function (React) {
  "use strict";
  function Comp1() {
    return /* @__PURE__ */ React.createElement("div", null, "Comp1");
  }
  return Comp1;
});

这里有俩选择,走 AMD,或者走 globalThis 来做依赖管理

先看最终预期

export const COMPONENT_DEP_URLS = {
  REACT_CDN_URL:
    "https://gw.alipayobjects.com/os/lib/react/17.0.2/umd/react.production.min.js",
  REACT_DOM_CDN_URL:
    "https://gw.alipayobjects.com/os/lib/react-dom/17.0.2/umd/react-dom.production.min.js",
};

class Comp {
  js: string;

  constructor({ js }) {
    this.js = js;
  }

  async render(dom) {
    const React = await importer.importScript(COMPONENT_DEP_URLS.REACT_CDN_URL);

    const ReactDOM = await importer.importScript(
      COMPONENT_DEP_URLS.REACT_DOM_CDN_URL
    );
    const vDom = await importer.importScript(this.js);
    ReactDOM.render(React.createElement(vDom), dom);
  }
}

这里的核心问题是,通过 simpleSandbox(scriptCode, proxyWindow); 后模块就被挂到了 proxyWindow 上,但从 proxyWindow 拿的时候用什么 key 呢?

这里的做法是用文件名

class Importer {
  async importScript(url) {
    const { data: scriptCode } = await axios.get<string>(url, {
      responseType: "text",
    });

    simpleSandbox(scriptCode, proxyWindow);
    const defaultModuleName = getDefaultModuleName(url);

    if (defaultModuleName === "ReactDom") {
      return proxyWindow["ReactDOM"];
    }
    return proxyWindow[defaultModuleName];
  }
}

image

这个 name 是 在打包的时候构建工具来定的,react-dom -> ReactDOM

image

会发现人家声明的和我们通过文件名搞出来的不一致,在这简化版本的实现里,我硬编码来解决这个问题

    if (defaultModuleName === "ReactDom") {
      return proxyWindow["ReactDOM"];
    }

有没有别的解法呢?

这里有俩选择,走 AMD,或者走 globalThis 来做依赖管理

刚刚走的算是用 gloablThis 这个判断来做的依赖组织,我们可以写个 AMD 解析来统一 key 的定义,限于篇幅我这里不再展开,实际的实现走的是 AMD 的解析模式

5. 样式呢

与 JavaScirpt 同理,但样式并不需要运行,这里可以利用 react 来进行组合

    const js = await importer.importScript(this.js);
    const css = await importer.importStyle(this.css);

    const cssCom = React.createElement(
      "style",
      {
        key: "cssCom",
      },
      [css]
    );

    const vDom = React.createElement(
      React.Fragment,
      null,
      cssCom,
      React.createElement(js)
    );

    ReactDOM.render(vDom, dom);

image

样式正常展示了,我这边故意搞了个会发生样式冲突的场景,接下来通过 WebComponent 来解决这个问题

6. WebComponent

这一步不复杂,就是用 WebComponent 包一层

function reactToWebComponent(
  ReactComponent: React.ComponentType,
  React: typeof ReactType,
  ReactDOM: typeof ReactDOMType
): CustomElementConstructor {
  class WebComponent extends HTMLElement {
    reactToWebComponent;
    constructor() {
      super();
      const container = this.attachShadow({ mode: "open" });
      ReactDOM.render(React.createElement(ReactComponent), container);
    }
  }
  return WebComponent;
}
    const h = () => {
      return React.createElement(
        React.Fragment,
        null,
        cssCom,
        React.createElement(js)
      );
    };

    const webComponentName = `web-com-${js.name.toLowerCase()}`;

    const webComponent = reactToWebComponent(h, React, ReactDOM);

    const dom = document.createElement(webComponentName);

    customElements.define(webComponentName, webComponent);

    container.appendChild(dom);

image

看结果

SDK 动态加载内容,并且做到了接入网站 和 引入代码的 样式 和 JavaScript 的隔离