快速入门
# 创建新项目
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 后的操作 |
componentShouldUpdate | Prop/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 loading | Tree-shaking 优化、bundler 集成 |
www | 开发服务器 + 预渲染静态站点 | 开发调试、静态站生成 |
dist-hydrate-script | Node.js 端渲染脚本(SSR) | 服务端渲染 |
docs-readme | 自动生成 Markdown 文档 | 组件 API 文档 |
docs-json | JSON 格式 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-button、my-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 Elementsdist 输出自动按需加载组件(推荐大型库);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 构建并在生产中使用。