WebComponents

Web Components 开始

不添加任何依赖来构建自己的定制组件
带有样式,拥有交互功能并且在各自文件中优雅组织的 HTML 标签

https://developer.mozilla.org/zh-CN/docs/Web/Web_Components

Web Components是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。

示例

https://github.com/mdn/web-components-examples

polyfill

https://www.webcomponents.org/polyfills

https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs

https://unpkg.com/browse/@webcomponents/webcomponentsjs@2.2.10/

npm install @webcomponents/webcomponentsjs
<!-- load webcomponents bundle, which includes all the necessary polyfills -->
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>

<!-- load the element -->
<script type="module" src="my-element.js"></script>

<!-- use the element -->
<my-element></my-element>

Web Component 是一系列 web 平台的 API,它们可以允许你创建全新可定制、可重用并且封装的 HTML 标签
定制的组件基于 Web Component 标准构建,可以在现在浏览器上使用,也可以和任意与 HTML 交互的 JavaScript 库和框架配合使用。

它赋予了仅仅使用纯粹的JS/HTML/CSS就可以创建可重用组件的能力。如果 HTML 不能满足需求,我们可以创建一个可以满足需求的 Web Component。

举个例子,你的用户数据和一个 ID 有关,你希望有一个可以填入用户 ID 并且可以获取相应数据的组件。HTML 可能是下面这个样子:

<user-card user-id="1"></user-card>

Web Component 的四个核心概念

HTML 和 DOM 标准定义了四种新的标准来帮助定义 Web Component。这些标准如下:

定制元素(Custom Elements):

web 开发者可以通过定制元素创建新的 HTML 标签、增强已有的 HTML 标签或是二次开发其它开发者已经完成的组件。这个 API 是 Web Component 的基石。

HTML 模板(HTML Templates):

HTML 模板定义了新的元素,描述一个基于 DOM 标准用于客户端模板的途径。模板允许你声明标记片段,它们可以被解析为 HTML。这些片段在页面开始加载时不会被用到,之后运行时会被实例化。

Shadow DOM:

Shadow DOM 被设计为构建基于组件的应用的一个工具。它可以解决 web 开发的一些常见问题,比如允许你把组件的 DOM 和作用域隔离开,并且简化 CSS 等等。

HTML 引用(HTML Imports):

HTML 模板(HTML Templates)允许你创建新的模板,同样的,HTML 引用(HTML imports)允许你从不同的文件中引入这些模板。通过独立的HTML文件管理组件,可以帮助你更好的组织代码。

组件的命名

定制元素的名称必须包含一个短横线。所以<my-tabs><my-amazing-website>是合法的名称, 而<foo><foo_bar>不行。
在 HTML 添加新标签时需要确保向前兼容,不能重复注册同一个标签。
定制元素标签不能是自闭合的,因为 HTML 只允许一部分元素可以自闭合。需要写成像 这样的闭合标签形式。

拓展组件

创建组件时可以使用继承的方式。
举个例子,如果想要为两种不同的用户创建一个 UserCard,

你可以先创建一个基本的 UserCard 然后将它拓展为两种特定的用户卡片。

Google web developers’ article https://developers.google.com/web/fundamentals/web-components/customelements#extend

组件元素是类的实例

组件元素是类的实例,就可以在这些类中定义公用方法。
这些公用方法可以用来允许其它定制组件/脚本来和这些组件产生交互,而不是只能改变这些组件的属性。

定义私有方法

可以通过多种方式定义私有方法。我倾向于使用(立即执行函数),因为它们易写和易理解。

(function() {})();

冻结类

为了防止新的属性被添加,需要冻结你的类。
这样可以防止类的已有属性被移除,或者已有属性的可枚举、可配置或可写属性被改变,同样也可以防止原型被修改。

class  MyComponent  extends  HTMLElement { ... }
const  FrozenMyComponent = Object.freeze(MyComponent);
customElements.define('my-component', FrozenMyComponent);

冻结类会阻止你在运行时添加补丁并且会让你的代码难以调试。

服务器渲染 项目 注意事项

鉴于 服务器的根路径的配置不统一
import 可以使用绝对路径

import 的 js 内部不可以再次 import ,会出现路径错误

<script type="module" async>
    import 'https://xxx/button.js';
</script>

定义定制元素

声明一个类,定义元素如何表现。这个类需要继承 HTMLElement 类

定制元素的生命周期方法

connectedCallback — 每当元素插入 DOM 时被触发。
disconnectedCallback — 每当元素从 DOM 中移除时被触发。
attributeChangedCallback — 当元素上的属性被添加、移除、更新或取代时被触发。

如果需要在元素属性变化后,触发attributeChangedCallback()回调函数,你必须监听这个属性。
这可以通过定义observedAttributes()get函数来实现
observedAttributes()函数体内包含一个 return语句,返回一个数组,包含了需要监听的属性名称:

static get observedAttributes() { return ['disabled','icon','loading'] }
constructor(){}

该段代码处于构造函数的上方。

user-card 元素

