Stencil.js完全指南

快速入门

# 创建新项目 npm init stencil # 选择 "component" 模板,输入项目名 cd my-components npm install # 开发模式(热重载) npm start # 生产构建 npm run build # 运行测试 npm test

Stencil 提供三种项目模板:component(组件库)、app(完整应用)和 ionic-pwa(Ionic PWA 应用)。组件库是最常见的选择。

组件基础

import { Component, Prop, State, Event, EventEmitter, h } from '@stencil/core'; @Component({ tag: 'my-counter', styleUrl: 'my-counter.css', shadow: true, }) export class MyCounter { @Prop() initial: number = 0; @Prop({ mutable: true, reflect: true }) value: number = 0; @State() history: number[] = []; @Event() valueChanged: EventEmitter<number>; componentWillLoad() { this.value = this.initial; } @Watch('value') watchValue(newVal: number) { this.history = [...this.history, newVal]; this.valueChanged.emit(newVal); } render() { return ( <Host> <button onClick={() => this.value--}>−</button> <span class="value">{this.value}</span> <button onClick={() => this.value++}>+</button> <p class="history">{this.history.join(' → ')}</p> </Host> ); } }

关键概念:Stencil 组件是标准 Web Components,编译为原生 Custom Elements。使用 TypeScript + JSX 编写,但输出零依赖的标准浏览器代码。@Component 装饰器的 tag 必须包含连字符(W3C 规范要求)。

装饰器完整参考

装饰器用途示例
@Component定义组件的标签名、样式文件和 Shadow DOM 模式@Component({ tag: 'my-cmp', shadow: true })
@Prop()公共属性,外部可设置,变化触发重渲染@Prop() name: string
@Prop({ mutable })组件内部也可修改的属性@Prop({ mutable: true }) open: boolean
@Prop({ reflect })值同步回 HTML 属性(用于 CSS 选择器)@Prop({ reflect: true }) color: string
@State()内部响应式状态,变化触发重渲染@State() items: string[] = []
@Watch()监听 Prop 或 State 变化的回调@Watch('value') onValueChange(n, o) {}
@Event()声明自定义 DOM 事件发射器@Event() myEvent: EventEmitter<T>
@Listen()监听 DOM 事件(含 window/document 级别)@Listen('resize', { target: 'window' })
@Method()暴露公共异步方法,可通过 JS 调用@Method() async open() {}
@Element()获取宿主 HTMLElement 的引用@Element() el: HTMLElement

生命周期方法

connectedCallback
componentWillLoad
componentWillRender
render()
componentDidRender
componentDidLoad
方法调用时机Async?常见用途
connectedCallback元素插入 DOM 时添加事件监听器
disconnectedCallback元素从 DOM 移除时清理(移除监听器、定时器)
componentWillLoad首次渲染前(仅一次)Fetch 数据、初始化状态
componentDidLoad首次渲染后(仅一次)操作 DOM、初始化第三方库
componentWillRender每次渲染前计算派生数据
componentDidRender每次渲染后更新 DOM 后的操作
componentShouldUpdateProp/State 变化时性能优化:返回 false 跳过渲染
componentWillUpdate因变更的重渲染前更新前的准备
componentDidUpdate因变更的重渲染后更新后的操作
注意

componentWillLoad 支持返回 Promise——Stencil 会等待 Promise 完成后再执行首次渲染,适合在此处 fetch 数据。

Props 深入

@Prop() name: string; // immutable by default @Prop({ mutable: true }) open: boolean = false; // can be changed internally @Prop({ reflect: true }) color: string; // syncs to HTML attribute @Prop({ attribute: 'full-name' }) fullName: string; // custom attribute name // Supported types: string, number, boolean, Object, Array // Objects/Arrays pass by reference (use JSON attribute for HTML) // <my-cmp items='["a","b"]'></my-cmp>

