写点什么

适合初学者的 Web Components 教程 [2019](译)

用户头像
西贝
关注
发布于: 2020 年 11 月 16 日
适合初学者的Web Components教程[2019](译)

原文

https://www.robinwieruch.de/web-components-tutorial


This tutorial teaches you how to build your first Web Components and how to use them in your applications. Before we get started, let's take a moment to learn more about Web Components in general: In recent years, Web Components, also called Custom Elements, have become a standard API for several browsers which allow developers to implement reusable components with only HTML, CSS and JavaScript. No React, Angular or Vue needed here. Instead, Custom Elements offer you encapsulation of all the structure (HTML), styling (CSS), and behavior (JavaScript) in one custom HTML element. For instance, imagine you could have a HTML dropdown component like the one in the following code snippet:


这篇教程会告诉你如何构建你的第一个 Web Components,并且如何在你的应用中使用它们。在开始之前,我们先花点时间了解一下 Web Components:最近几年,Web Components 也被称为自定义元素,它已经成为了多个浏览器的标准 API,该 API 可以使开发者仅用 HTML、CSS 和 JavaScript 就可以实现可复用的组件。这里不需要使用 React、Angular 或者 Vue。相反,自定义元素可以允许你在一个自定义的 HTML 元素中封装所有的结构(HTML),样式(CSS)和交互(JavaScript)。比如,想象你有一个 HTML 下拉组件,像下面这段代码一样


<my-dropdown  label="Dropdown"  option="option2"  options='{ "option1": { "label": "Option 1" }, "option2": { "label": "Option 2" } }'></my-dropdown>
复制代码


In this tutorial, we will implement this dropdown component step by step from scratch with Web Components. Afterward, you can continue using it across your application, make it an open source web component to install it somewhere else, or use a framework like React to build upon a solid foundation of Web Components for your React application.


这篇教程中,我们会用 Web Component 从头开始,一步一步的实现这个下拉组件。之后,你可以在你的应用中持续调用它,也可以使它成为一个开源的 web 组件,随时安装它,再或者可以用一个类似 React 框架给你的 React 应用构建一个稳定的 Web 组件


WHY WEB COMPONENTS?

为什么要用 web 组件


A personal story to illustrate how to benefit from Web Components: I picked up Web Components when a client of mine with many cross functional teams wanted to create a UI library based on a style guide. Two teams started to implement components based on the style guide, but each team used a different framework: React and Angular. Even though both implementations shared kinda the same structure (HTML) and style (CSS) from the style guide, the implementation of the behavior (e.g. opening/closing a dropdown, selecting an item in a dropdown) with JavaScript was up to each team to implement with their desired framework. In addition, if the style guide made mistakes with the style or structure of the components, each team fixed these mistakes individually without adapting the style guide afterward. Soonish both UI libraries diverged in their appearance and behavior.


个人见解,Web Component 可以给予我们怎样的好处:当我想要和许多基础团队基于同一个样式规范创建一个 UI 库时,我选择用 Web Component。两个团队基于这个样式规范开始实现组件,但是各个团队用的是不同的框架:React 和 Angular。虽然两种实现方式都基于样式规范分享了相同的结构(HTML)和样式(CSS),但是 JavaScript 部分的脚本的实现(比如,打开/关闭下拉框,在下拉列表中选择一个选项)是用各自团队熟悉的框架完成的。另外,如果样式规范在样式或者组件的结构上出现了错误,各个团队分别在新的样式规范上修复这些问题。很快,两个组件库在展示和交互上就会出现差异。


Note: Independent from Web Components, this is a common flaw in style guides, if they are not used pro actively (e.g. living style guide) in code, but only as documentation on the side which gets outdated eventually.


注意:抛开 Web Component,对于样式规范而言,如果它们在代码中没有被积极的使用,最终会仅仅是作为一个说明文档被放在一边,这种情况很常见,比如生活风格指南


Eventually both teams came together and discussed how to approach the problem. They asked me to look into Web Components to find out whether their problem could be solved with them. And indeed Web Components offered a compelling solution: Both teams could use implement common Web Components based on the style guide. Components like Dropdown, Button and Table would be implemented with only HTML, CSS, and JavaScript. Moreover, they weren't forced to use explicitly Web Components for their individual applications later on, but would be able to consume the components in their React or Angular applications. If the requirements of the style guide change, or a component needs to get fixed, both teams could collaborate on their shared Web Component UI library.


最后,两个团队坐在一起开始讨论如何解决这个问题。他们让我研究一下 Web Component,看是否可以用它来解决他们的问题。事实上 Web Component 确实提供了一种实现方案:两个团队可以基于样式规范实现通用的 Web Component。像 Dropsown、Button 和 Table 这些组件,仅用 HTML、CSS 和 JavaScript 就可以实现。而且,之后,也不会强制他们在各自的应用中使用这些组件,甚至可以在他们的 React 或者 Angular 应用中使用这些组件。如果在样式规范上有调整需求,或者这个组件需要修复问题,两个团队也可以协商共享他们的 Web Component UI 库


GETTING STARTED WITH WEB COMPONENTS

开始使用 Web Components


If you need a starter project for the following tutorial, you can clone this one from GitHub. You should look into the dist/ and src/ folders to make your adjustments from the tutorial along the way. The finished project from the tutorial can be found here on GitHub.


如果你需要这篇教程的启动项目,你可以从 GitHub 上克隆这个项目。接下来你可以根据教程,在dist/src/目录中进行修改。这篇教程的完整项目可以在 GitHub 上找到