在 UserCard 文件夹下创建 UserCard.js:

class UserCard extends HTMLElement {
  constructor() {
    super();

    this.addEventListener("click", e => {
      this.toggleCard();
    });
  }

  toggleCard() {
    console.log("Element was clicked!");
  }
}
customElements.define("user-card", UserCard);

customElements.define('user-card', UserCard)函数调用告知 DOM 我们已经创建了一个新的定制元素叫 user-card

它的行为被 UserCard 类定义。
现在可以在我们的 HTML 里使用 user-card 元素了。

创建模板

UserCard.html

<template id="user-card-template">
    <div>
        <h2>
            <span></span> (
            <span></span>)
        </h2>
        <p>Website: <a></a></p>
        <div>
            <p></p>
        </div>
        <button class="card__details-btn">More Details</button>
    </div>
</template>
<script src="/UserCard/UserCard.js"></script>

在类名前加了一个 card__ 前缀,避免意外的样式覆盖
在较早版本的浏览器中,我们不能使用 shadow DOM 来隔离组件 DOM

编写样式

UserCard.css

.card__user-card-container {
  text-align: center;
  display: inline-block;
  border-radius: 5px;
  border: 1px solid grey;
  font-family: Helvetica;
  margin: 3px;
  width: 30%;
}

.card__user-card-container:hover {
  box-shadow: 3px 3px 3px;
}

.card__hidden-content {
  display: none;
}

.card__details-btn {
  background-color: #dedede;
  padding: 6px;
  margin-bottom: 8px;
}

UserCard.html 文件的最前面引入这个 CSS 文件:

<template id="user-card-template">
<link  rel="stylesheet"  href="/UserCard/UserCard.css">
    <div>
        <h2>
            <span></span> (
            <span></span>)
        </h2>
        <p>Website: <a></a></p>
        <div>
            <p></p>
        </div>
        <button class="card__details-btn">More Details</button>
    </div>
</template>
<script src="/UserCard/UserCard.js"></script>

组件的功能

connectedCallback

constructor方法是元素被实例化时调用
connectedCallback方法是每次元素插入 DOM 时被调用。
connectedCallback方法在执行初始化代码时是很有用的,比如获取数据或渲染。
在 UserCard.js 的顶部,定义一个常量 currentDocument。它在被引入的 HTML 脚本中是必要的,允许这些脚本有途径操作引入模板的 DOM。像下面这样定义:

const currentDocument = document.currentScript.ownerDocument;

定义connectedCallback方法
把克隆好的模板绑定到 shadow root 上

// 元素插入 DOM 时调用
connectedCallback() {
  const shadowRoot = this.attachShadow({ mode: "open" });
  // 选取模板并且克隆它。最终将克隆后的节点添加到 shadowDOM 的根节点。

  // 当前文档需要被定义从而获取引入 HTML 的 DOM 权限。
  const template = currentDocument.querySelector("#user-card-template");

  const instance = template.content.cloneNode(true);
  shadowRoot.appendChild(instance);

  // 从元素中选取 user-id 属性
  // 注意我们要像这样指定卡片:
  // <user-card user-id="1"></user-card>

  const userId = this.getAttribute("user-id");
  // 根据 user ID 获取数据,并且使用返回的数据渲染

  fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
    .then(response => response.text())
    .then(responseText => {
      this.render(JSON.parse(responseText));
    })
    .catch(error => {
      console.error(error);
    });
}

渲染用户数据

render(userData) {
  // 使用操作 DOM 的 API 来填充卡片的不同区域
  // 组件的所有元素都存在于 shadow dom 中,所以我们使用了 this.shadowRoot 这个属性来获取 DOM
  // DOM 只可以在这个子树种被查找到
  this.shadowRoot.querySelector(".card__full-name").innerHTML = userData.name;
  this.shadowRoot.querySelector(".card__user-name").innerHTML =
    userData.username;
  this.shadowRoot.querySelector(".card__website").innerHTML = userData.website;
  this.shadowRoot.querySelector(".card__address").innerHTML = `<h4>Address</h4>
    ${userData.address.suite}, <br />
    ${userData.address.street},<br />
    ${userData.address.city},<br />
    Zipcode: ${userData.address.zipcode}`;
}

toggleCard() {
  let elem = this.shadowRoot.querySelector(".card__hidden-content");
  let btn = this.shadowRoot.querySelector(".card__details-btn");
  btn.innerHTML =
    elem.style.display == "none" ? "Less Details" : "More Details";

  elem.style.display = elem.style.display == "none" ? "block" : "none";
}

在任意项目中使用组件

既然组件已经完成,我们就可以把它用在任意项目中了。为了继续教程,我们需要创建一个 index.html 文件

<html>
    <head>
        <title>Web Component</title>
    </head>
    <body>
        <user-card user-id="1"></user-card>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.14/webcomponents-hi.js"></script>
        <link rel="import" href="./UserCard/UserCard.html">
    </body>
</html>

组件示例

image1

