🎉 Stenciljs,一个Web Components框架新体验

前言

看到公司的仓库中有内部的组件库,但是组件库采用的不是react、也不是vue,而且采用lit这样的web components搭建的一套移动端组件库。采用web components的好处是不限制框架,同时具有跨端的能力。对于公司内不同技术栈的项目,也能统一ui风格。

看了一下语法,感觉难度不大,就适当学习了一下,这篇文章当作学习的一个笔记吧🤣。

⚠️ 注:该教程对于基础不牢,刚上手 vue、react 的同学不太友好,如果有 nestjs 的基础,掌握起来会轻松很多。

官方文档:stenciljs.com/

中文文档:stenciljs.jikun.dev/

什么是 Stenciljs

Stencil 是一个生成 Web Components(更确切地说,是自定义元素)的编译器。通过使用 TypeScript、JSX 和 CSS 创建符合标准的 Web 组件,这些组件可以用来构建高质量的组件库。

根据这段描述,Stencil.js 就是一个编译器,它将组件代码编译成标准的 Web Components,像 lit 这个组件库也是采用的是 web components,相比于 vue、react 的组件库,使用web components 构建的组件更具有跨平台的特点,下面是对比:

特性 Stencil Ant Design
核心技术 Stencil.js + Web Components React + TypeScript
运行时依赖 无框架依赖 强依赖 React 生态
编译方式 编译为原生 Web Components React 组件库
跨框架支持 原生支持 React/Vue/原生 JS 需要独立适配版本

如何使用 Stenciljs

初始化项目到运行

  1. 命令初始化项目
bash 复制代码
npm init stencil
pnpm create stencil
  1. 选择对应内容
bash 复制代码
┌  Create Stencil App 🚀
│
◇  Select a starter project.
│  `component`                Collection of web components that can be used anywhere

│
◇  Project name
│  `my-web-component`
│
◇  Create component project with name "my-web-component"?

Confirm?
│  Yes
│
◇  Done!
│
◆  ✔ A new git repo was initialized
│
◆  ✔ All setup  in 9 ms
  1. 项目目录结构
  1. 项目文件介绍
  • my-component.tsx
ts 复制代码
import { Component, Prop, h } from '@stencil/core'

@Component({
  tag: 'my-component', // 定义web components的标签名
  styleUrl: 'my-component.css', // 引入样式文件
  shadow: true // 是否创建shadow dom
})
export class MyComponent {
  @Prop() first: string
  @Prop() middle: string
  @Prop() last: string

  private getText(): string {
    return this.first + this.middle + this.last
  }

  render() {
    return <div>Hello, World! I'm {this.getText()}</div>
  }
}
  • stencil.config.ts
ts 复制代码
import { Config } from '@stencil/core'

export const config: Config = {
  namespace: 'my-web-component', // 定义组件库的命名空间,用于生成组件的唯一标识
  outputTargets: [
    {
      type: 'dist',
      esmLoaderPath: '../loader' // 指定 ESM 加载器的路径
    },
    {
      type: 'dist-custom-elements', // 生成独立的自定义元素
      customElementsExportBehavior: 'auto-define-custom-elements', // 自动定义自定义元素
      externalRuntime: false // 将运行时包含在包中
    },
    {
      type: 'docs-readme' // 自动生成组件的 README 文档
    },
    {
      type: 'www', // 生成用于开发和演示的 WWW 目录
      serviceWorker: null // 禁用 service worker
    }
  ],
  testing: {
    browserHeadless: 'shell' // 在无头模式下运行浏览器测试
  }
}
  • package.json

这部分和上面的配置文件,都需要对工程化的知识有一定了解

json 复制代码
{
  "name": "my-web-component",
  "version": "0.0.1",
  "description": "Stencil Component Starter",
  "main": "dist/index.cjs.js", // commonjs模块的入口文件
  "module": "dist/index.js", // esm模块的入口文件
  "types": "dist/types/index.d.ts", // ts类型定义的入口文件
  "collection": "dist/collection/collection-manifest.json", // 指向组件集合的清单文件,包含组件元数据
  "collection:main": "dist/collection/index.js", // 指定集合的主要入口点
  "unpkg": "dist/my-web-component/my-web-component.esm.js", // 指定通过 unpkg CDN 服务分发的 ES 模块文件路径
  "exports": {
    ".": {
      // 包的主入口点
      "import": "./dist/my-web-component/my-web-component.esm.js", // esm导入时失业文件
      "require": "./dist/my-web-component/my-web-component.cjs.js" // commonjs导入时使用的文件
    },
    "./my-component": {
      "import": "./dist/components/my-component.js",
      "types": "./dist/components/my-component.d.ts"
    },
    "./loader": {
      "import": "./loader/index.js",
      "require": "./loader/index.cjs",
      "types": "./loader/index.d.ts"
    }
  },
  "files": ["dist/", "loader/"], // 指定发布到 npm 时包含的文件和目录,确保只有必要的文件被发布
  "scripts": {
    "build": "stencil build",
    "dev": "stencil build --dev --watch --serve",
    "test": "stencil test --spec --e2e",
    "test.watch": "stencil test --spec --e2e --watchAll",
    "generate": "stencil generate"
  },
  "devDependencies": {
    "@stencil/core": "^4.27.1",
    "@types/jest": "^29.5.14",
    "@types/node": "^22.13.5"
  },
  "license": "MIT"
}
  • index.html