Let's get started with our first web component. We will not start to implement the dropdown component from the beginning, but rather a simple button component which is used later on in the dropdown component. Implementing a simple button component with a Web Component doesn't make much sense, because you could use a <button> element with some CSS, however, for the sake of learning about Web Components, we will start out with this button component. Thus, the following code block is sufficient to create a Web Component for an individual button with custom structure and style:


开始第一个 web 组件。刚开始我们不实现 dropdown 组件,而是做一个简单的 button 组件,之后在 dropdown 组件中会用到它。用 Web Component 实现一个简单的 Button 组件没有太大用处,因为你可以用<button>元素,加上一些 CSS 样式就可以实现。但是,为了学习 Web Component,我们会实现这个 button 组件。所以,下面这段代码就实现了一个 Web Component,它是具有自定义结构和样式的一个有效的 button


const template = document.createElement('template'); template.innerHTML = `  <style>    .container {      padding: 8px;    }     button {      display: block;      overflow: hidden;      position: relative;      padding: 0 16px;      font-size: 16px;      font-weight: bold;      text-overflow: ellipsis;      white-space: nowrap;      cursor: pointer;      outline: none;       width: 100%;      height: 40px;       box-sizing: border-box;      border: 1px solid #a1a1a1;      background: #ffffff;      box-shadow: 0 2px 4px 0 rgba(0,0,0, 0.05), 0 2px 8px 0 rgba(161,161,161, 0.4);      color: #363636;      cursor: pointer;    }  </style>   <div class="container">    <button>Label</button>  </div>`; class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));  }} window.customElements.define('my-button', Button);
复制代码

Let's go through everything step by step. The definition of your Custom Element (Web Component) happens with a JavaScript class that extends from HTMLElement which helps you to implement any custom HTML element. By extending from it, you will have access to various class methods - for instance, lifecycle callbacks (lifecycle methods) of the component - which help you to implement your Web Component. You will see later how we make use of these class methods.


我们一步一步的分析这段代码。你的自定义元素(Web Component)用 JavaScript class 来定义,它继承了 HTMLElement,可以让你实现任意的自定义 HTML 元素。通过继承,你可以实现任意的 class 方法-比如,组件的 lifecycle callbacks (生命周期函数),这些 class 方法可以帮你实现你的 Web Component。稍后你会看到我们如何使用这些 class 方法。


In addition, Web Components are using Shadow DOM which shouldn't be mistaken for Virtual DOM (performance optimization). The Shadow DOM is used to encapsulate CSS, HTML, and JavaScript which ought to be hidden for the outside components/HTML that are using the Web Component. You can set a mode for your Shadow DOM, which is set to true in our case, to make the Shadow DOM kinda accessible to the outside world. Anyway, you can think of the Shadow DOM as its own subtree inside your custom element that encapsulates structure and style.


另外,Web Component 使用的是 Shadow DOM,它和虚拟 DOM(性能优化)不能混为一谈。Shadow DOM 是用于将 CSS、HTML 和 JavaScript 进行封装,对于外部正在使用这个 Web Component 的组件/HTML 是透明的。你可以调整 Shadow DOM 的模式,在我们提供的示例中,它被设置为 true,使得 Shadow DOM 可以被外面的元素所访问。总而言之,你可以认为 Shadow DOM 是封装好结构和样式的自定义元素的子树


There is another statement in the constructor which appends a child to our Shadow DOM by cloning the declared template from above. Templates are usually used to make HTML reusable. However, templates also play a crucial role in Web Components for defining the structure and style of it. At the top of our custom element, we defined the structure and style with the help of such template which is used in the constructor of our custom element.


在这个结构中还有另一种形式,通过克隆父级的已有模板将它添加到 Shadow DOM 内。模板通常是为了使 HTML 具有复用性。然而,模板在 Web Component 也扮演着定义结构和样式的重要角色。自定义元素的首要步骤是用自定义元素构造的模板定义结构和样式


The last line of our code snippet defines the custom element as valid element for our HTML by defining it on the window. Whereas the first argument is the name of our reusable custom element as HTML -- which has to have a hyphen -- and the second argument the definition of our custom element including the rendered template. Afterward, we can use our new custom element somewhere in our HTML with <my-button></my-button>. Note that custom elements cannot/shouldn't be used as self closing tags.


代码片段的最后一行将这个自定义元素作为一个在 HTML 上有效的元素定义在 window 上。而第一个参数是在 HTML 可复用的自定义元素的名称(必须用一个分隔符),第二个参数则是渲染的模板。之后,我们可以在 HTML 中使用这个新的自定义元素<my-button></my-button>。注意,自定义元素不能也不应该作为自闭合标签来使用



HOW TO PASS ATTRIBUTES TO WEB COMPONENTS?

如何给 Web Components 传递属性


So far, our custom element isn't doing much except for having its own structure and style. We could have achieved the same thing by using a button element with some CSS. However, for the sake of learning about Web Components, let's continue with the custom button element. As for now, we cannot alter what's displayed by it. For instance, what about passing a label to it as HTML attribute:


目前为止,自定义元素除了有它自身的结构和样式以外,没有其它内容。我们可以用一个带有一些 CSS 样式的 button 元素实现同样的内容。但是,为了学习 Web Components,我们继续使用自定义的 button 元素。现在,我们不能更改它展示的内容。例如,如何将 label 作为一个 HTML 属性传递给它


<my-button label="Click Me"></my-button>
复制代码


The rendered output would still show the internal custom element's template which uses a Label string. In order to make the custom element react to this new attribute, you can observe it, and do something with it by using class methods coming from the extended HTMLElement class:


渲染输出的结果仍然是展示内部用label字符串自定义元素的模板。为了在自定义元素上使这个新属性起作用,你可以观察一下,并且用继承了 HTMLElement 类的 class 方法做一些调整