构建3个组件。
第一个组件是人员列表。
第二个组件将显示我们从第一个组件中选择的人的信息。
父组件将协调这些组件,并允许我们独立开发子组件并将它们连接在一起。

代码组织

创建一个components包含所有组件的目录。
每个组件都有自己的目录,其中包含组件的HTML模板,JS和样式表。

仅用于创建其他组件且未重用的组件将放置在该组件目录中

src/
  index.html
  components/
    PeopleController/
      PeopleController.js
      PeopleController.html
      PeopleController.css
      PeopleList/
        PeopleList.js
        PeopleList.html
        PeopleList.css
      PersonDetail/
        PersonDetail.js
        PersonDetail.html
        PersonDetail.css

人员列表组件 PeopleList

PeopleList.html

<template id="people-list-template">
  <style>
  .people-list__container {
    border: 1px solid black;
  }
  .people-list__list {
    list-style: none
  }

  .people-list__list > li {
    font-size: 20px;
    font-family: Helvetica;
    color: #000000;
    text-decoration: none;
  }
  </style>
  <div class="people-list__container">
    <ul class="people-list__list"></ul>
  </div>
</template>
<script src="/components/PeopleController/PeopleList/PeopleList.js"></script>

PeopleList.js

(function () {
  const currentDocument = document.currentScript.ownerDocument;

  function _createPersonListElement(self, person) {
    let li = currentDocument.createElement('LI');
    li.innerHTML = person.name;
    li.className = 'people-list__name'
    li.onclick = () => {
      let event = new CustomEvent("PersonClicked", {
        detail: {
          personId: person.id
        },
        bubbles: true
      });
      self.dispatchEvent(event);
    }
    return li;
  }

  class PeopleList extends HTMLElement {
    constructor() {
      // If you define a constructor, always call super() first as it is required by the CE spec.
      super();

      // A private property that we'll use to keep track of list
      let _list = [];

      //使用defineProperty定义此对象的prop,即组件。
      //每当设置列表时,调用render。 这种方式当父组件设置一些数据时
      //在子对象上,我们可以自动更新子对象。
      Object.defineProperty(this, 'list', {
        get: () => _list,
        set: (list) => {
          _list = list;
          this.render();
        }
      });
    }

    connectedCallback() {
      // Create a Shadow DOM using our template
      const shadowRoot = this.attachShadow({ mode: 'open' });
      const template = currentDocument.querySelector('#people-list-template');
      const instance = template.content.cloneNode(true);
      shadowRoot.appendChild(instance);
    }

    render() {
      let ulElement = this.shadowRoot.querySelector('.people-list__list');
      ulElement.innerHTML = '';

      this.list.forEach(person => {
        let li = _createPersonListElement(this, person);
        ulElement.appendChild(li);
      });
    }
  }

  customElements.define('people-list', PeopleList);
})();

