【小程序 - 大智慧】Expareser 组件渲染框架

5.png



问题思考

首先,给大家抛出去几个问题:

  1. 前端框架 Vue React 都有自己的组件库,但是并不兼容,那么 不依赖框架能 自定义组件 吗?
  2. 微信小程序开发的时候都会自定义组件是吧,那么调试控制台出现的 shadow-root 是什么,有注意吗?
  3. 微信小程序编写 wxml 的时候,为什么和 html 语法不一致,多出来 view text 这些标签,里面究竟是如何实现的,彼此有什么关联?

课程目标

通过本节课程的学习,希望大家掌握如下的目标:

  1. 弄懂上述问题背后的执行逻辑
  2. 能够利用原生 Web Component 自定义一个简易的组件

Web Component

使用自定义元素 - Web API | MDN

Web Component直译过来就是 web 组件的意思,就是说明离开了前端框架的帮助,我们依然可以用原生组件来进行开发复用。

类型说明

如同官网所说,继承特定元素类得到的组件是 自定义内置元素组件(可以得到特定类型的属性和方法),继承元素基类得到的组件是 独立自定义元素,本质上两种没什么区别,接下来我们重点就放在第二个上面。

定义组件

<button is="my-button-one">内置按钮</button>
<my-button-two></my-button-two>


// 01 定义一个内置元素的按钮
class MyButtonOne extends HTMLButtonElement {
  constructor() {
    self = super();
  }

  // 元素添加到文档调用
  connectedCallback() {
    // 1.创建一个 div
    const div = document.createElement("div");

    // 2.设置 div 的样式
    div.style.width = "100px";
    div.style.height = "50px";
    div.style.textAlign = "center";
    div.style.lineHeight = "50px";
    div.style.cursor = "pointer";
    self.style.marginBottom = "20px";

    // 3.设置 div 的内容
    div.innerHTML = "自定义按钮";

    // 4.将 div 添加到页面
    self.appendChild(div);
  }
}

// 02 定义一个自定义的按钮
class MyButtonTwo extends HTMLElement {
  constructor() {
    // 先调用父类构造器,实例化 HTMLElement ,这样才能有 html 元素的基本属性
    super();
  }

  // 元素添加到文档调用
  connectedCallback() {
    console.log("自定义元素添加到页面", this);

    // 1.创建一个 div
    const div = document.createElement("div");

    // 2.设置 div 的样式
    div.style.width = "100px";
    div.style.height = "50px";
    div.style.backgroundColor = "red";
    div.style.color = "white";
    div.style.textAlign = "center";
    div.style.lineHeight = "50px";
    div.style.cursor = "pointer";

    // 3.设置 div 的内容
    div.innerHTML = "自定义按钮";

    // 4.将 div 添加到页面
    this.appendChild(div);
  }

  // 元素从文档中移除时调用
  disconnectedCallback() {
    console.log("自定义元素从页面移除");
  }

  // 元素被移动到新文档时调用
  adoptedCallback() {
    console.log("自定义元素被移动到新文档");
  }