class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));  }   static get observedAttributes() {    return ['label'];  }   attributeChangedCallback(name, oldVal, newVal) {    this[name] = newVal;  }}
复制代码


Every time the label attribute changes, the attributeChangedCallback() function gets called, because we defined the label as observable attribute in the observedAttributes() function. In our case, the callback function doesn't do much except for setting the label on our Web Component's class instance (here: this.label = 'Click Me'). However, the custom element still isn't rendering this label yet. In order to adjust the rendered output, you have to grab the actual HTML button and set its HTML:


每一次 label 属性发生变化时,attributeChangedCallback()函数都会被调用,因为我们在observedAttributes()函数中将 label 定义为可见属性。在这个示例中,回调函数除了在 Web Components 的类初始化时设置了 label(这里指this.label = 'Click Me')以外,并没有做什么。但是,这个自定义元素仍旧没有渲染这个 label。为了修改渲染的输出结果,你必须获取到真正的 HTML button,并且修改它的 HTML


class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));     this.$button = this._shadowRoot.querySelector('button');  }   static get observedAttributes() {    return ['label'];  }   attributeChangedCallback(name, oldVal, newVal) {    this[name] = newVal;     this.render();  }   render() {    this.$button.innerHTML = this.label;  }}
复制代码


Now, the initial label attribute is set within the button. In addition, the custom element will react to changes of the attribute as well. You can implement other attributes the same way. However, you will notice that non JavaScript primitives such as objects and arrays need to be passed as string in JSON format. We will see this later when implementing the dropdown component.


现在,初始的 label 属性已经被渲染到 button 上,而且,自定义元素也会随着属性的变化重新渲染。你可以用同样的方式实现其它的属性。但是,你需要注意,对于对象和数组这种非 JavaScript 原语需要通过 JSON 格式化为字符串之后再传递。稍后在实现 dropdown 组件时我们会看到这种效果


REFLECTING PROPERTIES TO ATTRIBUTES

properties 和 attributes 的映射


So far, we have used attributes to pass information to our Custom Element. Every time an attribute changes, we set this attribute as property on our Web Component's instance in the callback function. Afterward, we do all necessary changes for the rendering imperatively. However, we can also use a get method to reflect the attribute to a property. Doing it this way, we make sure that we always get the latest value without assigning it in our callback function ourselves. Then, this.label always returns the recent attribute from our getter function:


到现在为止,我们用 attributes 把数据信息传递给自定义元素。每次一个属性发生变化,我们需要在回调函数中把这个属性作为 property 在 Web Component 上做初始化。随之,我们必须在渲染实现上做所有的修改。然而,我们也可用get方法将attribute映射到property上。这样做的好处是,哪怕我们没有在回调函数上对它初始化,我们也可以随时获取到最新的值。这样,this.label始终可以通过getter函数返回当前的属性值


class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));     this.$button = this._shadowRoot.querySelector('button');  }   get label() {    return this.getAttribute('label');  }   static get observedAttributes() {    return ['label'];  }   attributeChangedCallback(name, oldVal, newVal) {    this.render();  }   render() {    this.$button.innerHTML = this.label;  }}
复制代码


That's it for reflecting an attribute to a property. However, the other way around, you can also pass information to an custom element with properties. For instance, instead of rendering our button with an attribute <my-button label="Click Me"></my-button>, we can also set the information as property for the element. Usually this way is used when assigning information like objects and arrays to our element:


这就是attributeproperty的映射。然而,还有另一种方式,你也可以将数据传递给自定义元素。比如,取消带有attribute的渲染<my-button label="Click Me"></my-button>,我们可以为元素设置property属性值。通常当传递类似对象和数组的数据给元素时,我们可以采用这种方式。


<my-button></my-button> <script>  const element = document.querySelector('my-button');  element.label = 'Click Me';</script>
复制代码


Unfortunately our callback function for the changed attributes isn't called anymore when using a property instead of an attribute, because it only reacts for attribute changes doesn't handle properties. That's where a set method on our class comes neatly into play:


可惜的是,当我们用property赋值的方式代替元素的attribute时,回调函数不会在attribute发生变化时被触发,因为它只监听attribute的变化,而不会监听property。这种情况下,就是 class 中set方法发挥作用的时候


class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));     this.$button = this._shadowRoot.querySelector('button');  }   get label() {    return this.getAttribute('label');  }   set label(value) {    this.setAttribute('label', value);  }   static get observedAttributes() {    return ['label'];  }   attributeChangedCallback(name, oldVal, newVal) {    this.render();  }   render() {    this.$button.innerHTML = this.label;  }}
复制代码


Now, since we set the property from the outside on our element, our custom element's setter method makes sure to reflect the property to an attribute, by setting the element's attribute to the reflected property value. Afterward, our attribute callback runs again, because the attribute has changed and thus we get the rendering mechanism back.


现在,当我们在元素外部设置property时,自定义元素的setter方法会确保propertyattribute之间的映射,将元素的attribute设置为映射的property值。之后,attribute的回调又重新起作用了,因为attribute 发生了变化,这样我们实现了渲染机制的回调


You can add console logs for each method of this class to understand the order on when each method happens. The whole reflection can also be witnessed in the DOM by opening the browser's developer tools: the attribute should appear on the element even though it is set as property.


你可以为这个 class 的每个方法添加输出,方便理解每个方法的执行顺序。打开浏览器的开发者工具,也可以在 DOM 中看到整个映射过程:虽然是在元素上设置property值,但是它会显示在attribute


Finally, after having getter and setter methods for our information in place, we can pass information as attributes and as properties to our custom element. The whole process is called reflecting properties to attributes and vice versa.


最后,在为数据添加gettersetter 方法之后,我们可以通过attributeproperty 给自定义元素传递数据。这个过程称之为propertiesattributes 之间的映射


