Web Components
Web Components 是一组由浏览器提供的技术,最早在 2011 年被 Google 提出,并在 2018 年 V1 版本开始被主流浏览器所支持。其中有很多概念和现代前端框架Vue、React等有相似之处(严重怀疑就是借鉴的😂)。Web Components 编写的组件并不区分框架环境,属于原生技术(HTML、CSS、JS),“一次编写终身可用”。
三个核心
Web Components 主要由三个核心技术构成:
Custom element(自定义元素):JavaScript API,允许定义 custom elements 及其行为,然后可以在用户界面中按照需要使用;
<body><hello-world id="hello"></hello-world><script>class HelloWord extends HTMLElement {constructor() {super();this.append('Hello World!')}say() {console.log('Hello World!');}}window.customElements.define("hello-world", HelloWord);</script></body>通过ES6类语法为Web Components组件添加DOM元素及实例方法
Shadow DOM(影子 DOM):JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,可以保持元素的功能私有,而不用担心与文档的其他部分发生冲突,常见的
<video>
和<audio>
标签就是影子 DOM,内部有很多元素,但是没有在开发者工具中显示。<body><style>h1 {color: red;}</style><h1>Out</h1><hello-world id="hello"></hello-world><script>class HelloWord extends HTMLElement {constructor() {super();const root = this.attachShadow({ mode: 'open' });const style = document.createElement('style');style.innerHTML = `h1 {color: green;}`;const div = document.createElement('div');div.innerHTML = `<h1>Inner</h1>`;root.append(style)root.append(div)}say() {console.log('Hello World!');}}window.customElements.define("hello-world", HelloWord);</script></body>可以看到shadow dom内外的CSS是相互隔离的
HTML template(HTML 模板):通过
<template>
和<slot>
元素,可以编写不在呈现页面中显示的标记模板,可以作为自定义元素结构的基础被多次重用(如果用过Vue,会更容易理解这里),模板元素并不会渲染,但是可以背JS访问到。<body><template id="tmplt"><h1>Template</h1><slot name="slot1">default slot1</slot><br /><slot name="slot2">default slot2</slot></template><hello-world id="hello"><span slot="slot1">replace slot1</span></hello-world><script>class HelloWord extends HTMLElement {constructor() {super();const root = this.attachShadow({ mode: 'open' });const content = document.getElementById("tmplt").content.cloneNode(true);root.append(content)}say() {console.log('Hello World!');}}window.customElements.define("hello-world", HelloWord);</script></body>
如果想要查看内置 Shadow DOM 元素,可以在 Chrome 开发者工具中开启
开启后video标签会变成以下内容
⚠️:如果Web Components解析失败,则会被当作普通div元素处理。
stencil
了解了Web Components之后会发现,虽然挺有诱惑力,但是应付起现代的前端需求还是比较吃力的,所以有了Stencil框架,可以帮助开发者更方便快捷地开发Web Components组件。
初始化
使用包管理工具来初始化项目
$ pnpm create stencil
选择要初始化的项目类型(通用组件、快速搭建应用程序或网站、PWA应用)
? Select a starter project. Starters marked as [community] are developed by the Stencil Community, rather than Ionic. For more information on the Stencil Community, please see github.com/stencil-community ❯ component Collection of web components that can be used anywhere app [community] Minimal starter for building a Stencil app or website ionic-pwa [community] Ionic PWA starter with tabs layout and routes
然后输入项目名称
✔ Pick a starter › component ? Project name › stencil-study
此时项目已经初始化完成,安装依赖就可以运行项目。
项目结构
通过脚手架初始化的项目结构如下
. ├── LICENSE ├── node_modules ├── package.json ├── pnpm-lock.yaml ├── readme.md ├── dist // 资源打包目录,包括多种模块化方案的编译结果 ├── loader // 对应资源的加载器 ├── src │ ├── components // 组件目录 │ │ └── my-component │ ├── components.d.ts │ ├── index.html // dev 环境入口文件,启动后会被copy到www目录下 │ ├── index.ts │ └── utils // 工具函数目录 │ ├── utils.spec.ts │ └── utils.ts ├── stencil.config.ts // stencil 配置 ├── tsconfig.json // ts 配置 └── www // 开发过程中启动服务器所用的资源目录
如果要新增组件,可以使用命令生成
╭─ ~/worksapce/study/stencil-study ╰─❯ npx stencil generate sten-button [32:42.6] @stencil/core [32:42.7] v4.11.0 🍝 ✔ Which additional files do you want to generate? › Stylesheet (.css), Spec Test (.spec.tsx), E2E Test (.e2e.ts) $ stencil generate my-button The following files have been generated: - src/components/my-button/my-button.tsx - src/components/my-button/my-button.css - src/components/my-button/test/my-button.spec.tsx - src/components/my-button/test/my-button.e2e.ts
可以通过插件引入CSS预编译工具,安装@stencil/sass
依赖,然后在配置文件中添加插件,就可以使用scss编写样式了。
import { Config } from '@stencil/core'; import { sass } from '@stencil/sass'; export const config: Config = { namespace: 'stencil-study', //...省略部分配置 plugins: [ sass(), // 使用插件,sass配置见文档:https://www.npmjs.com/package/@stencil/sass ] };
组件
Stencil最核心的API就是它的各种装饰器,通过ES2017的装饰器语法、面向对象编程搭配TSX语法,可以极大地提高编写组件的效率。(Angluar的注解,React的JSX语法,Vue的slot等语法,一眼看去将各种前端主流框架“抄”了一遍😂)
@Component
用于定于一个Web Components组件,接受一个对象参数,tag(string)为必填项标识要解析的标签,styleUrl(string)声明当前组件的css文件路径,shadow(boolean)控制是否开启shadow root,其他属性见文档。
@Component({ tag: 'my-button', styleUrl: 'my-button.scss', shadow: true, }) export class MyButton { render() { return ( <Host> <button> <slot></slot> </button> </Host> ); } }
上面我们定义了一个<my-button>
组件,渲染为一个button按钮
@Prop
用于声明当前组件 element 元素上的属性,可以自动解析DOM attributes到Class property,方便开发时使用。
@Component({ tag: 'my-button', styleUrl: 'my-button.scss', shadow: true, }) export class MyButton { @Prop() type: 'default' | 'primary' = 'default'; render() { return ( <Host> <button class={this.type}> <slot></slot> </button> </Host> ); } }
基于刚才的组件雏形,我们进行了完善,通过type属性设置button的默认样式(已经在my-button.scss中编写样式)
@State
定义组件内的数据,当期望数据变化引起重新渲染时,使用@State注解声明,如果确定某些值的更新不需要触发页面的变更,则不需要使用@State修饰。
@Component({ tag: 'my-button', styleUrl: 'my-button.scss', shadow: true, }) export class MyButton { @Prop() type: 'default' | 'primary' = 'default'; @Prop({ attribute: 'circle' }) isCircle: boolean = false; @State() count = 0; render() { return ( <Host> <button class={classnames(this.type, { circle: this.isCircle })} onClick={() => { this.count++; }}> <slot></slot> </button> </Host> ); } }
需要注意的是,对象和数组若要触发渲染,则需要创建新的对象和数组,如果直接push不会引起重新渲染,因为内存地址没有改变。
@Watch
类似于Vue中的Watch,可以用于监听Prop和State,当目标改变时触发回调,可以方便开发者将交互和副作用解耦。
@Component({ tag: 'my-button', styleUrl: 'my-button.scss', shadow: true, }) export class MyButton { @Prop() type: 'default' | 'primary' = 'default'; @Prop({ attribute: 'circle' }) isCircle: boolean = false; @State() count = 0; @Watch('count') countChanged() { console.log(this.count); } render() { return ( <Host> <button class={classnames(this.type, { circle: this.isCircle })} onClick={() => { this.count++; }}> <slot></slot> </button> </Host> ); } }
@Element
解析Web Components时会将组件根元素绑定到@Element修饰的变量上,可以通过此变量使用标准DOM的方法和属性
@Component({ tag: 'my-button', styleUrl: 'my-button.scss', shadow: true, }) export class MyButton { @Prop() type: 'default' | 'primary' = 'default'; @Prop({ attribute: 'circle' }) isCircle: boolean = false; @Element() el: HTMLElement; constructor() { console.log(this.el); } render() { return ( <Host> <button class={classnames(this.type, { circle: this.isCircle })} > <slot></slot> </button> </Host> ); } }
@Method
使用@Method修饰的方法会暴露给组件实例,⚠️对外暴露的方法必须标记为异步(async),私有有方法则没有这个要求。在外部调用暴露方法之前需要将Web Components注册。
@Component({ tag: 'my-button', styleUrl: 'my-button.scss', shadow: true, }) export class MyButton { @Prop() type: 'default' | 'primary' = 'default'; @Prop({ attribute: 'circle' }) isCircle: boolean = false; @Method() async sayHello() { console.log('hello'); } sayWorld() { console.log('world'); } render() { return ( <Host> <button class={classnames(this.type, { circle: this.isCircle })} > <slot></slot> </button> </Host> ); } }
@Event
用于推送事件和派发数据,声明Event之后可以通过emit方法来触发事件,自定义事件可以和@Listen搭配使用。
@Component({ tag: 'my-button', styleUrl: 'my-button.scss', shadow: true, }) export class MyButton { @Prop() type: 'default' | 'primary' = 'default'; @Prop({ attribute: 'circle' }) isCircle: boolean = false; @Event() myEvent: EventEmitter<string>; render() { return ( <Host> <button class={classnames(this.type, { circle: this.isCircle })} onClick={() => { this.myEvent.emit('event params'); }} > <slot></slot> </button> </Host> ); } }
@Event注解可以接受一个对象参数,内容如下
export interface EventOptions { // 用于覆盖默认值自定义事件名称的字符串。 eventName?: string; // 用来设置事件是否通过 DOM 冒泡的布尔值。 bubbles?: boolean; // 设置事件是否可取消的布尔值。 cancelable?: boolean; // 一个布尔值,指示事件是否可以跨越 shadow DOM 和常规 DOM 之间的边界冒泡。 composed?: boolean; }
@Listen
用与添加事件监听,在Stencil组件内部可以监听自组件派发的事件,有多种方法可以实现监听器的绑定。
第一种方法,在组件内部添加监听
@Component({ tag: 'my-card', styleUrl: 'my-card.scss', shadow: true, }) export class MyButton { // 方法一 @Listen('myEvent') myEventHandler(e: CustomEvent) { console.log(e.detail); } render() { return ( <Host> <slot></slot> </Host> ); } }
第二种方法在使用组件时添加绑定
<my-button onClick={e => console.log(e.detail)}> click </my-button>
第三种方法就是在使用DOM事件添加监听器
const btn = document.querySelector('my-button'); btn.addEventListener('myEvent', e => console.log(e))
生命周期
同其他框架一样,Stencil也是有生命周期钩子的
- connectedCallback():每次组件连接到DOM的时候调用;
- disconnectedCallback():组件与DOM断开连接时调用;
- componentWillLoad():组件第一次连接到 DOM 之后调用一次;
- componentDidLoad():在组件完全加载并第一次调用
render()
后调用一次; - componentShouldUpdate():当组件的
Prop
或State
属性更改并且即将请求重新渲染时,将调用此钩子,返回一个布尔值控制组件是否重新渲染,类似于React的shouldComponentUpdate; - componentWillRender():每次调用
render()
之前调用; - componentDidRender():每次调用
render()
之后调用; - componentWillUpdate():组件即将更新时调用;
- componentDidUpdate():在组件更新后立即调用。
如何在其他框架中引入
以React为例,安装@stencil/react-output-target
依赖用于构建React加载器,在配置文件中添加以下配置
import { Config } from '@stencil/core'; import { reactOutputTarget as react } from '@stencil/react-output-target'; export const config: Config = { namespace: 'stencil-study', outputTargets: [ react({ componentCorePackage: '@feng-j/stencil-study', proxiesFile: './dist/react/index.ts', includeDefineCustomElements: true, }), // ...... ] };
componentCorePackage
用于生命依赖包(当前组件库的npm包名),proxiesFile
制定加载器输出位置,我这里选择将加载器防到了包产物里面,最终的效果如下
react目录就是加载器,在React项目中引入一下看看
import yayJpg from '../assets/yay.jpg'; import { MyButton } from '@feng-j/stencil-study/dist/react'; export default function HomePage() { return ( <div> <h2>Yay! Welcome to umi!</h2> <MyButton type='primary' circle>1</MyButton> </div> ); }
可以看到还是我们之前的效果
其他框架的引入方式见文档。
总结
Web Components还是一个挺让人激动的技术,未来也可能是一个趋势,可以减少一部分的开发工作。Stencil这个框架虽然功能听挺多,但是真正实用的功能也就是组件库开发了,业务开发使用Stencil的话还是非常不方便的,纯Web Components开发业务是很不方便的,组件的关系不使用模块加载会很乱。