在该render方法中,我们需要使用创建人名列表/


  • 我们还将CustomEvent为每个元素创建一个。每当单击该元素时,其id将在DOM树中向上传播事件。

    PersonDetail组件

    我们创建了PeopleList一个按名称列出人员的组件。我们还想创建一个组件,当在该组件中单击人名时,该组件将显示人员详细信息
    PersonDetail.html

    <template id="person-detail-template">
      <link rel="stylesheet" href="/components/PeopleController/PersonDetail/PersonDetail.css">
      <div class="card__user-card-container">
        <h2 class="card__name">
          <span class="card__full-name"></span> (
          <span class="card__user-name"></span>)
        </h2>
        <p>Website: <a class="card__website"></a></p>
        <div class="card__hidden-content">
          <p class="card__address"></p>
        </div>
        <button class="card__details-btn">More Details</button>
      </div>
    </template>
    <script src="/components/PeopleController/PersonDetail/PersonDetail.js"></script>
    

    PersonDetail.css

    .card__user-card-container {
      text-align: center;
      border-radius: 5px;
      border: 1px solid grey;
      font-family: Helvetica;
      margin: 3px;
    }
    
    .card__user-card-container:hover {
      box-shadow: 3px 3px 3px;
    }
    
    .card__hidden-content {
      display: none;
    }
    
    .card__details-btn {
      background-color: #dedede;
      padding: 6px;
      margin-bottom: 8px;
    }
    

    /components/PeopleController/PersonDetail/PersonDetail.js

    (function () {
      const currentDocument = document.currentScript.ownerDocument;
    
      class PersonDetail extends HTMLElement {
        constructor() {
          // If you define a constructor, always call super() first as it is required by the CE spec.
          super();
    
          // Setup a click listener on <user-card>
          this.addEventListener('click', e => {
            this.toggleCard();
          });
        }
    
        // Called when element is inserted in DOM
        connectedCallback() {
          const shadowRoot = this.attachShadow({ mode: 'open' });
          const template = currentDocument.querySelector('#person-detail-template');
          const instance = template.content.cloneNode(true);
          shadowRoot.appendChild(instance);
        }
    
        // 创建API函数,以便其他组件可以使用它来填充此组件
        // Creating an API function so that other components can use this to populate this component
        updatePersonDetails(userData) {
          this.render(userData);
        }
    
        /// 填充卡的功能(可以设为私有)
        // Function to populate the card(Can be made private)
        render(userData) {
          this.shadowRoot.querySelector('.card__full-name').innerHTML = userData.name;
          this.shadowRoot.querySelector('.card__user-name').innerHTML = userData.username;
          this.shadowRoot.querySelector('.card__website').innerHTML = userData.website;
          this.shadowRoot.querySelector('.card__address').innerHTML = `<h4>Address</h4>
          ${userData.address.suite}, <br />
          ${userData.address.street},<br />
          ${userData.address.city},<br />
          Zipcode: ${userData.address.zipcode}`
        }
    
        toggleCard() {
          let elem = this.shadowRoot.querySelector('.card__hidden-content');
          let btn = this.shadowRoot.querySelector('.card__details-btn');
          btn.innerHTML = elem.style.display == 'none' ? 'Less Details' : 'More Details';
          elem.style.display = elem.style.display == 'none' ? 'block' : 'none';
        }
      }
    
      customElements.define('person-detail', PersonDetail);
    })()
    

    updatePersonDetails(userData)以便在单击Person组件时可以使用此函数更新此PeopleList组件。我们也可以使用属性完成此操作

    父组件 PeopleController

    HTML导入已从标准中删除,预计将被模块导入替换
    PeopleController.html

    <template id="people-controller-template">
      <link rel="stylesheet" href="/components/PeopleController/PeopleController.css">
      <people-list id="people-list"></people-list>
      <person-detail id="person-detail"></person-detail>
    </template>
    <link rel="import" href="/components/PeopleController/PeopleList/PeopleList.html">
    <link rel="import" href="/components/PeopleController/PersonDetail/PersonDetail.html">
    <script src="/components/PeopleController/PeopleController.js"></script>
    

    PeopleController.css

    #people-list {
      width: 45%;
      display: inline-block;
    }
    #person-detail {
      width: 45%;
      display: inline-block;
    }
    

    PeopleController.js

    (function () {
      const currentDocument = document.currentScript.ownerDocument;
    
      function _fetchAndPopulateData(self) {
        let peopleList = self.shadowRoot.querySelector('#people-list');
        fetch(`https://jsonplaceholder.typicode.com/users`)
          .then((response) => response.text())
          .then((responseText) => {
            const list = JSON.parse(responseText);
            self.peopleList = list;
            peopleList.list = list;
    
            _attachEventListener(self);
          })
          .catch((error) => {
            console.error(error);
          });
      }
      function _attachEventListener(self) {
        let personDetail = self.shadowRoot.querySelector('#person-detail');
    
        //Initialize with person with id 1:
        personDetail.updatePersonDetails(self.peopleList[0]);
    
        self.shadowRoot.addEventListener('PersonClicked', (e) => {
          // e contains the id of person that was clicked.
          // We'll find him using this id in the self.people list:
          self.peopleList.forEach(person => {
            if (person.id == e.detail.personId) {
              // Update the personDetail component to reflect the click
              personDetail.updatePersonDetails(person);
            }
          })
        })
      }
    
      class PeopleController extends HTMLElement {
        constructor() {
          super();
          this.peopleList = [];
        }
    
        connectedCallback() {
          const shadowRoot = this.attachShadow({ mode: 'open' });
          const template = currentDocument.querySelector('#people-controller-template');
          const instance = template.content.cloneNode(true);
          shadowRoot.appendChild(instance);
    
          _fetchAndPopulateData(this);
        }
      }
      customElements.define('people-controller', PeopleController);
    })()
    

    调用API来获取用户的数据。 这将采用我们之前定义的2个组件,填充PeopleList组件,并将此数据的第一个用户提供为PeopleDetail组件的初始数据。
    在父组件中监视PersonClicked事件,以便我们可以相应地更新PersonDetail对象。 因此,在上面的文件中创建2个私有函数

    使用组件

    创建一个名为index.html的新HTML文件

    <html>
        <head>
          <title>Web Component Part 2</title>
        </head>
        <body>
          <people-controller></people-controller>
          <script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.14/webcomponents-hi.js"></script>
          <link rel="import" href="./components/PeopleController/PeopleController.html">
        </body>
    </html>
    

    attributes 组件属性 观察/更新

    HTML中的元素具有属性; 这些是配置元素或以各种方式调整其行为以满足用户所需条件的其他值。
    使用以下属性创建一个组件UserCard:username,address和is-admin(布尔值告诉我们用户是否为admin)。

    观察这些属性以进行更改并相应地更新组件。

    定义属性

    <user-card username="Ayush" address="Indore, India" is-admin></user-card>
    

    使用JavaScript中的DOM API来使用getAttribute(attrName)和setAttribute(attrName,newVal)方法来获取和设置属性。

    let myUserCard = document.querySelector('user-card')
    myUserCard.getAttribute('username') // Ayush
    myUserCard.setAttribute('username', 'Ayush Gupta') 
    myUserCard.getAttribute('username') // Ayush Gupta
    

    观察属性更改

    自定义元素规范v1定义了一种观察属性更改并对这些更改采取操作的简便方法。 在创建我们的组件时,我们需要定义两件事:
    观察到的属性:要在属性更改时得到通知,必须在初始化元素时定义观察到的属性列表,方法是在返回属性名称数组的元素类上放置一个静态的observeAttributes getter。

    attributeChangedCallback(attributeName,oldValue,newValue,namespace):在元素上更改,追加,删除或替换属性时调用的生命周期方法。 它仅用于观察属性。

    创建UserCard组件

    构建UserCard组件,它将使用属性进行初始化,并且我们的组件将观察对其属性所做的任何更改。
    在项目目录中创建index.html文件。

    还可以使用以下文件创建UserCard目录:UserCard.html,UserCard.css和UserCard.js。

    UserCard.js

    (async () => {
      const res = await fetch('/UserCard/UserCard.html');
      const textTemplate = await res.text();
      const HTMLTemplate = new DOMParser().parseFromString(textTemplate, 'text/html')
                               .querySelector('template');
    
      class UserCard extends HTMLElement {
        constructor() { ... }
        connectedCallback() { ... }
        
        // Getter to let component know what attributes
        // to watch for mutation
        static get observedAttributes() {
          return ['username', 'address', 'is-admin']; 
        }
    
        attributeChangedCallback(attr, oldValue, newValue) {
          console.log(`${attr} was changed from ${oldValue} to ${newValue}!`)
        }
      }
    
      customElements.define('user-card', UserCard);
    })();
    

    使用属性初始化

    创建组件时,我们将为它提供一些初始值,它将用于初始化组件。

    <user-card username="Ayush" address="Indore, India" is-admin="true"></user-card>
    

    connectedCallback中,我们将使用这些属性并定义与每个属性相对应的变量。

    connectedCallback() {
      const shadowRoot = this.attachShadow({ mode: 'open' });
      const instance = HTMLTemplate.content.cloneNode(true);
      shadowRoot.appendChild(instance);
    
      // You can also put checks to see if attr is present or not
      // and throw errors to make some attributes mandatory
      // Also default values for these variables can be defined here
      this.username = this.getAttribute('username');
      this.address = this.getAttribute('address');
      this.isAdmin = this.getAttribute('is-admin');
    }
    
    // Define setters to update the DOM whenever these values are set
    set username(value) {
      this._username = value;
      if (this.shadowRoot)
        this.shadowRoot.querySelector('#card__username').innerHTML = value;
    }
    
    get username() {
      return this._username;
    }
    
    set address(value) {
      this._address = value;
      if (this.shadowRoot)
        this.shadowRoot.querySelector('#card__address').innerHTML = value;
    }
    
    get address() {
      return this._address;
    }
    
    set isAdmin(value) {
      this._isAdmin = value;
      if (this.shadowRoot)
        this.shadowRoot.querySelector('#card__admin-flag').style.display = value == true ? "block" : "none";
    }
    
    get isAdmin() {
      return this._isAdmin;
    }
    

    观察属性更改

    更改观察到的属性时,将调用attributeChangedCallback。 所以我们需要定义当这些属性发生变化时会发生什么。 重写函数以包含以下内容:

    attributeChangedCallback(attr, oldVal, newVal) {
      const attribute = attr.toLowerCase()
      console.log(newVal)
      if (attribute === 'username') {
        this.username = newVal != '' ? newVal : "Not Provided!"
      } else if (attribute === 'address') {
        this.address = newVal !== '' ? newVal : "Not Provided!"
      } else if (attribute === 'is-admin') {
        this.isAdmin = newVal == 'true';
      }
    }
    

    创建组件

    <template id="user-card-template">
      <h3 id="card__username"></h3>
      <p id="card__address"></p>
      <p id="card__admin-flag">I'm an admin</p>
    </template>
    

    使用组件

    使用2个输入元素和一个复选框创建index.html文件,并为所有这些元素定义onchange方法以更新组件的属性。 一旦属性更新,更改也将反映在DOM中。

    <html>
        <head>
          <title>Web Component</title>
        </head>
        <body>
          <input type="text" onchange="updateName(this)" placeholder="Name">
          <input type="text" onchange="updateAddress(this)" placeholder="Address">
          <input type="checkbox" onchange="toggleAdminStatus(this)" placeholder="Name">
          <user-card username="Ayush" address="Indore, India" is-admin></user-card>
          <script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.14/webcomponents-hi.js"></script>
          <script src="/UserCard/UserCard.js"></script>
          <script>
            function updateAddress(elem) {
              document.querySelector('user-card').setAttribute('address', elem.value);
            }
        
            function updateName(elem) {
              document.querySelector('user-card').setAttribute('username', elem.value);
            }
        
            function toggleAdminStatus(elem) {
              document.querySelector('user-card').setAttribute('is-admin', elem.checked);
            }
          </script>
        </body>
    </html>
    

    何时使用属性

    在上一篇文章中,我们为子组件创建了一个API,以便父组件可以使用此API初始化并与它们交互。在这种情况下,如果我们已经有一些配置,希望直接提供而不使用父/其他函数调用,将无法做到。
    使用属性,我们可以非常轻松地提供初始配置。然后可以在构造函数或connectedCallback中提取此配置以初始化组件。

    更改属性以与组件交互可能会有点单调乏味。假设您要将大量json数据传递给组件。这样做需要将json表示为字符串属性,并在组件使用时进行解析。

    我们有3种方法可以创建交互式Web组件:

    仅使用属性:这是我们在本文中看到的方法。我们使用属性来初始化组件以及与外部世界进行交互。
    仅使用已创建的函数:这​​是我们在本系列的第2部分中看到的方法,我们使用我们为它们创建的函数初始化并与组件交互。

    使用混合方法:应该使用IMO。在这种方法中,我们使用属性初始化组件,并且对于所有后续交互,只需使用对其API的调用。

    Web Components modal 模态弹窗

    image2

    定义模态组件

    modal.js

    class Modal extends HTMLElement {
        constructor() {
            super();
            this._modalVisible = false;
            this._modal;
            this.attachShadow({ mode: 'open' });
            this.shadowRoot.innerHTML = `
            <style>
                /* The Modal (background) */
                .modal {
                    display: none; 
                    position: fixed; 
                    z-index: 1; 
                    padding-top: 100px; 
                    left: 0;
                    top: 0;
                    width: 100%; 
                    height: 100%; 
                    overflow: auto; 
                    background-color: rgba(0,0,0,0.4); 
                }
    
                /* Modal Content */
                .modal-content {
                    position: relative;
                    background-color: #fefefe;
                    margin: auto;
                    padding: 0;
                    border: 1px solid #888;
                    width: 80%;
                    box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19);
                    -webkit-animation-name: animatetop;
                    -webkit-animation-duration: 0.4s;
                    animation-name: animatetop;
                    animation-duration: 0.4s
                }
    
                /* Add Animation */
                @-webkit-keyframes animatetop {
                    from {top:-300px; opacity:0} 
                    to {top:0; opacity:1}
                }
    
                @keyframes animatetop {
                    from {top:-300px; opacity:0}
                    to {top:0; opacity:1}
                }
    
                /* The Close Button */
                .close {
                    color: white;
                    float: right;
                    font-size: 28px;
                    font-weight: bold;
                }
    
                .close:hover,
                .close:focus {
                color: #000;
                text-decoration: none;
                cursor: pointer;
                }
    
                .modal-header {
                padding: 2px 16px;
                background-color: #000066;
                color: white;
                }
    
                .modal-body {padding: 2px 16px; margin: 20px 2px}
    
            </style>
            <button>Open Modal</button>
            <div class="modal">
                <div class="modal-content">
                    <div class="modal-header">
                        <span class="close">&times;</span>
                        <slot name="header"><h1>Default text</h1></slot>
                    </div>
                    <div class="modal-body">
                        <slot><slot>
                    </div>
                </div>
            </div>
            `
        }
        connectedCallback() {
            this._modal = this.shadowRoot.querySelector(".modal");
            this.shadowRoot.querySelector("button").addEventListener('click', this._showModal.bind(this));
            this.shadowRoot.querySelector(".close").addEventListener('click', this._hideModal.bind(this));
        }
        disconnectedCallback() {
            this.shadowRoot.querySelector("button").removeEventListener('click', this._showModal);
            this.shadowRoot.querySelector(".close").removeEventListener('click', this._hideModal);
        }
        _showModal() {
            this._modalVisible = true;
            this._modal.style.display = 'block';
        }
        _hideModal() {
            this._modalVisible = false;
            this._modal.style.display = 'none';
        }
    }
    customElements.define('pp-modal',Modal);
    

    使用 模态组件

    index.html

    <!DOCTYPE html>
    <html>
        <head>
          <meta name="viewport" content="width=device-width, initial-scale=1">
          <script src="./modal.js"></script>
        </head>
        <body>
          <h2>Modal web component with vanilla JS.</h2>
          <pp-modal>
            <h1 slot="header">Information Box</h1>
            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
            aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
          </pp-modal>
        </body>
    </html>
    

    template

    (function () {
        class MidociLayOut extends HTMLElement {
          static get observedAttributes() {
            return ['acitve-title', 'active-sub-title']
          }
          
          constructor() {
            super()
            this.attachShadow({mode: 'open'})
            this.shadowRoot.innerHTML = `
              <style>
              </style>
              <div class="wrapper">
              </div>
            `
            this._a = ''
          }
      
          connectedCallback() {
          }
      
          disconnectedCallback() {
          }
      
          attributeChangedCallback(attr, oldVal, newVal) {
            // const attribute = attr.toLowerCase()
            // if (attribute === 'descriptions') {
            //   console.log(1)
            //   this.render(newVal)
            // }
          }
        }
        const FrozenMidociLayOut = Object.freeze(MidociLayOut);
        customElements.define('midoci-lay-out', FrozenMidociLayOut);
      })()
    

    使用 web component 构建一个通用无依赖 html 单文件 select 组件

    效果
    image3

    体验
    https://codepen.io/WangShuXian6/pen/ymbMYe

    web components polyfill 兼容旧版本浏览器的支持插件
    https://www.webcomponents.org/polyfills

    源码

    (function () {
      const selectListDemo = [
        {name: 'test1', value: 1},
        {name: 'test2', value: 2},
        {name: 'test3', value: 3}
      ]
    
      class MidociSelect extends HTMLElement {
        static get observedAttributes() {
          return ['acitve-title', 'active-sub-title']
        }
    
        constructor() {
          super()
          this.attachShadow({mode: 'open'})
          this.shadowRoot.innerHTML = `
            <style>
              :host{
                --themeColor:rgb(24,144,255);
                box-sizing: border-box;
                font-size: 14px;
                --borderColor:#eee;
              }
              
              .wrapper{
                position: relative;
                display: inline-flex;
                align-items: center;
                padding-left: 10px;
                width: 95px;
                height: 36px;
                border: 1px solid var(--borderColor);
                color: #333;
                border-radius: 2px;
                user-select: none;
                transition: .3s cubic-bezier(.12, .4, .29, 1.46);
                outline:none
              }
              
              .wrapper:hover{
                border: 1px solid var(--themeColor);
                transition: .3s cubic-bezier(.12, .4, .29, 1.46);
              }
              
              .title{
                
              }
              
              .arrow-out{
                position: absolute;
                right: 12px;
                top: 50%;
                transform: translateY(0px) rotateX(0deg);
                transition: .3s cubic-bezier(.12, .4, .29, 1.46);
              }
              
              .wrapper.flip>.arrow-out{
                transform: translateY(-3px) rotateX(180deg);
                transition: .3s cubic-bezier(.12, .4, .29, 1.46);
              }
              
              .arrow{
                display: flex;
                width: 6px;
                height:6px;
                border: none;
                border-left: 1px solid #333;
                border-bottom: 1px solid #333;
                transform: translateY(-50%) rotateZ(-45deg);
                transition: .3s cubic-bezier(.12, .4, .29, 1.46);
              }
              
              .wrapper:hover .arrow{
                border-left: 1px solid var(--themeColor);
                border-bottom: 1px solid var(--themeColor);
                transition: .3s cubic-bezier(.12, .4, .29, 1.46);
              }
              
              
              
              .list{
                z-index: 100;
                position: absolute;
                top: 130%;
                left: 0;
                background-color: #fff;
                box-shadow: 0 0 10px rgba(0,0,0,0.1);
                visibility: hidden;
                min-width: 100%;
                border-radius: 3px;
                transform: scale(0);
                transform-origin: top;
                transition: .3s cubic-bezier(.12, .4, .29, 1.46);
              }
              
              .wrapper.flip>.list{
              visibility: visible;
                transform: scale(1);
                transition: .3s cubic-bezier(.12, .4, .29, 1.46);
              }
              
              .item{
                display: flex;
                align-items: center;
                padding-left: 10px;
                width: 95px;
                height: 36px;
                color: #333;
                border-radius: 2px;
                user-select: none;
                background-color: #fff;
                transition: background-color .3s ease-in-out;
              }
              
              .item:hover{
                background-color: rgba(24,144,255,0.1);
                transition: background-color .3s ease-in-out;
              }
            </style>
            
            <div class="wrapper" tabindex="1">
              <span class="title">1</span>
              <span class="arrow-out">
                <span class="arrow"></span>
              </span>
              <div class="list" >
                <div class="item">1</div>
                <div class="item">2</div>
                <div class="item">3</div>
                <div class="item">4</div>
              </div>
            </div>
          `
          this._wrapperDom = null
          this._listDom = null
          this._titleDom = null
          this._list = []
          this._arrowFlip = false
          this._value = null
          this._name = null
        }
    
        connectedCallback() {
          this._wrapperDom = this.shadowRoot.querySelector('.wrapper')
          this._listDom = this.shadowRoot.querySelector('.list')
          this._titleDom = this.shadowRoot.querySelector('.title')
          this.initEvent()
          this.list = selectListDemo
        }
    
        disconnectedCallback() {
          this._wrapperDom.removeEventListener('click', this.flipArrow.bind(this))
          this._wrapperDom.removeEventListener('blur', this.blurWrapper.bind(this))
    
          this.shadowRoot.querySelectorAll('.item')
            .forEach((item, index) => {
              item.removeEventListener('click', this.change.bind(this, index))
            })
        }
    
        attributeChangedCallback(attr, oldVal, newVal) {
          // const attribute = attr.toLowerCase()
          // if (attribute === 'descriptions') {
          //   console.log(1)
          //   this.render(newVal)
          // }
        }
    
        set list(list) {
          if (!this.shadowRoot) return
          this._list = list
          this.render(list)
        }
    
        get list() {
          return this._list
        }
    
        set value(value) {
          this._value = value
        }
    
        get value() {
          return this._value
        }
    
        set name(name) {
          this._name = name
        }
    
        get name() {
          return this._name
        }
    
        initEvent() {
          this.initArrowEvent()
          this.blurWrapper()
        }
    
        initArrowEvent() {
          this._wrapperDom.addEventListener('click', this.flipArrow.bind(this))
        }
    
        initChangeEvent() {
          this.shadowRoot.querySelectorAll('.item')
            .forEach((item, index) => {
              item.addEventListener('click', this.change.bind(this, index))
            })
        }
    
        change(index) {
          this.changeTitle(this._list, index)
    
          let changeInfo = {
            detail: {
              value: this._value,
              name: this._name
            },
            bubbles: true
          }
          let changeEvent = new CustomEvent('change', changeInfo)
          this.dispatchEvent(changeEvent)
        }
    
        changeTitle(list, index) {
          this._value = list[index].value
          this._name = list[index].name
          this._titleDom.innerText = this._name
        }
    
        flipArrow() {
          if (!this._arrowFlip) {
            this.showList()
          } else {
            this.hideList()
          }
        }
    
        showList() {
          this._arrowFlip = true
          this._wrapperDom.classList = 'wrapper flip'
        }
    
        hideList() {
          this._arrowFlip = false
          this._wrapperDom.classList = 'wrapper'
        }
    
        blurWrapper() {
          this._wrapperDom.addEventListener('blur', (event) => {
            event.stopPropagation()
            this.hideList()
          })
        }
    
        render(list) {
          if (!list instanceof Array) return
          let listString = ''
          list.forEach((item) => {
            listString += `
              <div class="item" data-value="${item.value}">${item.name}</div>
            `
          })
          this._listDom.innerHTML = listString
          this.changeTitle(list, 0)
          this.initChangeEvent()
        }
      }
    
      const FrozenMidociSelect = Object.freeze(MidociSelect);
      customElements.define('midoci-select', FrozenMidociSelect);
    })()
    

    注意:如果父元素高度太低,需要关闭父元素的 overflow 属性,否则会遮盖 下拉列表

    使用

    <script type="module" async>
        import './MidociSelect.js'
    </script>
    
    <midoci-select></midoci-select>
    
    <script>
        const list = [
            {name: '全平台', value: 1},
            {name: '东券', value: 2},
            {name: '京券', value: 3}
          ]
    
        window.onload=function(){
            document.querySelector('midoci-select').list=list
            
            console.log(document.querySelector('midoci-select').value)
            console.log(document.querySelector('midoci-select').name)
        
            document.querySelector('midoci-select').addEventListener('change', (event) => {
            console.log('选中的 value:', event.detail.value)
            console.log('选中的 name:', event.detail.name)
          })
        }
    </script>
    

    服务器渲染 项目 注意事项

    鉴于 服务器的根路径的配置不统一
    import 可以使用绝对路径
    import 的 js 内部不可以再次 import ,会出现路径错误

    <script type="module" async>
        import 'https://xxx/button.js';
    </script>
    

    https://github.com/WangShuXian6/blog/issues/78

    其他

    在event内访问shadowRoot

    this.shadowRoot.querySelector('button').addEventListener('click',(){
        this.getRootNode().host.querySelector('label').innerText = 'button clicked';
    });
    

    自定义元素嵌套

    最近造轮子过程中,使用了嵌套自定义元素.类似如下代码:

    <my-place title="place1">
        <my-car name="name1"></my-car>
        <my-car name="name2"></my-car>
        <my-car name="name3"></my-car>
    </my-place>
    

    my-place自定义元素在初始化过程中,会根据子元素slot填充内部属性,但是在获取my-car属性的时候,总是提示undefine.
    其中有一个页面,颠倒两个class的定义顺序,能正常解析到my-car的属性,但是另外一个页面却无效了.
    后来在my-place的初始化操作中,做了延时操作,能正常解析到my-car的属性了

    class MyPlace{
        init(){
            setTimeout(() => {
                mycar.name
                // name1 ...
            },500);
        }
    }
    

    但是这个办法有点low,所以后来直接使用dom属性获取的方法实现

    class MyPlace{
        init(){
            let mycar = document.getElementByTagName('my-car')[0];
            mycar.getAttribute('name');
            // name1 ...
        }
    }
    

    虽然同样有点low,但是能保证效率.这里说些题外话,前端浏览器对于源码的加载顺序还是不太了解.最开始为了用前端的方式解决问题,使用javascript异步读取template,template中定义了link元素,用来加载css信息,但是在实际显示过程中,前端页面渲染完毕的时候,页面只会显示基本文字信息,然后闪烁以下(异步加载自定义元素),重构页面.用户体验不太好,所以就改用后端读取自定义元素相关文件,直接填充到请求的网页模板中.

    相关链接

    Web Components入门实例教程-阮一峰
    https://www.html5rocks.com/zh/tutorials/webcomponents/shadowdom-201/#toc-style-scoped