HOW TO PASS A FUNCTION TO A WEB COMPONENT?

如何传递一个函数给 Web Component?


Last but not least, we need to make our custom element work when clicking it. First, the custom element could register an event listener to react on an user's interaction. For instance, we can take the button and add an event listener to it:


最后重要的一点,我们需要使我们的自定义元素点击有效。首先,自定义元素可以注册一个事件监听,实现一个用户交互。比如,我们可以获取这个 button 并且给它添加一个事件监听:


class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));     this.$button = this._shadowRoot.querySelector('button');     this.$button.addEventListener('click', () => {      // do something    });  }   get label() {    return this.getAttribute('label');  }   set label(value) {    this.setAttribute('label', value);  }   static get observedAttributes() {    return ['label'];  }   attributeChangedCallback(name, oldVal, newVal) {    this.render();  }   render() {    this.$button.innerHTML = this.label;  }}
复制代码

Note: It would be possible to add this listener simply from the outside on the element -- without bothering about it in the custom element -- however, defining it inside of the custom element gives you more control of what should be passed to the listener that is registered on the outside.


注意:它可以在元素的外部简单的添加这个监听(不一定要在自定义元素的内部操作),但是,在自定义元素内部定义监听,可以让你更方便的操作哪些数据可以传递给在外部注册的监听


What's missing is a callback function given from the outside that can be called within this listener. There are various ways to solve this task. First, we could pass the function as attribute. However, since we have learned that passing non primitives to HTML elements is cumbersome, we would like to avoid this case. Second, we could pass the function as property. Let's see how this would look like when using our custom element:


缺少一个回调函数,这个回调函数由外部提供,可以在内部监听中被调用。有很多方法可以解决这个问题。首先,我们可以用attribute传递一个函数属性。但是我们已经学习过,传递一个非原语属性给 HTML 元素是很麻烦的,所以这个方案可以忽略。第二,我们可以用property传递函数。我们来看一下在自定义元素上会发生什么


<my-button label="Click Me"></my-button> <script>  document.querySelector('my-button').onClick = value =>    console.log(value);</script>
复制代码


We just defined an onClick handler as function to our element. Next, we could call this function property in our custom element's listener:


我们只需要在元素上定义一个onClick事件函数。接下来,我们就可以在自定义元素内的监听中调用这个函数


class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));     this.$button = this._shadowRoot.querySelector('button');     this.$button.addEventListener('click', () => {      this.onClick('Hello from within the Custom Element');    });  }   ... }
复制代码

See how you are in charge what's passed to the callback function. If you wouldn't have the listener inside the custom element, you would simply receive the event. Try it yourself. Now, even though this works as expected, I would rather use the built-in event system provided by the DOM API. Therefore, let's register an event listener from the outside instead without assigning the function as property to the element:


可以看到是如何将内容传递给回调函数的。假设在自定义元素内没有监听,你仅仅是获取了这个事件。你可以试一下,现在虽然是按照预期运行,但是我更希望用 DOM API 提供的内置事件。因此,我们可以在外部注册一个事件监听,而不需要将这个函数作为property传递给元素


<my-button label="Click Me"></my-button> <script>  document    .querySelector('my-button')    .addEventListener('click', value => console.log(value));</script>
复制代码


The output when clicking the button is identical to the previous one, but this time with an event listener for the click interaction. That way, the custom element is still able to send information to the outside world by using the click event, because our message from the inner workings of the custom element is still send and can be seen in the logging of the browser. Doing it this way, you can also leave out the definition of the event listener within the custom element, if no special behavior is needed, as mentioned before.


点击按钮时,输出的结果和之前一致,只是这次在点击的交互上增加了事件监听。使用这种方式,自定义元素仍旧会通过点击事件将数据传递给外面,因为在自定义元素内部,信息依然在发送,并且可以在浏览器的日志中看到。按这种方式,如果不需要特殊的交互,和之前提过的,你可以删掉自定义元素内定义的事件监听。


There is one caveat by leaving everything this way though: We can only use the built-in events for our custom element. However, if you would later on use your Web Component in a different environment (e.g. React), you may want to offer custom events (e.g. onClick) as API for your component as well. Of course, we could also map manually the click event from the custom element to the onClick function from our framework, but it would be less a hassle if we could simply use the same naming convention there. Let's see how we can take our previous implementation one step further to support custom events too:


这种方式有一个警告:对于自定义元素,我们只能用内置的事件。但是,如果以后你想在不同的环境(比如:React)下使用 Web Component,你可能想要为你的组件提供自定义事件(比如:onClick)API。当然,我们可以将自定义元素的click事件映射到框架的onClick函数上,但是如果我们直接用相同的名字覆盖事件,事情会变得更简单。接下来,我们看一下如何用我们之前的实现方式来支持自定义事件


class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));     this.$button = this._shadowRoot.querySelector('button');     this.$button.addEventListener('click', () => {      this.dispatchEvent(        new CustomEvent('onClick', {          detail: 'Hello from within the Custom Element',        })      );    });  }   ... }
复制代码


Now we are exposing a custom event as API to the outside called onClick whereas the information is passed through the optional detail property. Next, we can listen to this new custom event instead:


现在,我们向外暴露了一个自定义事件 API onClick ,虽然信息是通过可选属性detail被传递的。接下来,我们可以监听这个新的自定义事件


<my-button label="Click Me"></my-button> <script>  document    .querySelector('my-button')    .addEventListener('onClick', value => console.log(value));</script>
复制代码

This last refactoring from a built-in event to a custom event is optional though. It's only there to show you the possibilities of custom events and perhaps to give you an easier time for using Web Components later in your favorite framework if that's what you are looking for.