html 复制代码
<!DOCTYPE html>
<html
  dir="ltr"
  lang="en"
>
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0"
    />
    <title>Stencil Component Starter</title>
    <!-- 导入组件 -->
    <script
      type="module"
      src="/build/my-web-component.esm.js"
    ></script>
    <script
      nomodule
      src="/build/my-web-component.js"
    ></script>
  </head>
  <body>
    <!-- 使用组件 -->
    <my-component
      first="Stencil"
      middle="'Don't call me a framework'"
      last="JS"
    ></my-component>
  </body>
</html>
  1. 运行项目

执行 pnpm dev 运行项目即可,会自动打开开发服务器的。

创建组件

这里采用命令的方式创建组件。

  1. 执行命令
bash 复制代码
# npm
npm run generate
# pnpm
pnpm run generate
  1. 填写配置
bash 复制代码
✔ Component tag name (dash-case): ... app-button
✔ Which additional files do you want to generate? › Stylesheet (.css)

$ stencil generate app-button

The following files have been generated:
  - src/components/app-button/app-button.tsx
  - src/components/app-button/app-button.css

组件装饰器

装饰器器用于定义组件的元数据,比如组件的标签名、属性、事件、样式,这个是 ts 就支持的用法,用过 nestjs 的应该知道。

stenciljs 可以方便的构建交互式组件,支持以下装饰器:

  • Component
  • State
  • Prop
  • Watch
  • Method
  • Element
  • Event
  • Listen

@Component 装饰器

@Component 是一个装饰器,它将 TypeScript 类指定为 Stencil 组件。 每个模板组件在构建时都会转换为 Web Component。

ts 复制代码
import { Component } from '@stencil/core'

@Component({
  tag: 'todo-list'
  // 附加选项
})
export class TodoList {
  // implementation omitted
}

@Component 装饰器参数:

  • tag:组件的标签名,必选
  • assetsDirs:包含组件所需的静态文件(assets)的目录的相对路径数组
  • scoped:如果为 true,组件将使用 scoped stylesheets。 如果启用了 shadow ,则此选项不能设置为 true。
  • shadow:如果为 true,组件将使用原生 Shadow DOM 封装。如果浏览器原生不支持 shadow-dom,它将回退到 scoped。
  • styleUrl:外部样式表的相对 URL,其中包含要应用于组件的样式
  • styleUrls:外部样式表的相对 url 列表,其中包含要应用于组件的样式。 此外,还可以提供一个对象,将命名的 "mode" 映射到一个或多个样式表。并根据不同的平台或主题来应用不同的样式。
ts 复制代码
import { Component } from '@stencil/core'

@Component({
  tag: 'my-component',
  styleUrls: {
    ios: 'my-component.ios.scss', // iOS 样式
    md: 'my-component.md.scss', // Android/Material Design 样式
    dark: 'my-component.dark.css', // 自定义主题
    light: 'my-component.light.css' // 自定义主题
  }
})
export class MyComponent {
  @Prop() mode: string // 可以通过 mode 属性切换样式
}

使用:自定义 key 需要通过组件的 mode 属性来激活

html 复制代码
<my-component mode="dark"></my-component>
<my-component mode="light"></my-component>
  • styles:包含内联 CSS 而不是使用外部样式表的字符串,当使用 styles 时,只允许使用 CSS。
ts 复制代码
import { Component } from '@stencil/core'

@Component({
  tag: 'todo-list',
  styles: 'div { background-color: #fff }'
})
export class TodoList {
  // implementation omitted
}

@Prop 装饰器

基本使用

这个属性和 vue、react 的 props 一样,用于定义组件的属性。