  // 监听属性变化
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}`);
  }
}

// 组件注册
customElements.define("my-button-one", MyButtonOne, { extends: "button" });
customElements.define("my-button-two", MyButtonTwo);

// 监听组件状态
customElements.whenDefined("my-button-two").then(() => {
  console.log("my-button-two 组件已定义");
});

自定义组件的命名规则是有限制的:

  • 自定义元素的名称,必须包含短横线(-)。它可以确保html解析器能够区分常规元素和自定义元素,还能确保html标记的兼容性。
  • 自定义元素只能一次定义一个,一旦定义无法撤回。
  • 自定义元素不能单标记封闭。比如 <custom-component />,必须写一对开闭标记。比如 <custom-component></custom-component>

上面两个就是最基本的自定义组件,但是这个也没有样式 class 属性传值 事件方法都没有,下面我们一步步加上。

属性添加

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <style>
      * {
        margin: 0;
        padding: 0;
      }

      body {
        width: 100px;
        margin: 200px auto;
        background-color: #f5f5f5;
      }

      .my-button-two {
        width: 180px;
        height: 50px;
        background-color: red;
        color: white;
        text-align: center;
        line-height: 50px;
        cursor: pointer;
      }
    </style>
    <my-button-two color="pink" text="Custom Component" @click="clickButton()"></my-button-two>
    <title>02_属性添加</title>
  </head>
  <body>
    <script>
      // 自定义方法
      const clickButton = () => {
        alert("点击了自定义按钮");
      };

      class MyButtonTwo extends HTMLElement {
        // 监控属性变化
        static observedAttributes = ["color", "text", "@click"];

        constructor() {
          // 先调用父类构造器,实例化 HTMLElement ,这样才能有 html 元素的基本属性
          super();
        }

        // 元素添加到文档调用
        connectedCallback() {
          // 1.创建一个 div
          const div = document.createElement("div");

          // 2.设置 div 的样式
          div.className = "my-button-two";

          // 3.设置 div 的内容
          const bgColor = this.getAttribute("color");
          const textValue = this.getAttribute("text");
          const clickValue = this.getAttribute("@click");

          // 需要在同一个 js 执行环境内部执行
          div.addEventListener("click", () => {
            eval(clickValue);
          });

          div.style.backgroundColor = bgColor;
          div.innerHTML = textValue;

          // 4.将 div 添加到页面
          this.appendChild(div);
        }

        // 元素从文档中移除时调用
        disconnectedCallback() {
          console.log("自定义元素从页面移除");
        }

        // 元素被移动到新文档时调用
        adoptedCallback() {
          console.log("自定义元素被移动到新文档");
        }

        // 监听属性变化
        attributeChangedCallback(name, oldValue, newValue) {
          console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}`);
        }
      }

      customElements.define("my-button-two", MyButtonTwo);

      // 监听组件状态
      customElements.whenDefined("my-button-two").then(() => {
        console.log("my-button-two 组件已定义");
      });
    </script>
  </body>
</html>

对着调试控制台我们可以发现,当前 html 写的样式可以影响到组件内部,这并不符合我们之前说的组件和外部彼此 属性隔离 的特点,这就需要了解到下一个概念了。

Shadow DOM

使用影子 DOM - Web API | MDN

影子 DOM(Shadow DOM)允许你将一个 DOM 树附加到一个元素上,并且使该树的内部对于在页面中运行的 JavaScriptCSS 是隐藏的。

在这里插入图片描述

有一些 影子 DOM 术语 需要注意:

  • 影子宿主(Shadow host):影子 DOM 附加到的常规 DOM 节点。
  • 影子树(Shadow tree):影子 DOM 内部的 DOM 树。
  • 影子边界(Shadow boundary):影子 DOM 终止,常规 DOM 开始的地方。
  • 影子根(Shadow root):影子树的根节点。

这里的 影子宿主(Shadow host) 可以选取普通的 div 标签,但是由于我们是自定义元素,这里的 挂载节点 就是 自定义组件 Web Component 了,接下来我们举一个例子:

const shadow = this.attachShadow({ mode: "open" });