从内置事件到自定义事件的最后一次重构是可选的。这么做只是向你展示自定义事件是可行的,如果你正在寻找某种解决方式,那么在你喜欢的框架中使用 Web Component 可能会更方便。


WEB COMPONENTS LIFECYCLE CALLBACKS

Web Components 的生命周期回调


We have almost finished our custom button. Before we can continue with the custom dropdown element -- which will use our custom button element -- let's add one last finishing touch. At the moment, the button defines an inner container element with a padding. That's useful for using these custom buttons side by side with a natural margin to each other. However, when using the button in another context, for instance a dropdown component, you may want to remove this padding from the container. Therefore, you can use one of the lifecycle callbacks of a Web Component called connectedCallback:


我们基本完成了自定义 button。在继续实现自定义 dropdown 元素(会用到自定义 button 元素)之前,我们需要再做一件事,当前,button 在内部容器上定义了内边距(padding),目的是当这些自定义元素并排展示时,彼此之间有一个空隙。但是,当我们把 button 放到另一个上下文中时,比如放到 dropdown 组件内,你可能会想把容器的内边距去掉。所以,你可以用 Web Component 的生命周期回调函数connectedCallback来实现


class Button extends HTMLElement {  constructor() {    super();     this._shadowRoot = this.attachShadow({ mode: 'open' });    this._shadowRoot.appendChild(template.content.cloneNode(true));     this.$container = this._shadowRoot.querySelector('.container');    this.$button = this._shadowRoot.querySelector('button');     ...  }   connectedCallback() {    if (this.hasAttribute('as-atom')) {      this.$container.style.padding = '0px';    }  }   ... }
复制代码


In our case, if there is an existent attribute called as-atom set on the element, it will reset our button container's padding to zero. That, by the way, is how you can create a great UI library with atomic design principles in mind whereas the custom button element is an atom and the custom dropdown element a molecule. Maybe both end up with another element later in a greater organism. Now our button can be used without padding in our dropdown element the following way: <my-button as-atom></my-button>. The label of the button will be later set by using a property.


在上面的示例中,如果在元素上有一个as-atom的属性存在,那么元素会将 button 的容器内边距重置为 0。按照这种方式,你就可以依据原子设计元素创建一个好看的 UI 库。正如,自定义 button 元素就是一个原子,自定义 dropdown 元素就是一个分子。可能之后两个元素会在一个更大的元素里面形成另一个元素。现在按照这种方式<my-button as-atom></my-button> button 元素在 dropdown 元素中使用时,是不会产生内边距的。button 的 label 也会在之后用property的方式被写入。


But what about the lifecycle callback? The connectedCallback runs once the Web Component got appended to the DOM. That's why you can do all the things that need to be done once the component gets rendered. There exists an equivalent lifecycle callback for when the component gets removed called disconnectedCallback. Also you have already used a lifecycle method in your custom element before called attributeChangedCallback to react on attribute changes. There are various lifecycle callbacks available for Web Components, so make sure to check them out in detail.


但究竟什么是生命周期回调呢?connectedCallback仅仅在 Web Component 生成 DOM 的时候运行一次。这也是为什么,你可以把所有你想要在组件渲染时完成的事情在这个方法中完成。在组件被移除时,还存在一个和生命周期回调相对应的方法disconnectedCallback 。在响应属性变化之前,你必须在自定义元素中已经实现了被称为attributeChangedCallback 生命周期函数。在 Web Components 中有许多好用的生命周期回调函数,在使用之前,确保你已经详细的了解过它们


WEB COMPONENTS WITHIN WEB COMPONENT

Web Components 中使用 Web Component


Last but not least, we want to use our finished Button Web Component within another Web Component. Therefore, we will implement a custom dropdown element which should be used the following way:


同样重要的,我们想要在另一个 Web Component 中使用完成的 Button Web Component。所以,我们用以下方式实现一个自定义的 dropdown 元素


<my-dropdown  label="Dropdown"  option="option2"  options='{ "option1": { "label": "Option 1" }, "option2": { "label": "Option 2" } }'></my-dropdown>
复制代码


Note that the options -- which are an object -- are passed as JSON formatted attribute to the custom element. As we have learned, it would be more convenient to pass objects and arrays as properties instead:


注意options,它是一个对象,它传递一个 JSON 格式的属性给自定义元素。正如我们之前学到的,使用property传递对象和数组比attribute更方便


<my-dropdown  label="Dropdown"  option="option2"></my-dropdown> <script>  document.querySelector('my-dropdown').options = {    option1: { label: 'Option 1' },    option2: { label: 'Option 2' },  };</script>
复制代码


Let's dive into implementation of the custom dropdown element. We will start with a straightforward foundation that defines our structure, style, and boilerplate code for the class that defines our Web Component. The latter is used for setting the mode of the Shadow DOM, attaching the template to our Custom Element, defining getter and setter methods for our attributes/properties, observing our attribute changes and reacting to them:


接下来我们深入研究自定义 dropdown 元素的实现。首先用一个简单的基础代码为 Web Component 类来定义结构、样式和模板代码。这些模板代码实现了 Shadow DOM 的模式,关联了自定义元素的模板,定义了attribute / propertygettersetter 方法,监听attribute 变化,并作出响应


