《可视化搭建系统》“星空” webcomponents 从0到1(二)

上一篇星空整体架构(一)简单聊了下星空的大致框架,这一篇是系列(二),主要围绕星空核心 webcomponents 展开。

文章写的比较久,比较长,建议看不完的可以先收藏起来!!

大致分为以下几个部分:

  • webcomponents 简介
  • webcomponents 业务侧使用场景
  • webcomponents 框架 direflow 介绍及源码实现分析
  • webcomponents 框架 magic-microservices 介绍及源码分析
  • magic-microservices 在“星空”整体架构中的作用

Web components 简介

Web components 是一组 Web 平台 API,允许创建自定义、可重用、封装的 HTML 标签,以在网页和 Web 应用程序中使用,可以跨浏览器工作,并且可以与任何支持 HTML 的 JavaScript 库或框架一起使用。

简单来说,基于这组 API,可以创建具备自定义功能的“div”,“div” 可以运行在哪些环境,它就可以运行在哪些环境。具体可参考:www.webcomponents.org/introductio…

下面是一个具体的例子,自定义的 print-log 标签,每隔 1 秒钟输出 “hello, 掘金”。

《可视化搭建系统》“星空” webcomponents 从0到1(二)

<!DOCTYPE html>
<html lang="en">
  <body>
    <print-log id="printLog"></print-log>
  </body>
  <script>
    class PrintLog extends HTMLElement {
      constructor() {
        super();

        // 创建 shadow root,并设置样式
        this._shadowRoot = this.attachShadow({ mode: "open" });
        const style = document.createElement("style");
        style.textContent = `
      .log-container {
        border: 1px solid #ccc;
        padding: 10px;
        margin: 10px;
      }
    `;
        this._shadowRoot.appendChild(style);

        // 创建 log 容器
        this._logContainer = document.createElement("div");
        this._logContainer.classList.add("log-container");
        this._shadowRoot.appendChild(this._logContainer);
      }

      // 监听 log 属性的变化
      static get observedAttributes() {
        return ["log"];
      }

      attributeChangedCallback(name, oldValue, newValue) {
        // 当 log 属性发生变化时,更新 log 容器的内容
        this._logContainer.textContent = newValue;
      }
    }

    // 定义新元素
    customElements.define("print-log", PrintLog);

    setInterval(() => {
      printLog.setAttribute("log", `hello, 掘金, log time: ${new Date()}`);
    }, 1000);
  </script>
</html>

上面的代码定义了一个名为 PrintLog 的 Web Component。在 constructor 方法中,我们创建了一个 shadow root,以及一个 log 容器,用于显示传入的 log 内容。

在类中基于 observedAttributes 静态方法,监听 log 属性的变化。每当 log 属性发生变化时,attributeChangedCallback 方法就会被调用,我们就可以在其中更新 log 容器的内容了。

最后,通过 customElements.define 注册元素,我们就可以在 HTML 中使用 print-log 元素,并设置 log 属性来展示 log 内容了。

通过上面这个简单的 dmeo,可以看到 web components 的 API 使用起来还是比较容易的。其实浏览器暴露出的 API,就是一个元素的生命周期,从挂载到卸载。

class MyElement extends HTMLElement {
      /* 初始化,创建元素 */
      constructor() {
        super();
      }

      /* 元素被添加到文档时调用 */
      connectedCallback() {}

      /* 元素从文档移除时调用 */
      disconnectedCallback() {}

      /* 属性数组,数组中的属性变化会被监视 */
      static get observedAttributes() {
        return [];
      }

      /* 当上面数组中的属性发生变化时调用 */
      attributeChangedCallback(name, oldValue, newValue) {}
      
      /* 还有更多的属性跟方法,一般上面的足矣 */
}

customElements.define("my-element", MyElement);

Web components 业务侧运用

场景一

当时我们隔壁兄弟团队,是业务侧的基建团队,需要写一些公共组件,给到上层业务团队去接入。

