组件化是现代前端开发的核心思想,它将UI拆分为独立、可复用的组件。本章将介绍组件化开发的设计理念和最佳实践。
const componentDefinition = {
definition: '组件是可复用的UI单元,封装了结构、样式和行为',
characteristics: [
'独立性 - 组件独立运作,不依赖外部状态',
'可复用 - 可在不同场景重复使用',
'可组合 - 小组件组合成大组件',
'可测试 - 可独立测试组件功能'
],
composition: {
structure: 'HTML模板结构',
style: 'CSS样式',
behavior: 'JavaScript逻辑',
data: '组件状态和属性'
}
};
const componentTypes = {
presentational: {
name: '展示组件',
description: '只负责UI展示,不包含业务逻辑',
characteristics: [
'接收props渲染UI',
'不直接修改数据',
'可复用性高',
'易于测试'
],
example: 'Button, Card, List, Input'
},
container: {
name: '容器组件',
description: '负责数据获取和业务逻辑',
characteristics: [
'管理状态',
'处理数据获取',
'协调子组件',
'包含业务逻辑'
],
example: 'UserListContainer, ProductPage'
},
higherOrder: {
name: '高阶组件',
description: '接收组件返回增强组件',
characteristics: [
'复用组件逻辑',
'属性代理',
'渲染劫持',
'状态抽象'
],
example: 'withAuth, withLoading'
}
};
class UserProfile extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div class="profile">
<user-avatar src="${this.avatar}"></user-avatar>
<user-info name="${this.name}" email="${this.email}"></user-info>
<user-stats posts="${this.posts}" followers="${this.followers}"></user-stats>
</div>
`;
}
}
class UserAvatar extends HTMLElement {
static get observedAttributes() {
return ['src', 'size'];
}
connectedCallback() {
const size = this.getAttribute('size') || 'medium';
this.innerHTML = `
<img class="avatar avatar-${size}"
src="${this.getAttribute('src')}"
alt="用户头像">
`;
}
}
class UserInfo extends HTMLElement {
static get observedAttributes() {
return ['name', 'email'];
}
connectedCallback() {
this.innerHTML = `
<div class="user-info">
<h3>${this.getAttribute('name')}</h3>
<p>${this.getAttribute('email')}</p>
</div>
`;
}
}
const interfaceDesign = {
props: {
description: '组件输入,父组件传递数据',
guidelines: [
'使用明确的数据类型',
'提供默认值',
'验证必要属性',
'避免过多属性'
]
},
events: {
description: '组件输出,向父组件通信',
guidelines: [
'使用自定义事件',
'传递有意义的数据',
'命名清晰',
'支持事件取消'
]
},
slots: {
description: '内容分发,灵活组合',
guidelines: [
'提供默认内容',
'命名插槽',
'文档说明'
]
}
};
class Button extends HTMLElement {
static get observedAttributes() {
return ['type', 'size', 'disabled', 'loading'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
const type = this.getAttribute('type') || 'default';
const size = this.getAttribute('size') || 'medium';
const disabled = this.hasAttribute('disabled');
const loading = this.hasAttribute('loading');
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-block; }
button {
padding: var(--btn-padding, 8px 16px);
border-radius: var(--btn-radius, 4px);
cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.size-small { padding: 4px 8px; font-size: 12px; }
button.size-large { padding: 12px 24px; font-size: 16px; }
button.type-primary { background: #007bff; color: white; }
button.type-danger { background: #dc3545; color: white; }
</style>
<button
class="type-${type} size-${size}"
?disabled="${disabled || loading}">
${loading ? '<span class="spinner"></span>' : ''}
<slot></slot>
</button>
`;
this.shadowRoot.querySelector('button').addEventListener('click', (e) => {
if (!disabled && !loading) {
this.dispatchEvent(new CustomEvent('click', { bubbles: true }));
}
});
}
}
class Counter extends HTMLElement {
constructor() {
super();
this._count = 0;
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
get count() {
return this._count;
}
set count(value) {
this._count = value;
this.render();
}
increment() {
this.count++;
this.dispatchEvent(new CustomEvent('change', {
detail: { count: this.count },
bubbles: true
}));
}
decrement() {
this.count--;
this.dispatchEvent(new CustomEvent('change', {
detail: { count: this.count },
bubbles: true
}));
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: 8px; }
button { padding: 4px 8px; }
</style>
<button id="dec">-</button>
<span>${this.count}</span>
<button id="inc">+</button>
`;
this.shadowRoot.getElementById('inc')
.addEventListener('click', () => this.increment());
this.shadowRoot.getElementById('dec')
.addEventListener('click', () => this.decrement());
}
}
class TemperatureInput extends HTMLElement {
static get observedAttributes() {
return ['value', 'scale'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const value = this.getAttribute('value') || '';
const scale = this.getAttribute('scale');
const scaleNames = { c: '摄氏', f: '华氏' };
this.shadowRoot.innerHTML = `
<fieldset>
<legend>输入${scaleNames[scale]}温度:</legend>
<input type="number" value="${value}">
</fieldset>
`;
this.shadowRoot.querySelector('input').addEventListener('input', (e) => {
this.dispatchEvent(new CustomEvent('temperature-change', {
detail: { value: e.target.value, scale },
bubbles: true
}));
});
}
}
class Calculator extends HTMLElement {
constructor() {
super();
this._temperature = '';
this._scale = 'c';
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
handleCelsiusChange(value) {
this._temperature = value;
this._scale = 'c';
this.render();
}
handleFahrenheitChange(value) {
this._temperature = value;
this._scale = 'f';
this.render();
}
toCelsius(fahrenheit) {
return (fahrenheit - 32) * 5 / 9;
}
toFahrenheit(celsius) {
return (celsius * 9 / 5) + 32;
}
render() {
const celsius = this._scale === 'f'
? this.toCelsius(parseFloat(this._temperature) || 0)
: parseFloat(this._temperature) || 0;
const fahrenheit = this._scale === 'c'
? this.toFahrenheit(parseFloat(this._temperature) || 0)
: parseFloat(this._temperature) || 0;
this.shadowRoot.innerHTML = `
<temperature-input scale="c" value="${celsius}"></temperature-input>
<temperature-input scale="f" value="${fahrenheit}"></temperature-input>
`;
this.shadowRoot.querySelectorAll('temperature-input').forEach(input => {
input.addEventListener('temperature-change', (e) => {
if (e.detail.scale === 'c') {
this.handleCelsiusChange(e.detail.value);
} else {
this.handleFahrenheitChange(e.detail.value);
}
});
});
}
}
class Card extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; border: 1px solid #ddd; border-radius: 8px; }
::slotted([slot="header"]) { padding: 16px; border-bottom: 1px solid #eee; }
::slotted([slot="body"]) { padding: 16px; }
::slotted([slot="footer"]) { padding: 16px; border-top: 1px solid #eee; }
</style>
<slot name="header"></slot>
<slot name="body"></slot>
<slot name="footer"></slot>
`;
}
}
customElements.define('app-card', Card);
<app-card>
<div slot="header">
<h3>卡片标题</h3>
</div>
<div slot="body">
<p>卡片内容</p>
</div>
<div slot="footer">
<button>确定</button>
</div>
</app-card>
class Tabs extends HTMLElement {
constructor() {
super();
this._activeIndex = 0;
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.setupListeners();
}
setupListeners() {
this.shadowRoot.addEventListener('tab-select', (e) => {
this._activeIndex = e.detail.index;
this.render();
});
}
render() {
const tabs = this.querySelectorAll('app-tab');
const tabList = Array.from(tabs).map((tab, index) => ({
label: tab.getAttribute('label'),
active: index === this._activeIndex
}));
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.tab-list { display: flex; border-bottom: 1px solid #ddd; }
.tab { padding: 8px 16px; cursor: pointer; }
.tab.active { border-bottom: 2px solid #007bff; color: #007bff; }
.tab-content { padding: 16px; }
</style>
<div class="tab-list">
${tabList.map((tab, index) => `
<div class="tab ${tab.active ? 'active' : ''}"
data-index="${index}">
${tab.label}
</div>
`).join('')}
</div>
<div class="tab-content">
<slot name="tab-${this._activeIndex}"></slot>
</div>
`;
this.shadowRoot.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const index = parseInt(tab.dataset.index);
this.shadowRoot.dispatchEvent(new CustomEvent('tab-select', {
detail: { index },
bubbles: true
}));
});
});
}
}
class Tab extends HTMLElement {
static get observedAttributes() {
return ['label'];
}
}
customElements.define('app-tabs', Tabs);
customElements.define('app-tab', Tab);
🧩 组件设计要点
- 小而美:组件越小,复用性越强
- 接口清晰:Props进,Events出
- 单一职责:一个组件只做一件事
- 可测试:组件应该易于测试
🔄 组件通信方式
- Props:父传子
- Events:子传父
- Provide/Inject:跨层级
- 状态管理:全局共享
下一章将探讨 虚拟DOM,学习虚拟DOM的原理和实现。