const template = document.createElement('template'); template.innerHTML = `  <style>    :host {      font-family: sans-serif;    }     .dropdown {      padding: 3px 8px 8px;    }     .label {      display: block;      margin-bottom: 5px;      color: #000000;      font-size: 16px;      font-weight: normal;      line-height: 16px;    }     .dropdown-list-container {      position: relative;    }     .dropdown-list {      position: absolute;      width: 100%;      display: none;      max-height: 192px;      overflow-y: auto;      margin: 4px 0 0;      padding: 0;      background-color: #ffffff;      border: 1px solid #a1a1a1;      box-shadow: 0 2px 4px 0 rgba(0,0,0, 0.05), 0 2px 8px 0 rgba(161,161,161, 0.4);      list-style: none;    }     .dropdown-list li {      display: flex;      align-items: center;      margin: 4px 0;      padding: 0 7px;      font-size: 16px;      height: 40px;      cursor: pointer;    }  </style>   <div class="dropdown">    <span class="label">Label</span>     <my-button as-atom>Content</my-button>     <div class="dropdown-list-container">      <ul class="dropdown-list"></ul>    </div>  </div>`; class Dropdown extends HTMLElement {  constructor() {    super();     this._sR = this.attachShadow({ mode: 'open' });    this._sR.appendChild(template.content.cloneNode(true));  }   static get observedAttributes() {    return ['label', 'option', 'options'];  }   get label() {    return this.getAttribute('label');  }   set label(value) {    this.setAttribute('label', value);  }   get option() {    return this.getAttribute('option');  }   set option(value) {    this.setAttribute('option', value);  }   get options() {    return JSON.parse(this.getAttribute('options'));  }   set options(value) {    this.setAttribute('options', JSON.stringify(value));  }   static get observedAttributes() {    return ['label', 'option', 'options'];  }   attributeChangedCallback(name, oldVal, newVal) {    this.render();  }   render() {   }} window.customElements.define('my-dropdown', Dropdown);
复制代码


There are several things to note here: First, in our style we can set a global style for our custom element with the :host selector. Second, the template uses our custom button element, but doesn't give it a label attribute yet. And third, there are getters and setters for each attribute/property, however, the getter and setter for the options attribute/property reflection are parsing the object from/to JSON.


有一些注意事项:首先,在样式上,我们可以为自定义元素设置全局样式:host选择器。第二,模板用到了自定义 button 元素,但是还没有给它label 属性。第三,每一个attributeproperty拥有gettersetter 方法,但是对于optionsgetter 方法用 JSON 将其从对象进行转化,setter用 JSON 将其重新转化为对象


Note: Except for all the mentioned things, you may also notice lots of boilerplate for all of our getter and setter methods for the property/attribute reflection. Also the lifecycle callback for our attributes looks repetitive and the constructor is the same as the one in our custom button element. You may learn later that there exist various lightweight libraries (e.g. LitElement with LitHTML) to be used on top of Web Components to remove this kind of repetitiveness for us.


注意:除了上述提到的所有内容以外,你可能也注意到,有很多针对propertyattribute映射关系的settergetter 方法的模板,同样的,attribute 的生命周期回调看起来很复杂,并且和自定义 button 元素有同样的构造函数结构。稍后,你会学到,存在很多的轻量库(比如,拥有LitHTMLLitElement)可以用在 Web Components 上,可以删除这些重复的内容。


So far, all the passed properties and attributes are not used yet. We are only reacting to them with an empty render method. Let's make use of them by assigning them to the dropdown and button elements:


到目前,这些传递过来的这些propertyattribute 还没有被用到。我们仅仅是用一个空的渲染方法来触发它们。接下来,我们会把它们放到 dropdown 和 button 元素上使用


class Dropdown extends HTMLElement {  constructor() {    super();     this._sR = this.attachShadow({ mode: 'open' });    this._sR.appendChild(template.content.cloneNode(true));     this.$label = this._sR.querySelector('.label');    this.$button = this._sR.querySelector('my-button');  }   ...   static get observedAttributes() {    return ['label', 'option', 'options'];  }   attributeChangedCallback(name, oldVal, newVal) {    this.render();  }   render() {    this.$label.innerHTML = this.label;     this.$button.setAttribute('label', 'Select Option');  }} window.customElements.define('my-dropdown', Dropdown);
复制代码


Whereas the dropdown gets its label from the outside as attribute to be set as inner HTML, the button sets an arbitrary label as attribute for now. We will set this label later based on the selected option from the dropdown. Also, we can make use of the options to render the actual selectable items for our dropdown:


dropdown 从外部获取 label 属性,将其设置为内部 HTML,然而 button 从现在开始需要设置一个动态的 label。稍后,我们会根据 dropdown 的下拉选项来设置 label。另外,我们可以用这些选项为 dropdown 来渲染实际的可选项


class Dropdown extends HTMLElement {  constructor() {    super();     this._sR = this.attachShadow({ mode: 'open' });    this._sR.appendChild(template.content.cloneNode(true));     this.$label = this._sR.querySelector('.label');    this.$button = this._sR.querySelector('my-button');    this.$dropdownList = this._sR.querySelector('.dropdown-list');  }   ...   render() {    this.$label.innerHTML = this.label;     this.$button.setAttribute('label', 'Select Option');     this.$dropdownList.innerHTML = '';     Object.keys(this.options || {}).forEach(key => {      let option = this.options[key];      let $option = document.createElement('li');      $option.innerHTML = option.label;       this.$dropdownList.appendChild($option);    });  }} window.customElements.define('my-dropdown', Dropdown);
复制代码


In this case, on every render we wipe the inner HTML of our dropdown list, because the options could have been changed. Then, we dynamically create a list element for each option in our options object and append it to our list element with the option property's label. If the properties are undefined, we use a default empty object to avoid running into an exception here, because there exists a race condition between incoming attributes and properties. However, even though the list gets rendered, our style defines the CSS display property as none. That's why we cannot see the list yet, but we will see it in the next step after we added some more JavaScript for the custom element's behavior.


