文章

基于Stencil构建的web component

概述

Web component

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

[—— MDN](Web Components | MDN)

Web component旨在解决代码复用和跨框架的问题。它由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。

  • Custom elements(自定义元素):一组JavaScript API,允许您定义custom elements及其行为,然后可以在您的用户界面中按照需要使用它们。
  • Shadow DOM(影子DOM):一组JavaScript API,用于将封装的“影子”DOM树附加到元素(与主文档DOM分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
  • HTML templates(HTML模板):<template><slot>元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

Stencil

Stencil是Ionic Framework团队开发的一款用于开发Design Systems 或 Component Libraries的web component编译工具。

Stencil 旨在将最流行的前端框架的最佳概念结合到编译时工具而不是运行时工具中。需要强调的是,Stencil 的目标不是成为或被视为“框架”,而是Stencil的目标是提供出色的开发人员体验和框架所期望的工具,同时在运行时在浏览器中使用 Web 标准.在许多情况下,鉴于浏览器中现在提供的功能,Stencil 可以用作传统前端框架的替代品,尽管不需要使用它。

stencil.js

stencil吸收了Vue、React等优秀框架的优点,因此你可以发现在stencil有很多语法与这类框架相似,此外stencil广泛地使用了装饰器(Decorator),这使得stencil学习起来比较容易。

stencil提供以下装饰器:

  • @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

JSX

stencil组件使用jSX来呈现,JSX是一种流行的声明式模板语法,每个组件都有一个渲染函数(render),该函数返回运行时渲染到DOM的组件树。

stencil与React的类组件相似,示例如下

@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export className MyComponent {
render(){
return <div>hello stencil</div>
}
}

具体的JSX语法可以参考Reactjs文档或者stenciljs文档

Typescript

stencil支持开箱即用的typescript,这一点在代码中有所体现。

装饰器

装饰器在stencil中起很重要的作用,比如声明一个stencil组件需要在类组件上面声明@Component

@Component

@component是stencil最重要的装饰器,必须在每个stencil组件上面使用@component进行装饰@component接收一个参数,即组件配置对象ComponentOptions至少需要传一个tag参数来表明组件的HTML标签名。

export interface ComponentOptions {
/**
* Tag name of the web component. Ideally, the tag name must be globally unique,
* so it's recommended to choose a unique prefix for all your components within the same collection.
*
* In addition, tag name must contain a '-'
$1*/

tag: string;

/**
* If \`true\`, the component will use scoped stylesheets. Similar to shadow-dom,
* but without native isolation. Defaults to \`false\`.
$1*/

scoped?: boolean;

/**
* If \`true\`, the component will use native shadow-dom encapsulation, it will fallback to \`scoped\` if the browser
* does not support shadow-dom natively. Defaults to \`false\`.
*
* If an object literal containing \`delegatesFocus\` is provided, the component will use native shadow-dom
* encapsulation. When \`delegatesFocus\` is set to \`true\`, the component will have \`delegatesFocus: true\` added to its
* shadow DOM. When \`delegatesFocus\` is \`true\` and a non-focusable part of the component is clicked:
* - the first focusable part of the component is given focus
* - the component receives any available \`focus\` styling
* Setting \`delegatesFocus\` to \`false\` will not add the \`delegatesFocus\` property to the shadow DOM and therefore
* will have the focusing behavior described for \`shadow: true\`.
$1*/

shadow?: boolean | { delegatesFocus: boolean };

/**
* Relative URL to some external stylesheet file. It should be a \`.css\` file unless some
* external plugin is installed like \`@stencil/sass\`.
$1*/

styleUrl?: string;

/**
* Similar as \`styleUrl\` but allows to specify different stylesheets for different modes.
$1*/

styleUrls?: string[] | d.ModeStyles;

/**
* String that contains inlined CSS instead of using an external stylesheet.
* The performance characteristics of this feature are the same as using an external stylesheet.
*
* Notice, you can't use sass, or less, only \`css\` is allowed using \`styles\`, use \`styleUrl\` if you need more advanced features.
$1*/

styles?: string;

/**
* Array of relative links to folders of assets required by the component.
$1*/

assetsDirs?: string[];

/**
* @deprecated Use \`assetsDirs\` instead
$1*/

assetsDir?: string;
}

@Method

stencil提供@Method装饰器来供开发者声明暴露出来的公开的方法,也就是说这些方法可以在外部被调用。

除了使用@Method装饰器声明方法外,也可以直接声明方法。两者的区别在于@Method声明的方法可以在外部被调用,而原生的方法不会。

// my-button
@Component({
tag: 'my-button',
shadow: true,
})
export className MyButton {
@Method() method1() {
console.log('method1');
}
method2() {
console.log('method2');
}
render(){return <div></div>}
}

// my-component
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export className MyComponent {
componentDidRender() {
customElements.whenDefined('my-button');
let test = document.querySelector('my-button');
console.log(test); // web component
test.method1(); // method1
test.method2(); // 失败
}
render(){
return (<div> <my-button></my-button> </div>)
}
}

注意: 在尝试调用公共方法之前,开发人员应该确保组件已经使用了注册自定义元素的whenDefined方法。

@Method装饰的公共方法必须是异步的。可以使用async函数或者返回Promise

大多数情况下不建议声明公开的方法,尽可能地使用默认的属性和事件,随着应用地不断扩展,通过@Prop方法来管理和传递数据会更加容易

@Prop

和Vue这类框架很类似,stencil通过prop来向子组件传递数据。

@Component({
tag: 'todo-list',
})
export className TodoList {
// Second, we decorate a className member with @Prop()
@Prop() name: string;

render() {
// Within the component's className, its props are
// accessed via \`this\`. This allows us to render
// the value passed to \`todo-list\`
return <div>To-Do List Name: {this.name}</div>
}
}

Prop被严格限制,内部无法改变它。

Prop或State值发生改变后会重新触发Render函数重新渲染。

Prop可以提供默认值: @Prop num:number = 1

Prop支持类型有:boolean, number, string, ObjectArray

数据流

与Vue等框架一样,stencil的数据流是从父组件流向子组件。这也解释了为什么组件内部无法更改Prop。

父组件向子组件传递数据通过Prop,而子组件向父组件传递数据需要用到事件。

@Event

stencil提供事件发射装饰器(Event Emitter decorator)来发送自定义事件

// 子组件
...
export className TodoList {
@Event() todoCompleted: EventEmitter<Todo>;
todoCompletedHandler(todo: Todo) {
this.todoCompleted.emit(todo);
}
}
// 父组件
...
export className Todo {
render(){
return <todo-list onTodoCompletedHandler={()=>{ ... }}></todo-list>
}
}

这其实就是JSX的语法,只不过需要注意如果要发送事件则必须要用到@Event。

@Event装饰器同样接收可以接收一个参数EventOptions

export interface EventOptions {
/**
* A string custom event name to override the default.
$1*/

eventName?: string;
/**
* A Boolean indicating whether the event bubbles up through the DOM or not.
$1*/

bubbles?: boolean;

/**
* A Boolean indicating whether the event is cancelable.
$1*/

cancelable?: boolean;

/**
* A Boolean value indicating whether or not the event can bubble across the boundary between the shadow DOM and the regular DOM.
$1*/

composed?: boolean;
}

@Listen

与Vuejs的vm.$emitvm.$on相似,stencil也提供事件监听功能,只不过区别是stencil的事件是真实的DOM事件。

默认情况下stencil的Listen事件监听器是挂载在当前组件,当然也可以修改。

使用@Listen也可以监听到@Event事件,因此也可以作为父子组件通讯地方法之一。

...
export className Todo {
@Listen('click')
clickHandler() {
console.log('Listen');
}
render(){
return <div>123</div>
}
}

@Listen接收两个参数,第一个参数是监听的事件名,第二个参数是配置对象

export interface ListenOptions {
target?: 'body' | 'document' | 'window'; // 表明事件监听器挂载到哪里,默认是当前组件
capture?: boolean;
passive?: boolean;
}

@State()

stencil提供装饰器@State来声明状态,当Prop或State的值发生改变时会触发render函数重新执行渲染。

@Component({
tag: 'current-time',
})
export className CurrentTime {
@State() time: number = Date.now();
render() {
return (
<span>{time}</span>
);
}
}

并非所有的类成员都需要@State装饰,如果你确定该值不会更改或不需要触发重新呈现,那么就没必要使用@State

出于性能的考虑,stencil在处理数组和对象时只有当引用改变时才会触发重新渲染。也就是说:

// 以下写法不会触发重新渲染
arr.push(1)
arr.pop()
arr[2] = 1
obj['a'] = '1'
// 以下方式可以触发重新渲染
arr = [...arr,1]
arr = arr.concat([1,2,3])
obj = {...obj,a:'1'}

@Watch

@Watch类似Vuejs中的vm.$watch,当属性或状态成员被更改时,触发对应的函数。@watch装饰的函数接收prop/state 的新值以及旧值,这对于验证或处理副作用很有用,组件首次加载时,装饰器不会触发。

export className LoadingIndicator {
@Prop() activated: boolean;
@State() busy: boolean;

@Watch('activated')
watchPropHandler(newValue: boolean, oldValue: boolean) {
console.log('The new value of activated is: ', newValue);
}
@Watch('busy')
watchStateHandler(newValue: boolean, oldValue: boolean) {
console.log('The new value of busy is: ', newValue);
}
}

组件生命周期

<svg version=“1.1” viewBox=“0 0 642.5 922” xmlns=“http://www.w3.org/2000/svg”><g fill-rule=“evenodd”><g fill=“none” stroke-linecap=“square”><path d=“m552 743c49.706 0 90-40.294 90-90v-488c0-58.5-47.2-106-105.5-106-58.393 0.16547-105.61 47.607-105.5 106l0.4 68” stroke=“#7b83a6” class=“path-update”></path><path d=“m437.7 600.3-6.3 6.3-6.3-6.3” stroke=“#b3b6c5” class=“path-update”></path><path d=“m431.4 347.5v258” stroke=“#7b83a6” class=“path-update”></path><path d=“m431.4 282.5v24.337” stroke=“#7b83a6” stroke-dasharray=“1, 6” class=“path-update”></path><path d=“m126.4 50v555.5” stroke=“#212431” class=“path-init”></path><path d=“m132.7 600.3-6.3 6.3-6.3-6.3” stroke=“#212431” class=“path-init”></path><path d=“m278 50v27.5” stroke=“#575e7f” class=“path-attached”></path><path d=“m284.3 71.993-6.3 6.3-6.3-6.3” stroke=“#575e7f” class=“path-attached”></path><path d=“m278.5 829v29.5” stroke=“#575e7f” class=“path-removed”></path><path d=“m284.8 852.3-6.3 6.3-6.3-6.3” stroke=“#575e7f” class=“path-removed”></path><g font-family=“SFMono-Regular, ‘SF Mono’, ‘Lucida Console’, monospace” font-size=“15px”><g><a href=“#componentdidload” class=“path-init”><rect y=“620” width=“252” height=“49” rx=“24.5” ry=“24.5” fill=“#212431”></rect><text x=“2.2028809” y=“166.83597” fill=“#fff”><tspan x=“45.202881” y=“648.83594”>componentDidLoad()</tspan></text></a></g><g><a href=“#componentdidupdate” class=“path-update”><rect x=“303” y=“620” width=“252” height=“49” rx=“24.5” ry=“24.5” fill=“#7b83a6”></rect><text x=“2.8501587” y=“166.83597” fill=“#fff”><tspan x=“339.15015” y=“648.83594”>componentDidUpdate()</tspan></text></a></g><g><a href=“#disconnectedcallback” class=“path-removed”><rect x=“152.5” y=“873” width=“252” height=“49” rx=“24.5” ry=“24.5” fill=“#4b516e”></rect><text x=“-18.570755” y=“148.19852” fill=“#fff”><tspan x=“179.72925” y=“902.19849”>disconnectedCallback()</tspan></text></a></g><g><a href=“/docs/reactive-data#watch-decorator” class=“path-update”><rect x=“303” y=“161” width=“252” height=“49” rx=“24.5” ry=“24.5” fill=“#7b83a6”></rect><text x=“2.5288086” y=“-8” fill=“#fff”><tspan x=“348.52881” y=“190”>@Watch(‘propName’)</tspan></text></a></g><g><a href=“/docs/templating-jsx” class=“path-init path-update”><rect y=“451” width=“555” height=“49” rx=“24.5” ry=“24.5” fill=“#39b54a”></rect><text x=“1.035553” y=“109.1985” fill=“#fff”><tspan x=“241.43555” y=“480.19852”>render()</tspan></text></a></g><g><a href=“#connectedcallback” class=“path-init path-attached”><rect x=“65” y=“89” width=“275” height=“49” rx=“24.5” ry=“24.5” fill=“#4b516e”></rect><text x=“78.77652” y=“26.198486” fill=“#fff”><tspan x=“117.17651” y=“118.19849”>connectedCallback()</tspan></text></a></g><g><a href=“#componentshouldupdate” class=“path-update”><rect x=“303” y=“233” width=“252” height=“49” rx=“24.5” ry=“24.5” fill=“#7b83a6”></rect><text x=“-6.5288005” y=“2.8359385” fill=“#fff”><tspan x=“325.57117” y=“261.83594”>componentShouldUpdate()</tspan></text></a></g><g><a href=“#componentwillrender” class=“path-init path-update”><rect x=“77.5” y=“377” width=“400” height=“49” rx=“24.5” ry=“24.5” fill=“#4b516e”></rect><text x=“-148.97623” y=“146.83594” fill=“#fff”><tspan x=“183.12378” y=“405.83594”>componentWillRender()</tspan></text></a></g><g><a href=“#componentwillupdate” class=“path-update”><rect x=“303” y=“305” width=“252” height=“49” rx=“24.5” ry=“24.5” fill=“#7b83a6”></rect><text x=“2.5237732” y=“74.835938” fill=“#fff”><tspan x=“334.62378” y=“333.83594”>componentWillUpdate()</tspan></text></a></g><g><a href=“#componentdidrender” class=“path-init path-update”><rect x=“77.5” y=“523” width=“400” height=“49” rx=“24.5” ry=“24.5” fill=“#4b516e”></rect><text x=“-144.44986” y=“292.83597” fill=“#fff”><tspan x=“187.65015” y=“551.83594”>componentDidRender()</tspan></text></a></g><g><a href=“#componentwillload” class=“path-init”><rect y=“161” width=“252” height=“49” rx=“24.5” ry=“24.5” fill=“#212431”></rect><text x=“2.2765121” y=“97.835938” fill=“#fff”><tspan x=“40.676514” y=“189.83594”>componentWillLoad()</tspan></text></a></g></g><g font-size=“14px” letter-spacing=“-.2” text-align=“center” text-anchor=“middle”><g class=“trigger path-init”><rect width=“190” height=“50” rx=“4” ry=“4” fill=“#fdf5e4”></rect><text x=“111.24316” y=“-1.0898438” fill=“#9a6400”><tspan x=“95.239258” y=“28.910156”>Component initialized</tspan></text></g><g class=“trigger path-update”><rect x=“1” y=“718” width=“555” height=“50” rx=“4” ry=“4” fill=“#fdf5e4”></rect><text x=“344.75723” y=“166.90677” fill=“#9a6400”><tspan x=“278.08643” y=“746.90674”>Change in a value of prop or state triggers rerender</tspan></text></g><g class=“trigger path-removed”><rect x=“1” y=“779” width=“555” height=“50” rx=“4” ry=“4” fill=“#fdf5e4”></rect><text x=“138.02324” y=“167.91019” fill=“#9a6400”><tspan x=“278.73926” y=“807.91016”>Component removed</tspan></text></g><g class=“trigger path-attached”><rect x=“215” width=“190” height=“50” rx=“4” ry=“4” fill=“#fdf5e4”></rect><text x=“330.85489” y=“-1.0898438” fill=“#9a6400”><tspan x=“310.23926” y=“28.910156”>Component reattached</tspan></text></g></g></g></g></svg>

框架集成

web component与框架无关,被原生HTML所支持,因此基本上Vuejs、Reactjs等框架都能使用,此外也可以直接用于原生HTML中。

下面以集成原生JavaScript和Vue.js为例。

原生HTML

打包后直接将与项目同名的js文件引入到html中,即可使用web component

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<my-button>click me</my-button>
</body>
<script type="module" src="./dist/first-webcomponent/first-webcomponent.esm.js"></script>

Vuejs

首先设置Vue-cli或vite,使得vue跳过web component的解析。

Vite 配置示例

// vite.config.js
import vue from '@vitejs/plugin-vue'

export default {
plugins: [
vue({
template: {
compilerOptions: {
// 将所有包含短横线的标签作为自定义元素处理
isCustomElement: tag => tag.includes('-')
}
}
})
]
}

Vue CLI 配置示例

// vue.config.js
module.exports = {
chainWebpack: config => {
config.module
.rule('vue')
.use('vue-loader')
.tap(options => ({
...options,
compilerOptions: {
// 将所有以 ion- 开头的标签作为自定义元素处理
isCustomElement: tag => tag.startsWith('ion-')
}
}))
}
}

然后打包项目,将/dist目录放到Vue项目目录中,重命名webcomponents,在Vue的main.js中执行

import { defineCustomElements } from "./webcomponents/esm/loader";
defineCustomElements();

之后就可以直接在组件中使用web component了。

踩坑笔记

注意this的指向

由于stencil是基于类来构建组件,因此要注意在方法中的this的指向,例如一般情况下this指向实例,但是当方法是当做事件被调用时,this指向真实的节点。

解决事件中this指向节点的问题可以使用bind或者箭头函数

<button onClick={() => handleLink}></button>
// 或者
<button onClick={handleLink.bind(this)}></button>

enmu与type

“@stencil/core”: “^2.13.0”

在目前为止,stencil有个很奇怪的bug,如果直接在组件文件中编写enum或type,stencil无法识别,例如。

// 报错
...
enum Color{
success = 'success',
info = 'info'
}
export className MProgress {
@Prop() color: Color;
}

stencil会报错,提示找不到Color类型。解决的方法是把枚举的值直接写在类里面,或者新建一个ts文件,把enum和type写在ts文件里,然后在组件里面导入

// 直接在类里面写
...
export className MProgress {
@Prop() color: 'info' | 'success';
}
// 或者从ts文件中导入
...
import {Color} from "Color.ts"
export className MProgress {
@Prop() color: Color;
}