Web 组件:从零到英雄
Web 组件:从零到英雄
Web 组件:从零到英雄
编写原始 Web 组件的介绍
Web 组件正受到越来越多的关注。随着 Edge 团队最近宣布实现自定义元素和 Shadow DOM,所有主流浏览器很快都将原生支持 Web 组件。Github、Netflix、Youtube 和 ING 等公司甚至已经在生产环境中使用 Web 组件。太棒了!然而,令人惊讶的是,这些成功的大型公司中,没有一家实现了(你猜对了)待办事项应用!
所以今天,我们将开发一款待办事项应用,因为目前市面上还没有足够多的待办事项应用。你可以在这里查看我们将要开发的内容。
在开始之前,我想先声明一下:这篇博文旨在帮助您更好地掌握Web Components 的基础知识。Web Components 是底层的,如果不使用任何辅助库,可能不适合用来编写完整的应用程序,也不应该将其与成熟的框架进行比较。
🙋 什么是 Web 组件?
- [x] 制作演示
- [ ] 无聊的东西
- [ ] 设置属性
- [ ] 设置属性
- [ ] 将属性反映到特性
- [ ] 事件
- [ ] 总结
首先:Web 组件是一套标准,允许我们编写模块化、可重用且封装的 HTML 元素。它们最棒的地方在于:由于它们基于 Web 标准,我们无需安装任何框架或库即可使用它们。现在,您就可以使用原生 JavaScript 编写 Web 组件了!
但在我们开始动手之前,让我们先看看允许我们编写 Web 组件的规范。
自定义元素
自定义元素API 允许我们创建自己的 DOM 元素。使用该 API,我们可以定义一个自定义元素,并告知解析器如何正确构造该元素,以及该类的元素应如何响应更改。您是否曾经想要拥有自己的 HTML 元素,例如<my-cool-element>
?现在,您可以!
影子 DOM
Shadow DOM为我们提供了一种封装组件样式和标记的方法。它是一棵附加到 DOM 元素的子 DOM 树,以确保我们的样式不会泄露,也不会被任何外部样式覆盖。这使得它非常适合模块化。
ES 模块
ES模块规范以基于标准、模块化、高性能的方式定义了 JS 文档的包含和重用。
HTML模板
HTML标签允许我们编写可复用的 DOM 块。在模板内部,脚本不会运行,图片不会加载,样式/标记也不会渲染。模板标签本身甚至不被视为文档的一部分,直到它被激活。HTML 模板非常棒<template>
,因为对于我们元素的每个实例,只使用一个模板。
现在我们已经了解了 Web 组件所依赖的规范,接下来我们来看看自定义元素的生命周期。我知道,我们很快就能开始写代码了!
♻️ 组件的生命周期
让我们看一下自定义元素的生命周期。考虑以下元素:
class MyElement extends HTMLElement {
constructor() {
// always call super() first
super();
console.log('constructed!');
}
connectedCallback() {
console.log('connected!');
}
disconnectedCallback() {
console.log('disconnected!');
}
attributeChangedCallback(name, oldVal, newVal) {
console.log(`Attribute: ${name} changed!`);
}
adoptedCallback() {
console.log('adopted!');
}
}
window.customElements.define('my-element', MyElement);
构造函数()
constructor
每当元素创建时,但在元素附加到文档之前,都会运行。我们将使用它constructor
来设置一些初始状态、事件监听器以及创建影子 DOM。
已连接回调()
当元素插入到 DOM 时,会调用该方法connectedCallback
。这里适合运行设置代码,例如获取数据或设置默认属性。
断开连接回调()
disconnectedCallback
每当元素从 DOM 中移除时,都会调用 。清理时间到了!我们可以使用它来disconnectedCallback
移除任何事件监听器,或者取消间隔。
attributeChangedCallback(名称,旧值,新值)
attributeChangedCallback
每当元素的观察属性发生变化时,都会调用该方法。我们可以通过实现静态 getter 来观察元素的属性observedAttributes
,如下所示:
static get observedAttributes() {
return ['my-attr'];
}
在这种情况下,只要my-attr
属性发生变化,attributeChangedCallback
就会运行。我们将在后续博文中更深入地探讨这一点。
✨嘿!听着!
仅 getter 中列出的属性
observedAttributes
会受到影响attributeChangedCallback
。
adoptedCallback()
<iframe>
每次将自定义元素移动到新文档时,都会调用 adoptedCallback。只有当您的页面中包含元素时,才会遇到此用例。
注册我们的元素
最后,虽然不是生命周期的一部分,但我们将元素注册到CustomElementRegistry
类似的东西中:
window.customElements.define('my-element', MyElement);
是CustomElementRegistry
一个接口,提供注册自定义元素和查询已注册元素的方法。registries 方法的第一个参数define
是元素的名称,在本例中为 register <my-element>
,第二个参数传递我们创建的类。
✨嘿!听着!
务必注意 Web 组件的命名方式。自定义元素名称必须始终包含连字符。例如:
<my-element>
is 正确,<myelement>
is 错误。这样做是为了避免元素名称冲突,并区分自定义元素和常规元素。自定义元素也不能自闭合,因为 HTML 只允许少数元素自闭合。这些元素被称为空元素,例如
<br/>
或<img/>
,或者不允许有子节点的元素。允许自闭合元素需要修改 HTML 解析器,而这会带来问题,因为 HTML 解析对安全敏感。HTML 生成器需要能够依赖特定 HTML 片段的解析方式,才能实现 XSS 安全的 HTML 生成。
⚒ 构建我们的待办事项应用
- [x] 制作演示
- [x] 无聊的东西
- [ ] 设置属性
- [ ] 设置属性
- [ ] 将属性反映到特性
- [ ] 事件
- [ ] 总结
现在我们已经完成了所有无聊的事情,终于可以开始动手构建我们的待办事项应用程序了!点击此处查看最终结果。
让我们首先概述一下我们将要构建的内容。
-
元素
<to-do-app>
:- 包含待办事项数组作为属性
- 添加待办事项
- 删除待办事项
- 切换待办事项
-
元素
<to-do-item>
:- 包含描述属性
- 包含索引属性
- 包含已检查的属性
太棒了!让我们开始为我们的待办事项应用奠定基础:
to-do-app.js
:
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: block;
font-family: sans-serif;
text-align: center;
}
button {
border: none;
cursor: pointer;
}
ul {
list-style: none;
padding: 0;
}
</style>
<h1>To do</h1>
<input type="text" placeholder="Add a new to do"></input>
<button>✅</button>
<ul id="todos"></ul>
`;
class TodoApp extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ 'mode': 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$todoList = this._shadowRoot.querySelector('ul');
}
}
window.customElements.define('to-do-app', TodoApp);
我们将一步一步地进行。首先,我们<template>
通过调用创建一个组件const template = document.createElement('template');
,然后在其中设置一些 HTML。我们只在模板上设置一次innerHTML 。我们使用模板的原因是,克隆模板比调用.innerHTML
组件的所有实例要便宜得多。
接下来,我们就可以开始定义元素了。我们将使用 来连接constructor
shadowroot ,并将其设置为模式。然后,我们将模板克隆到 shadowroot。太棒了!现在我们已经使用了 2 个 Web 组件规范,并成功创建了一个封装的子 DOM 树。open
这意味着我们现在拥有了一个不会泄漏任何样式或被覆盖的 DOM 树。请考虑以下示例:
我们有一个全局h1
样式,可以将 light DOM 中的任何 h1 元素设置为红色。但由于我们的 h1 元素位于 shadow-root 中,因此它不会被全局样式覆盖。
请注意to-do-app
,我们在组件中使用了:host
伪类,这样我们就可以从内部为组件添加样式。需要注意的是,display
始终设置为display: inline;
,这意味着您无法设置元素的宽度或高度。因此,除非您更喜欢默认的 inline 样式,否则请务必设置:host
显示样式(例如 block、inline-block、flex)。
✨嘿!听着!
Shadow DOM 可能有点令人困惑。请允许我稍微解释一下这些术语:
轻量级 DOM:
Light DOM 位于组件的 Shadow DOM 之外,基本上指任何不属于Shadow DOM 的内容。例如,
<h1>Hello world</h1>
上方的元素就位于 Light DOM 中。Light DOM 这一术语用于将其与 Shadow DOM 区分开来。使用 Light DOM 制作 Web 组件完全没问题,但您会错过 Shadow DOM 的强大功能。打开影子 DOM:
从 Shadow DOM 规范的最新版本 (V1) 开始,我们现在可以使用
open
Shadowclosed
DOM。Open Shadow DOM 允许我们在 Light DOM 旁边创建一个子 DOM 树,为组件提供封装。我们的 Shadow DOM 仍然可以被 JavaScript 穿透,如下所示:document.querySelector('our-element').shadowRoot
。Shadow DOM 的缺点之一是 Web 组件相对年轻,许多外部库尚未考虑到这一点。封闭的影子 DOM:
封闭式 Shadow root 不太适用,因为它会阻止任何外部 JavaScript 穿透 Shadowroot。封闭式 Shadow DOM 会降低组件对您和最终用户的灵活性,通常应避免使用。
一些使用封闭阴影 DOM 的元素示例是
<video>
元素。
📂 设置属性
太棒了!我们的第一个 Web 组件已经制作完成了,但目前为止,它完全没用。如果能给它传递一些数据,然后渲染一个待办事项列表就好了。
让我们实现一些 getter 和 setter。
to-do-app.js
:
class TodoApp extends HTMLElement {
...
_renderTodoList() {
this.$todoList.innerHTML = '';
this._todos.forEach((todo, index) => {
let $todoItem = document.createElement('div');
$todoItem.innerHTML = todo.text;
this.$todoList.appendChild($todoItem);
});
}
set todos(value) {
this._todos = value;
this._renderTodoList();
}
get todos() {
return this._todos;
}
}
现在我们有了一些 getter 和 setter,我们可以将一些丰富的数据传递给元素了!我们可以像这样查询组件并设置数据:
document.querySelector('to-do-app').todos = [
{text: "Make a to-do list", checked: false},
{text: "Finish blog post", checked: false}
];
现在,我们已经成功地在组件上设置了一些属性,它现在看起来应该是这样的:
太棒了!但它仍然没用,因为如果不使用控制台,我们就无法与任何事物交互。让我们快速实现一些功能,将新的待办事项添加到列表中。
class TodoApp extends HTMLElement {
...
constructor() {
super();
this._shadowRoot = this.attachShadow({ 'mode': 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$todoList = this._shadowRoot.querySelector('ul');
this.$input = this._shadowRoot.querySelector('input');
this.$submitButton = this._shadowRoot.querySelector('button');
this.$submitButton.addEventListener('click', this._addTodo.bind(this));
}
_addTodo() {
if(this.$input.value.length > 0){
this._todos.push({ text: this.$input.value, checked: false })
this._renderTodoList();
this.$input.value = '';
}
}
...
}
这应该很容易理解,我们在中设置了querySelectors
一些,并且在点击事件中,我们希望将输入推送到待办事项列表中,渲染它,然后再次清除输入。很简单👏。addEventListeners
constructor
💅 设置属性
- [x] 制作演示
- [x] 无聊的东西
- [x] 设置属性
- [ ] 设置属性
- [ ] 将属性反映到特性
- [ ] 事件
- [ ] 总结
事情到这里可能会有点混乱,因为我们将探索属性 (attributes)和特性 (properties)之间的区别,并且还将把特性 (properties) 映射到属性 (attributes) 上。抓紧!
首先,让我们创建一个<to-do-item>
元素。
to-do-item.js
:
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: block;
font-family: sans-serif;
}
.completed {
text-decoration: line-through;
}
button {
border: none;
cursor: pointer;
}
</style>
<li class="item">
<input type="checkbox">
<label></label>
<button>❌</button>
</li>
`;
class TodoItem extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ 'mode': 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$item = this._shadowRoot.querySelector('.item');
this.$removeButton = this._shadowRoot.querySelector('button');
this.$text = this._shadowRoot.querySelector('label');
this.$checkbox = this._shadowRoot.querySelector('input');
this.$removeButton.addEventListener('click', (e) => {
this.dispatchEvent(new CustomEvent('onRemove', { detail: this.index }));
});
this.$checkbox.addEventListener('click', (e) => {
this.dispatchEvent(new CustomEvent('onToggle', { detail: this.index }));
});
}
connectedCallback() {
// We set a default attribute here; if our end user hasn't provided one,
// our element will display a "placeholder" text instead.
if(!this.hasAttribute('text')) {
this.setAttribute('text', 'placeholder');
}
this._renderTodoItem();
}
_renderTodoItem() {
if (this.hasAttribute('checked')) {
this.$item.classList.add('completed');
this.$checkbox.setAttribute('checked', '');
} else {
this.$item.classList.remove('completed');
this.$checkbox.removeAttribute('checked');
}
this.$text.innerHTML = this._text;
}
static get observedAttributes() {
return ['text'];
}
attributeChangedCallback(name, oldValue, newValue) {
this._text = newValue;
}
}
window.customElements.define('to-do-item', TodoItem);
请注意,由于我们使用的是 ES 模块,因此我们可以
const template = document.createElement('template');
再次使用,而无需覆盖我们之前制作的模板。
让我们将_renderTodolist
函数改成to-do-app.js
这样:
class TodoApp extends HTMLElement {
...
_renderTodoList() {
this.$todoList.innerHTML = '';
this._todos.forEach((todo, index) => {
let $todoItem = document.createElement('to-do-item');
$todoItem.setAttribute('text', todo.text);
this.$todoList.appendChild($todoItem);
});
}
...
}
好吧,这里发生了很多不同的事情。让我们深入了解一下。之前,当将一些富数据(数组)传递给<to-do-app>
组件时,我们将其设置如下:
document.querySelector('to-do-app').todos = [{ ... }];
我们这样做是因为它是元素的todos
一个属性。属性的处理方式不同,并且不允许使用富数据,事实上,由于 HTML 的限制,它们只允许使用字符串类型。属性则更灵活,可以处理对象或数组等复杂的数据类型。
区别在于,属性是在 HTML 元素上定义的。当浏览器解析 HTML 时,会创建一个相应的 DOM 节点。这个节点是一个对象,因此它具有属性。例如,当浏览器解析:<to-do-item index="1">
时,会创建一个HTMLElement对象。这个对象已经包含了一些属性,例如children
、clientHeight
、classList
等,以及一些方法,例如appendChild()
或click()
。我们也可以实现自己的属性,就像我们在to-do-app
元素中那样,我们赋予了它一个todos
属性。
下面是实际操作的一个例子。
<img src="myimg.png" alt="my image"/>
浏览器将解析此<img>
元素,创建一个DOM 元素对象src
,并方便地为我们设置和的属性。需要注意的是,并非所有alt
属性都具备属性反射功能。(例如:元素上的 属性不会反射。的属性始终是 的当前文本内容,而属性将是初始文本内容。)我们稍后会更深入地了解如何将属性反射到属性。value
<input>
value
<input>
<input>
value
所以我们现在知道 alt 和 src属性是作为字符串类型处理的,如果我们想将待办事项数组传递给我们的<to-do-app>
元素,如下所示:
<to-do-app todos="[{...}, {...}]"></to-do-app>
我们不会得到期望的结果;我们期望一个数组,但实际上,该值只是一个看起来像数组的字符串。
✨嘿!听着!
- 旨在仅接受丰富的数据(对象、数组)作为属性。
- 不要将丰富的数据特性反映到属性中。
设置属性的方式也与设置属性的方式不同,请注意,我们没有实现任何 getter 或 setter 方法。我们将text
属性添加到 getter 方法中static get observedAttributes
,以便能够监听text
属性的变化。并且,我们实现了attributesChangedCallback
来响应这些变化。
此时,我们的应用程序应该是这样的:
布尔属性
我们还没完成属性的学习。如果能在完成一些待办事项后勾选它们就好了,我们也会用到属性来实现这一点。不过,我们需要稍微改变一下布尔属性的处理方式。
元素上布尔属性的存在代表该
True
值,而属性的不存在代表该False
值。如果该属性存在,则其值必须是空字符串,或者是与该属性的规范名称不区分大小写的 ASCII 匹配的值,且没有前导或尾随空格。
布尔属性不允许使用“true”和“false”值。要表示 false 值,必须完全省略该属性。
<div hidden="true">
这是错误的。
这意味着只有以下示例才可以接受真实值:
<div hidden></div>
<div hidden=""></div>
<div hidden="hidden"></div>
还有一个为假:
<div></div>
因此让我们checked
为我们的<to-do-item>
元素实现属性!
将您的更改to-do-app.js
为:
_renderTodoList() {
this.$todoList.innerHTML = '';
this._todos.forEach((todo, index) => {
let $todoItem = document.createElement('to-do-item');
$todoItem.setAttribute('text', todo.text);
// if our to-do is checked, set the attribute, else; omit it.
if(todo.checked) {
$todoItem.setAttribute('checked', '');
}
this.$todoList.appendChild($todoItem);
});
}
并将to-do-item
其更改为:
class TodoItem extends HTMLElement {
...
static get observedAttributes() {
return ['text', 'checked'];
}
attributeChangedCallback(name, oldValue, newValue) {
switch(name){
case 'text':
this._text = newValue;
break;
case 'checked':
this._checked = this.hasAttribute('checked');
break;
}
}
...
}
太棒了!我们的应用程序应该如下所示:
♺ 将属性反映到特性
- [x] 制作演示
- [x] 无聊的东西
- [x] 设置属性
- [x] 设置属性
- [ ] 将属性反映到特性
- [ ] 事件
- [ ] 总结
太棒了,我们的应用进展顺利。但如果最终用户能够查询组件的状态就更好了checked
。to-do-item
目前我们只将其设置为一个属性 (attribute),但我们希望它也能作为属性 (property ) 使用。这称为将属性 (property) 反射到属性 (attributes)。
为此,我们要做的就是添加一些 getter 和 setter。将以下内容添加到您的to-do-item.js
:
get checked() {
return this.hasAttribute('checked');
}
set checked(val) {
if (val) {
this.setAttribute('checked', '');
} else {
this.removeAttribute('checked');
}
}
现在,每次我们更改属性或特性时,值总是会同步。
🎉 活动
- [x] 制作演示
- [x] 无聊的东西
- [x] 设置属性
- [x] 设置属性
- [x] 将属性反映到特性
- [ ] 事件
- [ ] 总结
呼,既然我们已经解决了最难的部分,现在该开始做有趣的事情了。我们的应用程序目前已经按照我们想要的方式处理和公开数据,但它实际上还不能移除或切换待办事项。让我们来处理一下这个问题。
首先,我们需要追踪sindex
的to-do-item
。让我们设置一个属性!
to-do-item.js
:
static get observedAttributes() {
return ['text', 'checked', 'index'];
}
attributeChangedCallback(name, oldValue, newValue) {
switch(name){
case 'text':
this._text = newValue;
break;
case 'checked':
this._checked = this.hasAttribute('checked');
break;
case 'index':
this._index = parseInt(newValue);
break;
}
}
注意,我们在这里是如何将字符串类型的值解析为整数的,因为属性只允许字符串类型,但我们希望最终用户能够将索引属性作为整数获取。现在,我们也有一个很好的例子来说明如何处理字符串/数字/布尔类型的属性,以及如何将属性和属性按其实际类型处理。
因此让我们添加一些 getter 和 setter 到to-do-item.js
:
set index(val) {
this.setAttribute('index', val);
}
get index() {
return this._index;
}
并将我们的_renderTodoList
函数改为to-do-app.js
:
_renderTodoList() {
this.$todoList.innerHTML = '';
this._todos.forEach((todo, index) => {
let $todoItem = document.createElement('to-do-item');
$todoItem.setAttribute('text', todo.text);
if(todo.checked) {
$todoItem.setAttribute('checked', '');
}
$todoItem.setAttribute('index', index);
$todoItem.addEventListener('onRemove', this._removeTodo.bind(this));
this.$todoList.appendChild($todoItem);
});
}
注意我们是如何设置的$todoItem.setAttribute('index', index);
。现在我们有了一些状态来跟踪待办事项的索引。我们还设置了一个事件监听器来监听元素onRemove
上的事件to-do-item
。
接下来,我们需要在点击删除按钮时触发constructor
该事件。将of更改to-do-item.js
为以下内容:
constructor() {
super();
this._shadowRoot = this.attachShadow({ 'mode': 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$item = this._shadowRoot.querySelector('.item');
this.$removeButton = this._shadowRoot.querySelector('button');
this.$text = this._shadowRoot.querySelector('label');
this.$checkbox = this._shadowRoot.querySelector('input');
this.$removeButton.addEventListener('click', (e) => {
this.dispatchEvent(new CustomEvent('onRemove', { detail: this.index }));
});
}
✨嘿!听着!
我们可以设置
{ detail: this.index, composed: true, bubbles: true }
让事件从我们的组件影子 DOM 中冒泡出来。
并添加_removeTodo
以下函数to-do-app.js
:
_removeTodo(e) {
this._todos.splice(e.detail, 1);
this._renderTodoList();
}
太棒了!我们可以删除待办事项了:
最后,让我们也创建一个切换功能。
to-do-app.js
:
class TodoApp extends HTMLElement {
...
_toggleTodo(e) {
const todo = this._todos[e.detail];
this._todos[e.detail] = Object.assign({}, todo, {
checked: !todo.checked
});
this._renderTodoList();
}
_renderTodoList() {
this.$todoList.innerHTML = '';
this._todos.forEach((todo, index) => {
let $todoItem = document.createElement('to-do-item');
$todoItem.setAttribute('text', todo.text);
if(todo.checked) {
$todoItem.setAttribute('checked', '');
}
$todoItem.setAttribute('index', index);
$todoItem.addEventListener('onRemove', this._removeTodo.bind(this));
$todoItem.addEventListener('onToggle', this._toggleTodo.bind(this));
this.$todoList.appendChild($todoItem);
});
}
...
}
和to-do-item.js
:
class TodoItem extends HTMLElement {
...
constructor() {
super();
this._shadowRoot = this.attachShadow({ 'mode': 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$item = this._shadowRoot.querySelector('.item');
this.$removeButton = this._shadowRoot.querySelector('button');
this.$text = this._shadowRoot.querySelector('label');
this.$checkbox = this._shadowRoot.querySelector('input');
this.$removeButton.addEventListener('click', (e) => {
this.dispatchEvent(new CustomEvent('onRemove', { detail: this.index }));
});
this.$checkbox.addEventListener('click', (e) => {
this.dispatchEvent(new CustomEvent('onToggle', { detail: this.index }));
});
}
...
}
成功!我们可以创建、删除和切换待办事项了!
👻 浏览器支持和 polyfill
- [x] 制作演示
- [x] 无聊的东西
- [x] 设置属性
- [x] 设置属性
- [x] 将属性反映到特性
- [x] 事件
- [ ] 总结
在这篇博文中,我最后想讨论的是浏览器支持问题。在撰写本文时,Microsoft Edge 团队最近宣布他们将实现自定义元素以及影子 DOM,这意味着所有主流浏览器很快都将原生支持 Web 组件。
在此之前,您可以使用Google 维护的webcomponentsjs polyfill。只需导入 polyfill 即可:
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.0.0/webcomponents-bundle.js"></script>
为了简单起见,我使用了 unpkg,但您也可以使用 安装 webcomponentsjs NPM
。为了确保 polyfill 已成功加载,我们可以等待事件WebComponentsReady
触发,如下所示:
window.addEventListener('WebComponentsReady', function() {
console.log('Web components ready!');
// your web components here
});
💫 总结
- [x] 制作演示
- [x] 无聊的东西
- [x] 设置属性
- [x] 设置属性
- [x] 将属性反映到特性
- [x] 事件
- [x] 总结
完毕!
如果你已经完成了所有步骤,那么恭喜你!你已经了解了 Web 组件规范、(轻量/开放/封闭)阴影 DOM、模板、属性 (Attribute) 和特性 (Property) 之间的区别,以及如何将特性 (Property) 反射到属性 (Attribute)。
但你可能已经注意到,我们编写的很多代码可能感觉有点笨重,我们写了很多样板代码(getter、setter、查询选择器等等),而且很多事情都是命令式处理的。我们对待办事项列表的更新也不太高效。
“ Web 组件很简洁,但我不想花那么多时间编写样板代码和命令式设置东西,我想编写声明式代码! ”你哭喊道。
输入lit-html,我们将在下一篇博客文章中介绍。
文章来源:https://dev.to/thepassle/web-components-from-zero-to-hero-4n4m