由于历史原因,上层业务团队技术栈分散,有 react 的(存在多个版本),有 vue 的(存在多个版本),相应的上层 ui 组件库也是多个版本的分散(多个 antd 版本)。

由于各个环境差异,当时定下来采用 Web components 的方案。紧接着,出现了第二个问题,Web components 框架的选型问题。

基于原生的 API 直接写不可以吗,当然是 ok 的,但是需要考虑成本问题。基于原生的 API 去做,一个是现有内部一些成熟的 react 组件不能复用,其次语法不够友好,存在一定的上手以及后期的维护成本。

调研下来,我们选用了 direflow,图片来自官网的截图。
《可视化搭建系统》“星空” webcomponents 从0到1(二)

基于此框架,可以将 react 组件无缝转换成 Web components,从而给到上层业务团队接入。

最终使用下来遇到的一些问题:

  • 原始组件一定是 react 的,vue 不支持
  • 组件 props 传参不友好(针对引用类型)
  • 框架略重,需要自行 hack 一些问题,比如 devServer 的一些配置项

场景二

需要把内部的 WebRTC 组件提供给三方开发者 (官方文档),当时第一版也采用了上面 direflow 的方案,但是开发者接入体验相对后面第二版的 magic-microservices 没有那么友好。

《可视化搭建系统》“星空” webcomponents 从0到1(二)

magic-microservices 解决了 direflow 上面提到的系列问题,于团队而言,组件更好维护。于开发者而言,接入体验更加友好。

webcomponents 框架 direflow

简介

上述业务场景一,就是使用的 direflow。

direflow 提供了一套 cli,基于 cli 可以快速初始化环境,基于模板使用 react 开发自定义组件。然后通过其提供的打包命令,可以快速生成一个标准的 webcomponents 组件。具体的可查看官方入门文档

于企业而言,是一套相对完整的脚手架工具。不过如果真正运用到生产中,或许还需要考虑一些问题:

  • 打包产物的发布,可以接入企业的静态资源发布系统
  • 多个 webcomponents 资源复用的问题,比如有 10 个 webcomponents 组件,且都使用到了 react 及 direflow 的一些公共依赖,可以考虑抽离一个公共的基础 sdk,防止资源的重复加载
  • 内部封装“胶水层”,用来降低上层业务方接入成本

源码解析

初步转换

我们先写一个最简易的 react 组件

<!DOCTYPE html>
<html lang="en">
<script crossorigin src="https://cdn.bootcdn.net/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script crossorigin src="https://cdn.bootcdn.net/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>

<body>
  <div id="root"></div>
</body>

<script>
  ReactDOM.render(`hello, 掘金, 我是 react 渲染出来的`, root);
</script>

</html>

这很简单,没有什么特别的,接下来我们基于 webcomponents 的 API,将上面的 react dom 变成一个 webcomponents,如下代码:

class MyElement extends HTMLElement {
  /* 初始化,创建元素 */
  constructor() {
    super();
    this._shadowRoot = this.attachShadow({ mode: "open" });
  }

  /* 元素被添加到文档时调用 */
  connectedCallback() {
    // 渲染 react 组件至 shadow 节点中
    ReactDOM.render(`hello, 掘金, 我是 react 渲染出来的`, this._shadowRoot);
  }
}

customElements.define("my-element", MyElement);

《可视化搭建系统》“星空” webcomponents 从0到1(二)
到这里,一个最简版 react to webcomponents 其实已经完成。

支持 props

接下来,我们处理比较复杂的 react props,让我们转换出的 webcomponents 能够支持 props 的动态变化。

可能会想到基于 observedAttributes 跟 attributeChangedCallback 来做,类似下面这样:

class MyElement extends HTMLElement {
    /* 初始化,创建元素 */
    constructor() {
      super();
      this._shadowRoot = this.attachShadow({ mode: "open" });
    }

    /* 元素被添加到文档时调用 */
    connectedCallback() {
      // 渲染 Hello 组件 shadow 节点中
      ReactDOM.render(`hello, 掘金, 我是 react 渲染出来的`, this._shadowRoot);
    }

