5
头图

在上文我们简单的了解过 Web Components 的使用场景,它可以让我们像使用原生标签一样使用我们定义的组件,而 Stencil 又可以让我们像写 React 一样高效的书写 Web Components 组件。

京东的跨端框架 Taro 的组件部分,就是用基于 Web Components 的工具链 Stencil 开发,可以看出,Stencil 和 Web Components 已经逐渐被前端开发者所认可接受。

那么大家就会有疑问了:

  • Web Components 到底提供了哪些 api 来定义浏览器可以识别的标签组件?
  • Stencil 又是如何基于原生 Web Components 封装语法糖提高开发效率更高呢?

我们带着以上两个问题,我们来一步一步了解下 Web Components 与 Stencil。

Web Components

首先来了解下 Web Components 的基本概念, Web Component 是指一系列加入 w3c 的 HTML与DOM的特性,目的是为了从原生层面实现组件化,可以使开发者开发、复用、扩展自定义组件,实现自定义标签。

这是目前前端开发的一次重大的突破。它意味着我们前端开发人员开发组件时,不必关心那些其他MV*框架的兼容性,真正可以做到 “Write once, run anywhere”。

例如:

// 假如我已经构建好一个 Web Components 组件 <hello-world>并导出
// 在 html 页面,我们就可以直接引用组件
<script src="/my-component.js"></script>

// 而在 html 里面我们可以这样使用
<hello-world></hello-word>

而且跟任何框架无关,代表着它不需要任何外部 runtime 的支持,也不需要复杂的Vnode算法映射到实际DOM,只是浏览器api本身对标签内部逻辑进行一些编译处理,性能必定会比一些MV*框架要好一些。

那它是怎么做到高性能的呢?主要和它的核心API有关。其实在上篇中我们已经简单提到了 Web Components 的三个核心 API,接下来我带大家详细分析各个api所承担的功能和实际用法,想必了解过 Web Component 核心技术后,大家就不会对它感到陌生了。

三个核心API

Custom elements(自定义元素)

首先来了解下自定义元素,其实它是作为 Web Component 的基石。那么我们来看下这个基石提供了哪些方法,提供给我们进行高楼大厦的建设。

  1. 自定义元素挂载方法

自定义元素通过CustomElementRegistry 来自定义可以直接渲染的html元素,挂载在 window.customElements.define 来供开发者调用,demo 如下:

// 假如我已经构建好一个 Web Components 组件 <hello-world>并导出

