前言
你是一个写了多年 react 的资深前端,某天老板交给你一个八百年前的项目。
项目很简单,只有一个 html,虽说用的全是纯原生 js,但你依旧信心满满:这么点小页面,随手就能搞定了。
但是越改越眉头紧锁,太不优雅了,这一大坨代码怎么可以都放在一起。一想到这个项目后续都要你来维护,你决定稍微处理一下。
问题:不能热更新,每次都要手动刷新
解法:引入 vite,轻量,更新快。
问题:只有一个 html,且老板要求最后给他的也只有一个 html
解法:拆分出 js、css,只在写的时候分很多文件,最后给老板的时候只需要通过一些打包插件处理即可(vite-plugin-singlefile)
搞定这两步,你很满意,项目终于有了点样子,但是还不够。你发现虽然 js、css 这些可以拆出去,但 html 这部分是不可拆的,最多使用创建节点的方案,再拼接进那个 html 中。
作为用了许多年的 react 模块化的程序员,你不允许这种不好看的代码出现。但同时,你不愿意使用 react 重构这个 项目,就要搞纯原生 js。
思索片刻,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。
不过还有一个问题: 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,页面也成功跑了起来。
太妙了,这下就解决模块化问题了。你握紧左拳,嘴角也上扬了半分。
再遇困境
正当你想用这个新东西水一篇文档,作为这周的分享时,你似乎隐隐发觉了不对,再翻看刚才的代码:
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 文件。文件既小,又实现了模块化,还几乎没有学习成本。
优雅,太优雅了。保持优雅是每一位工程师的责任。
更进一步
使用原生组件化的基本操作大概如上,接下来就是一些常见的用法了。这部分比较繁琐,建议有需求的时候再翻阅。
生命周期
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>
整体流程为:
- 在 static get observedAttributes 添加要监听的变量。这里我们添加一个 show 作为组件的显示隐藏条件。
- 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.
小伙伴们仔细看会发现,这个函数只是定义在类中,而不是全局,自然访问不到。那么要如何解决呢:
- 方案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 改为对应的节点。
关于路由
这里先欠着吧,因为暂时没有使用场景,思路大概为:
-
分析 url,得到 base 路由
-
做一个 router-view 组件,负责显示
-
当组件切换时,销毁之前的显示节点,并追加新的显示节点
关于 <template>
因为看到许多文章提到了 <template>
在 web components 的使用,所以也简单说下我的看法:
完全用不到 <template>
其优势在于,可以写入 html 中,并不发起渲染。但是你可以 query 到这个 dom,然后进行复制之类的操作。
但是其主要问题是,我必须要把这段 template 整个写入 html 中。截取一个 demo:
发现问题没有,如果我必须写入 html,那这算什么模块化,不还是没拆分过吗(甚至还多了一层<template>
),完全违背了我们拆分 html 的初衷。
抽象
我将其整理并抽象成一套能力,这里是源码:github.com/imoo666/js-…
世界的终极
你是一个写了多年 react 的资深前端,某天老板交给你一个八百年前的项目。你通过 web components 完美的优化了整个项目,老板很看好你,从此担任 ceo,迎娶白富美,走向人生巅峰...
你醒啦,做什么梦笑这么开心,该起床搬砖了。