    observedAttributes() {
      // 属性数组,数组中的属性变化会被监视
      return []
    }

    attributeChangedCallback(name, oldValue, newValue) {
      // 当上面数组中的属性发生变化时调用 ReactDOM.render, 渲染出最新的界面
      ReactDOM.render(newValue, this._shadowRoot);
    }
  }

  customElements.define("my-element", MyElement);

这样做的问题是 observedAttributes 只能监听 Attributes 的改变,如果你的 Attributes 是引用类型,那么 webcomponents 内部将获取不到正确的 newValue。

当然引用类型你可以先 JSON.stringify 一下,内部再 JSON.parse。但如果 props 是个 function 呢?

JSON.stringify({}) // '{}'
JSON.stringify(() => {}) // undefined

ok,换个思路,既然 Attributes 不是很适合处理引用类型,我们可以通过设置 dom property 来传递引用类型。

那 direflow 具体是怎么做的呢?

《可视化搭建系统》“星空” webcomponents 从0到1(二)

  • 使用一个内部对象存储 props
  • attribute 变化时, 通过 observedAttributes 监听触发 attributeChangedCallback 回调获取 newValue,同步到内部对象
  • property 变化时,由于内部会对所有 property key 进行代理,因为可以在 Object.defineProperties 的 set 中获取 newValue,从而赋值给内部对象
  • attribute 或 property 变化,都会触发 ReactDOM.render 重新渲染,此时从内部对象可以获取到最新的 props,从而渲染出最新的界面

具体代码实现如下,源码中一些无关乎主流程的代码我移除了,并且有详细的注释。(代码略多,下面的代码之间特意没有间距,可以自行 copy 格式化一下了再看舒服些)

<!DOCTYPE html>
<html lang="en">
<script crossorigin src="https://cdn.bootcdn.net/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script crossorigin src="https://cdn.bootcdn.net/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>
<body>
<my-element id="myElement" name="掘金"></my-element>
<button onclick="changeAttribute()">改变 Attribute,传递基本类型数据</button>
<button onclick="changeProperty()">property, 传递引用类型数据</button>
</body>
<script>
function changeAttribute() { myElement.setAttribute('name', 'Attribute 被改变了!') }
function changeProperty() { myElement.name = () => 'property 被改变了' }
function Hello(props) {
if (typeof props.name === 'function') {
return `hello, ${props.name()}`;
}
return `hello, ${props.name}`;
}
class WebComponentFactory {
constructor({ componentProperties }) {
this.componentProperties = componentProperties; // 需要代理的属性
this.componentAttributes = {}; // 需要监听的属性
this.reflectPropertiesToAttributes();
}
reflectPropertiesToAttributes() {
Object.entries(this.componentProperties || {}).forEach(([key, value]) => {
// 非基础类型,直接返回, attributes 只能监听基本类型
if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') return;
this.componentAttributes[key.toLowerCase()] = {
property: key,
value,
};
});
}
create() {
const factory = this;
return class WebComponent extends HTMLElement {
initialProperties = factory.componentProperties;
properties = {}; // 内部对象,存储最新的 props
hasConnected = false; // 是否已挂载
constructor() {
super();
this.subscribeToProperties();
this._shadowRoot = this.attachShadow({ mode: "open" });
}
// 代理属性(attribute + property, 确保 property 改变时,propertyChangedCallback 被触发
subscribeToProperties() {
const propertyMap = {};
Object.keys(this.initialProperties).forEach((key) => {
propertyMap[key] = {
configurable: true,
enumerable: true,
get: () => {
const currentValue = this.properties.hasOwnProperty(key) ? this.properties[key]: this.initialProperties[key];
return currentValue;
},
set: (newValue) => {
const oldValue = this.properties.hasOwnProperty(key) ? this.properties[key] : this.initialProperties[key];
this.propertyChangedCallback(key, oldValue, newValue);
},
};
});
Object.defineProperties(this, propertyMap);
}
// 更新内部对象的值, 调用 mountReactApp
propertyChangedCallback(name, oldValue, newValue) {
if (!this.hasConnected) return;
if (oldValue === newValue) return;
this.properties[name] = newValue;
this.mountReactApp();
}
syncronizePropertiesAndAttributes() {
Object.keys(this.initialProperties).forEach((key) => {
if (this.properties.hasOwnProperty(key)) return;
if (this.getAttribute(key) !== null) { // 不为空直接取 Attribute 的值
this.properties[key] = this.getAttribute(key);
return;
}
this.properties[key] = this.initialProperties[key]; // 为空取默认值
});
}
reactProps() {
this.syncronizePropertiesAndAttributes();
return this.properties;
}
mountReactApp() { // 重新渲染
const element = React.createElement(Hello, this.reactProps(), null);
ReactDOM.render(element, this._shadowRoot);
}
connectedCallback() { // 元素被添加到文档时调用
this.mountReactApp();
this.hasConnected = true;
}
static get observedAttributes() {
return Object.keys(factory.componentAttributes); // 属性数组,数组中的属性变化会被监视
}
attributeChangedCallback(name, oldValue, newValue) { // 当上面数组中的属性发生变化时调用
if (!this.hasConnected) return;
if (!factory.componentAttributes.hasOwnProperty(name)) return;
const propertyName = factory.componentAttributes[name].property;
this.properties[propertyName] = newValue; // 同步 attribute newValue 到内部对象
this.mountReactApp();
}
}
}
}
customElements.define("my-element", new WebComponentFactory({ componentProperties: { name: ''}}).create());
</script>
</html>

