通过quark-design了解webComponent使用封装
提示
前言:Web Components 是一套不同的技术,是实现组件化的一种解决方案, 它允许我们创建一个可重用的定制元素,并且在 web 应用中使用它们。
该文篇幅较长,需要了解的知识点较多,可以分次慢慢阅读。
# WebComponent (opens new window)
我们可以通过阅读mdn的文档,了解实现webComponent的基本流程。
- 首先,创建一个类或函数来指定 web 组件的功能
- js提供了 window.customElements (opens new window) 及其API来创建自定义元素,这里提及两个后面会用到的API:
- customElements.get(tagName):返回引用的构造函数的自定义元素的名字,若无该元素,则返回undefined
- customElements.define(tagName, constructor, options): 创建自定义元素,tagName为元素名,constructor为该元素的构造器,比如可以是一个类,options是一个对象,目前仅有extends属性,用于支持指定继承的已创建的元素。被用于创建自定义元素。
- 使用Element.attachShadow() (opens new window) 方法将一个 shadow DOM 附加到自定义元素上。使用通常的 DOM 方法向 shadow DOM 中添加子元素、事件监听器等等。有人要问,什么是Shadow DOM?你可以将shadow DOM (opens new window)视为“DOM中的DOM”。它是自己独立的DOM树,它以 shadow root 节点为起始根节点, 具有自己的元素和样式,与原始DOM完全隔离,就像一个沙盒,这也是我们用它的原因。
- 在页面任何喜欢的位置使用自定义元素,就像使用常规 HTML 元素那样。
除此之外,我们还需要对以下知识有一定了解:
至此,我们可以开始从quark-design组件库来了解webComponent的强大之处了。
# quark-design
quark-design 是一款通过webcomponent实现沙箱环境,并借助preact渲染dom,以实现跨技术栈皆可使用的一款UI框架,其除去打包相关内容,最重要的实现则是在quark-core (opens new window)上,下面我们先搭建一个阅读源码的环境。
# 搭建环境
- 首先,我们需要将框架的源码拉取下来 :
git clone git@github.com:hellof2e/quark-design.git
- 安装其官方支持的脚手架工具[Quark CLI]
npm i -g @quarkd/quark-cli
npx create-quark
cd quark-project
npm install
2
3
4
- 将quark-design中package里的quark-core/src文件夹整个复制到我们创建的项目的src下,文件夹命名为core
- 将src\main.tsx 下的@quarkd/core 引用 修改成 ./core即可开始本地调试
# 代码流程
接下来,我们通过阅读、断点core文件的内容,来逐步解析其实现原理。
# dblKeyMap.ts
export default class DblKeyMap<Key1, Key2, Value> {
private map: Map<Key1, Map<Key2, Value>> = new Map();
get(key1: Key1, key2: Key2) {
const subMap = this.map.get(key1);
if (subMap) {
return subMap.get(key2);
}
}
set(key1: Key1, key2: Key2, value: Value) {
let subMap = this.map.get(key1);
if (!subMap) {
subMap = new Map();
this.map.set(key1, subMap);
}
subMap?.set(key2, value);
}
forEach(cb: (value: Value, key1: Key1, key2: Key2) => void) {
this.map.forEach((subMap, key1) => {
subMap.forEach((value, key2) => {
cb(value, key1, key2);
});
});
}
delete(key1: Key1) {
this.map.delete(key1);
}
deleteAll() {
this.map.forEach((_, key1) => {
this.map.delete(key1);
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
DblKeyMap文件实现了一个DblKeyMap文件类,其作用其实是通过Map做了一个二维数组的增删改查遍历,以供后续使用。
# eventController.ts
eventController文件的作用是实现了一个EventController类,该类会在指定的元素上注册自定义事件,并将该事件记录到DblKeyMap中,并在指定的时刻可以移除所有监听的事件。其操作主要用于实现发布订阅模式,更具体点实现Vue的 $on, 和 $emit,当$on时bindListener,$emit时dispatchEvent触发该事件。
import DblKeyMap from './dblKeyMap';
export type EventHandler = (evt: Event) => void;
export class EventController {
private eventMap: DblKeyMap<Element, string, Set<EventHandler>> =
new DblKeyMap();
/** 添加事件监听 */
bindListener = (
el: Element | null,
eventName: string,
eventHandler: EventHandler
) => {
if (!el || !eventName || !eventHandler) {
return;
}
let list = this.eventMap.get(el, eventName);
if (!list) {
list = new Set();
this.eventMap.set(el, eventName, list);
}
if (!list.has(eventHandler)) {
list.add(eventHandler);
el.addEventListener(eventName, eventHandler, true);
}
};
/** 移除所有监听事件 */
removeAllListener = () => {
this.eventMap.forEach((list, el, eventName) => {
list.forEach((handler) => {
el.removeEventListener(eventName, handler);
});
});
this.eventMap.deleteAll();
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 通用方法、代理,相关常量
/** 判空函数 */
const isEmpty = (val: any) => !(val || val === false || val === 0);
export interface PropertyDeclaration {
/**
* 是否响应式属性,接收外部的参数变化,会自动加入observedAttributes数组中
*/
readonly observed?: boolean | string;
/**
* 属性类型,会针对类型做不同的特殊处理。
* Boolean, Number, String
*/
readonly type?: TypeHint;
/**
* 从外部获取属性时的值转换方法
*/
readonly converter?: converterFunction;
}
/** 默认的属性装饰器 -- 对数字和布尔值做了处理 */
const defaultConverter = (value: any, type?: any): any => {
let newValue = value;
switch (type) {
case Number:
newValue = isEmpty(value) ? value : Number(value);
break;
case Boolean:
newValue = !([null, "false", false, undefined].indexOf(value) > -1);
break;
}
return newValue;
};
const defaultPropertyDeclaration: PropertyDeclaration = {
observed: true,
type: String,
converter: defaultConverter,
};
const ElementProperties: DblKeyMap<
typeof QuarkElement,
string,
PropertyDeclaration
> = new DblKeyMap();
const Descriptors: DblKeyMap<
typeof QuarkElement,
string,
(defaultValue?: any) => PropertyDescriptor
> = new DblKeyMap();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# index关键实现
接下来我们看下组件的组件的实现方式:
import QuarkElement, { customElement, property, state } from "./core";
import style from "./style.css";
@customElement({
tag: "my-component",
style,
})
export default class MyComponent extends QuarkElement {
@state() counter: number = 0;
@property() atitle: string = "";
@property() w: boolean;
componentDidMount() {
console.log(this.atitle, 333);
}
handleClick = () => {
this.counter++;
};
render() {
return (
<div>
<h1>{this.atitle}</h1>
<div class="card">
<button onClick={this.handleClick} type="button">
count is {this.counter}
</button>
</div>
<p class="read-the-docs">Click on the Quark logo to learn more</p>
</div>
);
}
}
// 使用:
<my-component atitle="Test Demo!" w="true"></my-component>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
我们可以看到创建组件使用了许多的装饰器 , 并继承了QuarkElement类,我们先看下QuarkElement做了什么:
export default class QuarkElement extends HTMLElement {
static h = h;
/** 获取、配置元素上的属性的装饰器 */
protected static getPropertyDescriptor(
name: string,
options: PropertyDeclaration
): (defaultValue?: any) => PropertyDescriptor {
return (defaultValue?: any) => {
return {
get(this: QuarkElement): any {
// 调用了继承了HTMLELEMENT的原生方法
let val = this.getAttribute(name);
if (!isEmpty(defaultValue)) {
// 判断val是否为空值
// const isEmpty = () => !(val && val === false && val === 0)
// 当类型为非Boolean时,通过isEmpty方法判断val是否为空值
// 当类型为Boolean时,在isEmpty判断之外,额外认定空字符串不为空值
//
// 条件表达式推导过程
// 由:(options.type !== Boolean && isEmpty(val)) || (options.type === Boolean && isEmpty(val) && val !== '')
// 变形为:isEmpty(val) && (options.type !== Boolean || (options.type === Boolean && val !== ''))
// 其中options.type === Boolean显然恒等于true:isEmpty(val) && (options.type !== Boolean || (true && val !== ''))
// 得出:isEmpty(val) && (options.type !== Boolean || val !== '')
if (isEmpty(val) && (options.type !== Boolean || val !== "")) {
return defaultValue;
}
}
if (typeof options.converter === "function") {
val = options.converter(val, options.type) as string;
}
return val;
},
set(this: QuarkElement, value: string | boolean | null) {
let val = value as string;
if (typeof options.converter === "function") {
val = options.converter(value, options.type) as string;
}
if (val) {
if (typeof val === "boolean") {
this.setAttribute(name, "");
} else {
this.setAttribute(name, val);
}
} else {
this.removeAttribute(name);
}
},
configurable: true,
enumerable: true,
};
};
}
/** 获取、配置元素对象上值的装饰器 */
protected static getStateDescriptor(): () => PropertyDescriptor {
return (defaultValue?: any) => {
let _value = defaultValue;
return {
get(this: QuarkElement): any {
return _value;
},
set(this: QuarkElement, value: string | boolean | null) {
_value = value;
this._render();
},
configurable: true,
enumerable: true,
};
};
}
static createProperty(name: string, options: PropertyDeclaration) {
const newOpt = Object.assign({}, defaultPropertyDeclaration, options);
ElementProperties.set(this, name, newOpt);
Descriptors.set(this, name, this.getPropertyDescriptor(name, newOpt));
}
static createState(name: string) {
Descriptors.set(this, name, this.getStateDescriptor());
}
getStyles(): string {
return "";
}
private eventController: EventController = new EventController();
private lastRootVNode?: VNode;
private rootPatch = (newRootVNode: any) => {
if (this.shadowRoot) {
render(newRootVNode, this.shadowRoot);
}
};
/**
* 延迟patch,用于优化减少patch次数
* 存在一些不可预知的问题,暂时不用
*/
// private delayPatch = delay(this.rootPatch);
// private getRootEl() {
// return [].slice.call(this.shadowRoot?.children || []).slice(1);
// }
private _render() {
let newRootVNode: VNode = this.render() as any;
if (newRootVNode) {
this.rootPatch(newRootVNode);
}
}
private _updateProperty() {
(this.constructor as any).observedAttributes.forEach(
(propertyName: string) => {
(this as any)[propertyName] = (this as any)[propertyName];
}
);
}
private _updateBooleanProperty(propertyName: string) {
// 判断是否是 boolean
if ((this.constructor as any).isBooleanProperty(propertyName)) {
// 针对 false 场景走一次 set, true 不需要重新走 set
if (!(this as any)[propertyName]) {
(this as any)[propertyName] = (this as any)[propertyName];
}
}
}
$on = (eventName: string, eventHandler: EventHandler, el?: Element) => {
return this.eventController.bindListener(
el || this,
eventName,
eventHandler
);
};
$emit<T>(eventName: string, customEventInit?: CustomEventInit<T>) {
return this.dispatchEvent(
new CustomEvent(
eventName,
Object.assign({ bubbles: true }, customEventInit)
)
);
}
/**
* 此时组件 dom 已插入到页面中,等同于 connectedCallback() { super.connectedCallback(); }
*/
componentDidMount() {}
/**
* disconnectedCallback 触发时、dom 移除前执行,等同于 disconnectedCallback() { super.disconnectedCallback(); }
*/
componentWillUnmount() {}
/**
* 控制当前属性变化是否导致组件渲染
* @param propName 属性名
* @param oldValue 属性旧值
* @param newValue 属性新值
* @returns boolean
*/
shouldComponentUpdate(propName: string, oldValue: string, newValue: string) {
console.log({ oldValue, newValue });
return oldValue !== newValue;
}
componentDidUpdate(propName: string, oldValue: string, newValue: string) {}
/**
* 组件的render方法,
* 自动执行this.shadowRoot.innerHTML = this.render()
* @returns VNode
*/
render() {
return "" as any;
}
// 当自定义元素第一次被连接到文档 DOM 时被调用,这个自定义元素在这里被初始化
connectedCallback() {
this._updateProperty();
/**
* 初始值重写后首次渲染
*/
this._render();
if (typeof this.componentDidMount === "function") {
console.log("adasdsa");
this.componentDidMount();
}
}
attributeChangedCallback(name: string, oldValue: string, value: string) {
// @ts-ignore
// 因为 React 的属性变更并不会触发 set,此时如果 boolean 值变更,这里的 value 会是字符串,组件内部通过 get 操作可以获取到正确的类型
const newValue = this[name] || value;
if (typeof this.shouldComponentUpdate === "function") {
if (!this.shouldComponentUpdate(name, oldValue, newValue)) {
return;
}
}
this._render();
if (typeof this.componentDidUpdate === "function") {
this.componentDidUpdate(name, oldValue, newValue);
}
// 因为 React的属性变更并不会触发set,此时如果boolean值变更,这里的value会是字符串,组件内部通过get操作可以正常判断类型,但css里面有根据boolean属性设置样式的将会出现问题
if (value !== oldValue) {
// boolean 重走set
this._updateBooleanProperty(name);
}
}
disconnectedCallback() {
if (typeof this.componentWillUnmount === "function") {
this.componentWillUnmount();
}
this.eventController.removeAllListener();
this.rootPatch(null);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
首先说web component的生命周期:
- connectedCallback:当自定义元素第一次被连接到文档 DOM 时被调用。
- disconnectedCallback:当自定义元素与文档 DOM 断开连接时被调用。
- adoptedCallback:当自定义元素被移动到新文档时被调用。
- attributeChangedCallback:当自定义元素的一个属性被增加、移除或更改时被调用。
QuarkElement类在web component 的生命周期中通过执行等同react组件执行时机的函数实现了于react类似的生命周期。 其类的执行顺序相当于:
- constructor (挂载 shadow dom ,将自定义元素属性挂载在示例上)
- 挂载属性触发 attributeChangedCallback 函数,其内部触发 shouldComponentUpdate 生命周期。并进行 render,修改渲染页面内容,完成后触发 componentDidUpdate 函数。
- 每一个新增的属性都会触发 shouldComponentUpdate -> render -> componentDidUpdate 流程
- defineProperty 调用 了getPropertyDescriptor为属性做了监听,从而实现了更新的行为。
最后,看看customElement函数:
export function customElement(
params: string | { tag: string; style?: string }
) {
const { tag, style = "" } =
typeof params === "string" ? { tag: params } : params;
return (target: typeof QuarkElement) => {
/**
* 创建一个新的类,其继承于你创建的组件类,同时也继承了QuarkElement类的方法
*/
class NewQuarkElement extends target {
/**
* 获取被监听的属性。在QuarkElement类中,会对元素上书写的属性进行监听
*/
static get observedAttributes() {
const attributes: string[] = [];
ElementProperties.forEach((elOption, constructor, elName) => {
if (constructor === target && elOption.observed) {
attributes.push(elName);
}
});
return attributes;
}
/**
* 获取被监听的属性是否为布尔类型。
*/
static isBooleanProperty(propertyName: string) {
let isBoolean = false;
ElementProperties.forEach((elOption, constructor, elName) => {
if (
constructor === target &&
elOption.type === Boolean &&
propertyName === elName
) {
isBoolean = true;
return isBoolean;
}
});
return isBoolean;
}
// 首先执行constructor
constructor() {
super();
if (style) {
this.getStyles = () => style;
}
// 方法给指定的元素挂载一个 Shadow DOM,并且返回对 ShadowRoot 的引用。
// 我们可以将自定义元素的内容挂载添加到它上面
// https://developer.mozilla.org/zh-CN/docs/Web/API/Element/attachShadow
const shadowRoot = this.attachShadow({ mode: "open" });
if (shadowRoot) {
if (typeof this.getStyles === "function") {
const styleEl = document.createElement("style");
styleEl.innerHTML = this.getStyles();
shadowRoot.append(styleEl);
}
}
/**
* 重写类的属性描述符,并重写属性初始值。
* 注:由于子类的属性初始化晚于当前基类的构造函数,同名属性会导致属性描述符被覆盖,所以必须放在基类构造函数之后执行
*/
Descriptors.forEach((descriptorCreator, constructor, propertyName) => {
console.log({
Descriptors,
descriptorCreator,
constructor,
propertyName,
});
if (constructor === target) {
Object.defineProperty(
this,
propertyName,
descriptorCreator((this as any)[propertyName])
);
}
});
}
}
// 判断当前环境下是否存在该自定义标签,若不存在,将组件全局注册该标签
if (!customElements.get(tag)) {
customElements.define(tag, NewQuarkElement);
}
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91