ts 复制代码
// 首先, 从 '@stencil/core' 导入 Prop
import { Component, Prop, h } from '@stencil/core'

@Component({
  tag: 'todo-list'
})
export class TodoList {
  // 其次, 我们使用 @Prop() 装饰一个类成员
  @Prop() name: string

  render() {
    // 在组件的类中,
    // 它的 props 可以通过 `this` 来访问。
    // 这允许我们渲染传递给 `todo-list` 的值。
    return <div>To-Do List Name: {this.name}</div>
  }
}

使用:

html 复制代码
<!-- jsx中使用 -->
<todo-list name={"Tuesday's To-Do List"}></todo-list>;
<!-- html中使用 -->
<todo-list name="Tuesday's To-Do List"></todo-list>

对于驼峰命名的使用如下:

html 复制代码
<!-- jsx -->
<todo-list-item thingToDo={"Learn about Stencil Props"}></todo-list-item>
<!-- html -->
<todo-list-item thing-to-do="Learn about Stencil Props"></todo-list-item>
必填的 prop

通过在 prop 名称之后附加 !,Stencil 根据需要标记该 attribute/property 为必填。这样可以确保在 TSX 中使用组件时,将使用该属性:

ts 复制代码
import { Component, Prop, h } from '@stencil/core'

@Component({
  tag: 'todo-list-item'
})
export class ToDoListItem {
  // Note the '!' after the variable name.
  @Prop() thingToDo!: string
}
Prop 校验

要校验 Prop, 可以使用 @Watch() 装饰器

@Watch() 装饰器这里就顺带看一下,后续会说详细用法

ts 复制代码
import { Component, Prop, Watch, h } from '@stencil/core'

@Component({
  tag: 'todo-list-item'
})
export class TodoList {
  @Prop() thingToDo!: string

  @Watch('thingToDo')
  validateName(newValue: string, _oldValue: string) {
    const isBlank = typeof newValue !== 'string' || newValue === ''
    if (isBlank) {
      throw new Error('thingToDo is a required property and cannot be empty')
    }
    const has2chars = typeof newValue === 'string' && newValue.length >= 2
    if (!has2chars) {
      throw new Error('thingToDo must have a length of more than 1 character')
    }
  }
}
可变性

默认情况下,Prop 在组件逻辑中是不可变的。一旦用户设置了值,组件就不能在内部更新它。如果需要可变,可以看下面的@Prop() Options

这里还是放个例子,设置@Prop({ mutable: true })就行

ts 复制代码
import { Component, Prop, h } from '@stencil/core'

@Component({
  tag: 'todo-list-item'
})
export class ToDoListItem {
  @Prop({ mutable: true }) thingToDo: string

  componentDidLoad() {
    this.thingToDo = 'Ah! A new value!'
  }
}
@Prop() Options

@Prop() 装饰器接受一个可选参数来指定特定的选项,以修改组件上 prop 的行为

ts 复制代码
export interface PropOptions {
  attribute?: string
  mutable?: boolean
  reflect?: boolean
}
  1. attribute

Properties 和组件 attributes 是强关联的,但不一定是同一件事。attributes 是一个 HTML 概念,但 properties 是 JavaScript 中面向对象编程固有的概念。

通常,property 名与 attribute 名相同,但情况并不总是如此。以下面的组件为例:

ts 复制代码
import { Component, Prop, h } from '@stencil/core'
import { MyHttpService } from '../some/local/directory/MyHttpService'

@Component({
  tag: 'todo-list-item'
})
export class ToDoListItem {
  @Prop() isComplete: boolean
  @Prop() thingToDo: string
  @Prop() httpService: MyHttpService
}

这个组件有 3 properties, 但是编译器只会创建 2 attributes: is-complete 和 thing-to-do.

html 复制代码
<todo-list-item is-complete="false" thing-to-do="Read Attribute Naming Section of Stencil Docs"></my-cmp>

请注意,httpService 类型不是原始类型(例如,不是 number、boolean 或 string)。由于 DOM 属性只能是字符串,因此使用 "http-service" 作为 DOM 属性是没有意义的。

不能像下面这样通过 HTML 属性来设置 Object 类型的 prop:

html 复制代码
<todo-list-item http-service="{ get: () => {} }"></todo-list-item>

但是可以通过 js 设置属性:

html 复制代码
<todo-list-item></todo-list-item>

<script>
  document.querySelector('todo-list-item').httpService = {
    get: async (url) => {
      const res = await fetch(url)
      return await res.json()
    }
  }
</script>
  1. mutable