《可视化搭建系统》“星空” webcomponents 从0到1(二)

direflow 核心源码就说到这里,其实内部还有挺多细节都做的很好,比如插件机制,事件机制,想了解的可以去看完整的源码

webcomponents 框架 magic-microservices

接下来细讲一下第二个框架,上述场景二就是使用的此框架,字节开源,整体设计思路不错,不局限于 react,这也是“星空”选择 magic 的主要原因。

简介

direflow 是 react to webcomponents,而 magic 是 any to webcomponents。

《可视化搭建系统》“星空” webcomponents 从0到1(二)

通过上面 direflow 源码分析,可以发现,基于 webcomponents 生命周期,配合 ReactDOM.render,可以快速实现 react to webcomponents。

那我们思考下,本质上来讲,就是 react 渲染出来的 dom 被插入到了 webcomponents 节点中,并没有什么神奇的东西。

那既然 react 可以渲染出 dom,vue 也可以。换句话说,各种框架最后的本质都是渲染出 dom,只是说这个 dom 是一个普通的原生 dom,还是被 webcomponents 包了一层的自定义 dom / shadow dom

最后,再配合 webcomponents 生命周期,封装一套 props 的处理逻辑,那么 any to webcomponents 理论上就完成了。magic 就是这么一套框架。

架构图

magic 具体是怎么做的呢,可以先简单看下架构图:

《可视化搭建系统》“星空” webcomponents 从0到1(二)

可以看到,其实核心分为两个部分,一部分是 module 动态传入,从而实现不局限于 react。以 module.mount 为例, react 跟 vue 的区别如下:

// react
export async function mount(container, props) {
ReactDOM.render(React.createElement(<Hello />, props, null), container);
}
// vue
export async function mount(container, props) {
Vue.createApp({
...App,
data() {
return props;
},
}).mount(container);
}

另外一部分是 props 的映射处理,前面 direflow 的做法是,做一层代理,利用 dom property 来进行引用数据的传递。

magic 的做法是,既然 attribute 只能传递基本类型,引用类型无法传递,那换个思路,如果可以用基本类型来标志引用类型,那是不是就可以解决引用类型的问题了

例如我们要传递一个函数:

const fn = () => {}; // 函数引用类型
cosnt hashFn = 'xxxxxx'; // 基本字符类型
window.heap = {
hashFn: fn // 做一层映射
}

