本文来自于团队的分享,对外脱敏版本
下面来一步一步实现这个 SDK 的能力「演示版」
用 UMD 的方式将组件打包 生成 Comp1.umd.cjs
接下来把远程组件直接通过 script 的方式来引入看看
全局挂载正常,接着尝试进行渲染 Comp1
HTML<!-- 引入 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 在页面上已经搞出来了
SDK 的用法大致如下
JavaScriptSdk.init({/* 鉴权 */}); const ms = Sdk.create({ name: "Com1" }); ms.render(document.querySelector("#open"));
远程清单就是一个资源配置文件,出于演示目的,这里进行简化,直接固定从 pubilc 拿
JavaScriptimport 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); } }
JavaScriptimport { Sdk } from "./sdk/index.ts"; Sdk.init(); const ms = Sdk.create(); ms.render(document.getElementById("open"));
注意到这里用 eval 来执行源代码,让 Comp1 挂载到了全局上,从目前的效果上来看和动态挂个 scirpt 是一样的效果
如题,SDK 的设计不希望影响全局变量也不希望被全局变量环境干扰
这里技术实现前置依赖几个信息输入
JavaScript(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 入参
然后走判定逻辑
兜底走 globalThis, this, self
with 语句
JavaScriptwindow.a = 1; const foo = { a: "foo", }; with (foo) { console.log(a); // foo } console.log(a) // 1
JavaScriptconst fn = new Function("foo", `console.log(foo)`); fn(66); // 66
前置理解说完了,接下来看核心实现
JavaScriptfunction 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, };
修改下业务代码,扔进去一些对全局的污染
JavaScriptfunction 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 };
注意到全局 window 上没有被干扰,并且我们对全局的修改被扔到了 fakeWindow 上,依赖 window 上的一些方法通过 ProxyWindow 可以正常取到,并且页面渲染依然正常
目前的打包是把 React 、ReactDOM 都扔进业务代码的,还需要业务代码暴露一个 render 的方法进行调用(1)
从我们的预期上,组件是有很多的,并且他们之间不是一个仓库,SDK 在引入的时候也只需要引入需要的文件
这个时候如果有多个业务模块同时引用,公共依赖就是重复的(2)
为了解决这个问题,还需要把 UMD 的依赖能力用起来,要注意 ⚠️,我们在 3 的时候搞了 Proxy Window
先把 业务打包的 external 打开,观察下打包的产物
JavaScript(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 来做依赖管理
先看最终预期
JavaScriptexport 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 呢?
这里的做法是用文件名
JavaScriptclass 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]; } }
这个 name 是 在打包的时候构建工具来定的,react-dom -> ReactDOM
会发现人家声明的和我们通过文件名搞出来的不一致,在这简化版本的实现里,我硬编码来解决这个问题
JavaScriptif (defaultModuleName === "ReactDom") { return proxyWindow["ReactDOM"]; }
有没有别的解法呢?
这里有俩选择,走 AMD,或者走 globalThis 来做依赖管理
刚刚走的算是用 gloablThis 这个判断来做的依赖组织,我们可以写个 AMD 解析来统一 key 的定义,限于篇幅我这里不再展开,实际的实现走的是 AMD 的解析模式
与 JavaScirpt 同理,但样式并不需要运行,这里可以利用 react 来进行组合
JavaScriptconst 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);
样式正常展示了,我这边故意搞了个会发生样式冲突的场景,接下来通过 WebComponent 来解决这个问题
这一步不复杂,就是用 WebComponent 包一层
JavaScriptfunction 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; }
JavaScriptconst 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);
SDK 动态加载内容,并且做到了接入网站 和 引入代码的 样式 和 JavaScript 的隔离