// 这里的 this 就是标识 自定义组件 DOM 元素
// mode 分为 open closed 表示能否通过 dom.shadowRoot 获取
// 不能获取的话,只能在内部通过 shadow 访问了
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <style>
      * {
        margin: 0;
        padding: 0;
      }

      body {
        width: 100px;
        margin: 200px auto;
        background-color: #f5f5f5;
      }

      .my-button-two {
        width: 180px;
        height: 50px;
        background-color: red;
        color: white;
        text-align: center;
        line-height: 50px;
        cursor: pointer;
      }
    </style>
    <my-button-two color="pink" text="Custom Component" @click="clickButton()"></my-button-two>
    <title>03_shadow dom</title>
  </head>
  <body>
    <script>
      // 自定义方法
      const clickButton = () => {
        alert("点击了自定义按钮");
      };

      class MyButtonTwo extends HTMLElement {
        // 监控属性变化
        static observedAttributes = ["color", "text", "@click"];

        constructor() {
          // 先调用父类构造器,实例化 HTMLElement ,这样才能有 html 元素的基本属性
          super();
        }

        // 元素添加到文档调用
        connectedCallback() {
          // 隔离 DOM
          const shadow = this.attachShadow({ mode: "open" });

          // 1.创建一个 div
          const div = document.createElement("div");

          // 2.设置 div 的样式
          div.className = "my-button-two";

          // 3.设置 div 的内容
          const bgColor = this.getAttribute("color");
          const textValue = this.getAttribute("text");
          const clickValue = this.getAttribute("@click");

          // 需要在同一个 js 执行环境内部执行
          div.addEventListener("click", () => {
            eval(clickValue);
          });

          div.style.backgroundColor = bgColor;
          div.innerHTML = textValue;

          // 4.将 div 添加到页面
          shadow.appendChild(div);
        }

        // 元素从文档中移除时调用
        disconnectedCallback() {
          console.log("自定义元素从页面移除");
        }

        // 元素被移动到新文档时调用
        adoptedCallback() {
          console.log("自定义元素被移动到新文档");
        }

        // 监听属性变化
        attributeChangedCallback(name, oldValue, newValue) {
          console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}`);
        }
      }

      customElements.define("my-button-two", MyButtonTwo);

      // 监听组件状态
      customElements.whenDefined("my-button-two").then(() => {
        console.log("my-button-two 组件已定义");
      });
    </script>
  </body>
</html>

这里我们可以看到 文档的样式已经无法影响我们的自定义组件了,这是因为被 shadow 阻隔了,接下来就可以继续完善这段逻辑了。

Template and Slot

使用模板和插槽 - Web API | MDN

前端组件开发中有两套我们熟悉的 Template(模板)和 Slot(插槽),接下来就利用这两个功能继续完善一下我们的代码逻辑。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <style>
      * {
        margin: 0;
        padding: 0;
      }

      body {
        width: 100px;
        margin: 200px auto;
        background-color: #f5f5f5;
      }

      .my-button-two {
        width: 180px;
        height: 50px;
        background-color: red;
        color: white;
        text-align: center;
        line-height: 50px;
        cursor: pointer;
      }
    </style>
    <template id="button-template">
      <style>
        .my-button-two {
          width: 180px;
          height: 50px;
          background-color: red;
          color: white;
          text-align: center;
          line-height: 50px;
          cursor: pointer;
        }
      </style>
      <div class="my-button-two">
        <slot name="text"></slot>
      </div>
    </template>
    <my-button-two id="my-button-two" color="pink" @click="clickButton()"
      ><span slot="text">Custom Component</span></my-button-two
    >
    <title>04_Tempalte and Slot</title>
  </head>
  <body>
    <script>
      // 自定义方法
      const clickButton = () => {
        alert("点击了自定义按钮");
      };

      class MyButtonTwo extends HTMLElement {
        // 监控属性变化
        static observedAttributes = ["color", "text", "@click"];

        constructor() {
          // 先调用父类构造器,实例化 HTMLElement ,这样才能有 html 元素的基本属性
          super();
        }

        // 元素添加到文档调用
        connectedCallback() {
          // 隔离 DOM
          const shadow = this.attachShadow({ mode: "closed" });

          // 1.获取模板
          const template = document.querySelector("#button-template");

          // 2.克隆模板
          const content = template.content.cloneNode(true);

          // 3.显示文本
          const clickValue = this.getAttribute("@click");

          // 4.执行函数
          const clickEvent = content.querySelector(".my-button-two");
          clickEvent.addEventListener("click", () => {
            eval(clickValue);
          });

          // 5.将 template 添加到页面
          shadow.appendChild(content);
        }

        // 元素从文档中移除时调用
        disconnectedCallback() {
          console.log("自定义元素从页面移除");
        }

        // 元素被移动到新文档时调用
        adoptedCallback() {
          console.log("自定义元素被移动到新文档");
        }

        // 监听属性变化
        attributeChangedCallback(name, oldValue, newValue) {
          console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}`);
        }
      }

      customElements.define("my-button-two", MyButtonTwo);

      // 监听组件状态
      customElements.whenDefined("my-button-two").then(() => {
        console.log("my-button-two 组件已定义");
      });
    </script>
  </body>
