谁在搜索Web Components?
搜索Web Components的通常是不使用Web Components的,就像你和我,但是由于闲着没事和热爱学习,又或者应付一下前端面试,不得不了解下。
不使用Web Components是有很多客观原因的,例如你和Web Components之间大概有n个前端框架,这些框架是你面试工作必备的,不单你要有基于其它们的大型应用的实战,而且还要有理解其源码原理的能力。
所以Web Components很自然成为你的短板之一。
为什么Web Components
个人觉得这些年前端一直围绕着一个问题:组件化,比如前端三国演义(React,Vue,Angular)的发展及其火热程度足以说明,但是有一个问题一直没解决,那就是组件复用问题,说白就是怎么防止重复造轮子问题,尽管我不认为这是问题,但是W3C认为这是问题,所以我们不得不来学习Web Components。
W3C的解决方法就是,通过制定规范和标准,让所有浏览器提供一系列平台API来支持自定义HTML标签,这样你基于这些API所编写的组件就可以运行在所有支持的浏览器中,从而达到组件复用。
Web Components的内容
如果你被W3C或者网上其它言论洗脑,你会相信Web Components就是未来,什么三国演义都会俱往矣,所以你需要知道怎么样去编写Web Components。
首先Web Components基于四个规范:自定义元素,影子DOM,ES模块,HTML模版,我劝你还是别点进去,规范就像懒婆娘的裹脚,又臭又长,一个简单的hello world或todo才是浅尝辄止的我们所需要的。
hello-world.js
const template = document.createElement('template');
template.innerHTML = `
Hello: World
`;
class HelloWorld extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$headline = this._shadowRoot.querySelector('h2');
this.$span = this._shadowRoot.querySelector('span');
}
connectedCallback() {
if(!this.hasAttribute('color')) {
this.setAttribute('color', 'orange');
}
if(!this.hasAttribute('text')) {
this.setAttribute('text', '');
}
this._render();
}
static get observedAttributes() {
return ['color', 'text'];
}
attributeChangedCallback(name, oldVal, newVal) {
switch(name) {
case 'color':
this._color = newVal;
break;
case 'text':
this._text = newVal;
break;
};
this._render();
}
_render() {
this.$headline.style.color = this._color;
this.$span.innerHTML = this._text;
}
}
window.customElements.define('hello-world', HelloWorld);
hello-world.html
Hello World Web Components
可以看出写一个组件还是算简单的,其实现在你的脑海里大致有个Web Components的雏形了,接下来我们来分析一下每一行的代码,及其所对应的规范和标准。
1. 自定义元素定义:
class HelloWorld extends HTMLElement {...}
只要继承HTMLElement类,你便可以编写自定义标签/元素,里面的构造函数和生命周期函数暂时都不要管。
2. HTML模版
const template = document.createElement('template');
template.innerHTML = ...
HTML标签里面包含了具体样式和DOM,
影子DOM
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
HelloWorld类和模版目前还是没有任何关联,影子DOM的第一个作用就是粘合HelloWorld类和模版,然后作为一个子DOM树被添加。同时影子DOM也可以保证样式不会被污染或泄漏,有点模块化封装的意思。
3. 全局注册组件
window.customElements.define('hello-world', HelloWorld);
组件注册之后,通过引用这个js文件,你便可以使用这个Web Components了。
至此,创建一个简单Web Components的流程,我们都大致了解了,但是想要应用到大型复杂的项目还是需要更多的API支持。
组件生命周期函数
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!');
}
}
1. constructor()
元素创建但还没附加到document时执行,通常用来初始化状态,事件监听,创建影子DOM。
2. connectedCallback()
元素被插入到DOM时执行,通常用来获取数据,设置默认属性。
3. disconnectedCallback()
元素从DOM移除时执行,通常用来做清理工作,例如取消事件监听和定时器。
4. attributeChangedCallback(name, oldValue, newValue)
元素关注的属性变化时执行,如果监听属性变化呢?
static get observedAttributes() {
return ['my-attr'];
}
只要my-attr属性变化,就会触发attributeChangedCallback
5. adoptedCallback()
自定义元素被移动到新的document时执行。
现在我们几乎知道所有关于Web Components的知识,让我们看一下怎么用它做一个稍微复杂的TODO应用。
TODO应用
简单做一下逻辑划分,我们需要两个自定义组件:
- to-do-app 元素
接受一个数组作为属性,可以添加/删除/标记to-do。
- to-to-item 元素
设置描述信息,索引属性,checked属性
to-do-app.js
const template = document.createElement("template");
template.innerHTML = `
To do App
`;
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");
thisl.todos = [];
}
}
window.customElements.define("to-do-app", TodoApp);
我们通过setter和getter实现添加一个新属性:
set todos(value) {
this._todos = value;
this._renderTodoList();
}
get todos() {
return this._todos;
}
当传递给这个属性值时渲染to-do列表:
_renderTodoList() {
this.$todoList.innerHTML = "";
this._todos.forEach((todo, index) => {
let $todoItem = document.createElement("div");
$todoItem.innerHTML = todo.text;
this.$todoList.appendChild($todoItem);
});
}
我们需要对输入框和按钮添加事件:
constructor() {
super();
...
this.$input = this._shadowRoot.querySelector("input");
this.$submitButton = this._shadowRoot.querySelector("button");
this.$submitButton.addEventListener("click", this._addTodo.bind(this));
}
添加一个TOOD:
_addTodo() {
if(this.$input.value.length > 0){
this._todos.push({ text: this.$input.value, checked: false })
this._renderTodoList();
this.$input.value = '';
}
}
现在我们可以TODO app可以添加todo了。
为了实现删除和标记,我们需要创建一个to-do-item.js
to-do-item.js
const template = document.createElement('template');
template.innerHTML = `
`;
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);
在_renderTodolist中开始渲染我们的to-do-item,当让使用之前要import,这就我们之前没说的ES模块规范。
_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);
});
}
组件通过事件通知父组件(删除按钮和勾选框):
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 }));
});
});
父组件监听:
$todoItem.addEventListener('onRemove', this._removeTodo.bind(this));
$todoItem.addEventListener('onToggle', this._toggleTodo.bind(this));
组件监听属性变化:
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;
}
}
现在我们todo app都已经编写完成
to-do-app.js
import "./components/to-do-item";
const template = document.createElement("template");
template.innerHTML = `
Raw web components
To do
`;
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.todos = [];
this.$submitButton = this._shadowRoot.querySelector("button");
this.$submitButton.addEventListener("click", this._addTodo.bind(this));
}
_removeTodo(e) {
this._todos.splice(e.detail, 1);
this._renderTodoList();
}
_toggleTodo(e) {
const todo = this._todos[e.detail];
this._todos[e.detail] = Object.assign({}, todo, {
checked: !todo.checked
});
this._renderTodoList();
}
_addTodo() {
if (this.$input.value.length > 0) {
this._todos.push({ text: this.$input.value, checked: false });
this._renderTodoList();
this.$input.value = "";
}
}
_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);
});
}
set todos(value) {
this._todos = value;
this._renderTodoList();
}
get todos() {
return this._todos;
}
}
window.customElements.define("to-do-app", TodoApp);
to-do-item.js
const template = document.createElement("template");
template.innerHTML = `
`;
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() {
if (!this.hasAttribute("text")) {
this.setAttribute("text", "placeholder");
}
this._renderTodoItem();
}
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;
}
}
_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;
}
set index(val) {
this.setAttribute("index", val);
}
get index() {
return this._index;
}
get checked() {
return this.hasAttribute("checked");
}
set checked(val) {
if (val) {
this.setAttribute("checked", "");
} else {
this.removeAttribute("checked");
}
}
}
window.customElements.define("to-do-item", TodoItem);
index.html
Web Components
说好的放弃
不知道时候会用到Web Components,就像我在文中开篇所讲,你和Web Components中间隔着那些框架,而且Web Components也没有解决我目前的任何问题,还有存在浏览器兼容问题(尽管可以用polyfill),我都建议大家保持观望,暂时放弃。