纯原生 js 也能模块化?web components 优雅实战攻略

906 阅读10分钟

前言

你是一个写了多年 react 的资深前端,某天老板交给你一个八百年前的项目。

项目很简单,只有一个 html,虽说用的全是纯原生 js,但你依旧信心满满:这么点小页面,随手就能搞定了。

image.png

但是越改越眉头紧锁,太不优雅了,这一大坨代码怎么可以都放在一起。一想到这个项目后续都要你来维护,你决定稍微处理一下。

问题:不能热更新,每次都要手动刷新

解法:引入 vite,轻量,更新快。

问题:只有一个 html,且老板要求最后给他的也只有一个 html

解法:拆分出 js、css,只在写的时候分很多文件,最后给老板的时候只需要通过一些打包插件处理即可(vite-plugin-singlefile)

搞定这两步,你很满意,项目终于有了点样子,但是还不够。你发现虽然 js、css 这些可以拆出去,但 html 这部分是不可拆的,最多使用创建节点的方案,再拼接进那个 html 中。

作为用了许多年的 react 模块化的程序员,你不允许这种不好看的代码出现。但同时,你不愿意使用 react 重构这个 项目,就要搞纯原生 js。

image.png

思索片刻,web components 的原生模块化能力出现在你的脑海中。

初试牛刀

这个模块化到底怎么用呢,你翻了很多文章,抓住了关键的 api。

 customElements.define(tagName, CustomElement);
// index.js 里定义,第一个参数为标签名,第二个参数为 CustomElementConstructor
customElements.define("components-mask", Index);

// index.html 中使用
<div>
    <components-mask></components-mask>
</div>

只要你在 js 里定义好一个组件,就可以在 html 中使用这个组件,这样一来,一个庞大的 html 就可以拆分成许多个组件了,这很 react。

image.png

不过还有一个问题: customElements.define 中的第二个参数,也就是示例中的 Index 是什么?要怎么定义它?写起来是什么样的?

你参照文档,写了一个最小 demo

  // index.js
  class CustomElement extends HTMLElement {
    constructor() {
      super();
      const shadow = this.attachShadow({ mode: "open" });
      shadow.innerHTML = "<div>这是一个组件</div>";
    }
  }
  customElements.define("components-demo", CustomElement);
  
  // index.html
<div class="root">
    <div>这是外层</div>
    <components-demo></components-demo>
    <div>这也是外层</div>
</div>

随着键入 yarn dev,页面也成功跑了起来。

太妙了,这下就解决模块化问题了。你握紧左拳,嘴角也上扬了半分。

image.png

再遇困境

正当你想用这个新东西水一篇文档,作为这周的分享时,你似乎隐隐发觉了不对,再翻看刚才的代码:

shadow.innerHTML = "<div>这是一个组件</div>";

不对,这样不行,你眉头又紧锁了。

如果这样写,那跟用 insertAdjacentHTML 插入 html 其实没有特别大的区别。

你不愿意用这个 api 的原因就是,html 会被转为字符串,这样就失去了格式化能力,出了错也不太好排查。不好写也不好改,专业点说就是:可维护性差,可读性差。

问题:如何正常写 html 文件,还能将其变为字符串嵌入到 js 中。

解法:...

冥思苦想了许久,感觉不用 node 的能力好像做不到这个事情,原生的 js 没法读取 html 文件。到底如何才能引用 html 文件里的内容呢?

你在网上翻了许久,终于在一个答案面前眼前一亮。

居然可以这样用,仅需在引入的最后加上 ?raw 即可。

你抱着好奇的态度去查询了其他的参数,可惜似乎只有这一个比较用得上(vite 静态资源处理相关

很快,你修改了自己的代码

// components.html
<div>这是一个组件</div>

// index.js
import html from "./components.html?raw";
class CustomElement extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = html;
  }
}
customElements.define("components-demo", CustomElement);

// index.html
<div class="root">
    <div>这是外层</div>
    <components-demo></components-demo>
    <div>这也是外层</div>
</div>

太好了,这样 html 就可以作为单独的文件了。既可以享受格式化处理,又不妨碍正常的读取与使用。

但是你遇到了一个小问题:

ts 出现了报错,这是一种非法写法,好在这种小 case 你有 gpt 帮你处理。只需要在根目录新建一个 type.d.ts,并写入:

declare module "*.html?raw";

大功告成,按照这个方式,你改造了一下你的项目,一个纯原生项目,就拥有了这样的结构

而最后打包交给老板的产物也只有一个 html 文件。文件既小,又实现了模块化,还几乎没有学习成本。