默认情况下,Prop 在组件内部是不可变的。然而,通过将 Prop 声明为可变的,可以显式地允许在组件内部改变 Prop,如下面的例子所示:

ts 复制代码
import { Component, Prop, h } from '@stencil/core'

@Component({
  tag: 'todo-list-item'
})
export class ToDoListItem {
  @Prop({ mutable: true }) thingToDo: string

  componentDidLoad() {
    this.thingToDo = 'Ah! A new value!'
  }
}

对于数组或对象的变更如下:

ts 复制代码
// 错误
this.contents.push('Stencil')
// 正确
this.contents = [...this.contents, 'Stencil']
  1. reflect

在某些情况下,让 prop 和 attribute 保持同步可能很有用。在这种情况下,你可以将 @Prop() 装饰器中的 reflect 选项设置为 true。 当一个 prop 被反射时,它会作为一个 HTML 属性呈现在 DOM 中。例如:

ts 复制代码
import { Component, Prop, h } from '@stencil/core'

@Component({
  tag: 'todo-list-item'
})
export class ToDoListItem {
  @Prop({ reflect: false }) isComplete: boolean = false
  @Prop({ reflect: true }) timesCompletedInPast: number = 2
  @Prop({ reflect: true }) thingToDo: string =
    'Read Reflect Section of Stencil Docs'
}

上面的组件使用了默认值,可以直接在 html 中使用:

html 复制代码
<todo-list-item></todo-list-item>

当在 DOM 中渲染时,配置了 reflect: true 的 prop 将作为 HTML 属性在 DOM 中反映:

html 复制代码
<todo-list-item
  times-completed-in-past="2"
  thing-to-do="Read Reflect Section of Stencil Docs"
></todo-list-item>

可以通过 document.querySelector('todo-list-item').isComplete 来获取属性。

@State 装饰器

Stencil 提供了一个装饰器,用于在某些类成员更改时触发重新渲染。触发重新渲染的组件的类成员必须使用 Stencil 的@State()装饰器进行装饰,如下所示:

ts 复制代码
import { Component, h, State } from '@stencil/core'

@Component({
  tag: 'app-button',
  styleUrl: 'app-button.css',
  shadow: true
})
export class AppButton {
  @State() count = 0

  render() {
    return (
      <div>
        <slot></slot>
        <span onClick={() => this.count++}>点击+1</span>
        <p>{this.count}</p>
      </div>
    )
  }
}

这个写法和 react 的类组件是一样的,当 count 改变时,组件会重新渲染。

@Watch() 装饰器

@Watch() 是一个应用于 Stencil 组件的方法的装饰器。该装饰器接受一个参数,即用 @Prop()@State() 装饰的类成员的名称。 使用 @Watch() 修饰的方法将在其关联的类成员发生更改时自动运行。

这个内容就看下面的例子吧,写的也比较清楚。可以叠加使用,依赖的数据变化会触发监听回调。

⚠️ 注意:@Watch() 装饰器不会在组件初始加载时触发

ts 复制代码
import { Component, Prop, State, Watch, h } from '@stencil/core'

@Component({
  tag: 'watch-demo',
  styleUrl: 'watch-demo.css',
  shadow: true
})
export class WatchDemo {
  @Prop({ mutable: true }) num1: number = 0
  @State() num2: number = 0

  @Watch('num1')
  num1Changed(newValue: number, oldValue: number) {
    console.log('newValue', newValue, 'oldValue', oldValue)
  }

  @Watch('num2')
  num2Changed(newValue: number, oldValue: number) {
    console.log('newValue', newValue, 'oldValue', oldValue)
  }

  @Watch('num1')
  @Watch('num2')
  allChanged(newValue: boolean, oldValue: boolean, propName: string) {
    // num1变化触发一次,num2变化触发一次,如果num1和num2都变化,则触发两次
    console.log('同时监听', newValue, oldValue, propName)
  }

  render() {
    return (
      <div>
        <button onClick={() => this.num1++}>num1++</button>
        <button onClick={() => this.num2++}>num2++</button>
        <button
          onClick={() => {
            this.num1++
            this.num2++
          }}
        >
          都变化
        </button>
      </div>
    )
  }
}

更新数组或对象

对于数组,标准的可变数组操作,如 push()unshift() 不会触发组件更新。这些函数会改变数组的内容,但不会改变对数组本身的引用。

为了修改数组,应该使用非可变数组运算符。不可变数组操作符返回一个新数组的副本,可以高效地检测。 这些包括 map()filter(),以及展开运算符语法. 由 map()filter() 等返回的值应该被分配给正在被 @Prop()@State() 修饰的类成员。