class HelloWorld extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
           <style>
                :host {
                    display: block;
                    padding: 10px;
                    background-color: #eee;
                }
            </style>
            <h1>Hello World!</h1>
        `;
    }
}

// 挂载
window.customElements.define('hello-world', HelloWorld)

// 然后就可以在 html 中使用
<hello-world></hello-world>

注意:自定义元素必须用'-'连接符连接,来作为特定的区分,如果没有检测到自定义元素,则浏览器会作为空div处理。

渲染结果:

  1. 自定义元素的类

由上面的例子 "class HelloWorld extends HTMLElement { xxx } " 发现,自定义元素的构造都是基于 HTMLElement,所以它继承了 HTML 元素特性,当然,也可以继承 HTMLElement的派生类,如:HTMLButtonElement 等,来作为现有标签的扩展。

  1. 自定义元素的生命周期

类似于现有MV*框架的生命周期,自定义元素的基类里面也包含了完整的生命周期 hook 来提供给开发者实现一些业务逻辑的应用:

class HelloWorld extends HTMLElement {
    constructor() {
        // 1 构建组件的时候的逻辑 hook
        super();
    }
  // 2 当自定义元素首次被渲染到文档时候调用 
  connectedCallback(){
  } 
  // 3 当自定义元素在文档中被移除调用 
  disconnectedCallback(){ 
  } 
  // 4 当自定义组件被移动到新的文档时调用
  adoptedCallback(){ 
  } 
  // 5 当自定义元素的属性更改时调用
  attributeChangedCallback(){  
  }
}
  1. 添加自定义方法和属性

由于自定义元素由一个类来构造,所以添加自定义属性和方法就如同平常开发类的方法一致。

class HelloWorld extends HTMLElement {
    constructor() {
        super();
    }
    

    tag = 'hello-world'
    
    say(something: string) {
        console.log(`hello world, I want to say ${this.tag} ${something}`)
    }
}



// 调用方法如下
const hw = document.querySelector('hello-world'); 
hw.say('good'); 


// 控制台打印效果如下

Shadow DOM(影子DOM)

有了自定义元素作为基石,我们想要更加顺畅的进行组件化封装,必定少不了对于DOM树的操作。那么好的,Shadow DOM(影子DOM)就应运而生了。

顾名思义,影子DOM就是用来隔离自定义元素不受到外界样式或者一些副作用的影响,或者内部的一些特性不会影响外部。使自定义元素保持一个相对独立的状态。

在我们日常开发html页面的时候也会接触到一些使用 Shadow DOM 的标签,比如:audio 和 video 等;在具体dom树中它会一一个标签存在,会隐藏内部的结构,但是其中的控件,比如:进度条、声音控制等,都会以一个Shadow DOM存在于标签内部,如果想要查看具体的DOM结构,则可以尝试在chrome的控制台 -> Preferences -> Show user agent Shadow DOM, 就可以查看到内部的结构构成。

如果组件使用Shadow host,常规document中会存在一个 Shadow host节点用来挂载 Shadow DOM,Shadow DOM内部也会存在一个DOM树:Shadow Tree,根节点为Shadow root,外部可以用伪类:host来访问,Shadow boundary其实就是Shadow DOM的边界。具体架构图如下:

下面我们通过一个简单的例子来看下Shadow DOM的实际用处:

// Shadow DOM 开启方式为

this.attachShadow( { mode: 'open' } ); 
  • 不使用Shadow DOM
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Components</title>
    <style>
        h1 {
            font-size: 20px;
            color: yellow;
        }
    </style>
  </head>

  <body>
    <div></div>
    <hello-world></hello-world>
    <h1>Hello World! 外部</h1>
    <script type="module">
        class HelloWorld extends HTMLElement {
            constructor() {
                super();
                // 关闭 shadow DOM
                // this.attachShadow({ mode: 'open' });


                const d = document.createElement('div');
                const s = document.createElement('style');
                s.innerHTML = `h1 {
                            display: block;
                            padding: 10px;
                            background-color: #eee;
                        }`
                d.innerHTML = `
                    <h1>Hello World! 自定义组件内部</h1>
                `;

                this.appendChild(s);
                this.appendChild(d);
            }

            tag = 'hello-world'
    
            say(something) {
                console.log(`hello world, I want to say ${this.tag} ${something}`)
            }
        }

        window.customElements.define('hello-world', HelloWorld);
        const hw = document.querySelector('hello-world'); 
        hw.say('good'); 
    </script>
  </body>
</html>

渲染效果为,可以看到样式已经互相污染:

  • 使用 Shadow DOM
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Components</title>
    <style>
       h1 {
            font-size: 20px;
            color: yellow;
        }
    </style>
  </head>
  <body>
    <div></div>
    <hello-world></hello-world>
    <h1>Hello World! 外部</h1>
    <script type="module">
        class HelloWorld extends HTMLElement {
            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                this.shadowRoot.innerHTML = `
                    <style>
                        h1 {
                            font-size: 30px;
                            display: block;
                            padding: 10px;
                            background-color: #eee;
                        }
                    </style>
                    <h1>Hello World! 自定义组件内部</h1>
                `;
            }

            tag = 'hello-world'
    
            say(something) {
                console.log(`hello world, I want to say ${this.tag} ${something}`)
            }
        }

        window.customElements.define('hello-world', HelloWorld);
        const hw = document.querySelector('hello-world'); 
        hw.say('good'); 
    </script>
  </body>
</html>

渲染结果为:

可以清晰的看到样式直接互相隔离无污染,这就是Shadow DOM的好处。

HTML templates(HTML模板)

template模板可以说是大家比较熟悉的一个标签了,在Vue项目中的单页面组件中我们经常会用到,但是它也是 Web Components API 提供的一个标签,它的特性就是包裹在 template 中的 HTML 片段不会在页面加载的时候解析渲染,但是可以被 js 访问到,进行一些插入显示等操作。所以它作为自定义组件的核心内容,用来承载 HTML 模板,是不可或缺的一部分。

使用场景如下:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Components</title>
    <style>
        h1 {
            font-size: 20px;
            color: yellow;
        }
    </style>
</head>

<body>
    <div></div>
    <hello-world></hello-world>

    <template id="hw"> 
    <style> 
    .box { 
        padding: 20px;
    } 

    .box > .first { 
        font-size: 24px; 
        color: red;
    } 

    .box > .second { 
        font-size: 14px; 
        color: #000;
    }

    </style> 
   

    <div class="box"> 
        <p class="first">Hello</p> 
        <p class="second">World</p> 
    </div> 
    </template>

    <script type="module">
        class HelloWorld extends HTMLElement { 
            constructor() {
                super(); 
                const root = this.attachShadow({ mode: 'open' });
               root.appendChild(document.getElementById('hw').content.cloneNode(true));
            }
        } 
        window.customElements.define('hello-world', HelloWorld);
    </script>
</body>

</html>

渲染结果为:

Slot 大家应该也比较熟悉了,相当于一个连接组件内部和外部的一个占位机制,可以用来传递 HTML 代码片段,这里我就不过多赘述,有需要继续了解的同学,Google一下即可。

说完了 Web Components 的“三驾马车”,大家一定对于 Web Components 有了深入的了解,也熟悉了 Web Components 一些常规写法。

不过,深入了解后我们发现,原生的 Web Component 处理封装组件并不流畅,我们需要大量的特殊处理对于数据的监听、DOM的渲染等等,所以针对这些不符合现在开发模式的情况,帮助我们提高开发效率的 “轮子” Stencil 应运而生。

那么 Stencil是什么?它又解决了什么问题?对比原生 Web Component 写法有什么优势呢?我们来继续探索。

Stencil

首先说下它的背景。Stencil 由 Ionic 核心团队推出,由团队成员社区联合维护,已经在github上拥有 10K+ star。

Stencil 可以理解为一个用于快速构建 Web Components 的工具集。也可以理解为一个编译器,这意味着,当你的组件一旦经过 build 完成后,就会脱离 Stencil,不再依赖。并且 Stencil 相对原生 Web Components 提供了完善的项目目录架构和配置,并提供了诸多的语法糖和封装函数。

为什么要使用 Stencil 来构建 Web Components 组件呢?它有哪些优势呢?我们继续探究。

首选,我们来看下 Stencil 官方所描述的自身的优点有哪些:

  • Virtual DOM

<!---->

  • Async rendering (inspired by React Fiber) fiber 的性能优势 像Fiber一样的调度模式

<!---->

  • Reactive data-binding 单向数据流

<!---->

  • TypeScript

<!---->

  • 组件懒加载

<!---->

  • JSX支持

<!---->

  • 无依赖性组件

<!---->

  • 虚拟DOM

<!---->

  • 静态网站生成(SSG)

列了一堆优点,“不明觉厉”,但是这样我们也感受不到什么,我们接着来看下它的 Demo :

import { Component, Prop, h } from '@stencil/core';


@Component({
  tag: 'my-first-component',
})
export class MyComponent {
  @Prop() name: string;

  render() {
    return (
      <p>
        My name is {this.name}
      </p>
    );
  }
}

是不是很类似于 React 的写法,而 @ 的装饰器又似乎找到了一些 Angular 的影子,总体风格更加偏向于目前主流框架。

我们切合实际开发,再加上我的使用体验,来实打实掰扯下 Stencil 对比开发原生 Web Components 能解决我们什么痛点:

  1. 完善的文档。可以在 Stencil 的官网上查阅到详细且完备的文档,从项目初始化、开发、部署、各个框架的接入方法,FAQ等等,很完整。可以解决我们在具体开发中遇到的很多问题。这就可以看出官网真的很用心在维护这个框架。
  2. Stencil 提供完整的入门设置项和 cli 工具,从 "npm init stencil" 开始,Stencil 会提供保姆式的选项配置:

经过配置后,Stencil 会提供一套完整的项目目录,包含各种初始化配置,做到了真正的开箱即用。

  1. 由上面 Web Components 使用 DOM 的例子可以看出,原生 Web Components 操作 DOM 并不是很流畅,类似于原生的写法并不高效,例如:
const d = document.createElement('div');

const s = document.createElement('style');

s.innerHTML = `h1 {

            display: block;

            padding: 10px;

            background-color: #eee;

        }`

d.innerHTML = `

    <h1>Hello World! 自定义组件内部</h1>

