关于 stenciljs-component-development
这是一项面向开发者的 Stencil.js 组件开发技能,用于构建基于 TypeScript 的 Web Components。内容涵盖组件结构、生命周期、渲染、状态、事件、测试,以及面向多种 Web 框架复用组件的实践。
主要功能
- 01类型化组件属性与状态
- 02生命周期与属性变更处理
- 03自定义事件与 DOM 监听
- 04JSX 渲染与组件组织
- 05单元测试与端到端测试
- 06Web Component 设计实践
适用场景
- 01构建可复用的 Stencil.js 组件
- 02设计组件属性和事件 API
- 03测试组件渲染与行为
- 04在不同 Web 框架间共享组件
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ corlab-tech/ skills --skill stenciljs-component-development安装后在 Claude Code 中输入/stenciljs-component-development确认已加载。
详细介绍
Stencil.js - 组件开发
使用 Stencil.js、TypeScript 和 Web Component 标准,构建可扩展、适用于企业的 Web 组件。Stencil 组件与框架无关,可分发到 React、Angular、Vue 和传统 Web 应用中。
核心概念
组件结构
Stencil 组件是一个使用 @Component() 装饰的 TypeScript 类:
import { Component, Prop, State, Event, EventEmitter, Watch, h } from '@stencil/core';
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
@Prop() name: string;
@State() isActive: boolean = false;
render() {
return (
<div>
<p>Hello, {this.name}!</p>
</div>
);
}
}
组件装饰器选项
@Component({
tag: 'my-component', // Required: Custom element tag name (must contain hyphen)
styleUrl: 'my-component.css', // Single stylesheet
styleUrls: { // Multiple stylesheets for different modes
ios: 'my-component.ios.css',
md: 'my-component.md.css'
},
shadow: true, // Use Shadow DOM (recommended)
scoped: false, // Scoped CSS without Shadow DOM
assetsDirs: ['assets'], // Asset directories to copy
formAssociated: false, // Form-associated custom element
})
装饰器
@Prop() - 组件属性
作为属性公开的公共属性:
// Basic prop
@Prop() name: string;
// With default value
@Prop() size: 'small' | 'medium' | 'large' = 'medium';
// Mutable prop (can be changed internally)
@Prop({ mutable: true }) value: string;
// Reflect to attribute (sync prop changes to DOM attribute)
@Prop({ reflect: true }) active: boolean;
// Attribute name different from property name
@Prop({ attribute: 'data-id' }) dataId: string;
最佳实践:
- 始终为 props 指定类型
- 使用 JSDoc 注释编写公共 API 文档
- 在适当情况下设置合理的默认值
- 谨慎使用
reflect: true(会影响性能)
@State() - 内部状态
更改时会触发重新渲染的私有状态:
@State() isOpen: boolean = false;
@State() items: string[] = [];
// Updating state triggers re-render
this.isOpen = true;
重要提示:
- 状态仅供内部使用,不会作为属性公开
- 直接修改数组/对象不会触发重新渲染
- 对复杂状态使用不可变模式
@Watch() - 属性变更处理器
监听 prop 或状态的变化:
@Prop() value: string;
@Watch('value')
valueChanged(newValue: string, oldValue: string) {
console.log(`Value changed from ${oldValue} to ${newValue}`);
this.validateValue(newValue);
}
// Place @Watch() immediately after the prop it watches
@Prop() swipeEnabled: boolean = true;
@Watch('swipeEnabled')
swipeEnabledChanged(newSwipeEnabled: boolean, oldSwipeEnabled: boolean) {
this.updateState();
}
@Event() - 自定义事件
触发自定义事件:
@Event() itemSelected: EventEmitter<string>;
@Event() formSubmit: EventEmitter<{value: string}>;
handleClick() {
this.itemSelected.emit('item-1');
}
最佳实践:
- 使用描述性事件名称
- 为事件负载指定类型
- 使用 JSDoc 记录事件详情
@Listen() - 事件监听器
监听 DOM 事件:
@Listen('click')
handleClick(event: MouseEvent) {
console.log('Component clicked', event);
}
@Listen('scroll', { target: 'window' })
handleScroll(event: Event) {
console.log('Window scrolled');
}
@Listen('resize', { target: 'window', passive: true })
handleResize() {
this.updateDimensions();
}
@Element() - 宿主元素引用
对宿主元素的引用:
@Element() el: HTMLElement;
componentDidLoad() {
console.log('Host element:', this.el);
this.el.classList.add('loaded');
}
@Method() - 公共方法
公开公共异步方法:
@Method()
async open(): Promise<void> {
this.isOpen = true;
}
@Method()
async getValue(): Promise<string> {
return this.value;
}
重要提示:
- 所有公共方法都必须是异步方法
- 使用 JSDoc 编写公共 API 文档
生命周期方法
按执行顺序排列的生命周期方法:
export class MyComponent {
// 1. Called when component is connected to DOM (can be called multiple times)
connectedCallback() {
console.log('Component connected to DOM');
}
// 2. Called once before first render (good for async data loading)
componentWillLoad() {
console.log('Component will load');
// Can return Promise to delay first render
}
// 3. Called once after first render
componentDidLoad() {
console.log('Component loaded');
}
// 4. Called before every render (after first)
componentWillRender() {
console.log('Component will render');
}
// 5. Called after every render (after first)
componentDidRender() {
console.log('Component rendered');
}
// 6. Called when component will update (not on first render)
componentWillUpdate() {
console.log('Component will update');
}
// 7. Called after component updates (not on first render)
componentDidUpdate() {
console.log('Component updated');
}
// 8. Determine if component should re-render
componentShouldUpdate(newVal: any, oldVal: any, propName: string): boolean {
return newVal !== oldVal;
}
// 9. Called when disconnected from DOM (can be called multiple times)
disconnectedCallback() {
console.log('Component disconnected');
}
}
生命周期最佳实践:
- 使用
componentWillLoad()获取异步数据 - 使用
componentDidLoad()操作 DOM - 使用
connectedCallback()执行每次元素挂载时都要运行的逻辑 - 在
disconnectedCallback()中清理资源(移除监听器、清除计时器)
JSX 和渲染
render() 方法
render() 方法返回 JSX:
import { h } from '@stencil/core';
render() {
return (
<div class="container">
<h1>Hello, {this.name}!</h1>
{this.isActive && <p>Active!</p>}
<button onClick={() => this.handleClick()}>
Click me
</button>
</div>
);
}
宿主元素
使用 <Host> 为宿主元素设置属性:
import { Host } from '@stencil/core';
render() {
return (
<Host
class={{
'is-active': this.isActive,
'is-disabled': this.disabled
}}
aria-label={this.label}
>
<slot></slot>
</Host>
);
}
插槽
使用插槽投影内容:
render() {
return (
<div>
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> {/* Default slot */}
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
);
}
条件渲染
render() {
return (
<div>
{this.loading ? (
<div class="spinner">Loading...</div>
) : (
<div class="content">{this.content}</div>
)}
{this.error && <div class="error">{this.error}</div>}
</div>
);
}
列表
render() {
return (
<ul>
{this.items.map(item => (
<li key={item.id}>
{item.name}
</li>
))}
</ul>
);
}
文件结构与命名
目录结构
每个目录放置一个组件:
src/
├── components/
│ ├── my-button/
│ │ ├── my-button.tsx # Component implementation
│ │ ├── my-button.css # Styles
│ │ ├── my-button.spec.ts # Unit tests
│ │ ├── my-button.e2e.ts # E2E tests
│ │ └── readme.md # Component documentation
│ ├── my-card/
│ │ ├── my-card.tsx
│ │ ├── my-card.ios.css # iOS-specific styles
│ │ ├── my-card.md.css # Material Design styles
│ │ └── my-card.css # Base styles
命名约定
- 标签名:使用带连字符的 kebab-case(必需):
my-component、app-header - 组件类:PascalCase:
MyComponent、AppHeader - 文件名:与标签名一致:
my-component.tsx - Props:camelCase:
firstName、isActive - 事件:camelCase:
itemSelected、formSubmit - CSS 类:kebab-case:
button-primary、is-active
代码组织
在组件类中遵循以下顺序:
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
// 1. Own Properties (private, not decorated)
private internalValue: number;
someText = 'default';
// 2. Reference to host element
@Element() el: HTMLElement;
// 3. State() variables (alphabetical)
@State() isValidated: boolean;
@State() status = 0;
// 4. Public Property API - @Prop() (alphabetical)
@Prop() content: string;
@Prop() enabled: boolean;
@Prop() type = 'default';
// 5. Prop lifecycle events (@Watch) - immediately after related @Prop
@Prop() value: string;
@Watch('value')
valueChanged(newValue: string, oldValue: string) {
this.validate(newValue);
}
// 6. Events section (alphabetical)
@Event() itemSelected: EventEmitter<string>;
@Event() statusChanged: EventEmitter<number>;
// 7. Component lifecycle events (in natural order)
connectedCallback() {}
disconnectedCallback() {}
componentWillLoad() {}
componentDidLoad() {}
componentShouldUpdate() {}
componentWillRender() {}
componentDidRender() {}
componentWillUpdate() {}
componentDidUpdate() {}
// 8. Listeners
@Listen('click')
onClick(event: MouseEvent) {
console.log('Clicked');
}
// 9. Public methods API (alphabetical)
@Method()
async open(): Promise<void> {
this.isOpen = true;
}
@Method()
async close(): Promise<void> {
this.isOpen = false;
}
// 10. Local methods (private business logic)
private validate(value: string): boolean {
return value.length > 0;
}
private updateState() {
// Internal logic
}
// 11. render() function (always last)
render() {
return (
<Host>
<div class="container">
<slot></slot>
</div>
</Host>
);
}
}
最佳实践
1. 严格使用 TypeScript
// Good - Fully typed
@Prop() items: Array<{id: string; name: string}>;
@State() count: number = 0;
// Bad - Any types
@Prop() items: any;
@State() count;
2. 不可变地更新状态
// Bad - Direct mutation won't trigger re-render
this.items.push(newItem);
// Good - Create new array
this.items = [...this.items, newItem];
// Bad - Direct object mutation
this.user.name = 'New Name';
// Good - Create new object
this.user = { ...this.user, name: 'New Name' };
3. 尽可能使用 Shadow DOM
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true, // Encapsulation and style isolation
})
4. 编写公共 API 文档
/**
* The button component for user interactions
*/
@Component({
tag: 'my-button',
})
export class MyButton {
/**
* The button label text
*/
@Prop() label: string;
/**
* Emitted when the button is clicked
*/
@Event() buttonClick: EventEmitter<void>;
/**
* Opens the button's associated menu
*/
@Method()
async openMenu(): Promise<void> {
// ...
}
}
5. 正确处理异步操作
async componentWillLoad() {
try {
this.data = await this.fetchData();
} catch (error) {
console.error('Failed to load data:', error);
this.error = 'Failed to load';
}
}
6. 清理资源
private intervalId: number;
componentDidLoad() {
this.intervalId = window.setInterval(() => {
this.updateTime();
}, 1000);
}
disconnectedCallback() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
7. 简单场景使用函数组件
import { h, FunctionalComponent } from '@stencil/core';
interface IconProps {
name: string;
size?: number;
}
export const Icon: FunctionalComponent<IconProps> = ({ name, size = 24 }) => (
<svg width={size} height={size}>
<use xlinkHref={`#icon-${name}`} />
</svg>
);
反模式
❌ 不要修改 Props
// Bad
@Prop() value: string;
handleChange() {
this.value = 'new value'; // Error if not mutable
}
// Good
@Prop({ mutable: true }) value: string;
// Or better: emit event and let parent handle
@Event() valueChange: EventEmitter<string>;
handleChange() {
this.valueChange.emit('new value');
}
❌ 不要使用构造函数进行初始化
// Bad
constructor() {
this.data = this.fetchData(); // Won't work properly
}
// Good
async componentWillLoad() {
this.data = await this.fetchData();
}
❌ 不要在 render() 中访问 DOM
// Bad
render() {
const width = this.el.offsetWidth; // Can cause issues
return <div style={{ width: `${width}px` }}></div>;
}
// Good
componentDidLoad() {
this.width = this.el.offsetWidth;
}
render() {
return <div style={{ width: `${this.width}px` }}></div>;
}
❌ 不要忘记为列表项设置键
// Bad
render() {
return (
<ul>
{this.items.map(item => <li>{item.name}</li>)}
</ul>
);
}
// Good
render() {
return (
<ul>
{this.items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
❌ 不要在 render() 中为处理器使用箭头函数
// Bad - Creates new function on every render
render() {
return <button onClick={() => this.handleClick()}>Click</button>;
}
// Good - Bind in class or use arrow function property
private handleClick = () => {
console.log('Clicked');
}
render() {
return <button onClick={this.handleClick}>Click</button>;
}
测试
单元测试
import { newSpecPage } from '@stencil/core/testing';
import { MyComponent } from './my-component';
describe('my-component', () => {
it('renders', async () => {
const page = await newSpecPage({
components: [MyComponent],
html: `<my-component></my-component>`,
});
expect(page.root).toEqualHtml(`
<my-component>
<mock:shadow-root>
<div>
Hello, World!
</div>
</mock:shadow-root>
</my-component>
`);
});
it('renders with props', async () => {
const page = await newSpecPage({
components: [MyComponent],
html: `<my-component name="Stencil"></my-component>`,
});
expect(page.root).toEqualHtml(`
<my-component name="Stencil">
<mock:shadow-root>
<div>
Hello, Stencil!
</div>
</mock:shadow-root>
</my-component>
`);
});
});
端到端测试
import { newE2EPage } from '@stencil/core/testing';
describe('my-component', () => {
it('renders', async () => {
const page = await newE2EPage();
await page.setContent('<my-component></my-component>');
const element = await page.find('my-component');
expect(element).toHaveClass('hydrated');
});
it('emits event on click', async () => {
const page = await newE2EPage();
await page.setContent('<my-component></my-component>');
const itemSelected = await page.spyOnEvent('itemSelected');
const button = await page.find('my-component >>> button');
await button.click();
expect(itemSelected).toHaveReceivedEvent();
});
});
常见模式
与表单关联的组件
@Component({
tag: 'my-input',
formAssociated: true,
})
export class MyInput {
@Element() el: HTMLElement;
private internals: ElementInternals;
componentWillLoad() {
this.internals = (this.el as any).attachInternals();
}
@Prop() value: string = '';
@Watch('value')
valueChanged(newValue: string) {
this.internals.setFormValue(newValue);
}
}
加载状态
@State() loading: boolean = false;
@State() error: string | null = null;
@State() data: any = null;
async componentWillLoad() {
await this.loadData();
}
private async loadData() {
this.loading = true;
this.error = null;
try {
this.data = await fetch('/api/data').then(r => r.json());
} catch (e) {
this.error = 'Failed to load data';
} finally {
this.loading = false;
}
}
render() {
if (this.loading) return <div>Loading...</div>;
if (this.error) return <div class="error">{this.error}</div>;
return <div>{JSON.stringify(this.data)}</div>;
}
受控与非受控
// Controlled - value managed by parent
@Prop() value: string;
@Event() valueChange: EventEmitter<string>;
handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.valueChange.emit(value);
}
render() {
return <input value={this.value} onInput={e => this.handleInput(e)} />;
}
// Uncontrolled - internal state
@State() internalValue: string = '';
handleInput(event: Event) {
this.internalValue = (event.target as HTMLInputElement).value;
}
render() {
return <input value={this.internalValue} onInput={e => this.handleInput(e)} />;
}
性能提示
- 使用
componentShouldUpdate()避免不必要的渲染 - 避免在 render() 中进行复杂计算
- 使用
memoize处理开销较大的计算 - 使用
import()延迟加载组件 - 使用 Shadow DOM 封装样式
- 尽量减少来自父级的 prop 变更
- 对列表使用事件委托
Related Resources
评论
支持 Markdown