更新对象也是如此。

@Listen 装饰器 和 @Event 装饰器

对于事件的监听,@Listen()@Event() 都是用于监听事件。

@Listen()

Listen() 装饰器用于监听 DOM 事件,包括从 @Event 分发的事件。当组件从 DOM 中添加或删除时,事件监听器会自动添加或删除。

绑定事件一个是通过装饰器,另一个是通过 jsx 本身的onXXX来实现。

ts 复制代码
import { Component, h, Listen } from '@stencil/core'

@Component({
  tag: 'event-demo',
  styleUrl: 'event-demo.css',
  shadow: true
})
export class EventDemo {
  @Listen('click')
  handleClick(event: MouseEvent) {
    console.log('Clicked!', event)
  }

  handleClick2(event: MouseEvent) {
    console.log('Clicked!', event)
  }

  render() {
    return <div onClick={this.handleClick2}>hello</div>
  }
}

使用 @Listen() 装饰器,当外部点击该元素的时候,会触发 handleClick() 方法。装饰器给我的使用体验用来定义外部的事件监听更好,内部的还是用 jsx 的onXXX方式来定义。

外部使用:

html 复制代码
<todo-list onclick={(ev) => this.someMethod(ev)} /> <todo-list onclick={(ev) =>
this.someOtherMethod(ev)} />

同时 @Listen() 还可以监听自定义事件。例如:@Listen('my-custom-event')

@Event()

@Event() 是一个装饰器,用于定义组件的自定义事件 。它和 @Listen() 不太一样,点击外部元素(web component)的时候,不会自动触发事件监听。

内部通过 this.eventName.emit() 将数据传递给外部,外部通过 event.detail 获取数据。

ts 复制代码
import { Component, EventEmitter, Event, h } from '@stencil/core'

@Component({
  tag: 'event-demo',
  styleUrl: 'event-demo.css',
  shadow: true
})
export class EventDemo {
  @Event({
    eventName: 'my-custom-event', // 自定义事件名称,如果不指定,默认使用属性名作为事件名
    composed: true, // 事件组合性,设置为 true 时,事件可以从 Shadow DOM 内部传播到外部的 DOM
    cancelable: true, // 事件可取消性,监听器可以调用 event.preventDefault() 来阻止事件的默认行为
    bubbles: true // 事件冒泡性
  })
  clickEvent: EventEmitter // eventName,如果@Event的options设置了eventName会覆盖这个内容,但是内部使用的依然是这个值

  handleClick() {
    // 使用 this.eventName.emit(data) 来触发事件并传递数据
    // 通过 emit() 传递的数据会在监听器的 event.detail 中接收到
    // 这里需要注意this的绑定问题,在触发的时候需要用.bind(this)去改变当前内部的this指向问题
    // 也可以通过 handleClick = () => {},箭头函数的方式就不用考虑.bind(this)
    this.clickEvent.emit('@Event触发')
  }

  render() {
    return <div onClick={this.handleClick.bind(this)}>hello</div>
  }
}

外部使用:

html 复制代码
<event-demo></event-demo>

<script>
  const eventDemo = document.querySelector('event-demo')
  eventDemo.addEventListener('my-custom-event', (event) => {
    console.log(event.detail)
  })
</script>

@Method

可以方便的导出函数,方便外部调用。

需要注意:通过使用 @Method 装饰器的方法确保公有方法返回一个 Promise。

ts 复制代码
import { Method, Component } from '@stencil/core'

@Component({
  tag: 'todo-list'
})
export class TodoList {
  @Method()
  async showPrompt() {
    // show a prompt
  }
}

使用:在尝试调用公共方法之前,应该使用 customElements.whenDefined 方法来确保组件是已经被定义的。

js 复制代码
;(async () => {
  await customElements.whenDefined('event-demo')
  const todoListElement = document.querySelector('event-demo')
  await todoListElement.showPrompt()
})()

@Element

@Element() 装饰器用于访问类实例中的宿主元素。这将返回一个 HTMLElement 的实例,因此可以在这里使用标准的 DOM 方法/事件。

ts 复制代码
import { Component, Host, h, Element } from '@stencil/core'

@Component({
  tag: 'element-demo',
  styleUrl: 'element-demo.css',
  shadow: true
})
export class ElementDemo {
  @Element() el: HTMLElement

  getDomHeight(): number {
    // this.el获取到的是元素自身
    console.log(this.el)
    return this.el.getBoundingClientRect().height
  }