优雅,太优雅了。保持优雅是每一位工程师的责任

image.png

更进一步

使用原生组件化的基本操作大概如上,接下来就是一些常见的用法了。这部分比较繁琐,建议有需求的时候再翻阅。

生命周期

connectedCallback

  • 触发时机:自定义元素被添加到文档 DOM 中时会调用此方法。通常在元素被插入页面时触发。
  • 用途:在元素被插入文档时初始化组件、注册事件监听器、设置默认状态等。

disconnectedCallback

  • 触发时机:自定义元素从文档 DOM 中移除时会调用此方法。通常在元素被从页面移除时触发。
  • 用途:清理组件资源,如移除事件监听器、取消定时器等,以避免内存泄漏。

attributeChangedCallback

  • 触发时机:当自定义元素的属性发生变化时会调用此方法。这需要在定义元素时通过 static get observedAttributes() 明确列出需要观察的属性。
  • 用途:响应属性的变化,更新组件的内部状态或重新渲染组件。

adoptedCallback(基本不用)

  • 触发时机:当自定义元素从一个文档被移动到另一个文档时调用

  • 用途:处理元素在不同文档之间移动时需要执行的逻辑。

属性监听

利用刚才提到的**attributeChangedCallback** ****,我们可以实现对组件属性的监听,做出一个简易的数据绑定。我们来做一个经典的 mask 蒙版组件,用于屏蔽整个页面并弹出提示。

// index.html (省略css部分)
<div id="overlay">
  <div>这里是提示</div>
</div>

// index.js
import html from "./index.html?raw";
class Index extends HTMLElement {
  #overlay: HTMLElement | null = null;

  static get observedAttributes() {
    return ["show"];
  }

  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = html;

    this.#overlay = shadow.querySelector("#overlay") as HTMLElement;
  }

  attributeChangedCallback(
    name: string,
    _ : string | null,
    newValue: string | null
  ) {
    if (name === "show") {
      if (newValue === "true") {
        this.#overlay!.style.display = "flex";
      } else {
        this.#overlay!.style.display = "none";
      }
    }
  }

}

customElements.define("components-mask", Index);

// 使用
<components-mask show="false"></components-mask>

整体流程为:

  1. 在 static get observedAttributes 添加要监听的变量。这里我们添加一个 show 作为组件的显示隐藏条件。
  2. attributeChangedCallback 会监听所有已添加的变量,当 name 为对应值时,就会触发执行。当 name 为 show 时,执行对应的显示隐藏。

这样一来,只要组件的 show 变更,内部就会执行对应的逻辑,这很 react

看上去很美好对吧,但个人感觉不好用。因为 html 并不支持变量,你只能传递字符串,所以想要实现数据传递必须要做3件事:

  1、触发变化的文件(称为文件a)需要新建一个变量 data 和一个函数 setData(对标 react 的 useState)

  2、文件a 需要监听该 data,data 变化时,获取组件 dom 然后去更新属性值。

  3、组件 dom 需要监听属性,然后去执行对应的回调函数

对每一个属性都需要同样的这三步,太繁琐了。此外,如果不止一个地方能 setData,你还需要把 setData 也想办法传递出去。参见下文的组件通信

插槽功能

匿名插槽

刚才的蒙版中,我们使用了 <div>这里是提示</div> 这样的代码,但是众所众知,一个漂亮的组件应该有充分的可自定义的能力,如果说我想显示一个 “提示1” 和一个 “提示2”,总不能写两个组件出来吧?

这时候 slot 就派上用场了:

// index.html
<div id="overlay">
  <slot></slot>
</div>
// index.js 同上不变

// 使用时
<components-mask id="index-mask1" show="false">
  <div>提示1</div>
</components-mask>

<components-mask id="index-mask2" show="false">
  <div>提示2</div>
</components-mask>

此时就可以完美插入了。

具名插槽

听名字也知道了,直接来吧

// index.html
<div id="overlay">
  <slot name="header"></slot>
  <div>分割线</div>
  <slot name="content"></slot>
</div>
// index.js 同上不变

// 使用时
<components-mask show="false">
  <div slot="header">提示1</div>
  <div slot="conent">提示2</div>
</components-mask>

也就是加了个 slot name,进行对应的替换而已。

事件机制

绑定事件,这个是常用项了,我们很轻松的就写下如下代码:

// html
<div class="wrapper">
  <div class="text" onclick="handleClick">
    <slot></slot>
  </div>
</div>

