4

谁在搜索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基于四个规范:自定义元素影子DOMES模块HTML模版,我劝你还是别点进去,规范就像懒婆娘的裹脚,又臭又长,一个简单的hello world或todo才是浅尝辄止的我们所需要的。

hello-world.js

const template = document.createElement('template');

template.innerHTML = `
  <style>
    h2 {
      background-color: blue;
    }
  </style>
  <h2>Hello: <span>World</span></h2>
`;

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

<!DOCTYPE html>
<html>
  <head>
    <title>Hello World Web Components</title>
  </head>
  <body>
    <hello-world></hello-world>
    <script src="./hello-world.js"></script>
  </body>
</html>

可以看出写一个组件还是算简单的,其实现在你的脑海里大致有个Web Components的雏形了,接下来我们来分析一下每一行的代码,及其所对应的规范和标准。

1. 自定义元素定义:
class HelloWorld extends HTMLElement {...}

只要继承HTMLElement类,你便可以编写自定义标签/元素,里面的构造函数和生命周期函数暂时都不要管。

2. HTML模版
const template = document.createElement('template');
template.innerHTML = ...

HTML<template>标签里面包含了具体样式和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 = `
<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 App</h1>

<input type="text" placeholder="添加新的TODO"></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");
    
    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 = `
<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);

在_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 = `
    <style>
        :host {
            display: block;
            font-family: sans-serif;
            text-align: center;
        }
        button {
            border: none;
            cursor: pointer;
        }
        ul {
            list-style: none;
            padding: 0;
        }
    </style>
    <h3>Raw web components</h3>
    <br>
    <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");
    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 = `
    <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() {
    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

<!DOCTYPE html>
<html>
  <head>
    <title>Web Components</title>
  </head>
  <body>
    <to-do-app></to-do-app>
    <script src="to-do-app.js"></script>
  </body>
</html>

图片描述

说好的放弃

不知道时候会用到Web Components,就像我在文中开篇所讲,你和Web Components中间隔着那些框架,而且Web Components也没有解决我目前的任何问题,还有存在浏览器兼容问题(尽管可以用polyfill),我都建议大家保持观望,暂时放弃。


xupea
124 声望39 粉丝

致力于前端技术,Scrum敏捷开发和STEAM教育