  render() {
    return (
      <Host>
        <div onClick={this.getDomHeight.bind(this)}>获取dom高度</div>
      </Host>
    )
  }
}

小点

Host 和 Fragment 组件

Host 函数组件可以在 render 函数的根节点使用,为宿主元素本身设置属性和事件监听器。<Host> 可以作为 <Fragment>

嵌入或嵌套组件

由于构建的是 web components 组件,它们天然支持嵌套使用,就和 div、span 一样。

  • 提供方
ts 复制代码
import { Component, Prop, h } from '@stencil/core'

@Component({
  tag: 'my-embedded-component'
})
export class MyEmbeddedComponent {
  @Prop() color: string = 'blue'

  render() {
    return <div>My favorite color is {this.color}</div>
  }
}
  • 使用方
ts 复制代码
import { Component, h } from '@stencil/core'

@Component({
  tag: 'my-parent-component'
})
export class MyParentComponent {
  render() {
    return (
      <div>
        <my-embedded-component color='red'></my-embedded-component>
      </div>
    )
  }
}

jsx

插槽

大部分语法和 react 的 jsx 是一样的,同时该框架也支持插槽写法,其实这个是 web components 的一个特性。和 vue 的<slot>一样

innerHTML

vue 的v-html,react 的dangerouslySetInnerHTML

html 复制代码
<div innerHTML="{svgContent}"></div>

Styling Components 组件样式

使用 Shadow DOM 进行样式设置

启用 Shadow DOM 后,阴影根内的元素是作用域化的,组件外部的样式不会应用。因此,组件内部的 CSS 选择器可以简化,因为它们只会应用到组件内的元素。我们不必包含任何特定的选择器来将样式作用域限定到组件。

:host 伪类选择器用于选择组件的 Host 元素

css 复制代码
:host {
  color: black;
}

div {
  background: blue;
}
Shadow DOM 选择器

在使用 Shadow DOM 时,如果你想要查询 web 组件内的元素,你必须首先使用 @Element 装饰器来获取宿主元素,然后你可以使用 shadowRoot 属性来执行查询。

js 复制代码
this.el.shadowRoot.querySelector('xxx')
全局样式

要使全局样式对项目中的所有组件可用,stencil.config.ts 文件带有一个可选的 globalStyle 设置,它接受全局样式表的路径。

ts 复制代码
export const config: Config = {
  namespace: 'app',
  globalStyle: 'src/global/global.css',
  outputTarget: [
    {
      type: 'www'
    }
  ]
}

函数式组件

这个的话和 react 是一样的,只是 react 的children属性是在 props 中的,而 stencil 的children属性函数的第二个参数中。

ts 复制代码
const Hello = (props) => <h1>Hello, {props.name}!</h1>
// children
const Hello = (props, children) => [<h1>Hello, {props.name}</h1>, children]
// ts函数组件类型 FunctionalComponent
import { FunctionalComponent, h } from '@stencil/core'
interface HelloProps {
  name: string
}
export const Hello: FunctionalComponent<HelloProps> = ({ name }) => (
  <h1>Hello, {name}!</h1>
)

处理子组件

函数组件的第二个参数接收传递的子组件,但为了使用它们,FunctionalComponent 提供了一个 utils 对象, 该对象暴露了一个 map() 方法来转换子组件,以及一个 forEach() 方法来读取它们。 不建议读取 children 数组,因为 stencil 编译器可能会在 prod 模式下重命名 vNode 属性。

例如:

ts 复制代码
export const AddClass: FunctionalComponent = (_, children, utils) =>
  utils.map(children, (child) => ({
    ...child,
    vattrs: {
      ...child.vattrs,
      class: `${child.vattrs.class} add-class`
    }
  }))

ts 类型:

ts 复制代码
export interface FunctionalUtilities {
  forEach: (
    children: VNode[],
    cb: (vnode: ChildNode, index: number, array: ChildNode[]) => void
  ) => void
  map: (
    children: VNode[],
    cb: (vnode: ChildNode, index: number, array: ChildNode[]) => ChildNode
  ) => VNode[]
}
export interface ChildNode {
  vtag?: string | number | Function
  vkey?: string | number
  vtext?: string
  vchildren?: VNode[]
  vattrs?: any
  vname?: string
}

组件生命周期方法

Stencil.js 的生命周期方法可按组件 "挂载 - 更新 - 卸载" 三大阶段划分,部分方法还遵循自定义元素规范,各方法功能与使用场景明确:

组件挂载阶段(首次连接 DOM 至首次渲染完成)