// js
import html from "./index.html?raw";
export class PageIndex extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = html;
  }
  handleClick() {
    console.log('hello');
  }
}

customElements.define("components-message", PageIndex);

编译器也很轻松的给你报错:handleClick is not defined.

image.png

小伙伴们仔细看会发现,这个函数只是定义在类中,而不是全局,自然访问不到。那么要如何解决呢:

  • 方案1:手动监视
export class PageIndex extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = html;
    
    // 使用 addEventListener 绑定事件
    shadow.querySelector('.text').addEventListener('click', this.handleClick.bind(this));
  }

  handleClick() {
    console.log('hello');
  }
}
  • 方案2:手动绑定
export class PageIndex extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = html;
    (shadow.querySelector(".text") as HTMLButtonElement).onclick = () => {
      this.handleClick();
    };
  }

  handleClick() {
    console.log('hello');
  }
}
  • 方案3:直接从 html 下手
<div class="wrapper">
  <div class="text" onclick="this.getRootNode().host.handleClick()">
    <slot></slot>
  </div>
</div>

export class PageIndex extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = html;
  }
  handleClick() {
    console.log("hello");
  }
}

这么看下来,方案 3 利用 this.getRootNode().host 拿到类是最舒服的,谁愿意不厌其烦的获取 dom 进行绑定啊。

但这时候有想进步的同学就要问了,有没有更优雅的写法啊,一个 click 每次要写这么大一坨啊?

诶,这就要涉及到自己封装一个click 了,请看代码:

export class ImooElement extends HTMLElement {
  connectedCallback() {
    this.setupEventHandlers();
  }

  // 设置事件处理程序的方法
  setupEventHandlers() {
    // 使用 shadowRoot 查询所有带有 "onImooClick" 属性的元素
    this.shadowRoot?.querySelectorAll("[onImooClick]").forEach((element) => {
      // 获取元素上的 "onImooClick" 属性值
      const onClickValue = element.getAttribute("onImooClick");
      if (onClickValue) {
        // 从当前类实例中获取属性值对应的处理函数
        const handler = (this as any)[onClickValue];
        if (typeof handler === "function") {
          // 如果处理函数存在且为函数类型,添加点击事件监听器
          element.addEventListener("click", (event) =>
            // 使用 call 方法绑定事件处理函数的上下文为当前实例,并传递事件对象
            handler.call(this, event)
          );
        }
      }
    });
  }
}

// js 改为从 ImooElement 继承,其他不变
// 使用时
  <div class="text" onImooClick="handleClick">
    <slot></slot>
  </div>

你看我就说上网能学到东西吧

组件通信

什么父子组件通信、祖孙组件通信,都是靠自定义事件和监听一把梭,我建议是直接定义两个函数

export const dispatchMessage = (name: string, value: object) => {
  document.dispatchEvent(
    new CustomEvent(name, {
      detail: value,
      composed: true,
    })
  );
};

export const listenMessage = (
  eventName: string,
  callback: (detail: any) => void
) => {
  document.addEventListener(eventName, (event: Event) => {
    const detail = (event as CustomEvent).detail;
    callback(detail);
  });
};

dispatchMessage 负责发送事件到全局,listenMessage 负责监听全局事件。不过这么搞大项目的话,容易有函数重名问题,这个时候可以手动收缩监听范围,把 document 改为对应的节点。

关于路由

这里先欠着吧,因为暂时没有使用场景,思路大概为:

  1. 分析 url,得到 base 路由

  2. 做一个 router-view 组件,负责显示

  3. 当组件切换时,销毁之前的显示节点,并追加新的显示节点

关于 <template>

因为看到许多文章提到了 <template> 在 web components 的使用,所以也简单说下我的看法:

完全用不到 <template>

其优势在于,可以写入 html 中,并不发起渲染。但是你可以 query 到这个 dom,然后进行复制之类的操作。

但是其主要问题是,我必须要把这段 template 整个写入 html 中。截取一个 demo:

发现问题没有,如果我必须写入 html,那这算什么模块化,不还是没拆分过吗(甚至还多了一层<template>),完全违背了我们拆分 html 的初衷。

抽象

我将其整理并抽象成一套能力,这里是源码:github.com/imoo666/js-…

世界的终极

你是一个写了多年 react 的资深前端,某天老板交给你一个八百年前的项目。你通过 web components 完美的优化了整个项目,老板很看好你,从此担任 ceo,迎娶白富美,走向人生巅峰...

你醒啦,做什么梦笑这么开心,该起床搬砖了。

image.png