此时,我们只用传递 hashFn 即可,内部获取 props 的时候,从 window.heap 就可以获取真实的 props。

源码解析

module 动态传入

module 的渲染周期,同样也是基于 webcomponents API 实现,然后暴露出 options.proptTypes,告诉 webcomponents 需要监听哪些 attributes,当其改变时,触发 update 回调。

代码比较好理解,可以参考下面的代码截图:
《可视化搭建系统》“星空” webcomponents 从0到1(二)

useProps 处理引用类型

先看下官方文档给出的使用姿势, 针对引用类型,使用 useProps 进行包裹。

《可视化搭建系统》“星空” webcomponents 从0到1(二)

接下来我们看下,useProps 是如何实现的。其实很简单,无非是做一层映射。
《可视化搭建系统》“星空” webcomponents 从0到1(二)

上面两部分,算是 magic 的核心。但是看完源码后,发现 magic 的插件机制,设计的也很不错,所以,顺带把 magic 插件机制的实现说一下

插件机制

magic 提供了一套插件机制,可以用来自定义插件,实现自定义逻辑。

比如之前使用 magic 的时候,遇到一个场景,react 17 事件是代理到 document 的,但是当使用 magic 对 react 进行转换后,react 会被插入 shadow dom,从而事件失效。此时可以使用 react-shadow-dom-retarget-events 这个库来进行 hack。

如果没有插件机制,那么就需要从源框架层来进行处理,这样对框架侵入性较大,后续有类似的问题,都需要从框架层来进行 hack,那就明显不是一个好的设计。

magic 怎么做的呢,本质上就是一个生产者,消费者模型
从框架提供的单测可以很明显的看到,生产 plugin 的地方。其中 beforeOptionsInit、alterHTMLTags、beforeElementDefinition 是框架定义消费 plugin 的阶段。
《可视化搭建系统》“星空” webcomponents 从0到1(二)

那我们接着看下消费的地方,以 beforeOptionsInit 为例:

《可视化搭建系统》“星空” webcomponents 从0到1(二)

通过这套生产者,消费者的插件机制,就可以实现自定义的逻辑。当然 magic 还有很多细节,具体可以自行阅读源码

magic-microservices 在星空中的作用

通过前面的分析,magic 就很明确了。理论上它可以将任意前端框架,转换为 webcomponents,从而实现 webcomponents 运行在任意框架中

用作物料模板

在第一章节,星空整体架构中提到了,物料是星空整体不可或缺的一部分。

一个是物料模板,基于 magic,星空提供了 react-template,以及 vue-template,这样开发者在进行物料开发的时候,可以跳脱框架选型的限制。

后续 CLI 章节会详细说。

用作页面渲染

星空页面由多个物料组成,并且物料有可能是 react 的,有可能是 vue 的,基于 magic,我们可以将其渲染到同一个页面上。

后续运营搭建后台部分,会详细说。

小结

本文主要围绕 webcomponents 展开,从使用场景,到两个典型框架的源码分析,最后聊到了其中 magic 在星空中的作用。

对于文中提到的两个框架(direflow、magic-microservices),如果有 webcomponents 相关业务场景的小伙伴,可以去细看一下,或许可以解决你的场景问题。

“星空”长这个样子

《可视化搭建系统》“星空” webcomponents 从0到1(二)

“星空”已完成的历史系列

“星空”后续系列

  • “星空”服务端 nodejs 实现(三)
  • 物料模板设计,脚手架实现(四)
  • “星空”搭建后台实现 (五)
  • docker、nginx 环境详解(六)
  • “星空”代码开源,本地启动流程详解(七)

这么认真且有点东西的系列,值得您的关注和赞!👍🏻

原文链接:https://juejin.cn/post/7223824593689460792 作者:接水怪

(0)
上一篇 2023年4月20日 上午10:21
下一篇 2023年4月20日 上午10:32

相关推荐

发表评论

登录后才能评论