该阶段仅在组件第一次被添加到 DOM 时执行一次(除 connectedCallback 可能多次调用外),主要用于初始化数据、加载异步资源等。

方法名 触发时机 核心作用 关键特性
connectedCallback 组件每次连接到 DOM 时(包括首次挂载、从 DOM 移除后重新挂载) 执行 DOM 连接相关逻辑(如启动定时器、绑定事件) 1. 遵循自定义元素规范; 2. 可多次调用(如组件被移动到 DOM 其他位置时); 3. 首次调用时在 componentWillLoad 之前执行
componentWillLoad 组件首次连接到 DOM 后,仅执行一次 异步加载数据、初始化状态(不触发额外重渲染) 1. 可返回 Promise,等待异步任务完成后再执行首次 render; 2. 子组件的该方法先于父组件执行
componentDidLoad 组件首次 render 完成后,仅执行一次 访问首次渲染后的 DOM 元素、初始化第三方库 1. 子组件的该方法先于父组件执行("从深到浅"冒泡); 2. 此时组件已完成首次渲染,可安全操作 DOM

组件更新阶段(Prop/State 变化触发重渲染)

当组件的 Prop(外部传入属性)或 State(内部状态)发生变化时,触发该阶段,用于控制重渲染逻辑、处理更新前后的操作。

方法名 触发时机 核心作用 关键特性
componentShouldUpdate Prop/State 变化后、重渲染前 决定组件是否需要重渲染 1. 接收 3 个参数:新值、旧值、变化的属性名; 2. 返回 true(需要重渲染)或 false(跳过重渲染); 3. 若多个属性同步变化,仅第一个触发重渲染时执行(后续跳过); 4. 不建议用于监听属性变化 ,优先用 @Watch 装饰器
componentWillUpdate Prop/State 变化后、重渲染前(首次渲染不触发) 重渲染前的准备工作(如处理数据格式) 1. 可返回 Promise,等待任务完成后再渲染; 2. 仅在组件需要更新时执行(受 componentShouldUpdate 结果影响)
componentWillRender 每次 render 前(包括首次渲染和更新渲染) 调整渲染相关状态(避免额外重渲染) 1. 可返回 Promise,延迟渲染; 2. 推荐在此处更新渲染状态 ,若在 componentDidLoad/componentDidUpdate 中更新,会触发额外渲染,影响性能
render 组件需要渲染时(首次或更新) 定义组件的 DOM 结构(JSX 模板) 1. 核心渲染方法,返回 JSX 元素; 2. 首次渲染由 componentWillLoad 触发,更新渲染由 Prop/State 变化触发
componentDidRender 每次 render 后(包括首次和更新) 访问渲染后的 DOM 元素(如计算 DOM 尺寸) 1. 若在此处更新状态,需做"脏检查"(判断数据是否真的变化),否则可能陷入无限循环
componentDidUpdate 组件更新渲染后(首次渲染不触发) 处理更新后的逻辑(如同步数据到父组件) 1. 若在此处更新状态,必须做"脏检查",避免无限重渲染; 2. 仅在组件实际完成更新时执行
@Watch('propName') 指定的 Prop/State 变化时 监听特定属性的变化并执行逻辑 1. 装饰器语法,需指定监听的属性名(如 @Watch('userName')); 2. 每次属性变化都会触发,不受重渲染逻辑影响,是监听属性变化的最佳选择

组件卸载阶段(从 DOM 移除)

该阶段在组件离开 DOM 时执行,用于清理资源,避免内存泄漏。

方法名 触发时机 核心作用 关键特性
disconnectedCallback 组件每次从 DOM 断开连接时(包括暂时移除) 清理资源(如清除定时器、解绑事件) 1. 遵循自定义元素规范; 2. 可多次调用(如组件被移除后重新挂载,会先触发此方法); 3. 不可与"销毁"事件混淆,仅表示组件与 DOM 断开,非彻底销毁

生命周期执行顺序规则

Stencil.js 生命周期方法的执行顺序严格遵循"子组件优先"和"异步兼容"原则,即使组件异步加载,顺序也不会错乱。

首次挂载与渲染(父子组件层级)

cmp-a > cmp-b > cmp-c(父-子-孙)的组件层级为例,执行顺序如下:

  1. cmp-a - connectedCallback(首次连接 DOM)
  2. cmp-a - componentWillLoad
  3. cmp-b - connectedCallback
  4. cmp-b - componentWillLoad
  5. cmp-c - connectedCallback
  6. cmp-c - componentWillLoad
  7. cmp-c - componentWillRender
  8. cmp-c - render
  9. cmp-c - componentDidRender
  10. cmp-c - componentDidLoad(孙组件加载完成)
  11. cmp-b - componentWillRender
  12. cmp-b - render
  13. cmp-b - componentDidRender
  14. cmp-b - componentDidLoad(子组件加载完成)
  15. cmp-a - componentWillRender
  16. cmp-a - render
  17. cmp-a - componentDidRender
  18. cmp-a - componentDidLoad(父组件加载完成)