在这个示例中,每一次渲染,我们都会清除掉 dropdown 列表的内部 HTML,因为,options 是变化的。接着,我们给options对象中的每一个option动态的创建一个 list 元素,并且将optionlabel属性添加到 list 元素上。因为传入的attributesproperties之间存在竞争条件,所以如果propertiesundefined,我们可以用一个默认的空对象避免运行产生的错误。虽然列表已经被渲染,但是定义的 CSS 样式display值为none。所以我们还是看不到列表,但是下一步,在我们为自定义元素的交互添加一些 JavaScript 脚本之后,我们将会看到列表


BEHAVIOR OF WEB COMPONENTS WITH JAVASCRIPT

Web Components 添加 JavaScript 交互


So far, we have mainly structured and styled our custom elements. We also reacted on changed attributes, but didn't do much in the rendering step yet. Now we are going to add behavior with more JavaScript to our Web Component. Only this way it is really different from a simple HTML element styled with CSS. You will see how all the behavior will be encapsulated in the custom dropdown element without any doings from the outside.


目前为止,我们主要对自定义元素进行了结构化和样式的设计,也可以在attributes改变时做出处理,但是在渲染的步骤并没有做很多事情。现在,我们将在 Web Component 上用更多的 JavaScript 来添加交互行为。它和一个简单的带有 CSS 样式的 HML 元素是十分不同的。你会看到所有的交互是如何被封装在自定义 dropdown 元素内部,而外部不需要做任何事情。


Let's start by opening and closing the dropdown with our button element which should make our dropdown list visible. First, define a new style for rendering the dropdown list with an open class. Remember that we have used display: none; for our dropdown list as default styling before.


我们从用 button 元素打开和关闭 dropdown 使下拉列表可见开始。首先,定义一个新的样式open来渲染 dropdown 列表的开启样式。要记得,我们之前用display: none;作为 dropdown 列表的默认样式。


const template = document.createElement('template'); template.innerHTML = `  <style>    :host {      font-family: sans-serif;    }     ...     .dropdown.open .dropdown-list {      display: flex;      flex-direction: column;    }     ...  </style>   ...`;
复制代码


In the next step, we define a class method which toggles the internal state of our custom element. Also, when this class method is called, the new class is added or removed to our dropdown element based on the new open state.


在下一步,我们会定义一个 class 方法,用来控制自定义元素内部的开关状态。也就是说,当这个方法被调用的时候,新的 class 会在新的open状态基础上被添加到或者移除 dropdown 元素。


class Dropdown extends HTMLElement {  constructor() {    super();     this._sR = this.attachShadow({ mode: 'open' });    this._sR.appendChild(template.content.cloneNode(true));     this.open = false;     this.$label = this._sR.querySelector('.label');    this.$button = this._sR.querySelector('my-button');    this.$dropdown = this._sR.querySelector('.dropdown');    this.$dropdownList = this._sR.querySelector('.dropdown-list');  }   toggleOpen(event) {    this.open = !this.open;     this.open      ? this.$dropdown.classList.add('open')      : this.$dropdown.classList.remove('open');  }   ...}
复制代码


Last but not least, we need to add an event listener for our custom button element's event to toggle the dropdown's internal state from open to close and vice versa. Don't forget to bind this to our new class method when using it, because otherwise it wouldn't have access to this for setting the new internal state or accessing the assigned $dropdown element.


继续,我们需要为自定义 button 元素添加一个事件监听,用来控制 dropdown 内部的开关状态。在使用这个方法时,不要忘记把this绑定到这个新方法上,否则它会因为没有this权限,无法设置新的内部状态,或者无法指定$dropdown元素


class Dropdown extends HTMLElement {  constructor() {    super();     this._sR = this.attachShadow({ mode: 'open' });    this._sR.appendChild(template.content.cloneNode(true));     this.open = false;     this.$label = this._sR.querySelector('.label');    this.$button = this._sR.querySelector('my-button');    this.$dropdown = this._sR.querySelector('.dropdown');    this.$dropdownList = this._sR.querySelector('.dropdown-list');     this.$button.addEventListener(      'onClick',      this.toggleOpen.bind(this)    );  }   toggleOpen(event) {    this.open = !this.open;     this.open      ? this.$dropdown.classList.add('open')      : this.$dropdown.classList.remove('open');  }   ...}
复制代码

Try your Web Component yourself now. It should be possible to open and close the custom dropdown element by clicking our custom button. That's our first real internal behavior of our custom element which would have been implemented in a framework like React or Angular otherwise. Now your framework can simply use this Web Component and expect this behavior from it. Let's continue with selecting one of the items from the opened list when clicking it:


现在你可以试试你的 Web Component。它应该可以通过点击自定义 button 来打开和关闭自定义 dropdown 元素。这是我们第一次真正像 React 或者 Angular 一样在一个框架里面的自定义元素上实现内部交互。现在你的框架可以简单的使用这个 Web Component 并且从它上面得到预期的交互。接下来继续,当点击它时,从打开的列表中选择一个选项


class Dropdown extends HTMLElement {   ...   render() {    ...     Object.keys(this.options || {}).forEach(key => {      let option = this.options[key];      let $option = document.createElement('li');      $option.innerHTML = option.label;       $option.addEventListener('click', () => {        this.option = key;         this.toggleOpen();         this.render();      });       this.$dropdownList.appendChild($option);    });  }}
复制代码


Each rendered option in the list gets an event listener for the click event. When clicking the option, the option is set as property, the dropdown toggles to close, and the component renders again. However, in order to see what's happening, let's visualize the selected option item in the dropdown list:


在列表中的每一个渲染的选项都有一个点击事件监听。当点击选项时,这个选项会被设置为property,dropdown 开关设置为close,然后组件再次渲染。然而,为了能看到整个发生的过程,我们在 dropdown 列表中可视化被选中的选项