</html>

艺龙酒店科技官网

举例 video 标签就是利用这套机制封装的…

Exparser 框架原理

Exparser 是微信小程序的组件组织框架,内置在小程序基础库中,为小程序提供各种各样的组件支撑。内置组件和自定义组件都有 Exparser 组织管理。

Exparser 的组件模型与 WebComponents 标准中的 Shadow DOM 高度相似,Exparser 会维护整个页面的节点树相关信息,包括节点的属性、事件绑定等,相当于一个简化版的 Shadow DOM 实现。Exparser 的主要特点包括以下几点:

  • 基于 Shadow DOM 模型:模型上与 WebComponentsShadow DOM 高度相似,但不依赖浏览器的原生支持,也没有其他依赖库;实现时,还针对性地增加了其他 API 以支持小程序组件编程。
  • 可在纯 JS 环境中运行:这意味着逻辑层也具有一定的组件树组织能力。
  • 高效轻量:性能表现好,在组件实例极多的环境下表现尤其优异,同时代码尺寸也较小。

自定义组件

上图是小程序利用 shadow dom 实现 样式和JS 逻辑隔离的组件,这只是第一层,里面的 view text 也是由 Exparser 从普通 div span 封装得来的,接下来让我们深入了解下:

内置组件

接下来带大家一步步过一遍微信小程序内置组件是如何渲染的

// 1.在微信开发工具找到解析命令 wcc
// wcc 是将 wxml 解析为 js 文件,然后逻辑线程注入 webview 执行的
微信web开发者工具\code\package.nw\node_modules\wcc-exec

// 2.将命令文件移动到文件目录下,开始执行解析
./wcc -js index.wxml >> dom.js

  • 可以看到本质上就是一个封装好的 $gwx 函数,它的作用是生成微信自定义的组件和虚拟 dom 节点( diff 算法),用来给后面的 Exparser 生成真实的 DOM 节点
  • 那这个函数是在哪里调用的呢,我们继续向下看
// 1. 调试控制台打开当前页面的 webview
document.getElementsByTagName('webview')
document.getElementsByTagName('webview')[0].showDevTools(true, null)

// 2. 可以发现编译后的 wxml 会利用 js 脚本以一定格式插入到页面中执行
var decodeName = decodeURI("./pages/command_component/index.wxml")
var generateFunc = $gwx(decodeName)

generateFunc()

// 3.传入数据
generateFunc({logs:[1,2,3]})


<view wx:for="{{ logs }}" wx:key="index">
  <text>{{ item }}</text>
</view>

可以看到如上图所示的虚拟节点数组,接下来我们详细剖析一下

  1. $gwx(decodeName) 不直接返回 dom 树,而是返回一个函数的原因是因为需要动态注入和相关配置,函数能够很好的把控时机
  2. 利用动态传参我们发现,包含循环数组和 key 的会带有 virtual 标识,用来后面的 DIff 算法比较
  3. document.dispatchEvent 触发自定义事件 将 generateFunc 当作参数传递给底层渲染库

  1. 可以看得到无论是 view 还是 text 底层都是通过 div span 的自定义组件构成的
  2. 这一切来源于 Exparser 框架,在 渲染层 会内置一系列方法,大致和上面自定义 web component 一致,进行对组件的定义,注册后将 js 脚本引入页面,那么当前页面就可用了
  3. 接下来带大家进行源码的拆解

下周计划

  1. 继续深入小程序原理(收益不高)
  2. 扩展前端其他的技术方向(感兴趣建议)
    1. 前端组件库实现拆解
    2. 前端调试能力提升
    3. 前端工程化能够了解
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

知心宝贝

你的鼓励是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值