`;

this.appendChild(s);

this.appendChild(d);

而 Stencil 为了解决这一个问题加入了JSX 语法,使操作DOM有了React的体验。

render() {
  return (
    <div>
    {this.name
      ? <p>Hello {this.name}</p>
      : <p>Hello World</p>
    }
    </div>
  );
}
  1. Stencil 提供的"@"语法糖装饰器可以提供 单选数据流、数据变动 hook 等,结合 JSX,带给我们了丝滑的开发体验。具体如下:
  • @Component() declares a new web component
  • @Prop() declares an exposed property/attribute
  • @State() declares an internal state of the component
  • @Watch() declares a hook that runs when a property or state changes
  • @Element() declares a reference to the host element
  • @Method() declares an exposed public method
  • @Event() declares a DOM event the component might emit
  • @Listen() listens for DOM events
// 定义 props name

// 传入值有变化时,触发重新渲染

@Prop() name: string;



render() {

return (
  <p>
    My name is {this.name}
  </p>
);
}
  1. Virtual DOM提供了一种到真实dom的映射,从虚拟dom之间的diff,并将diff info patch到real dom,类似于 React 和 Vue,这样的虚拟DOM映射,会使追踪数据变动,重新渲染的流程更加高效。
  2. Stencil 还提供了更加完善的生命周期。

  1. 内置完善的 单元测试 和 e2e测试框架,在我们生成组件时,使用组件生成指令时,提供配套的 unit 和 e2e 模板文件。
  2. 提供 custom elements polyfill 给予低版本框架更多支持。

  1. 还有一些其他的特性,比如 Async rendering 类似于 fiber、组件懒加载等等,也是我们日常开发中比较实用的技能。

从以上的种种特性可以看出,Stencil 对比原生 Web Components 更符合我们现在的开发方式,并且提供了完毕的语法糖生命周期。 配套的基础架构工具, 可以让我们无痛进行技术栈的转换。

了解了以上知识点,可能大家已经对 Stencil 有了初步的印象,但是还不深,没有关系。我会在以后的章节中仔细地为大家分析、实践。保证你对 Stencil 这个框架了若指掌。

下一章:浅析 Stencil 装饰器和生命周期


SuperX
3.6k 声望5.9k 粉丝

蓝湖内推