核心规律:子组件的"加载前"和"加载完成"方法先于父组件执行(从深到浅),确保父组件等待所有子组件准备就绪后再标记自身"加载完成"。

组件更新(Prop/State 变化)

当某组件的属性变化时,执行顺序如下:

  1. 触发 @Watch('propName')(若监听该属性)
  2. 执行 componentShouldUpdate(判断是否更新,返回 true 则继续)
  3. 执行 componentWillUpdate(更新前准备)
  4. 执行 componentWillRender(渲染前状态调整)
  5. 执行 render(重新渲染 DOM)
  6. 执行 componentDidRender(渲染后 DOM 操作)
  7. 执行 componentDidUpdate(更新后逻辑处理)
组件卸载与重新挂载
  1. 卸载 :组件从 DOM 移除时,触发 disconnectedCallback(清理资源)。
  2. 重新挂载 :组件再次添加到 DOM 时:
    • 触发 connectedCallback(再次执行连接逻辑)
    • 不触发 componentWillLoadcomponentDidLoad(仅首次挂载执行)
    • 直接进入更新阶段(执行 componentWillRenderrender 等)

关键注意事项与最佳实践

  1. 避免无限重渲染

    若在 componentDidUpdatecomponentDidRender 中更新 State,必须先做"脏检查"(对比新值与旧值是否不同),例如:

    ts 复制代码
    componentDidUpdate() {
      if (this.newData !== this.oldData) { // 脏检查
        this.oldData = this.newData;
        this.setState({ data: this.newData });
      }
    }
  2. 优先使用 @Watch 监听属性变化
    componentShouldUpdate 仅用于控制重渲染,且可能因"多属性同步变化"跳过部分调用,监听属性变化推荐用 @Watch,例如:

    ts 复制代码
    @Watch('userAge')
    onUserAgeChange(newAge: number, oldAge: number) {
      console.log(`年龄从 ${oldAge} 变为 ${newAge}`);
    }
  3. 异步任务用 componentWillLoad 处理

    首次加载的异步数据(如接口请求)建议在 componentWillLoad 中处理,并返回 Promise,确保父组件等待数据加载完成后再渲染:

    ts 复制代码
    async componentWillLoad() {
      const response = await fetch('/user-info.json');
      this.userInfo = await response.json();
    }
  4. 渲染状态更新在 componentWillRender 中执行

    若需调整渲染相关的状态(如格式化显示文本),优先在 componentWillRender 中处理,避免在 componentDidLoad 中更新导致额外渲染:

    ts 复制代码
    componentWillRender() {
      this.formattedTime = new Date(this.time).toLocaleTimeString(); // 渲染前格式化
    }
  5. connectedCallbackdisconnectedCallback 配对使用

    若在 connectedCallback 中启动资源(如定时器、事件监听),必须在 disconnectedCallback 中清理,避免内存泄漏:

    ts 复制代码
    connectedCallback() {
      this.timer = setInterval(() => { /* 逻辑 */ }, 1000);
    }
    disconnectedCallback() {
      clearInterval(this.timer); // 清理定时器
    }
相关推荐
天天进步2015几秒前
从零到一:现代化充电桩App的React前端参考
前端·react.js·前端框架
柯南二号11 分钟前
【大前端】React Native Flex 布局详解
前端·react native·react.js
龙在天1 小时前
npm run dev 做了什么❓小白也能看懂
前端
hellokai2 小时前
React Native新架构源码分析
android·前端·react native
li理2 小时前
鸿蒙应用开发完全指南:深度解析UIAbility、页面与导航的生命周期
前端·harmonyos
去伪存真2 小时前
因为rolldown-vite比vite打包速度快, 所以必须把rolldown-vite在项目中用起来🤺
前端
KubeSphere2 小时前
Kubernetes v1.34 重磅发布:调度更快,安全更强,AI 资源管理全面进化
前端
1024小神2 小时前
如何快速copy复制一个网站,或是将网站本地静态化访问
前端
掘金一周2 小时前
DeepSeek删豆包冲上热搜,大模型世子之争演都不演了 | 掘金一周 8.28
前端·人工智能·后端