Prop 类型映射:在 HTML 中,string/number/boolean 自动转换。Object 和 Array 需要通过 JSON 字符串传递(HTML 属性),或通过 JavaScript 属性赋值。reflect: true 允许 CSS 选择器如 [color="red"] 生效。

State

@State() items: string[] = []; @State() loading: boolean = false; // ❌ Wrong: mutating array in place won't trigger re-render this.items.push('new item'); // ✅ Correct: create a new reference this.items = [...this.items, 'new item']; // ✅ For objects: this.user = { ...this.user, name: 'new name' };
重要

Stencil 使用引用比较检测变化。对 Array/Object 类型的 State,必须创建新引用(展开运算符)才能触发重渲染。直接 push/修改属性不会被检测到。

事件系统

发射事件

@Event({ eventName: 'myCustomEvent', // optional: defaults to property name bubbles: true, // default: true composed: true, // crosses Shadow DOM boundary cancelable: true, }) myCustomEvent: EventEmitter<{ id: number; value: string }>; // Emit: this.myCustomEvent.emit({ id: 1, value: 'hello' });

监听事件

// Listen on the component itself @Listen('click') handleClick(ev: MouseEvent) { ... } // Listen on window @Listen('scroll', { target: 'window', passive: true }) handleScroll() { ... } // Listen on document @Listen('keydown', { target: 'document' }) handleKeydown(ev: KeyboardEvent) { ... } // In parent HTML/JS: document.querySelector('my-cmp').addEventListener('myCustomEvent', (e) => { console.log(e.detail); // { id: 1, value: 'hello' } });

样式与 Shadow DOM

shadow: true

完全封装:外部 CSS 无法影响内部,内部 CSS 不会泄漏。通过 CSS Custom Properties(变量)穿透。推荐用于组件库。

scoped: true

Stencil 自动添加 data 属性实现作用域隔离(类似 Vue scoped)。不使用 Shadow DOM,兼容性更好但封装性较弱。

无封装

不设 shadow/scoped:CSS 全局生效。适合应用级组件,不推荐用于共享库。

/* my-button.css */ :host { display: inline-block; /* style the component itself */ } :host([disabled]) { opacity: 0.5; /* style based on host attribute */ pointer-events: none; } ::slotted(span) { font-weight: bold; /* style slotted content */ } /* CSS Custom Properties for theming */ :host { --btn-bg: var(--primary-color, #6c63ff); --btn-radius: 8px; } button { background: var(--btn-bg); border-radius: var(--btn-radius); }

Slots 插槽

// Component with named and default slots render() { return ( <Host> <header><slot name="header">Default Header</slot></header> <main><slot></slot></main> {/* default slot */} <footer><slot name="footer"></slot></footer> </Host> ); } <!-- Usage: --> <my-card> <h2 slot="header">Card Title</h2> <p>This goes into the default slot.</p> <button slot="footer">Action</button> </my-card>

测试

单元测试 (spec)

import { newSpecPage } from '@stencil/core/testing'; import { MyCounter } from './my-counter'; describe('my-counter', () => { it('renders with default value', async () => { const page = await newSpecPage({ components: [MyCounter], html: `<my-counter></my-counter>`, }); expect(page.root.shadowRoot.querySelector('.value').textContent) .toBe('0'); }); it('increments on click', async () => { const page = await newSpecPage({ components: [MyCounter], html: `<my-counter initial="5"></my-counter>`, }); const btn = page.root.shadowRoot.querySelectorAll('button')[1]; btn.click(); await page.waitForChanges(); expect(page.rootInstance.value).toBe(6); }); });

E2E 测试

import { newE2EPage } from '@stencil/core/testing'; it('should toggle open', async () => { const page = await newE2EPage(); await page.setContent('<my-dropdown></my-dropdown>'); const el = await page.find('my-dropdown'); const event = await el.spyOnEvent('opened'); await el.click(); expect(event).toHaveReceivedEvent(); });

输出目标

目标说明适用场景
dist发布到 npm 的组件库(含 lazy loading)组件库发布
dist-custom-elements每个组件一个 ES Module,无 lazy loadingTree-shaking 优化、bundler 集成
www开发服务器 + 预渲染静态站点开发调试、静态站生成
dist-hydrate-scriptNode.js 端渲染脚本(SSR)服务端渲染
docs-readme自动生成 Markdown 文档组件 API 文档
docs-jsonJSON 格式 API 文档与文档系统集成
// stencil.config.ts export const config: Config = { outputTargets: [ { type: 'dist', esmLoaderPath: '../loader' }, { type: 'dist-custom-elements' }, { type: 'docs-readme' }, { type: 'www', serviceWorker: null }, ], };

配置参考

// stencil.config.ts import { Config } from '@stencil/core'; import { sass } from '@stencil/sass'; export const config: Config = { namespace: 'my-components', // library name globalStyle: 'src/global.css', // global stylesheet globalScript: 'src/global.ts', // runs once on load plugins: [sass()], // Sass/SCSS support taskQueue: 'async', // 'async' | 'congestionAsync' | 'immediate' extras: { experimentalImportInjection: true, }, testing: { browserArgs: ['--no-sandbox'], }, outputTargets: [ ... ], };

CLI 命令

命令说明
npm init stencil交互式创建新项目
npm start启动开发服务器(热重载)
npm run build生产构建
npm test运行 spec + e2e 测试
npm run test.watch监听模式测试
npm run generate交互式生成新组件文件
stencil build --docs构建并生成文档
stencil build --watch监听模式构建

最佳实践

组件命名

标签名必须包含连字符且全小写。建议使用统一前缀:my-buttonmy-modal。类名使用 PascalCase:MyButton

Props 默认不可变

除非设置 mutable: true,否则在组件内部修改 Prop 会触发警告。优先使用 @State 管理内部数据,仅在需要双向绑定时使用 mutable prop。

避免在 render() 中产生副作用

render() 应是纯函数——只返回 JSX,不修改状态、不发请求。副作用放在生命周期方法或事件处理函数中。

Shadow DOM vs Scoped

组件库发布到 npm 时优先用 shadow: true,保证样式完全隔离。应用内部组件可用 scoped: true,兼容性更好。

使用 Host 元素

<Host> 包裹 render 输出,可在宿主元素上设置 class、属性和事件。

懒加载 vs Custom Elements

dist 输出自动按需加载组件(推荐大型库);dist-custom-elements 输出无懒加载、支持 tree-shaking(推荐少量组件或打包使用)。

FAQ

Stencil 和 React/Vue/Angular 有什么区别?

Stencil 不是框架,而是编译器。它在构建时将 TypeScript + JSX 编译为标准 Web Components(Custom Elements + Shadow DOM),输出零运行时依赖的原生浏览器代码。生成的组件可在任何框架或无框架环境中使用。

Stencil 组件可以在 React/Vue/Angular 中使用吗?

可以。Stencil 组件是标准 Custom Elements,可在任何前端框架中直接使用。Stencil 还可通过 @stencil/react-output-target 等插件生成框架专用的 wrapper 组件,提供更原生的开发体验。

Stencil 适合什么场景?

① 跨框架共享的 UI 组件库(如 Ionic Framework 就用 Stencil 构建);② 设计系统(Design System);③ 微前端中的共享组件;④ 需要无依赖、轻量级 Web Components 的项目。

Stencil 支持 SSR 吗?

支持。通过 dist-hydrate-script 输出目标,可在 Node.js 环境中预渲染组件,实现服务端渲染和静态站点生成。

Stencil 的性能如何?

Stencil 使用虚拟 DOM diff、异步渲染队列和懒加载等优化。编译产物极小(无框架运行时),首屏加载仅加载用到的组件代码。Ionic 6+ 的全部 UI 组件(100+)都用 Stencil 构建并在生产中使用。

💬 留言讨论