const template = document.createElement('template'); template.innerHTML = `  <style>    ...     .dropdown-list li.selected {      font-weight: 600;    }  </style>   <div class="dropdown">    <span class="label">Label</span>     <my-button as-atom>Content</my-button>     <div class="dropdown-list-container">      <ul class="dropdown-list"></ul>    </div>  </div>`;
复制代码


Next we can set this new class in our render method whenever the option property matches the option from the list. With this new styling in place, and setting the styling dynamically on one of our options from the dropdown list, we can see that the feature actually works:


下一步,只要option从列表中匹配到选项,我们会在渲染方法中设置新的 class。有了这个新的样式,并且在 dropdown 列表中动态的在一个选项上设置样式,我们就可以发现这个功能是有效的


class Dropdown extends HTMLElement {   ...   render() {    ...     Object.keys(this.options || {}).forEach(key => {      let option = this.options[key];      let $option = document.createElement('li');      $option.innerHTML = option.label;       if (this.option && this.option === key) {        $option.classList.add('selected');      }       $option.addEventListener('click', () => {        this.option = key;         this.toggleOpen();         this.render();      });       this.$dropdownList.appendChild($option);    });  }}
复制代码


Let's show the current selected option in our custom button element instead of setting an arbitrary value:


接着,我们在自定义 button 元素上用当前选中的选项,不再设置一个随机值


class Dropdown extends HTMLElement {   ...   render() {    this.$label.innerHTML = this.label;     if (this.options) {      this.$button.setAttribute(        'label',        this.options[this.option].label      );    }     this.$dropdownList.innerHTML = '';     Object.keys(this.options || {}).forEach(key => {      ...    });  }}
复制代码


Our internal behavior for the custom dropdown element works. We are able to open and close it and we are able to set a new option by selecting one from the dropdown list. One crucial thing is missing: We need to offer again an API (e.g. custom event) to the outside world to notify them about a changed option. Therefore, dispatch a custom event for each list item click, but give each custom event a key to identify which one of the items got clicked:


自定义 dropdown 元素的内部交互是有效的。我们可以打开和关闭它,并且,我们可以选择 dropdown 列表中的一项将它设置为新值。有一件很重要的事忘记了:我们需要再提供一个 API(比如,自定义事件)给外部,通知它们变化的选项。所以,需要为列表中的选项点击定义一个自定义事件,并且需要给每个自定义事件一个唯一值,用于识别哪一个选项被点击了


class Dropdown extends HTMLElement {   ...   render() {    ...     Object.keys(this.options || {}).forEach(key => {      let option = this.options[key];      let $option = document.createElement('li');      $option.innerHTML = option.label;       if (this.option && this.option === key) {        $option.classList.add('selected');      }       $option.addEventListener('click', () => {        this.option = key;         this.toggleOpen();         this.dispatchEvent(          new CustomEvent('onChange', { detail: key })        );         this.render();      });       this.$dropdownList.appendChild($option);    });  }}
复制代码


Last, when using the dropdown as Web Component, you can add an event listener for the custom event to get notified about changes:


最后,当使用这个下拉 Web Component 时,你可以添加一个自定义事件监听器通知变化


<my-dropdown label="Dropdown" option="option2"></my-dropdown> <script>  document.querySelector('my-dropdown').options = {    option1: { label: 'Option 1' },    option2: { label: 'Option 2' },  };   document    .querySelector('my-dropdown')    .addEventListener('onChange', event => console.log(event.detail));</script>
复制代码


That's it. You have create a fully encapsulated dropdown component as Web Component with its own structure, style and behavior. The latter is the crucial part for a Web Component, because otherwise you could have simply used a HTML element with some CSS as style. Now, you have also the behvaior encapsulated in your new custom HTML element. Congratulations!


就这样了。你已经创建了一个完整的封装好的带有自己的结构、样式和交互的 Web Component 下拉组件。后半部分对一个 Web Component 是很重要的,否则你只是有一个简单的带有 CSS 样式的 HTML 元素。现在,在你的新自定义 HTML 元素内也封装了交互。恭喜!


The implementation of the dropdown and button element as Web Components can be found in this GitHub project with a few helpful extensions. As I said before, the custom button element is a bit unessential for the dropdown component, because it doesn't implement any special behavior. You could have used a normal HTML button element with CSS styling. However, the custom button element has helped us to grasp the concept of Web Components with a simple example. That's why I think it was a good thought to start with the button component which is used later in the dropdown component. If you want to continue to use your Web Components in React, check out this neat React hook or this Web Components for React tutorial. In the end, I hope you have learned a lot from this Web Components tutorial. Leave a comment if you have feedback or simply liked it :-)


在这个带有一些有用的扩展 GitHub 项目中可以找到 dropdown 和 button 元素的实现。真如我之前说的,自定义 button 元素对于 dropdown 组件并不是必要的,因为它没有实现任何特殊的交互。你可以用一个带有 CSS 样式的正常 HTML button 元素来实现。但是,自定义 button 元素用一个简单的示例帮助我们理解了 Web Component 的概念。这就是为什么我认为在 dropdown 组件中用 button 组件来开头是一个好想法。如果你想在 React 中继续使用你的 Web Component,查看这个 React Hook 或者 React 中的 Web Components 教程。最后,我希望你已经从这份 Web Component 教程中学会了很多。如果你有一些反馈或者仅仅是喜欢这篇文档,请留言

发布于: 2020 年 11 月 16 日阅读数: 41
用户头像

西贝

关注

还未添加个人签名 2019.02.15 加入

还未添加个人简介

评论

发布
暂无评论
适合初学者的Web Components教程[2019](译)