feat: biblioteca inteligente libs/ + 5 novas skills (20 skills total)
NOVAS SKILLS: - next-best-practices v0.1.0 (CLEAN) — Next.js App Router, RSC, caching, data - nextjs-patterns v1.0.0 (CLEAN) — Next.js 15: Server Actions, route handlers - vite v1.0.0 (CLEAN) — env vars, aliases, proxy, CJS compat - uncle-bob v1.0.0 (CLEAN) — Clean Code, SOLID, Clean Architecture - clean-code-review v1.0.0 (CLEAN) — naming, guard clauses, anti-patterns, refactoring - vue v1.0.0 (CLEAN) — Vue framework - vue-composition-api-best-practices v1.0.0 (CLEAN) — composables, Pinia, reactivity BIBLIOTECA INTELIGENTE libs/ (10 dominios, 11 arquivos): - typescript/ — TS safe + generics gotchas - react/ — Next.js App Router + Vite config - vue/ — Composition API + Pinia - linux/ — System diagnostic cheatsheet - database/ — PostgreSQL + MySQL patterns - browser/ — Chromium CLI + E2E testing - security/ — SAST audit (OWASP Top 10) - best-practices/ — Clean Code + SOLID + Clean Architecture - deploy/ — Docker multi-stack + OpenClaw ops - + INDEX.md como guia de navegacao .learnings/ — LRN-20260519-003 criado (biblioteca compartilhada)
This commit is contained in:
@@ -44,6 +44,14 @@
|
|||||||
"openclaw-agent-browser": {
|
"openclaw-agent-browser": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"installedAt": 1779234569458
|
"installedAt": 1779234569458
|
||||||
|
},
|
||||||
|
"next-best-practices": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"installedAt": 1779235116283
|
||||||
|
},
|
||||||
|
"vue-composition-api-best-practices": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"installedAt": 1779235185182
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,3 +51,35 @@ Fazer múltiplas searches com termos curtos e depois filtrar manualmente.
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!-- Novas entradas acima desta linha -->
|
<!-- Novas entradas acima desta linha -->
|
||||||
|
|
||||||
|
## [LRN-20260519-003] biblioteca-compartilhada-libs
|
||||||
|
|
||||||
|
**Logged**: 2026-05-19T21:30:00-03:00
|
||||||
|
**Priority**: medium
|
||||||
|
**Status**: pending
|
||||||
|
**Area**: config
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Criar biblioteca inteligente compartilhada em `libs/` para reuso entre projetos, com conhecimento extraído de todas as skills instaladas.
|
||||||
|
|
||||||
|
### Details
|
||||||
|
Toda skill que instalamos tem conhecimento valioso (padrões, gotchas, templates).
|
||||||
|
Ao invés de cada agente lembrar de cor, centralize em `libs/<dominio>/`:
|
||||||
|
- skills são extraídas e promovidas para arquivos .md limpos na biblioteca
|
||||||
|
- novos projetos copiam `libs/` como template de padrões
|
||||||
|
- o próprio agente consulte `libs/` antes de implementar qualquer coisa
|
||||||
|
|
||||||
|
### Suggested Action
|
||||||
|
Quando instalar nova skill:
|
||||||
|
1. Ler o SKILL.md
|
||||||
|
2. Extrair o conhecimento útil
|
||||||
|
3. Promover para `libs/<dominio>/` apropriado
|
||||||
|
4. Atualizar `libs/INDEX.md`
|
||||||
|
|
||||||
|
### Metadata
|
||||||
|
- Source: best_practice
|
||||||
|
- Tags: biblioteca, reuso, padroes, compartilhamento
|
||||||
|
- Pattern-Key: libs.shared_knowledge_base
|
||||||
|
- Recurrence-Count: 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ _Rastreia quantas vezes cada abordagem funcionou para identificar skills candida
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| clawhub.cli_path | 1 | 2026-05-19 | tracking |
|
| clawhub.cli_path | 1 | 2026-05-19 | tracking |
|
||||||
| clawhub.search_strategy | 1 | 2026-05-19 | tracking |
|
| clawhub.search_strategy | 1 | 2026-05-19 | tracking |
|
||||||
|
| libs.shared_knowledge_base | 1 | 2026-05-19 | tracking |
|
||||||
|
| skill.extra | 3 | 2026-05-19 | tracking |
|
||||||
|
|
||||||
_Quando Count >= 3 e visto em >= 2 tarefas distintas em 30 dias → promover para AGENTS.md_
|
_Quando Count >= 3 e visto em >= 2 tarefas distintas em 30 dias → promover para AGENTS.md_
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,18 @@ IA → nova-self-improver (auto-melhoria contínua)
|
|||||||
|
|
||||||
## 🔑 Comandos Linux rápidos (referência)
|
## 🔑 Comandos Linux rápidos (referência)
|
||||||
Ver AGENTS.md — seção Linux Analyst para a lista completa.
|
Ver AGENTS.md — seção Linux Analyst para a lista completa.
|
||||||
|
|
||||||
|
|
||||||
|
## 📚 Biblioteca Inteligente — libs/
|
||||||
|
Biblioteca compartilhada de padrões criada para ser usada em todos os projetos:
|
||||||
|
- `libs/typescript/` — TS safe patterns + generics/utility gotchas
|
||||||
|
- `libs/react/` — Next.js App Router + Vite config
|
||||||
|
- `libs/vue/` — Vue 3 Composition API + Pinia
|
||||||
|
- `libs/linux/` — Diagnóstico completo do sistema
|
||||||
|
- `libs/database/` — PostgreSQL + MySQL prático
|
||||||
|
- `libs/browser/` — Chromium CLI + E2E testing
|
||||||
|
- `libs/security/` — SAST audit guide (OWASP Top 10)
|
||||||
|
- `libs/best-practices/` — Clean Code + SOLID + Clean Architecture
|
||||||
|
- `libs/deploy/` — Docker multi-stack + OpenClaw ops
|
||||||
|
- `libs/docs/` — Templates de documentação
|
||||||
|
Ver `libs/INDEX.md` para índice completo.
|
||||||
|
|||||||
+6
-1
@@ -17,7 +17,12 @@ Expansão completa do agente: skills, Linux analyst, browser automation, TOOLS/A
|
|||||||
- [x] Expandir AGENTS.md com Linux analyst + full-stack strategy
|
- [x] Expandir AGENTS.md com Linux analyst + full-stack strategy
|
||||||
- [x] Configurar HEARTBEAT.md com tarefas úteis
|
- [x] Configurar HEARTBEAT.md com tarefas úteis
|
||||||
- [ ] Ler skills instaladas gradualmente quando for usá-las
|
- [ ] Ler skills instaladas gradualmente quando for usá-las
|
||||||
- [ ] Depois de usar as skills, fazer os primeiros logs em .learnings/
|
|
||||||
|
- [x] Instalar next, vite, uncle-bob, clean-code-review, vue
|
||||||
|
- [x] Criar biblioteca compartilhada em libs/ (10 domínios)
|
||||||
|
- [x] Extrair e promover conhecimento das skills
|
||||||
|
- [ ] Logar LRN-20260519-003 para "biblioteca compartilhada em libs/"
|
||||||
|
- [ ] Log LRN para std de cada domínio (TS, React, Linux, DB, etc.)
|
||||||
|
|
||||||
## Skills — resumo rápido
|
## Skills — resumo rápido
|
||||||
| Skill | Quando usar |
|
| Skill | Quando usar |
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 📚 Biblioteca Inteligente — Índice Completo
|
||||||
|
|
||||||
|
> Biblioteca dinâmica para compartilhar entre todos os projetos. Conhecimento extraído
|
||||||
|
> automaticamente das skills instaladas, organizado por domínio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Estrutura
|
||||||
|
|
||||||
|
```
|
||||||
|
libs/
|
||||||
|
├── README.md ← Este arquivo
|
||||||
|
├── typescript/ ← TS seguro: narrowing, generics, satisfies
|
||||||
|
├── react/ ← Next.js, Vite, App Router, RSC, Server Actions
|
||||||
|
├── vue/ ← Vue 3 Composition API, Pinia, Router
|
||||||
|
├── linux/ ← Diagnóstico de sistema, logs, rede, SSH
|
||||||
|
├── database/ ← PostgreSQL, MySQL — schemas, queries, EXPLAIN
|
||||||
|
├── browser/ ← Agent-browser (Chromium) + E2E testing
|
||||||
|
├── security/ ← SAST: OWASP Top 10, prompt injection, secrets
|
||||||
|
├── best-practices/ ← Clean Code, SOLID, Clean Architecture
|
||||||
|
├── deploy/ ← Docker multi-stack, xCloud, OpenClaw Gateway
|
||||||
|
└── docs/ ← Templates de documentação
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Typescript — 2 arquivos
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `typescript/TYPESCRIPT_SAFE_PATTERNS.md` | narrowing, satisfies, discriminated unions, literais |
|
||||||
|
| `typescript/GENERICS_UTILITY_GOTCHAS.md` | Armadilhas de generics & utility types `Partial<T>`, `Omit`, `Pick` |
|
||||||
|
|
||||||
|
## 📁 React — 2 arquivos
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `react/NEXTJS_BEST_PRACTICES.md` | App Router, RSC, Server Actions, caching, data fetching |
|
||||||
|
| `react/VITE_CONFIG.md` | Env vars, path aliases, Dev Server proxy, CJS compat |
|
||||||
|
|
||||||
|
## 📁 Vue — 1 arquivo
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `vue/VUE3_COMPOSITION_API.md` | `<script setup>`, composables, Pinia, reactivity, Router traps |
|
||||||
|
|
||||||
|
## 📁 Linux — 1 arquivo
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `linux/SYSTEM_DIAGNOSTIC.md` | Processes, disk, network, logs, SSH, shell scripts, permissions |
|
||||||
|
|
||||||
|
## 📁 Database — 2 arquivos
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `database/POSTGRESQL.md` | UUID, JSONB, GIN indexes, CTEs, window functions, EXPLAIN ANALYZE |
|
||||||
|
| `database/MYSQL.md` | AUTO_INCREMENT, JSON, InnoDB, diferenças vs PostgreSQL |
|
||||||
|
|
||||||
|
## 📁 Browser — 2 arquivos
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `browser/BROWSER_AUTOMATION.md` | Chromium: navegação, login, persists, screenshot, @refs, dev server proxy |
|
||||||
|
| `browser/E2E_TESTING.md` | Pirâmide de testes, selectors estáveis, princípios de determinismo |
|
||||||
|
|
||||||
|
## 📁 Security — 1 arquivo
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `security/SAST_AUDIT.md` | OWASP Top 10, prompt injection, hardcoded secrets, IDOR, SSRF |
|
||||||
|
|
||||||
|
## 📁 Best Practices — 1 arquivo
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `best-practices/CLEAN_CODE.md` | Naming, functions, SRP, SOLID, Clean Architecture dependency rule |
|
||||||
|
|
||||||
|
## 📁 Deploy — 2 arquivos
|
||||||
|
| Arquivo | Conteúdo |
|
||||||
|
|---------|----------|
|
||||||
|
| `deploy/DOCKER_DEPLOY.md` | Stack router, 3 cenários, Dockerfiles pré-prontos, composes template |
|
||||||
|
| `deploy/OPENCLAW_OPS.md` | CLI cheatsheet, $include modular, Telegram, env vars |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Como Usar
|
||||||
|
|
||||||
|
### Em cada novo projeto:
|
||||||
|
1. Copiar `libs/` → `projeto/docs/dev-standards/`
|
||||||
|
2. Selecionar pastas relevantes ao stack
|
||||||
|
3. Consulte antes de implementar cada camada
|
||||||
|
|
||||||
|
### Quando o agente codifica:
|
||||||
|
1. `libs/best-practices/CLEAN_CODE.md` — sempre primeiro
|
||||||
|
2. `libs/<stack>/` — padrões específicos
|
||||||
|
3. Aplica automaticamente — não precisa lembrar de cor
|
||||||
|
|
||||||
|
### Quando atualizar:
|
||||||
|
- Nova skill instalada → ler SKILL.md → extrair conhecimento → atualizar `libs/`
|
||||||
|
- Após cada projeto → feedback → atualizar padrões
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Clean Code & Architecture — Uncle Bob + SOLID
|
||||||
|
|
||||||
|
> Extracted from: `uncle-bob` v1.0.0 + `clean-code-review` v1.0.0
|
||||||
|
|
||||||
|
## 🧹 Clean Code — Regras Fundamentais
|
||||||
|
|
||||||
|
### Naming (nomes são a melhor documentação)
|
||||||
|
| Elemento | Convenção | ❌ Errado | ✅ Certo |
|
||||||
|
|----------|-----------|-----------|--------|
|
||||||
|
| Variáveis | Revelam intenção | `n`, `d`, `tmp` | `userCount`, `elapsed` |
|
||||||
|
| Funções | Verbo + substantivo | `user()`, `calc()` | `getUserById()`, `calculateTotal()` |
|
||||||
|
| Booleanos | Forma de pergunta | `active`, `flag` | `isActive`, `hasPermission`, `canEdit()` |
|
||||||
|
| Constantes | SNAKE_CASE | `max`, `timeout` | `MAX_RETRY_COUNT`, `REQUEST_TIMEOUT_MS` |
|
||||||
|
| Classes | Substantivo | `Manager`, `Data` | `UserRepository`, `OrderService` |
|
||||||
|
|
||||||
|
> Se precisa de comentário para explicar o nome, o nome está errado.
|
||||||
|
|
||||||
|
### Functions — Regras
|
||||||
|
| Regra | Guia |
|
||||||
|
|-------|------|
|
||||||
|
| **Small** | Máx 20 linhas, ideal 5-10 |
|
||||||
|
| **One Thing** | Faz uma coisa, e faz bem |
|
||||||
|
| **One Level** | Um nível de abstração por função |
|
||||||
|
| **Few Args** | Max 3 argumentos, preferir 0-2 |
|
||||||
|
| **No Side Effects** | Não muta entradas inesperadamente |
|
||||||
|
|
||||||
|
### Guard Clauses (evitar ninho profundidade)
|
||||||
|
```ts
|
||||||
|
// ❌ 5 níveis de profundidade
|
||||||
|
function processOrder(order: Order) {
|
||||||
|
if (order) {
|
||||||
|
if (order.items.length > 0) {
|
||||||
|
if (order.total > 0) { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Guard clauses — zero aninhamento
|
||||||
|
function processOrder(order: Order) {
|
||||||
|
if (!order) return;
|
||||||
|
if (order.items.length === 0) return;
|
||||||
|
if (order.total <= 0) return;
|
||||||
|
// lógica principal aqui
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SRP — Single Responsibility
|
||||||
|
> Uma classe tem **um** motivo para mudar. Um ator, uma responsabilidade.
|
||||||
|
|
||||||
|
### DRY + YAGNI
|
||||||
|
- **DRY**: Não duplique — três instâncias de duplicação é o limite para abstrair
|
||||||
|
- **YAGNI**: Você não vai precisar — não construa features não requisitadas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔷 SOLID Principles
|
||||||
|
|
||||||
|
| Princípio | Regra Curta |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **S** — Single Responsibility | Uma razão para mudar |
|
||||||
|
| **O** — Open/Closed | Aberto para extensão, fechado para modificação |
|
||||||
|
| **L** — Liskov Substitution | Subtipo deve ser substituível pelo seu tipo base |
|
||||||
|
| **I** — Interface Segregation | Muitas interfaces específicas > uma geral |
|
||||||
|
| **D** — Dependency Inversion | Dependa de abstrações, não de concretudes |
|
||||||
|
|
||||||
|
### D — Dependency Inversion Exemplo
|
||||||
|
```ts
|
||||||
|
// ❌ Alto nível depende de baixo nível
|
||||||
|
class ReportGenerator {
|
||||||
|
private db = new MySQLConnection(); // concreto!
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Depende de abstração
|
||||||
|
interface Database { query(sql: string): Promise<any[]> }
|
||||||
|
class ReportGenerator {
|
||||||
|
constructor(private db: Database) {} // injetado!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Clean Architecture — Dependency Rule
|
||||||
|
|
||||||
|
> Dependências de código apontam **para dentro** — para políticas de mais alto nível.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Entities / Domain │ ← Nada depende disto
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Use Cases / Business Logic │ ← Só depende de Entities
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Interface Adapters (Controllers) │ ← Só depende de Use Cases
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Frameworks & Drivers (DB, UI) │ ← Tudo depende para dentro
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regra da Dependência
|
||||||
|
```
|
||||||
|
Código de alto nível → abstração → código de baixo nível
|
||||||
|
(não pode depender de detalhes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quando Usar Clean Architecture
|
||||||
|
- Projetos com vida útil esperada > 2 anos
|
||||||
|
- Múltiplas equipes trabalhando no mesmo código
|
||||||
|
- Possibilidade de trocar framework
|
||||||
|
- Regras de negócio complexas
|
||||||
|
|
||||||
|
## Code Review Checklist (Clean Code)
|
||||||
|
- [ ] Nomes revelam intenção?
|
||||||
|
- [ ] Funções fazem uma coisa só?
|
||||||
|
- [ ] Sem comentários que só recontam o que o código faz?
|
||||||
|
- [ ] Sem código comentado fora?
|
||||||
|
- [ ] Sem mais de 3 argumentos em qualquer função?
|
||||||
|
- [ ] SRP respeitado?
|
||||||
|
- [ ] DRY aplicado mas sem over-abstração?
|
||||||
|
- [ ] Tratamento de erros centralizado?
|
||||||
|
- [ ] Sem uso de `any`/`as any` sem justificativa?
|
||||||
|
- [ ] O retorno do commit deixa o código melhor do que encontrou? (Boy Scout)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Browser Automation — Chromium CLI (agent-browser)
|
||||||
|
|
||||||
|
> CLI headless Chromium para navegação, login, raspagem e screenshots.
|
||||||
|
|
||||||
|
## Fluxo Padrão
|
||||||
|
```bash
|
||||||
|
agent-browser open https://exemplo.com/form
|
||||||
|
agent-browser snapshot -i # Descobre elementos → @e1, @e2, @e3
|
||||||
|
agent-browser fill @e1 "user@exemplo.com"
|
||||||
|
agent-browser fill @e2 "senha123"
|
||||||
|
agent-browser click @e3
|
||||||
|
agent-browser wait --load networkidle
|
||||||
|
agent-browser snapshot -i # Re-snapshot após mudança de DOM
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos Essenciais
|
||||||
|
```bash
|
||||||
|
# Navegação
|
||||||
|
agent-browser open <url>
|
||||||
|
agent-browser close
|
||||||
|
|
||||||
|
# Snapshot (sempre primeiro)
|
||||||
|
agent-browser snapshot -i # Elementos interativos + @refs
|
||||||
|
agent-browser snapshot -i -C # + cursor-interactive elements
|
||||||
|
|
||||||
|
# Interação com @refs
|
||||||
|
agent-browser click @e1
|
||||||
|
agent-browser fill @e2 "text" # Clear + type
|
||||||
|
agent-browser type @e2 "text" # Type sem limpar
|
||||||
|
agent-browser select @e1 "option"
|
||||||
|
agent-browser check @e1
|
||||||
|
agent-browser press Enter
|
||||||
|
agent-browser scroll down 500
|
||||||
|
agent-browser hover @e1
|
||||||
|
|
||||||
|
# Extração
|
||||||
|
agent-browser get text @e1
|
||||||
|
agent-browser get text body > page.txt
|
||||||
|
agent-browser get html @e1
|
||||||
|
agent-browser get url
|
||||||
|
agent-browser get title
|
||||||
|
|
||||||
|
# Espera
|
||||||
|
agent-browser wait @e1
|
||||||
|
agent-browser wait --load networkidle
|
||||||
|
agent-browser wait --url "**/dashboard"
|
||||||
|
|
||||||
|
# Captura
|
||||||
|
agent-browser screenshot page.png
|
||||||
|
agent-browser screenshot --full # Página inteira
|
||||||
|
agent-browser pdf relatorio.pdf
|
||||||
|
|
||||||
|
# Download
|
||||||
|
agent-browser download @e1 ./file.pdf
|
||||||
|
agent-browser wait --download ./output.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Login + Persistência de Sessão
|
||||||
|
```bash
|
||||||
|
# Login
|
||||||
|
agent-browser open https://app.exemplo.com/login
|
||||||
|
agent-browser fill @e1 "$USERNAME" && agent-browser fill @e2 "$PASSWORD"
|
||||||
|
agent-browser click @e3
|
||||||
|
agent-browser wait --url "**/dashboard"
|
||||||
|
agent-browser state save ~/auth.json # Salva cookies + sessão
|
||||||
|
|
||||||
|
# Reutilizar
|
||||||
|
agent-browser state load ~/auth.json
|
||||||
|
agent-browser open https://app.exemplo.com/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Segurança (ambiente)
|
||||||
|
```bash
|
||||||
|
export AGENT_BROWSER_ALLOWED_DOMAINS="exemplo.com"
|
||||||
|
export AGENT_BROWSER_MAX_OUTPUT=50000
|
||||||
|
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Ref Lifecycle
|
||||||
|
> Refs (`@e1`, `@e2`) são **invalidados** após qualquer mudança de DOM.
|
||||||
|
> Sempre faça `snapshot -i` de novo após:
|
||||||
|
> - Cliques que navegam
|
||||||
|
> - Submissão de formulário
|
||||||
|
> - Conteúdo dinâmico (dropdowns, modais)
|
||||||
|
|
||||||
|
## Sessões Paralelas
|
||||||
|
```bash
|
||||||
|
agent-browser --session site1 open https://site-a.com
|
||||||
|
agent-browser --session site2 open https://site-b.com
|
||||||
|
agent-browser session list
|
||||||
|
```
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# E2E Testing — Pirâmide e Padrões
|
||||||
|
|
||||||
|
> Extraído de `e2e-testing-patterns` v1.0.0
|
||||||
|
|
||||||
|
## 🏔️ Test Pyramid
|
||||||
|
```
|
||||||
|
/\ ← POUCOS: Critical paths (E2E)
|
||||||
|
/E2E\ ← MAIS: Component/API integration
|
||||||
|
/─────\
|
||||||
|
/Integr\ ← MUITOS: Unit tests (rápidos, isolados)
|
||||||
|
/────────\
|
||||||
|
/Unit Tests\
|
||||||
|
/────────────\
|
||||||
|
```
|
||||||
|
|
||||||
|
## O que E2E Tests DEVEM cobrir
|
||||||
|
| ✅ E2E | ❌ Não E2E |
|
||||||
|
|--------|-----------|
|
||||||
|
| Critical journeys (auth → dashboard → logout) | Lógica unitária → use unit tests |
|
||||||
|
| Multi-step flows (checkout, onboarding) | API contracts → use integration |
|
||||||
|
| Cross-browser | Edge cases (muito lento) |
|
||||||
|
| Real API integration | Internal implementation |
|
||||||
|
| Auth flows | Visual states → use Storybook |
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
| Princípio | Como |
|
||||||
|
|-----------|------|
|
||||||
|
| **Test behavior, not implementation** | Assert em outcomes visíveis, não estrutura DOM |
|
||||||
|
| **Independent tests** | Cada teste cria próprios dados e limpa |
|
||||||
|
| **Deterministic waits** | Esperar por condição, não timeout fixo |
|
||||||
|
| **Stable selectors** | Usar `data-testid`, roles, labels — nunca CSS classes |
|
||||||
|
| **Fast feedback** | Mockar serviços externos, paralelizar, shard |
|
||||||
|
|
||||||
|
## Selector Priority
|
||||||
|
```
|
||||||
|
data-testid → data-cy → role → text → id → class (último recurso)
|
||||||
|
```
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# MySQL — Patterns e Anti-patterns
|
||||||
|
|
||||||
|
> Conexão | Schemas | JSON | InnoDB | Otimização
|
||||||
|
|
||||||
|
## Comandos Rápidos
|
||||||
|
```bash
|
||||||
|
mysql -h localhost -u root -p mydb
|
||||||
|
mysql -h host -u user -p -e "SELECT NOW();" mydb
|
||||||
|
mysql -h host -u user -p mydb < migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema Design
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
> **SEMPRE** `ENGINE=InnoDB` — transações, FK, row-level locking. `utf8mb4` — suporta emoji e acentos completos.
|
||||||
|
|
||||||
|
## JSON Ops
|
||||||
|
```sql
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
metadata JSON,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT * FROM orders WHERE JSON_EXTRACT(metadata, '$.source') = 'web';
|
||||||
|
-- Shorthand (MySQL 8+):
|
||||||
|
SELECT * FROM orders WHERE metadata->>'$.source' = 'web';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Indexes
|
||||||
|
```sql
|
||||||
|
-- Index composto
|
||||||
|
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
||||||
|
|
||||||
|
-- Índice parcial (simulado com generated column + índice)
|
||||||
|
ALTER TABLE orders ADD COLUMN is_active TINYINT(1)
|
||||||
|
GENERATED ALWAYS AS (status != 'cancelled') STORED;
|
||||||
|
CREATE INDEX idx_orders_active ON orders(is_active);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Joins e Agregações
|
||||||
|
```sql
|
||||||
|
SELECT u.name, o.total, o.status
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN orders o ON o.user_id = u.id
|
||||||
|
WHERE o.created_at > '2026-01-01'
|
||||||
|
ORDER BY o.total DESC
|
||||||
|
LIMIT 10;
|
||||||
|
|
||||||
|
SELECT status, COUNT(*) AS cnt, SUM(total) AS revenue
|
||||||
|
FROM orders GROUP BY status HAVING cnt > 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diferenças principais vs PostgreSQL
|
||||||
|
| Aspecto | MySQL | PostgreSQL |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| Auto-increment | `AUTO_INCREMENT` | `SERIAL` / `IDENTITY` |
|
||||||
|
| UUID padrão | Requer plugin (não padrão) | `uuid-ossp` built-in |
|
||||||
|
| JSON | `JSON` type (baixa performance em queries) | `JSONB` indexável |
|
||||||
|
| CTEs | Suportado (MySQL 8+) | Nativo desde sempre |
|
||||||
|
| Window Functions | MySQL 8+ | Nativo |
|
||||||
|
| Transactions | InnoDB obrigatório | Sempre |
|
||||||
|
| Null-safe join | `<=>` (spaceship operator) | `IS NOT DISTINCT FROM` |
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# PostgreSQL — Patterns e Anti-patterns
|
||||||
|
|
||||||
|
> Conexão | Schemas | JSONB | Junções | Window Functions | Migrações | EXPLAIN
|
||||||
|
|
||||||
|
## Comandos Rápidos
|
||||||
|
```bash
|
||||||
|
psql "postgresql://user:pass@host:5432/db?sslmode=require"
|
||||||
|
psql -h localhost -U user -d db -c "SELECT NOW();"
|
||||||
|
psql -h host -U user -d db -f migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema Design
|
||||||
|
```sql
|
||||||
|
-- UUIDs para PK distribuído-friendly (ativar extensão primeiro)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user','admin','moderator')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Auto-update updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_modified_column()
|
||||||
|
RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_users_modtime BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||||
|
|
||||||
|
-- Enum
|
||||||
|
CREATE TYPE order_status AS ENUM ('pending','paid','shipped','cancelled');
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSONB Ops
|
||||||
|
```sql
|
||||||
|
INSERT INTO orders (metadata) VALUES ('{"source":"web","items":[{"sku":"A1","qty":2}]}'::jsonb);
|
||||||
|
|
||||||
|
SELECT * FROM orders WHERE metadata->>'source' = 'web';
|
||||||
|
SELECT * FROM orders WHERE metadata->'items' @> '[{"sku":"A1"}]'::jsonb; -- @> contains
|
||||||
|
UPDATE orders SET metadata = jsonb_set(metadata, '{source}', '"mobile"') WHERE id = '...';
|
||||||
|
SELECT metadata->>'coupon' AS coupon, COUNT(*) FROM orders GROUP BY 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Indexes
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_users_email ON users(email); -- único
|
||||||
|
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at); -- composto
|
||||||
|
CREATE INDEX idx_orders_active ON orders(user_id, created_at) -- parcial
|
||||||
|
WHERE status NOT IN ('delivered','cancelled');
|
||||||
|
CREATE INDEX idx_orders_metadata ON orders USING GIN(metadata); -- GIN para JSONB
|
||||||
|
```
|
||||||
|
|
||||||
|
## Window Functions
|
||||||
|
```sql
|
||||||
|
SELECT date, revenue,
|
||||||
|
SUM(revenue) OVER (ORDER BY date) AS cumulative_revenue, -- running total
|
||||||
|
AVG(revenue) OVER (PARTITION BY region) AS avg_by_region,
|
||||||
|
RANK() OVER (ORDER BY revenue DESC) AS ranking
|
||||||
|
FROM daily_sales;
|
||||||
|
```
|
||||||
|
|
||||||
|
## JOINS Rápidos
|
||||||
|
```sql
|
||||||
|
-- LEFT JOIN pega todos os usuários, mesmo sem pedidos
|
||||||
|
SELECT u.name, COUNT(o.id) AS order_count, COALESCE(SUM(o.total), 0) AS total_spent
|
||||||
|
FROM users u LEFT JOIN orders o ON o.user_id = u.id
|
||||||
|
GROUP BY u.id, u.name HAVING COUNT(o.id) > 5 ORDER BY total_spent DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## CTEs (WITH)
|
||||||
|
```sql
|
||||||
|
WITH active_users AS (
|
||||||
|
SELECT id, name FROM users WHERE active = true
|
||||||
|
)
|
||||||
|
SELECT au.name, COUNT(o.id) AS orders
|
||||||
|
FROM active_users au LEFT JOIN orders o ON o.user_id = au.id
|
||||||
|
GROUP BY au.name;
|
||||||
|
```
|
||||||
|
|
||||||
|
## EXPLAIN ANALYZE (antes de otimizar)
|
||||||
|
```bash
|
||||||
|
EXPLAIN ANALYZE SELECT ... -- SEMPRE antes de criar um índice
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations — Padrão
|
||||||
|
```bash
|
||||||
|
# Criar timestamped
|
||||||
|
psql -h host -U user -d db -f migrations/2025-06-19_add_field.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist de Otimização
|
||||||
|
- [ ] `EXPLAIN ANALYZE` antes e depois de cada mudança
|
||||||
|
- [ ] Medir tempo de resposta alvo antes de otimizar
|
||||||
|
- [ ] Evitar `SELECT *` em produção
|
||||||
|
- [ ] Criar índices para colunas em `WHERE`, `JOIN`, `ORDER BY`
|
||||||
|
- [ ] Índice parcial para subconjuntos pequenos
|
||||||
|
- [ ] Partial/Paginado para tabelas grandes
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Docker — Multi-Stack Deploy
|
||||||
|
|
||||||
|
> Extraído de `xcloud-docker-deploy` v1.2.1 + habilidades de openclaw-power-ops
|
||||||
|
|
||||||
|
## 🗺️ Stack Detection Router
|
||||||
|
|
||||||
|
| Arquivo encontrado | Stack | Ação |
|
||||||
|
|-------------------|-------|------|
|
||||||
|
| `wp-config.php` ou `wp-content/` | WordPress | Ver ref `xcloud-native-wordpress.md` |
|
||||||
|
| `composer.json` + `artisan` | Laravel | Ver ref `xcloud-native-laravel.md` |
|
||||||
|
| `package.json` + `next.config.*` | Next.js | Usar `dockerfiles/nextjs.Dockerfile` |
|
||||||
|
| `package.json` | Node.js | Ver ref `xcloud-native-nodejs.md` |
|
||||||
|
| `composer.json` (sem artisan) | PHP | Ver ref `xcloud-native-php.md` |
|
||||||
|
| `requirements.txt` ou `pyproject.toml` | Python | Usar `dockerfiles/python-fastapi.Dockerfile` |
|
||||||
|
| `go.mod` | Go | Gerar Dockerfile manualmente |
|
||||||
|
| `docker-compose.yml` existe | Docker existente | Prosseguir Step 1 |
|
||||||
|
|
||||||
|
## Step 1 — Cenário Detection
|
||||||
|
| Sinal | Cenário |
|
||||||
|
|-------|---------|
|
||||||
|
| `build:` ou `context: .` | **A** — Build-from-source |
|
||||||
|
| Caddy/Traefik/nginx-proxy | **B** — Proxy conflict |
|
||||||
|
| Múltiplos `ports:` por service | **B** — Multi-port |
|
||||||
|
| `./nginx.conf` volume mount | **B** — External config |
|
||||||
|
| Múltiplos services com `build:` | **C** — Multi-service build |
|
||||||
|
| `image: public-image`, 1 porta | ✅ Já compatível |
|
||||||
|
|
||||||
|
## Cenário A — Build-from-Source
|
||||||
|
1. Remover `build:` do compose
|
||||||
|
2. Trocar `image:` por `ghcr.io/OWNER/REPO:latest`
|
||||||
|
3. Gerar `.github/workflows/docker-build.yml`
|
||||||
|
4. Gerar `.env.example`
|
||||||
|
|
||||||
|
## Cenário B — Proxy Conflict
|
||||||
|
1. Remover Caddy/Traefik/nginx-proxy service
|
||||||
|
2. Remover SSL labels e multi-port — usar `expose:` (interno)
|
||||||
|
3. Adicionar `nginx-router` service com `configs:` inline
|
||||||
|
4. Expor uma porta (default: 3080)
|
||||||
|
|
||||||
|
## Cenário C — Multi-Service Build
|
||||||
|
- Matrix GitHub Actions — cada service com `build:` vira um GHCR image
|
||||||
|
- Compose atualizado com todas as imagens GHCR
|
||||||
|
|
||||||
|
## 🚨 Regras de Ouro (xCloud)
|
||||||
|
- ❌ Nunca incluir `build:` no compose final — xCloud ignora silenciosamente
|
||||||
|
- ❌ Nunca expor portas de DB diretas (`5432:5432`) — usar `expose:`
|
||||||
|
- ❌ Nunca incluir Caddy, Traefik, nginx-proxy, Let's Encrypt
|
||||||
|
- ✅ Preservar `environment:`, `volumes:`, `healthcheck:`
|
||||||
|
- ✅ Sempre usar `expose:` (interno), não `ports:` (para serviços internos)
|
||||||
|
|
||||||
|
## Dockerfiles Disponíveis
|
||||||
|
| Stack | Dockerfile |
|
||||||
|
|-------|------------|
|
||||||
|
| PHP genérico | `dockerfiles/php-generic.Dockerfile` |
|
||||||
|
| Laravel | `dockerfiles/laravel.Dockerfile` |
|
||||||
|
| Node.js | `dockerfiles/node-app.Dockerfile` |
|
||||||
|
| Next.js | `dockerfiles/nextjs.Dockerfile` |
|
||||||
|
| Python FastAPI | `dockerfiles/python-fastapi.Dockerfile` |
|
||||||
|
|
||||||
|
## Compose Templates
|
||||||
|
- `compose-templates/laravel-mysql.yml`
|
||||||
|
- `compose-templates/nextjs-postgres.yml`
|
||||||
|
- `compose-templates/nodejs-api-postgres.yml`
|
||||||
|
- `compose-templates/python-fastapi-postgres.yml`
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# OpenClaw — Gateway Ops CLI
|
||||||
|
|
||||||
|
> Referência rápida baseada em `openclaw-power-ops` + `openclaw-config`.
|
||||||
|
|
||||||
|
## 🌟 Golden Rules
|
||||||
|
1. ❌ NUNCA editar `openclaw.json` diretamente — sempre `openclaw config set/get/unset`
|
||||||
|
2. ✅ Sempre reiniciar gateway após mudanças de config
|
||||||
|
3. ❌ Telegram: NÃO usar campo `agent` dentro de config de conta — usar `bindings` array
|
||||||
|
4. ❌ Telegram `streaming`: deve ser string `"off"`, não booleano `false`
|
||||||
|
5. ✅ JSON values em `config set` precisam de `--strict-json`
|
||||||
|
|
||||||
|
## Diagnóstico
|
||||||
|
```bash
|
||||||
|
openclaw status # overview
|
||||||
|
openclaw status --deep # detalhado
|
||||||
|
openclaw doctor # encontra problemas
|
||||||
|
openclaw doctor --fix # auto-fix (com consentimento do usuário!)
|
||||||
|
openclaw gateway health # health check do gateway
|
||||||
|
openclaw security audit # scan de segurança
|
||||||
|
openclaw security audit --deep --fix # scan profundo + auto-fix
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config Segura — $include Modular
|
||||||
|
```json5
|
||||||
|
// ~/.openclaw/openclaw.json
|
||||||
|
{
|
||||||
|
"$include": ["./gateway.json5", "./channels/telegram.json5"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `$include`: resolved antes da validação de schema
|
||||||
|
- Objetos: merge recursivo; Arrays: concatenam; Primitivos: último vence
|
||||||
|
- Limite: profundidade máx 10; includes circulares são detectados
|
||||||
|
|
||||||
|
## Telegram — Exemplos
|
||||||
|
```bash
|
||||||
|
# DM policy open (cuidado!)
|
||||||
|
openclaw config set channels.telegram.dmPolicy '"open"' --json
|
||||||
|
openclaw config set channels.telegram.allowFrom '["*"]' --json
|
||||||
|
|
||||||
|
# Streaming OFF (string, não boolean)
|
||||||
|
openclaw config set channels.telegram.streaming '"off"' --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enabling Features
|
||||||
|
```bash
|
||||||
|
# Web search (Brave/Perplexity)
|
||||||
|
openclaw config set tools.web.search.enabled true --json
|
||||||
|
openclaw config set tools.web.search.provider '"brave"' --json
|
||||||
|
# Chave via env var: export BRAVE_API_KEY="***"
|
||||||
|
```
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# System Diagnostic — Linux Cheatsheet
|
||||||
|
|
||||||
|
> Comandos essenciais para análise de sistema, processos, disco, rede e segurança.
|
||||||
|
|
||||||
|
## Diagnóstico Rápido
|
||||||
|
```bash
|
||||||
|
uptime # Uptime + média de carga (1, 5, 15 min)
|
||||||
|
w # Usuários logados + carga
|
||||||
|
dmesg -T # Logs do kernel com timestamp
|
||||||
|
who # Quem está logado agora
|
||||||
|
last # Histórico de login
|
||||||
|
lastlog # Último login de cada usuário
|
||||||
|
lsb_release -a # Versão do SO
|
||||||
|
uname -a # Kernel info completo
|
||||||
|
lscpu # Info da CPU
|
||||||
|
lsblk # Dispositivos de bloco (discos)
|
||||||
|
mount | column -t # Filesystems montados
|
||||||
|
df -h # Uso de disco (human-readable)
|
||||||
|
df -i # Inodes por filesystem
|
||||||
|
free -h # RAM/Swap
|
||||||
|
swapon --show # Partições de swap ativas
|
||||||
|
cat /proc/loadavg # Load average preciso
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Saúde do Sistema
|
||||||
|
```bash
|
||||||
|
# Processos por CPU
|
||||||
|
ps aux --sort=-%cpu | head
|
||||||
|
ps aux --sort=-%mem | head
|
||||||
|
# Top em tempo real
|
||||||
|
htop
|
||||||
|
top
|
||||||
|
# Serviços com problema
|
||||||
|
systemctl --type=service --state=failed
|
||||||
|
systemctl list-units --state=failed
|
||||||
|
# Atualizações disponíveis
|
||||||
|
apt list --upgradable 2>/dev/null | grep "^.*/.*"
|
||||||
|
dnf check-update 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🕵️ Análise de Logs
|
||||||
|
```bash
|
||||||
|
# Logs de системы em tempo real
|
||||||
|
journalctl -f
|
||||||
|
journalctl -u nginx --since "1h"
|
||||||
|
journalctl -p err --since "30min" # Apenas erros recentes
|
||||||
|
|
||||||
|
# Logs de autenticação
|
||||||
|
grep "Failed password" /var/log/auth.log
|
||||||
|
grep "Accepted" /var/log/auth.log | tail -20
|
||||||
|
|
||||||
|
# Logs estruturados (JSON) com jq
|
||||||
|
jq 'select(.level=="ERROR") | .user + ": " + .message' app.log
|
||||||
|
|
||||||
|
# Tamanho de arquivos de log
|
||||||
|
du -sh /var/log/
|
||||||
|
find /var/log -name "*.log" -size +100M -exec ls -lh {} \;
|
||||||
|
|
||||||
|
# Erros em tempo real
|
||||||
|
tail -f /var/log/syslog
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🕸️ Diagnóstico de Rede
|
||||||
|
```bash
|
||||||
|
ss -tlnp # Portas TCP abertas (listening) — substitui netstat
|
||||||
|
ss -tunp # Todas as conexões (TCP/UDP)
|
||||||
|
ss -s # Estatísticas de conexão
|
||||||
|
ip addr # Endereços IP das interfaces
|
||||||
|
ip route # Tabela de roteamento
|
||||||
|
ping -c 5 8.8.8.8 # Teste de conectividade
|
||||||
|
traceroute 8.8.8.8 # Rota até destino
|
||||||
|
mtr --report 8.8.8.8 # Traceroute + estatísticas contínuas
|
||||||
|
nslookup dominio.com # DNS lookup
|
||||||
|
dig dominio.com A # DNS detalhado
|
||||||
|
|
||||||
|
# Velocidade de download
|
||||||
|
curl -o /dev/null -s -w 'Speed: %{speed_download} bytes/sec\n' http://example.com/file
|
||||||
|
|
||||||
|
# WHOIS
|
||||||
|
whois dominio.com
|
||||||
|
curl -s https://ipinfo.io/json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Usuários e Permissões
|
||||||
|
```bash
|
||||||
|
who # Quem está logado
|
||||||
|
w # Mais detalhes (idle, processo)
|
||||||
|
id # UID/GID do usuário atual
|
||||||
|
sudo -l -U root # O que root pode executar sem senha
|
||||||
|
grep sudo /etc/group # Quem está no grupo sudo
|
||||||
|
# Ou: lista de usuários com UID >= 1000
|
||||||
|
awk -F: '($3 >= 1000) && ($3 < 65534)' /etc/passwd
|
||||||
|
|
||||||
|
# Permissões de arquivo
|
||||||
|
find /pasta -perm /111 -ls # Todos os executáveis na pasta
|
||||||
|
namei -l /caminho/para/arquivo # Mostra permissões em cada nível
|
||||||
|
stat /caminho/arquivo # Permissões detalhadas + dono + timestamps
|
||||||
|
|
||||||
|
# SSH
|
||||||
|
sshd -T # Configuração SSH ativa (efetiva)
|
||||||
|
ssh-copy-id user@host # Copiar chave pública para remoto
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💻 Scripts Shell
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail # Erro se: erro (-e), variável não definida (-u), pipe falha (-o pipefail)
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log() {
|
||||||
|
echo "[$(date -Iseconds)] $*" >> /var/log/meu-script.log
|
||||||
|
}
|
||||||
|
log "Iniciando..."
|
||||||
|
|
||||||
|
# Trap para cleanup — executa no EXIT ou SIGINT
|
||||||
|
trap 'rm -f /tmp/$$tmpfile; log "Limpeza concluída"' EXIT
|
||||||
|
|
||||||
|
# Verificar binário antes de usar
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
log "ERRO: jq não encontrado"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Monitoramento de Saúde
|
||||||
|
```bash
|
||||||
|
# Verificar Uptime de um processo
|
||||||
|
systemd-analyze time
|
||||||
|
systemd-analyze critical-chain
|
||||||
|
# Histórico de uso de CPU/memória por processo
|
||||||
|
pidstat -u -p ALL 1 5
|
||||||
|
# Memória detalhada
|
||||||
|
cat /proc/meminfo | grep -E "MemTotal|MemAvailable|MemFree"
|
||||||
|
```
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Next.js — Best Practices
|
||||||
|
|
||||||
|
> Extraído de skills: `next-best-practices` v0.1.0 + `nextjs-patterns` v1.0.0
|
||||||
|
|
||||||
|
## 🏗️ Estrutura de Projeto (App Router)
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── (marketing)/ # Route group — não aparece na URL
|
||||||
|
│ └── page.tsx
|
||||||
|
├── (dashboard)/
|
||||||
|
│ ├── layout.tsx # Layout compartilhado do grupo
|
||||||
|
│ ├── [id]/
|
||||||
|
│ │ └── page.tsx # Página dinâmica
|
||||||
|
│ └── loading.tsx # Suspense boundary automático
|
||||||
|
├── api/
|
||||||
|
│ └── route.ts # Route Handler (REST/GraphQL)
|
||||||
|
├── error.tsx # Error boundary por rota
|
||||||
|
├── not-found.tsx # Página 404
|
||||||
|
└── layout.tsx # Root layout (obrigatório)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🖥️ Server vs Client Components
|
||||||
|
```tsx
|
||||||
|
// ✅ Server Component (padrão) — roda no servidor, nenhum JS no cliente
|
||||||
|
async function ProductList() {
|
||||||
|
// DB query acontece no servidor!
|
||||||
|
const products = await db.product.findMany();
|
||||||
|
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Client Component — só quando precisar de interatividade
|
||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
export function Counter() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
return <button onClick={() => setCount(n => n + 1)}>{count}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Server Actions — mutations sem criar API routes
|
||||||
|
async function createProduct(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
await db.product.create({ data: { ... } });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Padrões de Data Fetching
|
||||||
|
|
||||||
|
| Padrão | Quando usar | Exemplo |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **Server Component `await`** | Read, página ou componente | `const posts = await db.post.findMany()` |
|
||||||
|
| **Server Actions** | Write/mutations, formulários | `"use server"` + `revalidatePath` |
|
||||||
|
| **Route Handlers** | REST/GraphQL API, webhooks, integrações | `app/api/users/route.ts` |
|
||||||
|
| **`use()` hook** | Ler promise em Client Components | `const data = use(fetchData())` |
|
||||||
|
|
||||||
|
### Evitando Data Waterfalls
|
||||||
|
```tsx
|
||||||
|
// ❌ Ruim — sequencial, cada await aguarda o anterior
|
||||||
|
async function Page() {
|
||||||
|
const user = await getUser(); // 200ms
|
||||||
|
const posts = await getPosts(); // 300ms (só começa após user)
|
||||||
|
return <Profile user={user} posts={posts} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Bom — paralelo
|
||||||
|
async function Page() {
|
||||||
|
const [user, posts] = await Promise.all([
|
||||||
|
getUser(), // começa imediatamente
|
||||||
|
getPosts(), // começa imediatamente
|
||||||
|
]);
|
||||||
|
return <Profile user={user} posts={posts} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗄️ Database Client Singleton
|
||||||
|
```ts
|
||||||
|
// lib/db.ts — criar uma vez por request (Server Component boundary)
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const globalForDb = globalThis as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
|
export const db = globalForDb.prisma ?? new PrismaClient();
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForDb.prisma = db;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Caching — 4 Camadas Next.js
|
||||||
|
|
||||||
|
| Camada | O que cacheia | Controlado por |
|
||||||
|
|--------|---------------|----------------|
|
||||||
|
| **Request Memoization** | `fetch`, `React.cache()` | automático por request |
|
||||||
|
| **Data Cache** | `fetch` responses | `next: { revalidate: 60 }` |
|
||||||
|
| **Full Route Cache** | Render completo | `export const revalidate` |
|
||||||
|
| **Router Cache** | Estado do cliente após navegação | `next/router` |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Revalidar após 60s
|
||||||
|
fetch(url, { next: { revalidate: 60 } });
|
||||||
|
|
||||||
|
// Não cachear (sempre fresh)
|
||||||
|
fetch(url, { next: { revalidate: 0 } });
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Regra Fundamental
|
||||||
|
> **`"use client"` é uma barreira de escape.** Tudo dentro de um arquivo com essa diretiva
|
||||||
|
> roda no navegador. Tudo SEM ela roda no servidor. Quando possível, fique no servidor.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# SAST — Security Audit Guide
|
||||||
|
|
||||||
|
> Use APENAS quando o usuário explicitamente pedir análise de segurança.
|
||||||
|
|
||||||
|
## Severidade
|
||||||
|
| Nível | Impacto |
|
||||||
|
|-------|---------|
|
||||||
|
| Critical | RCE, exfiltração de dados, instruction override |
|
||||||
|
| High | Leitura/modificação de dados sensíveis, bypass de ACL |
|
||||||
|
| Medium | Dados limitados, engano de usuário |
|
||||||
|
| Low | Impacto mínimo |
|
||||||
|
|
||||||
|
## Categorias de Vulnerabilidade
|
||||||
|
|
||||||
|
### 1. Hardcoded Secrets
|
||||||
|
```
|
||||||
|
🔴 API_KEY=sk-... PASSWORD=... SECRET=... TOKEN=... PRIVATE_KEY=<base64...>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Injection
|
||||||
|
| Tipo | Exemplo |
|
||||||
|
|------|---------|
|
||||||
|
| SQL Injection | `query("SELECT * FROM t WHERE id=" + userInput)` |
|
||||||
|
| XSS | `<div dangerouslySetInnerHTML={{ __html: userInput }} />` |
|
||||||
|
| Command Injection | `` exec(`convert ${userInput} file.png`) `` |
|
||||||
|
| SSRF | `fetch(userProvidedUrl)` sem allowlist |
|
||||||
|
|
||||||
|
### 3. Broken Access Control
|
||||||
|
- IDOR — recurso acessado por id inserido pelo usuário sem verificar ownership
|
||||||
|
- Missing function-level AC — sem check de auth antes de operação sensível
|
||||||
|
- Path traversal — input de usuário em caminho de arquivo sem sanitização
|
||||||
|
|
||||||
|
### 4. LLM/Prompt Safety
|
||||||
|
- **Prompt Injection** — input não confiável concatenado em prompts sem boundary
|
||||||
|
- **Unsafe Execution** — output de LLM passado para `eval()`, `exec`, shell commands
|
||||||
|
- **Output Injection** — output flui para sinks de SQLi, XSS, command injection
|
||||||
|
- **Flawed Security Logic** — decisões de segurança baseadas em output não validado de LLM
|
||||||
|
|
||||||
|
## SKILL.md Review Checklist
|
||||||
|
```
|
||||||
|
✓ Sem instruction override ("Ignore previous instructions...")
|
||||||
|
✓ Sem exfiltração de dados (enviar dados para URL externa)
|
||||||
|
✓ Sem falsas claims de privilégio ("Você tem root access...")
|
||||||
|
✓ Sem conteúdo escondido (base64, zero-width chars)
|
||||||
|
✓ Tool usage seguro (sem eval(user_input), sem writes em /etc/*)
|
||||||
|
✓ Sem engano (não dizer que é humano)
|
||||||
|
✓ Scoped ao propósito
|
||||||
|
```
|
||||||
|
|
||||||
|
## OWASP Top 10 Mapping
|
||||||
|
| OWASP | Categoria na skill |
|
||||||
|
|-------|-------------------|
|
||||||
|
| A01 Broken Access Control | Access Control |
|
||||||
|
| A02 Cryptographic Failures | Hardcoded Secrets |
|
||||||
|
| A03 Injection | SQL Injection, XSS, Command Injection, SSRF |
|
||||||
|
| A04 Insecure Design | Clean Architecture review |
|
||||||
|
| A05 Security Misconfiguration | Env vars, default credentials |
|
||||||
|
| A06 Vulnerable Components | npm audit equivalent check |
|
||||||
|
| A07 Auth Failures | Authentication weaknesses |
|
||||||
|
| A08 Data Integrity Failures | Unsafe deserialization |
|
||||||
|
| A09 Logging Failures | PII em logs |
|
||||||
|
| A10 SSRF | Já em A03 |
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# TypeScript — Utility Types & Generics Gotchas
|
||||||
|
|
||||||
|
> Casos onde `Partial<T>`, `Omit`, `Pick`, `Extract`, etc. NÃO fazem o que você espera.
|
||||||
|
|
||||||
|
## Utility Type Gotchas
|
||||||
|
|
||||||
|
| Tipo | Armadilha | Solução |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `Partial<T>` | *Shallow* — nested continua required | Criar `DeepPartial<T>` recursivo |
|
||||||
|
| `Required<T>` | Não remove `undefined` da union | Usar `NonNullable<T[K]>` por campo |
|
||||||
|
| `Omit<T, K>` | Não verifica se K existe — `Omit<User,"typo">` compila | Tipar com cuidado |
|
||||||
|
| `Pick<T, K>` | Key inexistente também compila | Mesmo cuidado |
|
||||||
|
| `Record<string, T>` | Implica TODA key existe — acesso a inexistente retorna `T`, não `T\|undefined` | Usar `Record<string, T \| undefined>` |
|
||||||
|
| `Extract<T, U>` | Retorna `never` se não houver match — silenciosamente vazio | Checar se é `never` antes |
|
||||||
|
| `ReturnType<typeof fn>` | Com overload pega só a última signature | Evitar overloads ou tipar retorno manualmente |
|
||||||
|
| `NonNullable<T>` | Remove `null` E `undefined` — às vezes só quer um | Usar `Exclude<T, null>` ou `Exclude<T, undefined>` |
|
||||||
|
| `Awaited<T>` | Desempacota recursivamente — surpresa com `Promise<Promise<T>>` | Cuidado com promises encadeadas |
|
||||||
|
|
||||||
|
## Generics — Armadilhas Práticas
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ <T = any> foge do any para todo o código
|
||||||
|
function parse<T = any>(raw: string): T { ... }
|
||||||
|
|
||||||
|
// ✅ Deixe o caller fornecer o tipo, ou use unknown
|
||||||
|
function parse<T>(raw: string): T { ... }
|
||||||
|
// ou
|
||||||
|
function parse(raw: string): unknown { ... }
|
||||||
|
|
||||||
|
// ❌ <T extends object> permite arrays
|
||||||
|
function merge<T extends object>(a: T, b: T): T { ... }
|
||||||
|
merge([1, 2], [3, 4]) // compila, mas não deve
|
||||||
|
|
||||||
|
// ✅ Use Record<string, unknown> para objetos puros
|
||||||
|
function merge<T extends Record<string, unknown>>(a: T, b: T): T { ... }
|
||||||
|
|
||||||
|
// ❌ <T extends string> com literal infere string, não o literal
|
||||||
|
function prefix<T extends string>(p: T) { ... }
|
||||||
|
prefix("http") // T é "http", não "http" literal nesse contexto
|
||||||
|
|
||||||
|
// ✅ Use const assertion para preservar literais
|
||||||
|
const urls = ["/api", "/auth"] as const; // readonly ["/api", "/auth"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## `keyof` em Função Genérica
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ keyof T em função genérica é sempre string | number | symbol
|
||||||
|
function getProp<T, K extends keyof T>(obj: T, key: K) { ... }
|
||||||
|
|
||||||
|
// ✅ Para objeto puro, restringir:
|
||||||
|
function getProp<T extends Record<string, unknown>, K extends keyof T>(
|
||||||
|
obj: T, key: K
|
||||||
|
): T[K] {
|
||||||
|
return obj[key];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Covariância e Contravariância
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Arrays são COVARIANTES — Dog[] atribuível a Animal[] mas push(Cat) quebra runtime
|
||||||
|
const dogs: Dog[] = [];
|
||||||
|
const animals: Animal[] = dogs; // ✅ compila
|
||||||
|
animals.push(new Cat()); // 🚨 runtime error
|
||||||
|
|
||||||
|
// ❌ Parâmetros de função são CONTRAVARIANTES
|
||||||
|
type Handler = (animal: Animal) => void;
|
||||||
|
const dogHandler: Handler = (dog: Dog) => { ... }; // ❌ não compila
|
||||||
|
// (Animal) espera receber Animal, não só Dog
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mapped Types — Preservando Modificadores
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ { [K in keyof T]: T[K] } — PERDE readonly e optional
|
||||||
|
type StrictClone<T> = { [K in keyof T]: T[K] };
|
||||||
|
// → { readonly name: string } vira { name: string }
|
||||||
|
|
||||||
|
// ✅ Preservar modificadores com -readonly, -?
|
||||||
|
type Preserve<T> = { -readonly [K in keyof T]: T[K] };
|
||||||
|
type Optionalize<T> = { -? [K in keyof T]: T[K] };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Types — Distributividade
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Distribui sobre union
|
||||||
|
type NonNullableValues<T> = T extends null | undefined ? never : T;
|
||||||
|
type Result = NonNullableValues<string | null | number | undefined>;
|
||||||
|
// Result = string | number ← distribuiu corretamente
|
||||||
|
|
||||||
|
// ❌ Para não distribuir, usar [T] (tupla wrapper)
|
||||||
|
type WrapNonNullable<T> = [T] extends [null | undefined] ? never : T;
|
||||||
|
// Não distribui — útil quando a distribuição causa comportamento inesperado
|
||||||
|
```
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# TypeScript — Safe Patterns
|
||||||
|
|
||||||
|
> Extraído de skills: typescript v1.0.2, generics.md, utility-types.md, declarations.md
|
||||||
|
|
||||||
|
## Anti-pattern: Evite `any`
|
||||||
|
- Use `unknown` quando não souber o tipo — força narrowing antes do uso
|
||||||
|
- Respostas de API: tipar ou `unknown`, nunca `any`
|
||||||
|
|
||||||
|
## Narrowing Failures
|
||||||
|
|
||||||
|
| Padrão | Problema | Solução |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `filter(Boolean)` | Não narrovia | `.filter((x): x is T => Boolean(x))` |
|
||||||
|
| `Object.keys(obj)` | Retorna `string[]` | Usar `keyof typeof obj` é intencional — objetos podem ter chaves extras |
|
||||||
|
| `Array.isArray()` | Narrovia para `any[]` | Pode precisar assertion de tipo de elemento |
|
||||||
|
| `useState<User>()` | Infere `User \| undefined` | Tratar undefined inicial |
|
||||||
|
| `Promise.all([a(), b()])` | Infere tupla só com `as const` | Usar `as const` na array |
|
||||||
|
|
||||||
|
## Literal Type Traps
|
||||||
|
- `let x = "hello"` → `string`. Use `const` ou `as const` para literal
|
||||||
|
- Propriedades de objeto alargam: `{ status: "ok" }` → `status: string`
|
||||||
|
- Retorno de função alarga — anote explicitamente para retornos literais
|
||||||
|
|
||||||
|
## `satisfies` vs Type Annotation
|
||||||
|
```ts
|
||||||
|
// ❌ Perde info literal
|
||||||
|
const config: RouteConfig = { path: "/home", method: "GET" };
|
||||||
|
|
||||||
|
// ✅ Mantém literal + valida compatibilidade
|
||||||
|
const config = { path: "/home", method: "GET" } satisfies RouteConfig;
|
||||||
|
```
|
||||||
|
> Preferir `satisfies` para config objects.
|
||||||
|
|
||||||
|
## Strict Null Handling
|
||||||
|
- `?.` retorna `undefined`, não `null`
|
||||||
|
- `??` captura só `null`/`undefined`; `||` captura tudo falsy (`0`, `""`, `false`)
|
||||||
|
- `!` (non-null assertion) — último recurso; preferir narrowing ou early return
|
||||||
|
|
||||||
|
## Generics — Armadilhas Comuns
|
||||||
|
- `<T = any>` foge do `any` — restringe todo o código
|
||||||
|
- `<T extends object>` permite arrays — usar `Record<string, unknown>` para objetos puros
|
||||||
|
- Arrays são covariantes — `Dog[]` atribuível a `Animal[]` mas `push(Cat)` quebra em runtime
|
||||||
|
- Parâmetros de função são contravariantes — `(Animal) => void` NÃO é atribuível a `(Dog) => void`
|
||||||
|
- `Partial<T>` e `Required<T>` são *shallow* — não afetam nested:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DeepPartial<T> = {
|
||||||
|
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discriminated Unions
|
||||||
|
```ts
|
||||||
|
type Result<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
function handle(r: Result<User>) {
|
||||||
|
if (r.ok) {
|
||||||
|
console.log(r.data.name); // ✅ T automaticamente
|
||||||
|
} else {
|
||||||
|
console.log(r.error); // ✅ narrowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Switch exaustivo: `default: const _never: never = x` → erro de compilação se caso faltar
|
||||||
|
|
||||||
|
## Module Boundaries
|
||||||
|
- `import type` — stripped em runtime, evita problemas de bundler
|
||||||
|
- Re-export types: `export type { X }` — previne dependência runtime acidental
|
||||||
|
- `.d.ts` augmentation: usar `declare module` com path exato do módulo
|
||||||
|
|
||||||
|
## Declaration File Gotchas
|
||||||
|
- `declare module "x"` precisa de path EXATO — `"lodash"` ≠ `"lodash/index"`
|
||||||
|
- `interface` pode ser mergeado de outros arquivos — `type` não pode
|
||||||
|
- Export default em `.d.ts` é problemático — preferir named exports
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Vue 3 — Composition API Best Practices
|
||||||
|
|
||||||
|
> Extraído de skills: `vue` + `vue-composition-api-best-practices`
|
||||||
|
|
||||||
|
## `<script setup>` Padrão Ouro
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ✅ Importações no topo — tree-shakeable
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
// ✅ Composables começam com `use`
|
||||||
|
const user = useUserStore()
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// ✅ Estado + getters no mesmo lugar
|
||||||
|
const filteredUsers = computed(() =>
|
||||||
|
user.list.filter(u => u.active)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ Lifecycle no fim
|
||||||
|
onMounted(() => user.fetchAll())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button v-if="filteredUsers.length > 0" @click="selectedId = null">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composables — `useXxx` Pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// composables/usePagination.ts
|
||||||
|
export function usePagination<T>(items: T[], pageSize = 20) {
|
||||||
|
const page = ref(1)
|
||||||
|
const totalPages = computed(() => Math.ceil(items.value.length / pageSize))
|
||||||
|
const paginated = computed(() =>
|
||||||
|
items.value.slice((page.value - 1) * pageSize, page.value * pageSize)
|
||||||
|
)
|
||||||
|
return { page, totalPages, paginated }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## <script setup> Deep Dive
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ✅ BOM — validade automática, minificado -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ msg: string }>()
|
||||||
|
const emit = defineEmits<{ (e: 'update', id: number): void }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ❌ RUIM — option API misturado -->
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
data() { return { x: 1 } },
|
||||||
|
methods: { /* ... */ }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management — Pinia
|
||||||
|
```ts
|
||||||
|
// stores/counter.ts
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export const useCounterStore = defineStore('counter', () => {
|
||||||
|
const count = ref(0)
|
||||||
|
const double = computed(() => count.value * 2)
|
||||||
|
function increment() { count.value++ }
|
||||||
|
return { count, double, increment }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reactividade — Principais Armadilhas
|
||||||
|
- `ref` vs `reactive`: use `ref` por padrão (tipagem simples); `reactive` para objetos grandes
|
||||||
|
- Perda de reatividade ao desestruturar — usar `toRefs()` ou `storeToRefs()`
|
||||||
|
- `watch` vs `watchEffect`: `watch` é mais controlado; `watchEffect` é automático mas menos previsível
|
||||||
|
- `v-if` vs `v-show`: `v-if` remove do DOM; `v-show` togglea `display`
|
||||||
|
|
||||||
|
## Type-Safe Vue
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Props tipadas — importa de arquivo separado se for reutilizável
|
||||||
|
const props = defineProps<{
|
||||||
|
userId: number
|
||||||
|
items: Item[]
|
||||||
|
requiredValue: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'delete', id: number): void
|
||||||
|
(e: 'save', data: FormData): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vue Router Traps
|
||||||
|
- `useRoute()` para rota atual — reativa, usar em setup
|
||||||
|
- `useRouter()` para navegação — `router.push('/path')`
|
||||||
|
- Guards: `beforeEach`, `beforeResolve`, `afterEach` — retornar `false` cancela
|
||||||
|
- `<RouterView>` com named views — múltiplas views por rota
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
- `key` em `v-for` é obrigatório — `v-for="item in items" :key="item.id"`
|
||||||
|
- Ordem de event modifiers importa — `.prevent.stop` ≠ `.stop.prevent`
|
||||||
|
- `Teleport` para modais — `<Teleport to="body">` renderiza fora da árvore
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"registry": "https://clawhub.ai",
|
||||||
|
"slug": "clean-code-review",
|
||||||
|
"installedVersion": "1.0.0",
|
||||||
|
"installedAt": 1779235184537,
|
||||||
|
"fingerprint": "4b346d3428567b279a1b0e9bd1554f217fd666b5db431ab418bc26f1b1bd56e5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Clean Code
|
||||||
|
|
||||||
|
Pragmatic coding standards for writing clean, maintainable code — naming, functions, structure, anti-patterns, and pre-edit safety checks. Every name reveals intent, every function does one thing, and every abstraction earns its place.
|
||||||
|
|
||||||
|
## What's Inside
|
||||||
|
|
||||||
|
- Core principles (SRP, DRY, KISS, YAGNI, Boy Scout Rule)
|
||||||
|
- Naming rules and naming anti-patterns
|
||||||
|
- Function rules (small, one thing, few args, guard clauses, parameter objects)
|
||||||
|
- Code structure patterns (composition, colocation, extract function)
|
||||||
|
- Return type consistency with discriminated unions
|
||||||
|
- Anti-patterns catalog (21 common mistakes)
|
||||||
|
- Pre-edit safety check (dependency impact analysis)
|
||||||
|
- Reference guides for code smells, anti-patterns, and refactoring catalog
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Writing new code and wanting to follow best practices
|
||||||
|
- Refactoring existing code for clarity and maintainability
|
||||||
|
- Reviewing code quality in pull requests
|
||||||
|
- Establishing coding standards for a team
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx add https://github.com/wpank/ai/tree/main/skills/testing/clean-code
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenClaw / Moltbot / Clawbot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx clawhub@latest install clean-code-review
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
|
#### Cursor (per-project)
|
||||||
|
|
||||||
|
From your project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .cursor/skills
|
||||||
|
cp -r ~/.ai-skills/skills/testing/clean-code .cursor/skills/clean-code
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cursor (global)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.cursor/skills
|
||||||
|
cp -r ~/.ai-skills/skills/testing/clean-code ~/.cursor/skills/clean-code
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Claude Code (per-project)
|
||||||
|
|
||||||
|
From your project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .claude/skills
|
||||||
|
cp -r ~/.ai-skills/skills/testing/clean-code .claude/skills/clean-code
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Claude Code (global)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude/skills
|
||||||
|
cp -r ~/.ai-skills/skills/testing/clean-code ~/.claude/skills/clean-code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Skills
|
||||||
|
|
||||||
|
- [code-review](../code-review/) — Structured code review checklists
|
||||||
|
- [reducing-entropy](../reducing-entropy/) — Minimize codebase size through simplification
|
||||||
|
- [testing-patterns](../testing-patterns/) — Testing patterns for verifying clean code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Part of the [Testing](..) skill category.
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
---
|
||||||
|
name: clean-code
|
||||||
|
model: standard
|
||||||
|
category: testing
|
||||||
|
description: Pragmatic coding standards for writing clean, maintainable code — naming, functions, structure, anti-patterns, and pre-edit safety checks. Use when writing new code, refactoring existing code, reviewing code quality, or establishing coding standards.
|
||||||
|
version: 2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clean Code
|
||||||
|
|
||||||
|
> Be **concise, direct, and solution-focused**. Clean code reads like well-written prose — every name reveals intent, every function does one thing, and every abstraction earns its place.
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### OpenClaw / Moltbot / Clawbot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx clawhub@latest install clean-code
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
| Principle | Rule | Practical Test |
|
||||||
|
|-----------|------|----------------|
|
||||||
|
| **SRP** | Single Responsibility — each function/class does ONE thing | "Can I describe what this does without using 'and'?" |
|
||||||
|
| **DRY** | Don't Repeat Yourself — extract duplicates, reuse | "Have I written this logic before?" |
|
||||||
|
| **KISS** | Keep It Simple — simplest solution that works | "Is there a simpler way to achieve this?" |
|
||||||
|
| **YAGNI** | You Aren't Gonna Need It — don't build unused features | "Does anyone need this right now?" |
|
||||||
|
| **Boy Scout** | Leave code cleaner than you found it | "Is this file better after my change?" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Rules
|
||||||
|
|
||||||
|
Names are the most important documentation. A good name eliminates the need for a comment.
|
||||||
|
|
||||||
|
| Element | Convention | Bad | Good |
|
||||||
|
|---------|------------|-----|------|
|
||||||
|
| **Variables** | Reveal intent | `n`, `d`, `tmp` | `userCount`, `elapsed`, `activeUsers` |
|
||||||
|
| **Functions** | Verb + noun | `user()`, `calc()` | `getUserById()`, `calculateTotal()` |
|
||||||
|
| **Booleans** | Question form | `active`, `flag` | `isActive`, `hasPermission`, `canEdit` |
|
||||||
|
| **Constants** | SCREAMING_SNAKE | `max`, `timeout` | `MAX_RETRY_COUNT`, `REQUEST_TIMEOUT_MS` |
|
||||||
|
| **Classes** | Noun, singular | `Manager`, `Data` | `UserRepository`, `OrderService` |
|
||||||
|
| **Enums** | PascalCase values | `'pending'` string | `Status.Pending` |
|
||||||
|
|
||||||
|
> **Rule:** If you need a comment to explain a name, rename it.
|
||||||
|
|
||||||
|
### Naming Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Problem | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| Cryptic abbreviations (`usrMgr`, `cfg`) | Unreadable in 6 months | Spell it out — IDE autocomplete makes long names free |
|
||||||
|
| Generic names (`data`, `info`, `item`, `handler`) | Says nothing about purpose | Use domain-specific names that reveal intent |
|
||||||
|
| Misleading names (`getUserList` returns one user) | Actively deceives readers | Match name to behavior, or change the behavior |
|
||||||
|
| Hungarian notation (`strName`, `nCount`, `IUser`) | Redundant with type system | Let TypeScript/IDE show types; names describe purpose |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Function Rules
|
||||||
|
|
||||||
|
| Rule | Guideline | Why |
|
||||||
|
|------|-----------|-----|
|
||||||
|
| **Small** | Max 20 lines, ideally 5-10 | Fits in your head |
|
||||||
|
| **One Thing** | Does one thing, does it well | Testable and nameable |
|
||||||
|
| **One Level** | One level of abstraction per function | Readable top to bottom |
|
||||||
|
| **Few Args** | Max 3 arguments, prefer 0-2 | Easy to call correctly |
|
||||||
|
| **No Side Effects** | Don't mutate inputs unexpectedly | Predictable behavior |
|
||||||
|
|
||||||
|
### Guard Clauses
|
||||||
|
|
||||||
|
Flatten nested conditionals with early returns. Never nest deeper than 2 levels.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD — 5 levels deep
|
||||||
|
function processOrder(order: Order) {
|
||||||
|
if (order) {
|
||||||
|
if (order.items.length > 0) {
|
||||||
|
if (order.customer) {
|
||||||
|
if (order.customer.isVerified) {
|
||||||
|
return submitOrder(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Invalid order');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD — guard clauses flatten the structure
|
||||||
|
function processOrder(order: Order) {
|
||||||
|
if (!order) throw new Error('No order');
|
||||||
|
if (!order.items.length) throw new Error('No items');
|
||||||
|
if (!order.customer) throw new Error('No customer');
|
||||||
|
if (!order.customer.isVerified) throw new Error('Customer not verified');
|
||||||
|
|
||||||
|
return submitOrder(order);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameter Objects
|
||||||
|
|
||||||
|
When a function needs more than 3 arguments, use an options object.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD — too many parameters, order matters
|
||||||
|
createUser('John', 'Doe', 'john@example.com', 'secret', 'admin', 'Engineering');
|
||||||
|
|
||||||
|
// GOOD — self-documenting options object
|
||||||
|
createUser({
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
password: 'secret',
|
||||||
|
role: 'admin',
|
||||||
|
department: 'Engineering',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Structure Patterns
|
||||||
|
|
||||||
|
| Pattern | When to Apply | Benefit |
|
||||||
|
|---------|--------------|---------|
|
||||||
|
| **Guard Clauses** | Edge cases at function start | Flat, readable flow |
|
||||||
|
| **Flat > Nested** | Any nesting beyond 2 levels | Reduced cognitive load |
|
||||||
|
| **Composition** | Complex operations | Small, testable pieces |
|
||||||
|
| **Colocation** | Related code across files | Easier to find and change |
|
||||||
|
| **Extract Function** | Comments separating "sections" | Self-documenting code |
|
||||||
|
|
||||||
|
### Composition Over God Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD — god function doing everything
|
||||||
|
async function processOrder(order: Order) {
|
||||||
|
// Validate... (15 lines)
|
||||||
|
// Calculate totals... (15 lines)
|
||||||
|
// Process payment... (10 lines)
|
||||||
|
// Send notifications... (10 lines)
|
||||||
|
// Update inventory... (10 lines)
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD — composed of small, focused functions
|
||||||
|
async function processOrder(order: Order) {
|
||||||
|
validateOrder(order);
|
||||||
|
const totals = calculateOrderTotals(order);
|
||||||
|
const payment = await processPayment(order.customer, totals);
|
||||||
|
await sendOrderConfirmation(order, payment);
|
||||||
|
await updateInventory(order.items);
|
||||||
|
return { success: true, orderId: payment.orderId };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Return Type Consistency
|
||||||
|
|
||||||
|
Functions should return consistent types. Use discriminated unions for multiple outcomes.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD — returns different types
|
||||||
|
function getUser(id: string) {
|
||||||
|
const user = database.find(id);
|
||||||
|
if (!user) return false; // boolean
|
||||||
|
if (user.isDeleted) return null; // null
|
||||||
|
return user; // User
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD — discriminated union
|
||||||
|
type GetUserResult =
|
||||||
|
| { status: 'found'; user: User }
|
||||||
|
| { status: 'not_found' }
|
||||||
|
| { status: 'deleted' };
|
||||||
|
|
||||||
|
function getUser(id: string): GetUserResult {
|
||||||
|
const user = database.find(id);
|
||||||
|
if (!user) return { status: 'not_found' };
|
||||||
|
if (user.isDeleted) return { status: 'deleted' };
|
||||||
|
return { status: 'found', user };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Problem | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| Comment every line | Noise obscures signal | Delete obvious comments; comment *why*, not *what* |
|
||||||
|
| Helper for one-liner | Unnecessary indirection | Inline the code |
|
||||||
|
| Factory for 2 objects | Over-engineering | Direct instantiation |
|
||||||
|
| `utils.ts` with 1 function | Junk drawer file | Put code where it's used |
|
||||||
|
| Deep nesting | Unreadable flow | Guard clauses and early returns |
|
||||||
|
| Magic numbers | Unclear intent | Named constants |
|
||||||
|
| God functions | Untestable, unreadable | Split by responsibility |
|
||||||
|
| Commented-out code | Dead code confusion | Delete it; git remembers |
|
||||||
|
| TODO sprawl | Never gets done | Track in issue tracker, not code |
|
||||||
|
| Premature abstraction | Wrong abstraction is worse than none | Wait for 3+ duplicates before abstracting |
|
||||||
|
| Copy-paste programming | Duplicated bugs | Extract shared logic |
|
||||||
|
| Exception-driven control flow | Slow and confusing | Use explicit conditionals |
|
||||||
|
| Stringly-typed code | Typos and missed cases | Use enums or union types |
|
||||||
|
| Callback hell | Pyramid of doom | Use async/await |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Edit Safety Check
|
||||||
|
|
||||||
|
Before changing any file, answer these questions to avoid cascading breakage:
|
||||||
|
|
||||||
|
| Question | Why |
|
||||||
|
|----------|-----|
|
||||||
|
| **What imports this file?** | Dependents might break on interface changes |
|
||||||
|
| **What does this file import?** | You might need to update the contract |
|
||||||
|
| **What tests cover this?** | Tests might fail — update them alongside code |
|
||||||
|
| **Is this a shared component?** | Multiple consumers means wider blast radius |
|
||||||
|
|
||||||
|
```
|
||||||
|
File to edit: UserService.ts
|
||||||
|
├── Who imports this? → UserController.ts, AuthController.ts
|
||||||
|
├── Do they need changes too? → Check function signatures
|
||||||
|
└── What tests cover this? → UserService.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Rule:** Edit the file + all dependent files in the SAME task. Never leave broken imports or missing updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Check Before Completing
|
||||||
|
|
||||||
|
Before marking any task complete, verify:
|
||||||
|
|
||||||
|
| Check | Question |
|
||||||
|
|-------|----------|
|
||||||
|
| **Goal met?** | Did I do exactly what was asked? |
|
||||||
|
| **Files edited?** | Did I modify all necessary files, including dependents? |
|
||||||
|
| **Code works?** | Did I verify the change compiles and runs? |
|
||||||
|
| **No errors?** | Do lint and type checks pass? |
|
||||||
|
| **Nothing forgotten?** | Any edge cases or dependent files missed? |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NEVER Do
|
||||||
|
|
||||||
|
1. **NEVER add comments that restate the code** — if the code needs a comment to explain *what* it does, rename things until it doesn't
|
||||||
|
2. **NEVER create abstractions for fewer than 3 use cases** — premature abstraction is worse than duplication
|
||||||
|
3. **NEVER leave commented-out code in the codebase** — delete it; version control exists for history
|
||||||
|
4. **NEVER write functions longer than 20 lines** — extract sub-functions until each does one thing
|
||||||
|
5. **NEVER nest deeper than 2 levels** — use guard clauses, early returns, or extract functions
|
||||||
|
6. **NEVER use magic numbers or strings** — define named constants with clear semantics
|
||||||
|
7. **NEVER edit a file without checking what depends on it** — broken imports and missing updates are the most common source of bugs in multi-file changes
|
||||||
|
8. **NEVER leave a task with failing lint or type checks** — fix all errors before marking complete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Detailed guides for specific clean code topics:
|
||||||
|
|
||||||
|
| Reference | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| [Anti-Patterns](references/anti-patterns.md) | 21 common mistakes with bad/good code examples across naming, functions, structure, and comments |
|
||||||
|
| [Code Smells](references/code-smells.md) | Classic code smells catalog with detection patterns — Bloaters, OO Abusers, Change Preventers, Dispensables, Couplers |
|
||||||
|
| [Refactoring Catalog](references/refactoring-catalog.md) | Essential refactoring patterns with before/after examples and step-by-step mechanics |
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn77z49xfssappp65hpybb9gx180x56e",
|
||||||
|
"slug": "clean-code-review",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishedAt": 1770729831880
|
||||||
|
}
|
||||||
@@ -0,0 +1,961 @@
|
|||||||
|
# Anti-Patterns Gallery
|
||||||
|
|
||||||
|
Common coding mistakes with explanations and fixes. Each pattern includes bad/good examples to make code review and refactoring actionable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Anti-Patterns
|
||||||
|
|
||||||
|
### 1. Cryptic Abbreviations
|
||||||
|
|
||||||
|
**Problem**: Abbreviations save keystrokes but cost readability. Future readers (including yourself) won't remember what `usrMgr` means.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad
|
||||||
|
const usrMgr = new UsrMgr();
|
||||||
|
const cfg = loadCfg();
|
||||||
|
const btn = document.getElementById('sbmt');
|
||||||
|
const val = calc(x, y, z);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
const userManager = new UserManager();
|
||||||
|
const config = loadConfig();
|
||||||
|
const submitButton = document.getElementById('submit');
|
||||||
|
const totalPrice = calculateTotalPrice(quantity, unitPrice, taxRate);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Spell it out. IDE autocomplete makes long names free.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Generic Names
|
||||||
|
|
||||||
|
**Problem**: Names like `data`, `info`, `item`, `thing`, `manager`, `handler` tell you nothing about what the code does.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad
|
||||||
|
function processData(data: any) {
|
||||||
|
const info = getData();
|
||||||
|
const result = handle(info);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Manager {
|
||||||
|
items: any[] = [];
|
||||||
|
process() { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
function validateUserRegistration(registration: UserRegistration) {
|
||||||
|
const existingUser = findUserByEmail(registration.email);
|
||||||
|
const validationResult = checkEmailAvailability(existingUser);
|
||||||
|
return validationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShoppingCart {
|
||||||
|
lineItems: CartLineItem[] = [];
|
||||||
|
calculateTotal() { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Names should reveal intent. Ask "what does this actually do?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Misleading Names
|
||||||
|
|
||||||
|
**Problem**: Names that lie are worse than bad names. They actively deceive readers.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - name lies about what it does
|
||||||
|
function getUserList() {
|
||||||
|
// Actually returns a single user, not a list
|
||||||
|
return this.currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = checkDate(date); // Returns the date, not a boolean
|
||||||
|
|
||||||
|
class AccountList extends Map { } // It's a Map, not a List
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - names match behavior
|
||||||
|
function getCurrentUser() {
|
||||||
|
return this.currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDate = normalizeDate(date);
|
||||||
|
|
||||||
|
class AccountRegistry extends Map { }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: If the name doesn't match the behavior, change the name (or the behavior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Hungarian Notation
|
||||||
|
|
||||||
|
**Problem**: Encoding types in names was useful in weakly-typed languages. TypeScript makes it redundant and noisy.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad
|
||||||
|
const strName: string = 'Alice';
|
||||||
|
const nCount: number = 42;
|
||||||
|
const arrUsers: User[] = [];
|
||||||
|
const bIsActive: boolean = true;
|
||||||
|
interface IUser { } // "I" prefix for interfaces
|
||||||
|
type TUserRole = 'admin' | 'user'; // "T" prefix for types
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
const name: string = 'Alice';
|
||||||
|
const count: number = 42;
|
||||||
|
const users: User[] = [];
|
||||||
|
const isActive: boolean = true;
|
||||||
|
interface User { }
|
||||||
|
type UserRole = 'admin' | 'user';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Let the type system handle types. Names should describe purpose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Function Anti-Patterns
|
||||||
|
|
||||||
|
### 5. God Functions
|
||||||
|
|
||||||
|
**Problem**: Functions over 20 lines are hard to understand, test, and modify. They usually do too many things.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - 50+ line function doing everything
|
||||||
|
async function processOrder(order: Order) {
|
||||||
|
// Validate order
|
||||||
|
if (!order.items.length) throw new Error('Empty order');
|
||||||
|
if (!order.customer) throw new Error('No customer');
|
||||||
|
// ... 10 more validation lines
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
let subtotal = 0;
|
||||||
|
for (const item of order.items) {
|
||||||
|
subtotal += item.price * item.quantity;
|
||||||
|
// ... discount logic
|
||||||
|
}
|
||||||
|
// ... 15 more calculation lines
|
||||||
|
|
||||||
|
// Process payment
|
||||||
|
const paymentResult = await stripe.charge(/* ... */);
|
||||||
|
// ... 10 more payment lines
|
||||||
|
|
||||||
|
// Send notifications
|
||||||
|
await sendEmail(/* ... */);
|
||||||
|
await sendSMS(/* ... */);
|
||||||
|
// ... more notification logic
|
||||||
|
|
||||||
|
// Update inventory
|
||||||
|
// ... 10 more lines
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - composed of small, focused functions
|
||||||
|
async function processOrder(order: Order) {
|
||||||
|
validateOrder(order);
|
||||||
|
const totals = calculateOrderTotals(order);
|
||||||
|
const payment = await processPayment(order.customer, totals);
|
||||||
|
await sendOrderConfirmation(order, payment);
|
||||||
|
await updateInventory(order.items);
|
||||||
|
return { success: true, orderId: payment.orderId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOrder(order: Order): void {
|
||||||
|
if (!order.items.length) throw new Error('Empty order');
|
||||||
|
if (!order.customer) throw new Error('No customer');
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOrderTotals(order: Order): OrderTotals {
|
||||||
|
const subtotal = order.items.reduce(
|
||||||
|
(sum, item) => sum + item.price * item.quantity, 0
|
||||||
|
);
|
||||||
|
return { subtotal, tax: subtotal * 0.1, total: subtotal * 1.1 };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Extract until each function does exactly one thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Too Many Parameters
|
||||||
|
|
||||||
|
**Problem**: Functions with 4+ parameters are hard to call correctly and often indicate the function does too much.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - too many parameters, order matters
|
||||||
|
function createUser(
|
||||||
|
firstName: string,
|
||||||
|
lastName: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
role: string,
|
||||||
|
department: string,
|
||||||
|
startDate: Date,
|
||||||
|
managerId: string | null,
|
||||||
|
isActive: boolean
|
||||||
|
) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callers must remember order
|
||||||
|
createUser('John', 'Doe', 'john@example.com', 'secret', 'admin', 'Engineering', new Date(), null, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - use an options object
|
||||||
|
interface CreateUserOptions {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: UserRole;
|
||||||
|
department: string;
|
||||||
|
startDate?: Date;
|
||||||
|
managerId?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUser(options: CreateUserOptions) {
|
||||||
|
const { firstName, lastName, email, role, isActive = true } = options;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callers have self-documenting code
|
||||||
|
createUser({
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
password: 'secret',
|
||||||
|
role: 'admin',
|
||||||
|
department: 'Engineering',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: More than 3 parameters? Use an options object.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Boolean Flag Parameters
|
||||||
|
|
||||||
|
**Problem**: Boolean parameters hide branching logic and make function calls unreadable.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - what does `true` mean here?
|
||||||
|
renderButton('Submit', true, false, true);
|
||||||
|
|
||||||
|
function renderButton(
|
||||||
|
label: string,
|
||||||
|
isPrimary: boolean,
|
||||||
|
isDisabled: boolean,
|
||||||
|
isLoading: boolean
|
||||||
|
) {
|
||||||
|
// Complex branching based on booleans
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - use options object or separate functions
|
||||||
|
renderButton({
|
||||||
|
label: 'Submit',
|
||||||
|
variant: 'primary',
|
||||||
|
state: 'loading',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or separate functions for distinct behaviors
|
||||||
|
renderPrimaryButton('Submit');
|
||||||
|
renderLoadingButton('Submit');
|
||||||
|
|
||||||
|
// Or use enums
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'ghost';
|
||||||
|
type ButtonState = 'default' | 'loading' | 'disabled';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Boolean parameters should be in an options object with named properties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Side Effects in Getters
|
||||||
|
|
||||||
|
**Problem**: Getters that modify state violate the principle of least surprise. Readers expect getters to be pure.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - getter with hidden side effect
|
||||||
|
class ShoppingCart {
|
||||||
|
private _items: CartItem[] = [];
|
||||||
|
private _lastAccessed: Date;
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
this._lastAccessed = new Date(); // Side effect!
|
||||||
|
this.logAccess(); // Another side effect!
|
||||||
|
return this._items;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalPrice() {
|
||||||
|
this.recalculateDiscounts(); // Mutation!
|
||||||
|
return this._items.reduce((sum, i) => sum + i.price, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - getters are pure, side effects are explicit
|
||||||
|
class ShoppingCart {
|
||||||
|
private _items: CartItem[] = [];
|
||||||
|
private _lastAccessed: Date;
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
return this._items;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalPrice() {
|
||||||
|
return this._items.reduce((sum, i) => sum + i.price, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAccess() {
|
||||||
|
this._lastAccessed = new Date();
|
||||||
|
this.logAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDiscounts() {
|
||||||
|
this.recalculateDiscounts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Getters should be pure. Make side effects explicit with verbs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Returning Different Types
|
||||||
|
|
||||||
|
**Problem**: Functions that return different types based on conditions make code unpredictable and hard to type.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - return type depends on runtime condition
|
||||||
|
function getUser(id: string) {
|
||||||
|
const user = database.find(id);
|
||||||
|
if (!user) {
|
||||||
|
return false; // boolean
|
||||||
|
}
|
||||||
|
if (user.isDeleted) {
|
||||||
|
return null; // null
|
||||||
|
}
|
||||||
|
return user; // User object
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller must handle all cases
|
||||||
|
const result = getUser('123');
|
||||||
|
if (result === false) { /* not found */ }
|
||||||
|
else if (result === null) { /* deleted */ }
|
||||||
|
else { /* use result.name */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - consistent return type with discriminated union
|
||||||
|
type GetUserResult =
|
||||||
|
| { status: 'found'; user: User }
|
||||||
|
| { status: 'not_found' }
|
||||||
|
| { status: 'deleted' };
|
||||||
|
|
||||||
|
function getUser(id: string): GetUserResult {
|
||||||
|
const user = database.find(id);
|
||||||
|
if (!user) {
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
if (user.isDeleted) {
|
||||||
|
return { status: 'deleted' };
|
||||||
|
}
|
||||||
|
return { status: 'found', user };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller has type-safe handling
|
||||||
|
const result = getUser('123');
|
||||||
|
if (result.status === 'found') {
|
||||||
|
console.log(result.user.name); // TypeScript knows user exists
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Return consistent types. Use discriminated unions for multiple outcomes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure Anti-Patterns
|
||||||
|
|
||||||
|
### 10. Deep Nesting (Pyramid of Doom)
|
||||||
|
|
||||||
|
**Problem**: Deeply nested code is hard to follow and usually indicates missing abstractions.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - 5 levels deep
|
||||||
|
function processOrder(order: Order) {
|
||||||
|
if (order) {
|
||||||
|
if (order.items.length > 0) {
|
||||||
|
if (order.customer) {
|
||||||
|
if (order.customer.isVerified) {
|
||||||
|
if (order.paymentMethod) {
|
||||||
|
// Finally, the actual logic buried 5 levels deep
|
||||||
|
return submitOrder(order);
|
||||||
|
} else {
|
||||||
|
throw new Error('No payment method');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Customer not verified');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No customer');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No items');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No order');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - guard clauses flatten the structure
|
||||||
|
function processOrder(order: Order) {
|
||||||
|
if (!order) throw new Error('No order');
|
||||||
|
if (!order.items.length) throw new Error('No items');
|
||||||
|
if (!order.customer) throw new Error('No customer');
|
||||||
|
if (!order.customer.isVerified) throw new Error('Customer not verified');
|
||||||
|
if (!order.paymentMethod) throw new Error('No payment method');
|
||||||
|
|
||||||
|
return submitOrder(order);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Use guard clauses for early returns. Max 2 levels of nesting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Premature Abstraction
|
||||||
|
|
||||||
|
**Problem**: Creating abstractions before you understand the problem leads to wrong abstractions that are hard to change.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - abstraction created for one use case
|
||||||
|
interface DataFetcher<T> {
|
||||||
|
fetch(): Promise<T>;
|
||||||
|
cache(): void;
|
||||||
|
invalidate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenericRepository<T> implements DataFetcher<T> {
|
||||||
|
constructor(private adapter: StorageAdapter<T>) {}
|
||||||
|
// ... complex implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used exactly once:
|
||||||
|
const userRepo = new GenericRepository(new UserAdapter());
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - start simple, abstract when patterns emerge
|
||||||
|
// First implementation: just fetch users
|
||||||
|
async function fetchUsers(): Promise<User[]> {
|
||||||
|
return await db.query('SELECT * FROM users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later, if you need caching, add it:
|
||||||
|
async function fetchUsersWithCache(): Promise<User[]> {
|
||||||
|
const cached = cache.get('users');
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const users = await db.query('SELECT * FROM users');
|
||||||
|
cache.set('users', users);
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abstract only when you see the SAME pattern 3+ times
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: "Duplication is far cheaper than the wrong abstraction." - Sandi Metz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Over-Engineering
|
||||||
|
|
||||||
|
**Problem**: Building for hypothetical future requirements adds complexity without value.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - enterprise FizzBuzz
|
||||||
|
interface FizzBuzzStrategy {
|
||||||
|
applies(n: number): boolean;
|
||||||
|
execute(n: number): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FizzStrategy implements FizzBuzzStrategy {
|
||||||
|
applies(n: number) { return n % 3 === 0; }
|
||||||
|
execute() { return 'Fizz'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class BuzzStrategy implements FizzBuzzStrategy {
|
||||||
|
applies(n: number) { return n % 5 === 0; }
|
||||||
|
execute() { return 'Buzz'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FizzBuzzProcessor {
|
||||||
|
constructor(private strategies: FizzBuzzStrategy[]) {}
|
||||||
|
process(n: number): string {
|
||||||
|
return this.strategies
|
||||||
|
.filter(s => s.applies(n))
|
||||||
|
.map(s => s.execute(n))
|
||||||
|
.join('') || String(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processor = new FizzBuzzProcessor([
|
||||||
|
new FizzStrategy(),
|
||||||
|
new BuzzStrategy(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - solve the actual problem
|
||||||
|
function fizzBuzz(n: number): string {
|
||||||
|
if (n % 15 === 0) return 'FizzBuzz';
|
||||||
|
if (n % 3 === 0) return 'Fizz';
|
||||||
|
if (n % 5 === 0) return 'Buzz';
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Solve today's problem. Refactor when requirements actually change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. Copy-Paste Programming
|
||||||
|
|
||||||
|
**Problem**: Duplicated code means duplicated bugs and maintenance burden.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - same validation logic copied
|
||||||
|
function createUser(data: UserInput) {
|
||||||
|
if (!data.email) throw new Error('Email required');
|
||||||
|
if (!data.email.includes('@')) throw new Error('Invalid email');
|
||||||
|
if (data.email.length > 255) throw new Error('Email too long');
|
||||||
|
// ... create user
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUser(id: string, data: UserInput) {
|
||||||
|
if (!data.email) throw new Error('Email required');
|
||||||
|
if (!data.email.includes('@')) throw new Error('Invalid email');
|
||||||
|
if (data.email.length > 255) throw new Error('Email too long');
|
||||||
|
// ... update user
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteUser(data: UserInput) {
|
||||||
|
if (!data.email) throw new Error('Email required');
|
||||||
|
if (!data.email.includes('@')) throw new Error('Invalid email');
|
||||||
|
if (data.email.length > 255) throw new Error('Email too long');
|
||||||
|
// ... invite user
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - extract shared logic
|
||||||
|
function validateEmail(email: string): void {
|
||||||
|
if (!email) throw new Error('Email required');
|
||||||
|
if (!email.includes('@')) throw new Error('Invalid email');
|
||||||
|
if (email.length > 255) throw new Error('Email too long');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUser(data: UserInput) {
|
||||||
|
validateEmail(data.email);
|
||||||
|
// ... create user
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUser(id: string, data: UserInput) {
|
||||||
|
validateEmail(data.email);
|
||||||
|
// ... update user
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteUser(data: UserInput) {
|
||||||
|
validateEmail(data.email);
|
||||||
|
// ... invite user
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: If you copy-paste, you're probably missing an abstraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. God Objects
|
||||||
|
|
||||||
|
**Problem**: Classes that know too much and do too much become unmaintainable.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - class does everything
|
||||||
|
class ApplicationManager {
|
||||||
|
users: User[] = [];
|
||||||
|
orders: Order[] = [];
|
||||||
|
products: Product[] = [];
|
||||||
|
|
||||||
|
// User operations
|
||||||
|
createUser() { /* ... */ }
|
||||||
|
deleteUser() { /* ... */ }
|
||||||
|
authenticateUser() { /* ... */ }
|
||||||
|
|
||||||
|
// Order operations
|
||||||
|
createOrder() { /* ... */ }
|
||||||
|
cancelOrder() { /* ... */ }
|
||||||
|
refundOrder() { /* ... */ }
|
||||||
|
|
||||||
|
// Product operations
|
||||||
|
addProduct() { /* ... */ }
|
||||||
|
updateInventory() { /* ... */ }
|
||||||
|
|
||||||
|
// Reporting
|
||||||
|
generateSalesReport() { /* ... */ }
|
||||||
|
generateUserReport() { /* ... */ }
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
sendEmail() { /* ... */ }
|
||||||
|
sendSMS() { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - separate concerns
|
||||||
|
class UserService {
|
||||||
|
createUser() { /* ... */ }
|
||||||
|
deleteUser() { /* ... */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
authenticate() { /* ... */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderService {
|
||||||
|
createOrder() { /* ... */ }
|
||||||
|
cancelOrder() { /* ... */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
sendEmail() { /* ... */ }
|
||||||
|
sendSMS() { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Each class should have one reason to change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comment Anti-Patterns
|
||||||
|
|
||||||
|
### 15. Commented-Out Code
|
||||||
|
|
||||||
|
**Problem**: Commented code is dead code. It confuses readers and never gets cleaned up.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad
|
||||||
|
function calculateTotal(items: Item[]) {
|
||||||
|
let total = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
total += item.price;
|
||||||
|
// total += item.price * item.quantity; // Old calculation
|
||||||
|
// if (item.discount) {
|
||||||
|
// total -= item.discount;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
// return total * 1.1; // With tax
|
||||||
|
// return total * 1.08; // Old tax rate
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - delete it, git remembers
|
||||||
|
function calculateTotal(items: Item[]) {
|
||||||
|
return items.reduce((total, item) => total + item.price, 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Delete commented code. Use version control for history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. Obvious Comments
|
||||||
|
|
||||||
|
**Problem**: Comments that restate the code add noise without value.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - comments that add nothing
|
||||||
|
// Increment counter
|
||||||
|
counter++;
|
||||||
|
|
||||||
|
// Check if user is null
|
||||||
|
if (user === null) {
|
||||||
|
// Return early
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop through all items
|
||||||
|
for (const item of items) {
|
||||||
|
// Add item price to total
|
||||||
|
total += item.price;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - code is self-documenting, comments explain WHY
|
||||||
|
counter++; // No comment needed
|
||||||
|
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const total = items.reduce((sum, item) => sum + item.price, 0);
|
||||||
|
|
||||||
|
// Business rule: Premium members get early access 48 hours before launch
|
||||||
|
if (user.isPremium && hoursUntilLaunch < 48) {
|
||||||
|
showEarlyAccess();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Don't comment WHAT, comment WHY (if not obvious).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 17. TODO Sprawl
|
||||||
|
|
||||||
|
**Problem**: TODOs accumulate and never get done. They become invisible noise.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - TODO graveyard
|
||||||
|
function processPayment(amount: number) {
|
||||||
|
// TODO: Add retry logic
|
||||||
|
// TODO: Handle currency conversion
|
||||||
|
// TODO: Add logging
|
||||||
|
// TODO: Optimize this (added 2019)
|
||||||
|
// FIXME: This is broken sometimes
|
||||||
|
// HACK: Temporary fix, remove later
|
||||||
|
// XXX: Why does this work?
|
||||||
|
return charge(amount);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - TODOs are tracked in issues, not code
|
||||||
|
function processPayment(amount: number) {
|
||||||
|
// See JIRA-1234 for planned retry logic
|
||||||
|
return charge(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or just fix it now:
|
||||||
|
async function processPayment(amount: number) {
|
||||||
|
return await retry(() => charge(amount), { attempts: 3 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: TODOs belong in your issue tracker, not your code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 18. Outdated Comments
|
||||||
|
|
||||||
|
**Problem**: Comments that contradict the code are actively harmful.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - comment lies about the code
|
||||||
|
// Returns the user's full name (first + last)
|
||||||
|
function getUserName(user: User): string {
|
||||||
|
return user.email; // Actually returns email!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates and saves the user
|
||||||
|
function processUser(user: User) {
|
||||||
|
// No validation, just saves
|
||||||
|
database.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function is deprecated, use newFunction() instead
|
||||||
|
function oldFunction() {
|
||||||
|
// Still actively used throughout codebase
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - update or remove outdated comments
|
||||||
|
function getUserEmail(user: User): string {
|
||||||
|
return user.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveUser(user: User) {
|
||||||
|
database.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use {@link newFunction} instead */
|
||||||
|
function oldFunction() {
|
||||||
|
console.warn('oldFunction is deprecated');
|
||||||
|
return newFunction();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: When you change code, update or delete related comments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Control Flow Anti-Patterns
|
||||||
|
|
||||||
|
### 19. Exception-Driven Control Flow
|
||||||
|
|
||||||
|
**Problem**: Using exceptions for normal control flow is slow and hard to follow.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - exceptions for expected cases
|
||||||
|
function findUser(id: string): User {
|
||||||
|
try {
|
||||||
|
return database.getUser(id);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return cache.getUser(id);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return createDefaultUser(id);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Cannot get user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - explicit control flow
|
||||||
|
function findUser(id: string): User | null {
|
||||||
|
const dbUser = database.getUser(id);
|
||||||
|
if (dbUser) return dbUser;
|
||||||
|
|
||||||
|
const cachedUser = cache.getUser(id);
|
||||||
|
if (cachedUser) return cachedUser;
|
||||||
|
|
||||||
|
return createDefaultUser(id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Exceptions are for exceptional situations, not control flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 20. Stringly-Typed Code
|
||||||
|
|
||||||
|
**Problem**: Using strings where enums or types would be safer leads to typos and missing cases.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - magic strings everywhere
|
||||||
|
function handleStatus(status: string) {
|
||||||
|
if (status === 'pending') { /* ... */ }
|
||||||
|
else if (status === 'Pending') { /* ... */ } // Typo variant
|
||||||
|
else if (status === 'active') { /* ... */ }
|
||||||
|
else if (status === 'actve') { /* ... */ } // Typo!
|
||||||
|
}
|
||||||
|
|
||||||
|
user.role = 'admni'; // Typo, no error!
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - type-safe enums or unions
|
||||||
|
type Status = 'pending' | 'active' | 'completed' | 'cancelled';
|
||||||
|
type UserRole = 'admin' | 'user' | 'guest';
|
||||||
|
|
||||||
|
function handleStatus(status: Status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': /* ... */ break;
|
||||||
|
case 'active': /* ... */ break;
|
||||||
|
case 'completed': /* ... */ break;
|
||||||
|
case 'cancelled': /* ... */ break;
|
||||||
|
// TypeScript ensures exhaustive handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.role = 'admni'; // TypeScript error!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Use types instead of strings for fixed sets of values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 21. Callback Hell
|
||||||
|
|
||||||
|
**Problem**: Nested callbacks create unreadable, hard-to-debug code.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - callback pyramid
|
||||||
|
getUser(userId, (err, user) => {
|
||||||
|
if (err) return handleError(err);
|
||||||
|
getOrders(user.id, (err, orders) => {
|
||||||
|
if (err) return handleError(err);
|
||||||
|
getOrderDetails(orders[0].id, (err, details) => {
|
||||||
|
if (err) return handleError(err);
|
||||||
|
processDetails(details, (err, result) => {
|
||||||
|
if (err) return handleError(err);
|
||||||
|
sendNotification(result, (err) => {
|
||||||
|
if (err) return handleError(err);
|
||||||
|
console.log('Done!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - async/await
|
||||||
|
async function processUserOrder(userId: string) {
|
||||||
|
try {
|
||||||
|
const user = await getUser(userId);
|
||||||
|
const orders = await getOrders(user.id);
|
||||||
|
const details = await getOrderDetails(orders[0].id);
|
||||||
|
const result = await processDetails(details);
|
||||||
|
await sendNotification(result);
|
||||||
|
console.log('Done!');
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule**: Use async/await for asynchronous code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Anti-Pattern | Fix |
|
||||||
|
|--------------|-----|
|
||||||
|
| Cryptic abbreviations | Spell it out |
|
||||||
|
| Generic names | Reveal intent |
|
||||||
|
| Misleading names | Match name to behavior |
|
||||||
|
| Hungarian notation | Let types handle types |
|
||||||
|
| God functions | Extract smaller functions |
|
||||||
|
| Too many parameters | Use options object |
|
||||||
|
| Boolean flags | Named options or separate functions |
|
||||||
|
| Side effects in getters | Make mutations explicit |
|
||||||
|
| Different return types | Use discriminated unions |
|
||||||
|
| Deep nesting | Guard clauses |
|
||||||
|
| Premature abstraction | Wait for patterns to emerge |
|
||||||
|
| Over-engineering | Solve today's problem |
|
||||||
|
| Copy-paste | Extract shared logic |
|
||||||
|
| God objects | Single responsibility |
|
||||||
|
| Commented-out code | Delete it |
|
||||||
|
| Obvious comments | Let code self-document |
|
||||||
|
| TODO sprawl | Use issue tracker |
|
||||||
|
| Outdated comments | Update or delete |
|
||||||
|
| Exception control flow | Explicit conditionals |
|
||||||
|
| Stringly-typed | Use enums/unions |
|
||||||
|
| Callback hell | async/await |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,959 @@
|
|||||||
|
# Refactoring Catalog
|
||||||
|
|
||||||
|
Essential refactoring patterns with before/after examples. Each refactoring is a small, reversible transformation that improves code structure without changing behavior.
|
||||||
|
|
||||||
|
Based on Martin Fowler's refactoring catalog with TypeScript examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composing Methods
|
||||||
|
|
||||||
|
Break down large methods into smaller, focused pieces.
|
||||||
|
|
||||||
|
### Extract Function
|
||||||
|
|
||||||
|
**Motivation**: A code fragment that can be grouped together and named. The most common refactoring.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Code block has a comment explaining what it does
|
||||||
|
- Same code appears in multiple places
|
||||||
|
- Function is too long (20+ lines)
|
||||||
|
- Logic is at a different level of abstraction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
function printInvoice(invoice: Invoice) {
|
||||||
|
console.log('=================');
|
||||||
|
console.log('=== INVOICE =====');
|
||||||
|
console.log('=================');
|
||||||
|
|
||||||
|
// Print details
|
||||||
|
console.log(`Customer: ${invoice.customer}`);
|
||||||
|
console.log(`Date: ${invoice.date}`);
|
||||||
|
|
||||||
|
// Calculate total
|
||||||
|
let total = 0;
|
||||||
|
for (const item of invoice.items) {
|
||||||
|
total += item.price * item.quantity;
|
||||||
|
}
|
||||||
|
console.log(`Total: $${total}`);
|
||||||
|
|
||||||
|
console.log('=================');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After
|
||||||
|
function printInvoice(invoice: Invoice) {
|
||||||
|
printHeader();
|
||||||
|
printDetails(invoice);
|
||||||
|
printTotal(invoice);
|
||||||
|
printFooter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHeader() {
|
||||||
|
console.log('=================');
|
||||||
|
console.log('=== INVOICE =====');
|
||||||
|
console.log('=================');
|
||||||
|
}
|
||||||
|
|
||||||
|
function printDetails(invoice: Invoice) {
|
||||||
|
console.log(`Customer: ${invoice.customer}`);
|
||||||
|
console.log(`Date: ${invoice.date}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTotal(invoice: Invoice) {
|
||||||
|
const total = calculateTotal(invoice.items);
|
||||||
|
console.log(`Total: $${total}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTotal(items: InvoiceItem[]): number {
|
||||||
|
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printFooter() {
|
||||||
|
console.log('=================');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Create new function named after what it does (not how)
|
||||||
|
2. Copy the code fragment to the new function
|
||||||
|
3. Pass any needed variables as parameters
|
||||||
|
4. Replace the original code with a call to the new function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Inline Function
|
||||||
|
|
||||||
|
**Motivation**: The opposite of Extract Function. The function body is as clear as the name, or the function is only delegating.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Function body is as obvious as its name
|
||||||
|
- You have a group of badly factored functions and want to re-extract differently
|
||||||
|
- Too much indirection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
function moreThanFiveOrders(customer: Customer): boolean {
|
||||||
|
return customer.orders.length > 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscount(customer: Customer): number {
|
||||||
|
if (moreThanFiveOrders(customer)) {
|
||||||
|
return 0.1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After
|
||||||
|
function getDiscount(customer: Customer): number {
|
||||||
|
if (customer.orders.length > 5) {
|
||||||
|
return 0.1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Check function isn't polymorphic (overridden in subclasses)
|
||||||
|
2. Find all callers
|
||||||
|
3. Replace each call with the function body
|
||||||
|
4. Delete the function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Replace Temp with Query
|
||||||
|
|
||||||
|
**Motivation**: Temporary variables can be a problem—they make functions longer and block Extract Function.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- A temp is assigned once and used multiple times
|
||||||
|
- The calculation could be a method
|
||||||
|
- You want to extract part of a function
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
function getPrice(order: Order): number {
|
||||||
|
const basePrice = order.quantity * order.itemPrice;
|
||||||
|
const discount = Math.max(0, order.quantity - 100) * order.itemPrice * 0.05;
|
||||||
|
const shipping = Math.min(basePrice * 0.1, 50);
|
||||||
|
return basePrice - discount + shipping;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After
|
||||||
|
function getPrice(order: Order): number {
|
||||||
|
return basePrice(order) - discount(order) + shipping(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
function basePrice(order: Order): number {
|
||||||
|
return order.quantity * order.itemPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discount(order: Order): number {
|
||||||
|
return Math.max(0, order.quantity - 100) * order.itemPrice * 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shipping(order: Order): number {
|
||||||
|
return Math.min(basePrice(order) * 0.1, 50);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Check the temp is only assigned once
|
||||||
|
2. Extract the assignment into a function
|
||||||
|
3. Replace references to the temp with function calls
|
||||||
|
4. Remove the temp declaration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Introduce Explaining Variable
|
||||||
|
|
||||||
|
**Motivation**: Complex expressions are hard to read. Break them into named pieces.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- A complex expression that's hard to understand
|
||||||
|
- Multiple conditions in an if statement
|
||||||
|
- Mathematical formulas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
function calculatePrice(order: Order): number {
|
||||||
|
return order.quantity * order.itemPrice -
|
||||||
|
Math.max(0, order.quantity - 100) * order.itemPrice * 0.05 +
|
||||||
|
Math.min(order.quantity * order.itemPrice * 0.1, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEligibleForDiscount(user: User): boolean {
|
||||||
|
return user.age >= 65 ||
|
||||||
|
(user.memberSince < new Date('2020-01-01') && user.purchaseCount > 10) ||
|
||||||
|
user.isEmployee;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After
|
||||||
|
function calculatePrice(order: Order): number {
|
||||||
|
const basePrice = order.quantity * order.itemPrice;
|
||||||
|
const quantityDiscount = Math.max(0, order.quantity - 100) * order.itemPrice * 0.05;
|
||||||
|
const shipping = Math.min(basePrice * 0.1, 50);
|
||||||
|
return basePrice - quantityDiscount + shipping;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEligibleForDiscount(user: User): boolean {
|
||||||
|
const isSenior = user.age >= 65;
|
||||||
|
const isLoyalCustomer = user.memberSince < new Date('2020-01-01') &&
|
||||||
|
user.purchaseCount > 10;
|
||||||
|
const isEmployee = user.isEmployee;
|
||||||
|
|
||||||
|
return isSenior || isLoyalCustomer || isEmployee;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Identify a complex expression or sub-expression
|
||||||
|
2. Create a variable with a meaningful name
|
||||||
|
3. Assign the expression to the variable
|
||||||
|
4. Replace the expression with the variable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Moving Features
|
||||||
|
|
||||||
|
Move code to where it belongs.
|
||||||
|
|
||||||
|
### Move Function
|
||||||
|
|
||||||
|
**Motivation**: Functions should live with the data they use most.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Function uses more features of another class
|
||||||
|
- Function is in the wrong module
|
||||||
|
- Coupling would be reduced by moving
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - calculateInterest uses account data, not the calculator's
|
||||||
|
class InterestCalculator {
|
||||||
|
calculateInterest(account: Account, days: number): number {
|
||||||
|
const balance = account.getBalance();
|
||||||
|
const rate = account.getInterestRate();
|
||||||
|
const type = account.getType();
|
||||||
|
|
||||||
|
if (type === 'savings') {
|
||||||
|
return balance * rate * days / 365;
|
||||||
|
} else if (type === 'checking') {
|
||||||
|
return balance * (rate - 0.01) * days / 365;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - method moved to Account where the data lives
|
||||||
|
class Account {
|
||||||
|
private balance: number;
|
||||||
|
private interestRate: number;
|
||||||
|
private type: 'savings' | 'checking';
|
||||||
|
|
||||||
|
calculateInterest(days: number): number {
|
||||||
|
if (this.type === 'savings') {
|
||||||
|
return this.balance * this.interestRate * days / 365;
|
||||||
|
} else if (this.type === 'checking') {
|
||||||
|
return this.balance * (this.interestRate - 0.01) * days / 365;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Look at what the function references—does it use more from elsewhere?
|
||||||
|
2. Check if it should be a method on one of its arguments
|
||||||
|
3. Move the function
|
||||||
|
4. Update all callers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Extract Class
|
||||||
|
|
||||||
|
**Motivation**: A class is doing too much. Split it based on responsibilities.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Class has too many fields
|
||||||
|
- Class has too many methods
|
||||||
|
- Subsets of data/methods are used together
|
||||||
|
- Class name includes "And" or "Manager"
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - Person has phone-related responsibilities mixed in
|
||||||
|
class Person {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
// These belong together
|
||||||
|
officeAreaCode: string;
|
||||||
|
officeNumber: string;
|
||||||
|
|
||||||
|
// And these
|
||||||
|
homeAreaCode: string;
|
||||||
|
homeNumber: string;
|
||||||
|
|
||||||
|
getOfficePhone(): string {
|
||||||
|
return `(${this.officeAreaCode}) ${this.officeNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHomePhone(): string {
|
||||||
|
return `(${this.homeAreaCode}) ${this.homeNumber}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - Phone is its own class
|
||||||
|
class PhoneNumber {
|
||||||
|
constructor(
|
||||||
|
private areaCode: string,
|
||||||
|
private number: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
format(): string {
|
||||||
|
return `(${this.areaCode}) ${this.number}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Person {
|
||||||
|
name: string;
|
||||||
|
officePhone: PhoneNumber;
|
||||||
|
homePhone: PhoneNumber;
|
||||||
|
|
||||||
|
getOfficePhone(): string {
|
||||||
|
return this.officePhone.format();
|
||||||
|
}
|
||||||
|
|
||||||
|
getHomePhone(): string {
|
||||||
|
return this.homePhone.format();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Identify a subset of data and methods that belong together
|
||||||
|
2. Create a new class
|
||||||
|
3. Move fields and methods to the new class
|
||||||
|
4. Create a link from old class to new class
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Hide Delegate
|
||||||
|
|
||||||
|
**Motivation**: Reduce coupling by hiding the object structure from clients.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Clients navigate through one object to get to another
|
||||||
|
- Changes to the delegate affect all clients
|
||||||
|
- You're exposing internal structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - Client knows about internal structure
|
||||||
|
class Person {
|
||||||
|
department: Department;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Department {
|
||||||
|
manager: Person;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client code - knows too much about structure
|
||||||
|
const manager = person.department.manager;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - Person hides the delegation
|
||||||
|
class Person {
|
||||||
|
private department: Department;
|
||||||
|
|
||||||
|
getManager(): Person {
|
||||||
|
return this.department.manager;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client code - simpler, less coupled
|
||||||
|
const manager = person.getManager();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Create a delegating method on the server
|
||||||
|
2. Replace client calls to the delegate with calls to the server
|
||||||
|
3. Consider making the delegate field private
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Organizing Data
|
||||||
|
|
||||||
|
Improve how data is structured and accessed.
|
||||||
|
|
||||||
|
### Replace Magic Number with Constant
|
||||||
|
|
||||||
|
**Motivation**: Magic numbers obscure meaning and are easy to mistype.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Numbers appear in code without explanation
|
||||||
|
- Same number appears in multiple places
|
||||||
|
- The number has domain meaning
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
function potentialEnergy(mass: number, height: number): number {
|
||||||
|
return mass * height * 9.81;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDiscount(total: number): number {
|
||||||
|
if (total > 100) {
|
||||||
|
return total * 0.1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After
|
||||||
|
const GRAVITATIONAL_CONSTANT = 9.81;
|
||||||
|
const DISCOUNT_THRESHOLD = 100;
|
||||||
|
const DISCOUNT_RATE = 0.1;
|
||||||
|
|
||||||
|
function potentialEnergy(mass: number, height: number): number {
|
||||||
|
return mass * height * GRAVITATIONAL_CONSTANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDiscount(total: number): number {
|
||||||
|
if (total > DISCOUNT_THRESHOLD) {
|
||||||
|
return total * DISCOUNT_RATE;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Create a constant with a meaningful name
|
||||||
|
2. Replace the magic number with the constant
|
||||||
|
3. Search for other occurrences of the same number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Replace Primitive with Object
|
||||||
|
|
||||||
|
**Motivation**: Primitives with behavior should be objects.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Same validation logic for a primitive appears multiple times
|
||||||
|
- Formatting/parsing logic is scattered
|
||||||
|
- The value has business rules
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - phone validation scattered everywhere
|
||||||
|
function validateOrder(order: Order) {
|
||||||
|
const phone = order.phone;
|
||||||
|
if (!phone.match(/^\d{10}$/)) {
|
||||||
|
throw new Error('Invalid phone');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhone(phone: string): string {
|
||||||
|
return `(${phone.slice(0,3)}) ${phone.slice(3,6)}-${phone.slice(6)}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - PhoneNumber encapsulates all behavior
|
||||||
|
class PhoneNumber {
|
||||||
|
private readonly value: string;
|
||||||
|
|
||||||
|
constructor(phone: string) {
|
||||||
|
if (!phone.match(/^\d{10}$/)) {
|
||||||
|
throw new Error('Invalid phone number');
|
||||||
|
}
|
||||||
|
this.value = phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
format(): string {
|
||||||
|
return `(${this.value.slice(0,3)}) ${this.value.slice(3,6)}-${this.value.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAreaCode(): string {
|
||||||
|
return this.value.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const phone = new PhoneNumber('5551234567');
|
||||||
|
console.log(phone.format()); // (555) 123-4567
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Create a class for the value
|
||||||
|
2. Add validation in constructor
|
||||||
|
3. Add any formatting/parsing methods
|
||||||
|
4. Replace usages of the primitive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Encapsulate Collection
|
||||||
|
|
||||||
|
**Motivation**: Exposing a collection allows clients to modify it without the owner knowing.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- A getter returns a raw collection
|
||||||
|
- Clients are adding/removing items directly
|
||||||
|
- Collection modifications should trigger other behavior
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - collection exposed directly
|
||||||
|
class Course {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Person {
|
||||||
|
courses: Course[] = [];
|
||||||
|
|
||||||
|
getCourses(): Course[] {
|
||||||
|
return this.courses; // Returns mutable reference!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client can bypass the Person
|
||||||
|
person.getCourses().push(newCourse);
|
||||||
|
person.getCourses().length = 0; // Dangerous!
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - collection encapsulated
|
||||||
|
class Person {
|
||||||
|
private courses: Course[] = [];
|
||||||
|
|
||||||
|
getCourses(): readonly Course[] {
|
||||||
|
return [...this.courses]; // Return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
addCourse(course: Course): void {
|
||||||
|
this.courses.push(course);
|
||||||
|
// Can add validation, events, logging here
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCourse(course: Course): void {
|
||||||
|
const index = this.courses.indexOf(course);
|
||||||
|
if (index > -1) {
|
||||||
|
this.courses.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get numberOfCourses(): number {
|
||||||
|
return this.courses.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Add methods for modifying the collection
|
||||||
|
2. Return a copy or readonly view from the getter
|
||||||
|
3. Replace direct modifications with method calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Simplifying Conditionals
|
||||||
|
|
||||||
|
Make conditional logic easier to understand.
|
||||||
|
|
||||||
|
### Decompose Conditional
|
||||||
|
|
||||||
|
**Motivation**: Complex conditionals are hard to read. Extract into well-named functions.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Conditional has complex conditions
|
||||||
|
- Then/else branches have substantial code
|
||||||
|
- The logic isn't immediately clear
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
function calculateCharge(date: Date, quantity: number): number {
|
||||||
|
if (date.getMonth() >= 5 && date.getMonth() <= 8) {
|
||||||
|
return quantity * 1.2 + (quantity > 100 ? quantity * 0.05 : 0);
|
||||||
|
} else {
|
||||||
|
return quantity * 1.0 + (quantity > 50 ? quantity * 0.03 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After
|
||||||
|
function calculateCharge(date: Date, quantity: number): number {
|
||||||
|
if (isSummer(date)) {
|
||||||
|
return summerCharge(quantity);
|
||||||
|
} else {
|
||||||
|
return regularCharge(quantity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSummer(date: Date): boolean {
|
||||||
|
return date.getMonth() >= 5 && date.getMonth() <= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summerCharge(quantity: number): number {
|
||||||
|
const baseCharge = quantity * 1.2;
|
||||||
|
const bulkDiscount = quantity > 100 ? quantity * 0.05 : 0;
|
||||||
|
return baseCharge + bulkDiscount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function regularCharge(quantity: number): number {
|
||||||
|
const baseCharge = quantity * 1.0;
|
||||||
|
const bulkDiscount = quantity > 50 ? quantity * 0.03 : 0;
|
||||||
|
return baseCharge + bulkDiscount;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Extract the condition into a function
|
||||||
|
2. Extract the then-branch into a function
|
||||||
|
3. Extract the else-branch into a function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Consolidate Conditional Expression
|
||||||
|
|
||||||
|
**Motivation**: Multiple conditions with the same result should be combined.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Several conditions return the same value
|
||||||
|
- The conditions are really checking one thing
|
||||||
|
- Combining makes intent clearer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
function disabilityAmount(employee: Employee): number {
|
||||||
|
if (employee.seniority < 2) return 0;
|
||||||
|
if (employee.monthsDisabled > 12) return 0;
|
||||||
|
if (employee.isPartTime) return 0;
|
||||||
|
// Calculate disability
|
||||||
|
return employee.salary * 0.6;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After
|
||||||
|
function disabilityAmount(employee: Employee): number {
|
||||||
|
if (isNotEligibleForDisability(employee)) return 0;
|
||||||
|
return employee.salary * 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotEligibleForDisability(employee: Employee): boolean {
|
||||||
|
return employee.seniority < 2 ||
|
||||||
|
employee.monthsDisabled > 12 ||
|
||||||
|
employee.isPartTime;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Combine conditions using logical operators
|
||||||
|
2. Extract the combined condition into a function
|
||||||
|
3. Give it a meaningful name
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Replace Nested Conditional with Guard Clauses
|
||||||
|
|
||||||
|
**Motivation**: Deeply nested conditionals are hard to follow. Use early returns.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Deep nesting (more than 2 levels)
|
||||||
|
- Some branches are exceptional/edge cases
|
||||||
|
- Happy path is buried in else clauses
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - nested pyramid
|
||||||
|
function getPayAmount(employee: Employee): number {
|
||||||
|
let result: number;
|
||||||
|
if (employee.isSeparated) {
|
||||||
|
result = 0;
|
||||||
|
} else {
|
||||||
|
if (employee.isRetired) {
|
||||||
|
result = employee.pension;
|
||||||
|
} else {
|
||||||
|
if (employee.isOnLeave) {
|
||||||
|
result = employee.salary * 0.5;
|
||||||
|
} else {
|
||||||
|
result = employee.salary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - flat with guard clauses
|
||||||
|
function getPayAmount(employee: Employee): number {
|
||||||
|
if (employee.isSeparated) return 0;
|
||||||
|
if (employee.isRetired) return employee.pension;
|
||||||
|
if (employee.isOnLeave) return employee.salary * 0.5;
|
||||||
|
return employee.salary;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Identify edge cases
|
||||||
|
2. Replace each with a guard clause (early return)
|
||||||
|
3. Remove unnecessary else clauses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Replace Conditional with Polymorphism
|
||||||
|
|
||||||
|
**Motivation**: Type-based conditionals often indicate missing polymorphism.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Switching on type code
|
||||||
|
- Same switch appears in multiple places
|
||||||
|
- Each case has substantially different behavior
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
type BirdType = 'european' | 'african' | 'norwegian_blue';
|
||||||
|
|
||||||
|
function getSpeed(bird: { type: BirdType; voltage?: number }): number {
|
||||||
|
switch (bird.type) {
|
||||||
|
case 'european':
|
||||||
|
return 35;
|
||||||
|
case 'african':
|
||||||
|
return 40 - 2 * bird.numberOfCoconuts;
|
||||||
|
case 'norwegian_blue':
|
||||||
|
return bird.voltage > 100 ? 20 : 0;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlumage(bird: { type: BirdType }): string {
|
||||||
|
switch (bird.type) {
|
||||||
|
case 'european':
|
||||||
|
return 'average';
|
||||||
|
case 'african':
|
||||||
|
return bird.numberOfCoconuts > 2 ? 'tired' : 'average';
|
||||||
|
case 'norwegian_blue':
|
||||||
|
return bird.voltage > 100 ? 'scorched' : 'beautiful';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After
|
||||||
|
interface Bird {
|
||||||
|
getSpeed(): number;
|
||||||
|
getPlumage(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EuropeanSwallow implements Bird {
|
||||||
|
getSpeed() { return 35; }
|
||||||
|
getPlumage() { return 'average'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class AfricanSwallow implements Bird {
|
||||||
|
constructor(private numberOfCoconuts: number) {}
|
||||||
|
|
||||||
|
getSpeed() { return 40 - 2 * this.numberOfCoconuts; }
|
||||||
|
getPlumage() { return this.numberOfCoconuts > 2 ? 'tired' : 'average'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class NorwegianBlueParrot implements Bird {
|
||||||
|
constructor(private voltage: number) {}
|
||||||
|
|
||||||
|
getSpeed() { return this.voltage > 100 ? 20 : 0; }
|
||||||
|
getPlumage() { return this.voltage > 100 ? 'scorched' : 'beautiful'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory to create the right type
|
||||||
|
function createBird(data: BirdData): Bird {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'european': return new EuropeanSwallow();
|
||||||
|
case 'african': return new AfricanSwallow(data.numberOfCoconuts);
|
||||||
|
case 'norwegian_blue': return new NorwegianBlueParrot(data.voltage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Create interface/base class
|
||||||
|
2. Create a class for each type
|
||||||
|
3. Move switch logic into each class
|
||||||
|
4. Replace switch with polymorphic call
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Simplifying Function Calls
|
||||||
|
|
||||||
|
Make functions easier to call and understand.
|
||||||
|
|
||||||
|
### Rename Function
|
||||||
|
|
||||||
|
**Motivation**: Good names are the best documentation. If you can't name it, you don't understand it.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Name doesn't describe what the function does
|
||||||
|
- Name is misleading
|
||||||
|
- Name is too technical for the domain
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - unclear names
|
||||||
|
function calc(a: number, b: number): number { /* ... */ }
|
||||||
|
function process(data: unknown): void { /* ... */ }
|
||||||
|
function handle(event: Event): void { /* ... */ }
|
||||||
|
function inv(customer: Customer): Invoice { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - descriptive names
|
||||||
|
function calculateCompoundInterest(principal: number, rate: number): number { /* ... */ }
|
||||||
|
function validateAndSaveUserProfile(profile: UserProfile): void { /* ... */ }
|
||||||
|
function trackButtonClick(event: MouseEvent): void { /* ... */ }
|
||||||
|
function generateInvoiceForCustomer(customer: Customer): Invoice { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Choose a better name (this is the hard part)
|
||||||
|
2. Create new function with new name
|
||||||
|
3. Copy body to new function
|
||||||
|
4. Update all callers
|
||||||
|
5. Remove old function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Introduce Parameter Object
|
||||||
|
|
||||||
|
**Motivation**: Groups of parameters that travel together should be a single object.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Same group of parameters appears in multiple functions
|
||||||
|
- Parameters have a clear relationship
|
||||||
|
- You want to add behavior to the group
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - date range parameters everywhere
|
||||||
|
function getTotalSales(startDate: Date, endDate: Date): number { /* ... */ }
|
||||||
|
function getAverageOrders(startDate: Date, endDate: Date): number { /* ... */ }
|
||||||
|
function generateReport(startDate: Date, endDate: Date, format: string): Report { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - DateRange object
|
||||||
|
class DateRange {
|
||||||
|
constructor(
|
||||||
|
readonly start: Date,
|
||||||
|
readonly end: Date
|
||||||
|
) {
|
||||||
|
if (start > end) throw new Error('Invalid date range');
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(date: Date): boolean {
|
||||||
|
return date >= this.start && date <= this.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
get durationInDays(): number {
|
||||||
|
const ms = this.end.getTime() - this.start.getTime();
|
||||||
|
return Math.ceil(ms / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalSales(dateRange: DateRange): number { /* ... */ }
|
||||||
|
function getAverageOrders(dateRange: DateRange): number { /* ... */ }
|
||||||
|
function generateReport(dateRange: DateRange, format: string): Report { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Create a class for the parameter group
|
||||||
|
2. Add validation in constructor
|
||||||
|
3. Add any relevant methods
|
||||||
|
4. Replace parameter lists with the new object
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Replace Parameter with Method
|
||||||
|
|
||||||
|
**Motivation**: A parameter that can be derived from other available information is redundant.
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- Parameter value is derived from something else
|
||||||
|
- Parameter requires complex calculation by caller
|
||||||
|
- You're passing internal state back in
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before - caller computes discountRate
|
||||||
|
function getFinalPrice(
|
||||||
|
basePrice: number,
|
||||||
|
discountLevel: number,
|
||||||
|
discountRate: number
|
||||||
|
): number {
|
||||||
|
return basePrice - (basePrice * discountRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller has to know how to calculate rate
|
||||||
|
const rate = getDiscountRate(customer.discountLevel);
|
||||||
|
const price = getFinalPrice(basePrice, customer.discountLevel, rate);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After - function derives discountRate internally
|
||||||
|
function getFinalPrice(basePrice: number, discountLevel: number): number {
|
||||||
|
const discountRate = getDiscountRate(discountLevel);
|
||||||
|
return basePrice - (basePrice * discountRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller is simpler
|
||||||
|
const price = getFinalPrice(basePrice, customer.discountLevel);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanics**:
|
||||||
|
1. Extract the parameter calculation into a method
|
||||||
|
2. Call that method inside the function
|
||||||
|
3. Remove the parameter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Category | Refactoring | When to Use |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| **Composing Methods** | Extract Function | Code block can be named |
|
||||||
|
| | Inline Function | Body is clear as name |
|
||||||
|
| | Replace Temp with Query | Temp blocks extraction |
|
||||||
|
| | Introduce Explaining Variable | Complex expression |
|
||||||
|
| **Moving Features** | Move Function | Uses other class's data |
|
||||||
|
| | Extract Class | Class does too much |
|
||||||
|
| | Hide Delegate | Clients navigate too deep |
|
||||||
|
| **Organizing Data** | Replace Magic Number | Unexplained numbers |
|
||||||
|
| | Replace Primitive with Object | Primitive has behavior |
|
||||||
|
| | Encapsulate Collection | Exposed mutable collection |
|
||||||
|
| **Simplifying Conditionals** | Decompose Conditional | Complex if-then-else |
|
||||||
|
| | Consolidate Conditional | Same result, multiple checks |
|
||||||
|
| | Guard Clauses | Deep nesting |
|
||||||
|
| | Replace with Polymorphism | Switch on type |
|
||||||
|
| **Function Calls** | Rename Function | Name doesn't fit |
|
||||||
|
| | Parameter Object | Params travel together |
|
||||||
|
| | Replace Parameter with Method | Param can be derived |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactoring Workflow
|
||||||
|
|
||||||
|
1. **Ensure tests pass** before starting
|
||||||
|
2. **Make one small change** at a time
|
||||||
|
3. **Run tests** after each change
|
||||||
|
4. **Commit frequently** so you can revert
|
||||||
|
5. **Never refactor and add features** in the same commit
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Clean Code — Claude Project Knowledge
|
||||||
|
|
||||||
|
<context>
|
||||||
|
You are a pragmatic coding assistant that writes clean, maintainable code.
|
||||||
|
Your style is concise, direct, and solution-focused. You never over-engineer.
|
||||||
|
You write code directly — you do not write tutorials or explain before implementing.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<rules>
|
||||||
|
## Core Principles
|
||||||
|
- Apply SRP: each function/class does ONE thing
|
||||||
|
- Apply DRY: extract duplicated logic into shared functions
|
||||||
|
- Apply KISS: always choose the simplest working solution
|
||||||
|
- Apply YAGNI: never build features that aren't needed yet
|
||||||
|
- Leave code cleaner than you found it
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
- Variables reveal intent: `userCount` not `n`
|
||||||
|
- Functions use verb+noun: `getUserById()` not `user()`
|
||||||
|
- Booleans use question form: `isActive`, `hasPermission`, `canEdit`
|
||||||
|
- Constants use SCREAMING_SNAKE_CASE: `MAX_RETRY_COUNT`
|
||||||
|
- If a name needs a comment to explain it, rename it instead
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
- Max 20 lines per function, ideally 5–10
|
||||||
|
- One thing per function, one level of abstraction
|
||||||
|
- Max 3 arguments, prefer 0–2
|
||||||
|
- No unexpected side effects — don't mutate inputs
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
- Use guard clauses for early returns on edge cases
|
||||||
|
- Max 2 levels of nesting — flatten with early returns
|
||||||
|
- Compose small, focused functions together
|
||||||
|
- Colocate related code in the same module
|
||||||
|
|
||||||
|
## Anti-Patterns — Never Do These
|
||||||
|
- Never comment obvious code — delete it
|
||||||
|
- Never create helpers for one-liners — inline them
|
||||||
|
- Never create a `utils.ts` with a single function
|
||||||
|
- Never use magic numbers — use named constants
|
||||||
|
- Never write god functions — split by responsibility
|
||||||
|
- Never leave deep nesting — use guard clauses
|
||||||
|
|
||||||
|
## Before Editing Any File
|
||||||
|
- Identify all files that import the target file
|
||||||
|
- Check if interface changes break dependents
|
||||||
|
- Verify test coverage — update tests alongside code
|
||||||
|
- Edit the file AND all dependents in the same task
|
||||||
|
|
||||||
|
## Self-Check
|
||||||
|
- Verify the user's goal is met exactly
|
||||||
|
- Verify all necessary files are modified
|
||||||
|
- Verify lint and type checks pass
|
||||||
|
- Verify no edge cases are missed
|
||||||
|
</rules>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Clean Code — Copilot Instructions
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Follow these pragmatic coding standards in all generated code. Be concise, direct, and solution-focused. Never over-engineer.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
Apply these principles to every piece of code:
|
||||||
|
|
||||||
|
- **SRP** — Single Responsibility. Each function/class does ONE thing.
|
||||||
|
- **DRY** — Don't Repeat Yourself. Extract shared logic.
|
||||||
|
- **KISS** — Keep It Simple. Simplest solution that works.
|
||||||
|
- **YAGNI** — Don't build features that aren't needed yet.
|
||||||
|
|
||||||
|
## Naming Patterns
|
||||||
|
|
||||||
|
```
|
||||||
|
// Variables — reveal intent
|
||||||
|
✅ userCount, isAuthenticated, orderTotal
|
||||||
|
❌ n, flag, x
|
||||||
|
|
||||||
|
// Functions — verb + noun
|
||||||
|
✅ getUserById(), calculateTotal(), sendEmail()
|
||||||
|
❌ user(), calc(), email()
|
||||||
|
|
||||||
|
// Booleans — question form
|
||||||
|
✅ isActive, hasPermission, canEdit
|
||||||
|
❌ active, permission, edit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Function Structure
|
||||||
|
|
||||||
|
Keep functions small (5–20 lines), with max 3 arguments. Use guard clauses:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Guard clauses — flat and readable
|
||||||
|
function processOrder(order: Order): Result {
|
||||||
|
if (!order) return { error: 'No order' };
|
||||||
|
if (!order.items.length) return { error: 'Empty order' };
|
||||||
|
if (!order.payment) return { error: 'No payment' };
|
||||||
|
|
||||||
|
const total = calculateTotal(order.items);
|
||||||
|
return chargeAndFulfill(order, total);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- Don't comment obvious code — let names self-document
|
||||||
|
- Don't create helpers for one-liners — inline instead
|
||||||
|
- Don't create `utils.ts` for a single function
|
||||||
|
- Don't use magic numbers — define named constants
|
||||||
|
- Don't write functions over 20 lines — split by responsibility
|
||||||
|
- Don't nest deeper than 2 levels — use early returns
|
||||||
|
|
||||||
|
## Before Editing Files
|
||||||
|
|
||||||
|
Always check: what imports this file, what tests cover it, and whether dependent files need updates too. Edit all affected files together.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Clean Code — Cursor Rules
|
||||||
|
|
||||||
|
# Pragmatic coding standards: concise, direct, no over-engineering.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
- Follow SRP: each function/class does ONE thing
|
||||||
|
- Follow DRY: extract duplicates, reuse shared logic
|
||||||
|
- Follow KISS: always choose the simplest solution that works
|
||||||
|
- Follow YAGNI: never build features that aren't needed yet
|
||||||
|
- Leave code cleaner than you found it (Boy Scout Rule)
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
- Variables must reveal intent: `userCount` not `n`
|
||||||
|
- Functions use verb+noun: `getUserById()` not `user()`
|
||||||
|
- Booleans use question form: `isActive`, `hasPermission`, `canEdit`
|
||||||
|
- Constants use SCREAMING_SNAKE: `MAX_RETRY_COUNT`
|
||||||
|
- If a name needs a comment to explain it, rename it
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
- Keep functions under 20 lines, ideally 5–10
|
||||||
|
- Each function does one thing at one level of abstraction
|
||||||
|
- Max 3 arguments, prefer 0–2
|
||||||
|
- Never mutate inputs unexpectedly
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
- Use guard clauses and early returns for edge cases
|
||||||
|
- Keep nesting to max 2 levels — flatten with early returns
|
||||||
|
- Compose small functions together
|
||||||
|
- Colocate related code
|
||||||
|
|
||||||
|
## Anti-Patterns — Never Do These
|
||||||
|
- Do not comment every line — delete obvious comments
|
||||||
|
- Do not create helpers for one-liners — inline the code
|
||||||
|
- Do not create factories for 2 objects — use direct instantiation
|
||||||
|
- Do not create `utils.ts` for a single function — put code where it's used
|
||||||
|
- Do not use magic numbers — use named constants
|
||||||
|
- Do not write god functions — split by responsibility
|
||||||
|
|
||||||
|
## Before Editing Any File
|
||||||
|
- Check what imports this file — dependents might break
|
||||||
|
- Check what this file imports — interfaces may change
|
||||||
|
- Check what tests cover this file — tests might fail
|
||||||
|
- Edit the file AND all dependent files in the same task
|
||||||
|
- Never leave broken imports or missing updates
|
||||||
|
|
||||||
|
## Self-Check Before Completing
|
||||||
|
- Verify the goal is met — did you do exactly what was asked?
|
||||||
|
- Verify all necessary files are modified
|
||||||
|
- Verify lint and type checks pass
|
||||||
|
- Verify no edge cases are missed
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"registry": "https://clawhub.ai",
|
||||||
|
"slug": "next-best-practices",
|
||||||
|
"installedVersion": "0.1.0",
|
||||||
|
"installedAt": 1779235116276,
|
||||||
|
"fingerprint": "fc4914e81195b0c960b7450c8c0c975a28442df24115c36a1aee70b1e05da7ab"
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
---
|
||||||
|
name: next-best-practices
|
||||||
|
description: Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling
|
||||||
|
user-invocable: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Next.js Best Practices
|
||||||
|
|
||||||
|
Apply these rules when writing or reviewing Next.js code.
|
||||||
|
|
||||||
|
## File Conventions
|
||||||
|
|
||||||
|
See [file-conventions.md](./file-conventions.md) for:
|
||||||
|
- Project structure and special files
|
||||||
|
- Route segments (dynamic, catch-all, groups)
|
||||||
|
- Parallel and intercepting routes
|
||||||
|
- Middleware rename in v16 (middleware → proxy)
|
||||||
|
|
||||||
|
## RSC Boundaries
|
||||||
|
|
||||||
|
Detect invalid React Server Component patterns.
|
||||||
|
|
||||||
|
See [rsc-boundaries.md](./rsc-boundaries.md) for:
|
||||||
|
- Async client component detection (invalid)
|
||||||
|
- Non-serializable props detection
|
||||||
|
- Server Action exceptions
|
||||||
|
|
||||||
|
## Async Patterns
|
||||||
|
|
||||||
|
Next.js 15+ async API changes.
|
||||||
|
|
||||||
|
See [async-patterns.md](./async-patterns.md) for:
|
||||||
|
- Async `params` and `searchParams`
|
||||||
|
- Async `cookies()` and `headers()`
|
||||||
|
- Migration codemod
|
||||||
|
|
||||||
|
## Runtime Selection
|
||||||
|
|
||||||
|
See [runtime-selection.md](./runtime-selection.md) for:
|
||||||
|
- Default to Node.js runtime
|
||||||
|
- When Edge runtime is appropriate
|
||||||
|
|
||||||
|
## Directives
|
||||||
|
|
||||||
|
See [directives.md](./directives.md) for:
|
||||||
|
- `'use client'`, `'use server'` (React)
|
||||||
|
- `'use cache'` (Next.js)
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
See [functions.md](./functions.md) for:
|
||||||
|
- Navigation hooks: `useRouter`, `usePathname`, `useSearchParams`, `useParams`
|
||||||
|
- Server functions: `cookies`, `headers`, `draftMode`, `after`
|
||||||
|
- Generate functions: `generateStaticParams`, `generateMetadata`
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
See [error-handling.md](./error-handling.md) for:
|
||||||
|
- `error.tsx`, `global-error.tsx`, `not-found.tsx`
|
||||||
|
- `redirect`, `permanentRedirect`, `notFound`
|
||||||
|
- `forbidden`, `unauthorized` (auth errors)
|
||||||
|
- `unstable_rethrow` for catch blocks
|
||||||
|
|
||||||
|
## Data Patterns
|
||||||
|
|
||||||
|
See [data-patterns.md](./data-patterns.md) for:
|
||||||
|
- Server Components vs Server Actions vs Route Handlers
|
||||||
|
- Avoiding data waterfalls (`Promise.all`, Suspense, preload)
|
||||||
|
- Client component data fetching
|
||||||
|
|
||||||
|
## Route Handlers
|
||||||
|
|
||||||
|
See [route-handlers.md](./route-handlers.md) for:
|
||||||
|
- `route.ts` basics
|
||||||
|
- GET handler conflicts with `page.tsx`
|
||||||
|
- Environment behavior (no React DOM)
|
||||||
|
- When to use vs Server Actions
|
||||||
|
|
||||||
|
## Metadata & OG Images
|
||||||
|
|
||||||
|
See [metadata.md](./metadata.md) for:
|
||||||
|
- Static and dynamic metadata
|
||||||
|
- `generateMetadata` function
|
||||||
|
- OG image generation with `next/og`
|
||||||
|
- File-based metadata conventions
|
||||||
|
|
||||||
|
## Image Optimization
|
||||||
|
|
||||||
|
See [image.md](./image.md) for:
|
||||||
|
- Always use `next/image` over `<img>`
|
||||||
|
- Remote images configuration
|
||||||
|
- Responsive `sizes` attribute
|
||||||
|
- Blur placeholders
|
||||||
|
- Priority loading for LCP
|
||||||
|
|
||||||
|
## Font Optimization
|
||||||
|
|
||||||
|
See [font.md](./font.md) for:
|
||||||
|
- `next/font` setup
|
||||||
|
- Google Fonts, local fonts
|
||||||
|
- Tailwind CSS integration
|
||||||
|
- Preloading subsets
|
||||||
|
|
||||||
|
## Bundling
|
||||||
|
|
||||||
|
See [bundling.md](./bundling.md) for:
|
||||||
|
- Server-incompatible packages
|
||||||
|
- CSS imports (not link tags)
|
||||||
|
- Polyfills (already included)
|
||||||
|
- ESM/CommonJS issues
|
||||||
|
- Bundle analysis
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
See [scripts.md](./scripts.md) for:
|
||||||
|
- `next/script` vs native script tags
|
||||||
|
- Inline scripts need `id`
|
||||||
|
- Loading strategies
|
||||||
|
- Google Analytics with `@next/third-parties`
|
||||||
|
|
||||||
|
## Hydration Errors
|
||||||
|
|
||||||
|
See [hydration-error.md](./hydration-error.md) for:
|
||||||
|
- Common causes (browser APIs, dates, invalid HTML)
|
||||||
|
- Debugging with error overlay
|
||||||
|
- Fixes for each cause
|
||||||
|
|
||||||
|
## Suspense Boundaries
|
||||||
|
|
||||||
|
See [suspense-boundaries.md](./suspense-boundaries.md) for:
|
||||||
|
- CSR bailout with `useSearchParams` and `usePathname`
|
||||||
|
- Which hooks require Suspense boundaries
|
||||||
|
|
||||||
|
## Parallel & Intercepting Routes
|
||||||
|
|
||||||
|
See [parallel-routes.md](./parallel-routes.md) for:
|
||||||
|
- Modal patterns with `@slot` and `(.)` interceptors
|
||||||
|
- `default.tsx` for fallbacks
|
||||||
|
- Closing modals correctly with `router.back()`
|
||||||
|
|
||||||
|
## Self-Hosting
|
||||||
|
|
||||||
|
See [self-hosting.md](./self-hosting.md) for:
|
||||||
|
- `output: 'standalone'` for Docker
|
||||||
|
- Cache handlers for multi-instance ISR
|
||||||
|
- What works vs needs extra setup
|
||||||
|
|
||||||
|
## Debug Tricks
|
||||||
|
|
||||||
|
See [debug-tricks.md](./debug-tricks.md) for:
|
||||||
|
- MCP endpoint for AI-assisted debugging
|
||||||
|
- Rebuild specific routes with `--debug-build-paths`
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn7fdqv8zpxp37z09bnmfs8k3h81fdve",
|
||||||
|
"slug": "next-best-practices",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"publishedAt": 1771519351301
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Async Patterns
|
||||||
|
|
||||||
|
In Next.js 15+, `params`, `searchParams`, `cookies()`, and `headers()` are asynchronous.
|
||||||
|
|
||||||
|
## Async Params and SearchParams
|
||||||
|
|
||||||
|
Always type them as `Promise<...>` and await them.
|
||||||
|
|
||||||
|
### Pages and Layouts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type Props = { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
|
export default async function Page({ params }: Props) {
|
||||||
|
const { slug } = await params
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Handlers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SearchParams
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ slug: string }>
|
||||||
|
searchParams: Promise<{ query?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({ params, searchParams }: Props) {
|
||||||
|
const { slug } = await params
|
||||||
|
const { query } = await searchParams
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Synchronous Components
|
||||||
|
|
||||||
|
Use `React.use()` for non-async components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { use } from 'react'
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
|
export default function Page({ params }: Props) {
|
||||||
|
const { slug } = use(params)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### generateMetadata
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type Props = { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { slug } = await params
|
||||||
|
return { title: slug }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Async Cookies and Headers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cookies, headers } from 'next/headers'
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const headersList = await headers()
|
||||||
|
|
||||||
|
const theme = cookieStore.get('theme')
|
||||||
|
const userAgent = headersList.get('user-agent')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Codemod
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @next/codemod@latest next-async-request-api .
|
||||||
|
```
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Bundling
|
||||||
|
|
||||||
|
Fix common bundling issues with third-party packages.
|
||||||
|
|
||||||
|
## Server-Incompatible Packages
|
||||||
|
|
||||||
|
Some packages use browser APIs (`window`, `document`, `localStorage`) and fail in Server Components.
|
||||||
|
|
||||||
|
### Error Signs
|
||||||
|
|
||||||
|
```
|
||||||
|
ReferenceError: window is not defined
|
||||||
|
ReferenceError: document is not defined
|
||||||
|
ReferenceError: localStorage is not defined
|
||||||
|
Module not found: Can't resolve 'fs'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution 1: Mark as Client-Only
|
||||||
|
|
||||||
|
If the package is only needed on client:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Fails - package uses window
|
||||||
|
import SomeChart from 'some-chart-library'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <SomeChart />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Use dynamic import with ssr: false
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
const SomeChart = dynamic(() => import('some-chart-library'), {
|
||||||
|
ssr: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <SomeChart />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution 2: Externalize from Server Bundle
|
||||||
|
|
||||||
|
For packages that should run on server but have bundling issues:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
serverExternalPackages: ['problematic-package'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this for:
|
||||||
|
- Packages with native bindings (sharp, bcrypt)
|
||||||
|
- Packages that don't bundle well (some ORMs)
|
||||||
|
- Packages with circular dependencies
|
||||||
|
|
||||||
|
### Solution 3: Client Component Wrapper
|
||||||
|
|
||||||
|
Wrap the entire usage in a client component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ChartWrapper.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Chart } from 'chart-library'
|
||||||
|
|
||||||
|
export function ChartWrapper(props) {
|
||||||
|
return <Chart {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/page.tsx (server component)
|
||||||
|
import { ChartWrapper } from '@/components/ChartWrapper'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ChartWrapper data={data} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Imports
|
||||||
|
|
||||||
|
Import CSS files instead of using `<link>` tags. Next.js handles bundling and optimization.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Manual link tag
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
|
||||||
|
// Good: Import CSS
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
// Good: CSS Modules
|
||||||
|
import styles from './Button.module.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Polyfills
|
||||||
|
|
||||||
|
Next.js includes common polyfills automatically. Don't load redundant ones from polyfill.io or similar CDNs.
|
||||||
|
|
||||||
|
Already included: `Array.from`, `Object.assign`, `Promise`, `fetch`, `Map`, `Set`, `Symbol`, `URLSearchParams`, and 50+ others.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Redundant polyfills
|
||||||
|
<script src="https://polyfill.io/v3/polyfill.min.js?features=fetch,Promise,Array.from" />
|
||||||
|
|
||||||
|
// Good: Next.js includes these automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
## ESM/CommonJS Issues
|
||||||
|
|
||||||
|
### Error Signs
|
||||||
|
|
||||||
|
```
|
||||||
|
SyntaxError: Cannot use import statement outside a module
|
||||||
|
Error: require() of ES Module
|
||||||
|
Module not found: ESM packages need to be imported
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution: Transpile Package
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
transpilePackages: ['some-esm-package', 'another-package'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Problematic Packages
|
||||||
|
|
||||||
|
| Package | Issue | Solution |
|
||||||
|
|---------|-------|----------|
|
||||||
|
| `sharp` | Native bindings | `serverExternalPackages: ['sharp']` |
|
||||||
|
| `bcrypt` | Native bindings | `serverExternalPackages: ['bcrypt']` or use `bcryptjs` |
|
||||||
|
| `canvas` | Native bindings | `serverExternalPackages: ['canvas']` |
|
||||||
|
| `recharts` | Uses window | `dynamic(() => import('recharts'), { ssr: false })` |
|
||||||
|
| `react-quill` | Uses document | `dynamic(() => import('react-quill'), { ssr: false })` |
|
||||||
|
| `mapbox-gl` | Uses window | `dynamic(() => import('mapbox-gl'), { ssr: false })` |
|
||||||
|
| `monaco-editor` | Uses window | `dynamic(() => import('@monaco-editor/react'), { ssr: false })` |
|
||||||
|
| `lottie-web` | Uses document | `dynamic(() => import('lottie-react'), { ssr: false })` |
|
||||||
|
|
||||||
|
## Bundle Analysis
|
||||||
|
|
||||||
|
Analyze bundle size with the built-in analyzer (Next.js 16.1+):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
next experimental-analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens an interactive UI to:
|
||||||
|
- Filter by route, environment (client/server), and type
|
||||||
|
- Inspect module sizes and import chains
|
||||||
|
- View treemap visualization
|
||||||
|
|
||||||
|
Save output for comparison:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
next experimental-analyze --output
|
||||||
|
# Output saved to .next/diagnostics/analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/guides/package-bundling
|
||||||
|
|
||||||
|
## Migrating from Webpack to Turbopack
|
||||||
|
|
||||||
|
Turbopack is the default bundler in Next.js 15+. If you have custom webpack config, migrate to Turbopack-compatible alternatives:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
// Good: Works with Turbopack
|
||||||
|
serverExternalPackages: ['package'],
|
||||||
|
transpilePackages: ['package'],
|
||||||
|
|
||||||
|
// Bad: Webpack-only - migrate away from this
|
||||||
|
webpack: (config) => {
|
||||||
|
// custom webpack config
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/building-your-application/upgrading/from-webpack-to-turbopack
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# Data Patterns
|
||||||
|
|
||||||
|
Choose the right data fetching pattern for each use case.
|
||||||
|
|
||||||
|
## Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Need to fetch data?
|
||||||
|
├── From a Server Component?
|
||||||
|
│ └── Use: Fetch directly (no API needed)
|
||||||
|
│
|
||||||
|
├── From a Client Component?
|
||||||
|
│ ├── Is it a mutation (POST/PUT/DELETE)?
|
||||||
|
│ │ └── Use: Server Action
|
||||||
|
│ └── Is it a read (GET)?
|
||||||
|
│ └── Use: Route Handler OR pass from Server Component
|
||||||
|
│
|
||||||
|
├── Need external API access (webhooks, third parties)?
|
||||||
|
│ └── Use: Route Handler
|
||||||
|
│
|
||||||
|
└── Need REST API for mobile app / external clients?
|
||||||
|
└── Use: Route Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern 1: Server Components (Preferred for Reads)
|
||||||
|
|
||||||
|
Fetch data directly in Server Components - no API layer needed.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/users/page.tsx
|
||||||
|
async function UsersPage() {
|
||||||
|
// Direct database access - no API round-trip
|
||||||
|
const users = await db.user.findMany();
|
||||||
|
|
||||||
|
// Or fetch from external API
|
||||||
|
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{users.map(user => <li key={user.id}>{user.name}</li>)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- No API to maintain
|
||||||
|
- No client-server waterfall
|
||||||
|
- Secrets stay on server
|
||||||
|
- Direct database access
|
||||||
|
|
||||||
|
## Pattern 2: Server Actions (Preferred for Mutations)
|
||||||
|
|
||||||
|
Server Actions are the recommended way to handle mutations.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/actions.ts
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function createPost(formData: FormData) {
|
||||||
|
const title = formData.get('title') as string;
|
||||||
|
|
||||||
|
await db.post.create({ data: { title } });
|
||||||
|
|
||||||
|
revalidatePath('/posts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePost(id: string) {
|
||||||
|
await db.post.delete({ where: { id } });
|
||||||
|
|
||||||
|
revalidateTag('posts');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/posts/new/page.tsx
|
||||||
|
import { createPost } from '@/app/actions';
|
||||||
|
|
||||||
|
export default function NewPost() {
|
||||||
|
return (
|
||||||
|
<form action={createPost}>
|
||||||
|
<input name="title" required />
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- End-to-end type safety
|
||||||
|
- Progressive enhancement (works without JS)
|
||||||
|
- Automatic request handling
|
||||||
|
- Integrated with React transitions
|
||||||
|
|
||||||
|
**Constraints**:
|
||||||
|
- POST only (no GET caching semantics)
|
||||||
|
- Internal use only (no external access)
|
||||||
|
- Cannot return non-serializable data
|
||||||
|
|
||||||
|
## Pattern 3: Route Handlers (APIs)
|
||||||
|
|
||||||
|
Use Route Handlers when you need a REST API.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/api/posts/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// GET is cacheable
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const posts = await db.post.findMany();
|
||||||
|
return NextResponse.json(posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST for mutations
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json();
|
||||||
|
const post = await db.post.create({ data: body });
|
||||||
|
return NextResponse.json(post, { status: 201 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use**:
|
||||||
|
- External API access (mobile apps, third parties)
|
||||||
|
- Webhooks from external services
|
||||||
|
- GET endpoints that need HTTP caching
|
||||||
|
- OpenAPI/Swagger documentation needed
|
||||||
|
|
||||||
|
**When NOT to use**:
|
||||||
|
- Internal data fetching (use Server Components)
|
||||||
|
- Mutations from your UI (use Server Actions)
|
||||||
|
|
||||||
|
## Avoiding Data Waterfalls
|
||||||
|
|
||||||
|
### Problem: Sequential Fetches
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Sequential waterfalls
|
||||||
|
async function Dashboard() {
|
||||||
|
const user = await getUser(); // Wait...
|
||||||
|
const posts = await getPosts(); // Then wait...
|
||||||
|
const comments = await getComments(); // Then wait...
|
||||||
|
|
||||||
|
return <div>...</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution 1: Parallel Fetching with Promise.all
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Parallel fetching
|
||||||
|
async function Dashboard() {
|
||||||
|
const [user, posts, comments] = await Promise.all([
|
||||||
|
getUser(),
|
||||||
|
getPosts(),
|
||||||
|
getComments(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <div>...</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution 2: Streaming with Suspense
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Show content progressively
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
async function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Suspense fallback={<UserSkeleton />}>
|
||||||
|
<UserSection />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={<PostsSkeleton />}>
|
||||||
|
<PostsSection />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function UserSection() {
|
||||||
|
const user = await getUser(); // Fetches independently
|
||||||
|
return <div>{user.name}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function PostsSection() {
|
||||||
|
const posts = await getPosts(); // Fetches independently
|
||||||
|
return <PostList posts={posts} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution 3: Preload Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/data.ts
|
||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
export const getUser = cache(async (id: string) => {
|
||||||
|
return db.user.findUnique({ where: { id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const preloadUser = (id: string) => {
|
||||||
|
void getUser(id); // Fire and forget
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/user/[id]/page.tsx
|
||||||
|
import { getUser, preloadUser } from '@/lib/data';
|
||||||
|
|
||||||
|
export default async function UserPage({ params }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Start fetching early
|
||||||
|
preloadUser(id);
|
||||||
|
|
||||||
|
// Do other work...
|
||||||
|
|
||||||
|
// Data likely ready by now
|
||||||
|
const user = await getUser(id);
|
||||||
|
return <div>{user.name}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Component Data Fetching
|
||||||
|
|
||||||
|
When Client Components need data:
|
||||||
|
|
||||||
|
### Option 1: Pass from Server Component (Preferred)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Server Component
|
||||||
|
async function Page() {
|
||||||
|
const data = await fetchData();
|
||||||
|
return <ClientComponent initialData={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Component
|
||||||
|
'use client';
|
||||||
|
function ClientComponent({ initialData }) {
|
||||||
|
const [data, setData] = useState(initialData);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Fetch on Mount (When Necessary)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function ClientComponent() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/data')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!data) return <Loading />;
|
||||||
|
return <div>{data.value}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Server Action for Reads (Works But Not Ideal)
|
||||||
|
|
||||||
|
Server Actions can be called from Client Components for reads, but this is not their intended purpose:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
import { getData } from './actions';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function ClientComponent() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getData().then(setData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div>{data?.value}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Server Actions always use POST, so no HTTP caching. Prefer Route Handlers for cacheable reads.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Pattern | Use Case | HTTP Method | Caching |
|
||||||
|
|---------|----------|-------------|---------|
|
||||||
|
| Server Component fetch | Internal reads | Any | Full Next.js caching |
|
||||||
|
| Server Action | Mutations, form submissions | POST only | No |
|
||||||
|
| Route Handler | External APIs, webhooks | Any | GET can be cached |
|
||||||
|
| Client fetch to API | Client-side reads | Any | HTTP cache headers |
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Debug Tricks
|
||||||
|
|
||||||
|
Tricks to speed up debugging Next.js applications.
|
||||||
|
|
||||||
|
## MCP Endpoint (Dev Server)
|
||||||
|
|
||||||
|
Next.js exposes a `/_next/mcp` endpoint in development for AI-assisted debugging via MCP (Model Context Protocol).
|
||||||
|
|
||||||
|
- **Next.js 16+**: Enabled by default, use `next-devtools-mcp`
|
||||||
|
- **Next.js < 16**: Requires `experimental.mcpServer: true` in next.config.js
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/guides/mcp
|
||||||
|
|
||||||
|
**Important**: Find the actual port of the running Next.js dev server (check terminal output or `package.json` scripts). Don't assume port 3000.
|
||||||
|
|
||||||
|
### Request Format
|
||||||
|
|
||||||
|
The endpoint uses JSON-RPC 2.0 over HTTP POST:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:<port>/_next/mcp \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "<tool-name>",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
#### `get_errors`
|
||||||
|
Get current errors from dev server (build errors, runtime errors with source-mapped stacks):
|
||||||
|
```json
|
||||||
|
{ "name": "get_errors", "arguments": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `get_routes`
|
||||||
|
Discover all routes by scanning filesystem:
|
||||||
|
```json
|
||||||
|
{ "name": "get_routes", "arguments": {} }
|
||||||
|
// Optional: { "name": "get_routes", "arguments": { "routerType": "app" } }
|
||||||
|
```
|
||||||
|
Returns: `{ "appRouter": ["/", "/api/users/[id]", ...], "pagesRouter": [...] }`
|
||||||
|
|
||||||
|
#### `get_project_metadata`
|
||||||
|
Get project path and dev server URL:
|
||||||
|
```json
|
||||||
|
{ "name": "get_project_metadata", "arguments": {} }
|
||||||
|
```
|
||||||
|
Returns: `{ "projectPath": "/path/to/project", "devServerUrl": "http://localhost:3000" }`
|
||||||
|
|
||||||
|
#### `get_page_metadata`
|
||||||
|
Get runtime metadata about current page render (requires active browser session):
|
||||||
|
```json
|
||||||
|
{ "name": "get_page_metadata", "arguments": {} }
|
||||||
|
```
|
||||||
|
Returns segment trie data showing layouts, boundaries, and page components.
|
||||||
|
|
||||||
|
#### `get_logs`
|
||||||
|
Get path to Next.js development log file:
|
||||||
|
```json
|
||||||
|
{ "name": "get_logs", "arguments": {} }
|
||||||
|
```
|
||||||
|
Returns path to `<distDir>/logs/next-development.log`
|
||||||
|
|
||||||
|
#### `get_server_action_by_id`
|
||||||
|
Locate a Server Action by ID:
|
||||||
|
```json
|
||||||
|
{ "name": "get_server_action_by_id", "arguments": { "actionId": "<action-id>" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Get Errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:<port>/_next/mcp \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"get_errors","arguments":{}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rebuild Specific Routes (Next.js 16+)
|
||||||
|
|
||||||
|
Use `--debug-build-paths` to rebuild only specific routes instead of the entire app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild a specific route
|
||||||
|
next build --debug-build-paths "/dashboard"
|
||||||
|
|
||||||
|
# Rebuild routes matching a glob
|
||||||
|
next build --debug-build-paths "/api/*"
|
||||||
|
|
||||||
|
# Dynamic routes
|
||||||
|
next build --debug-build-paths "/blog/[slug]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this to:
|
||||||
|
- Quickly verify a build fix without full rebuild
|
||||||
|
- Debug static generation issues for specific pages
|
||||||
|
- Iterate faster on build errors
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Directives
|
||||||
|
|
||||||
|
## React Directives
|
||||||
|
|
||||||
|
These are React directives, not Next.js specific.
|
||||||
|
|
||||||
|
### `'use client'`
|
||||||
|
|
||||||
|
Marks a component as a Client Component. Required for:
|
||||||
|
- React hooks (`useState`, `useEffect`, etc.)
|
||||||
|
- Event handlers (`onClick`, `onChange`)
|
||||||
|
- Browser APIs (`window`, `localStorage`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function Counter() {
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
return <button onClick={() => setCount(count + 1)}>{count}</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: https://react.dev/reference/rsc/use-client
|
||||||
|
|
||||||
|
### `'use server'`
|
||||||
|
|
||||||
|
Marks a function as a Server Action. Can be passed to Client Components.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
export async function submitForm(formData: FormData) {
|
||||||
|
// Runs on server
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or inline within a Server Component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function Page() {
|
||||||
|
async function submit() {
|
||||||
|
'use server'
|
||||||
|
// Runs on server
|
||||||
|
}
|
||||||
|
return <form action={submit}>...</form>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: https://react.dev/reference/rsc/use-server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next.js Directive
|
||||||
|
|
||||||
|
### `'use cache'`
|
||||||
|
|
||||||
|
Marks a function or component for caching. Part of Next.js Cache Components.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use cache'
|
||||||
|
|
||||||
|
export async function getCachedData() {
|
||||||
|
return await fetchData()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `cacheComponents: true` in `next.config.ts`.
|
||||||
|
|
||||||
|
For detailed usage including cache profiles, `cacheLife()`, `cacheTag()`, and `updateTag()`, see the `next-cache-components` skill.
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/api-reference/directives/use-cache
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# Error Handling
|
||||||
|
|
||||||
|
Handle errors gracefully in Next.js applications.
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/getting-started/error-handling
|
||||||
|
|
||||||
|
## Error Boundaries
|
||||||
|
|
||||||
|
### `error.tsx`
|
||||||
|
|
||||||
|
Catches errors in a route segment and its children:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Something went wrong!</h2>
|
||||||
|
<button onClick={() => reset()}>Try again</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** `error.tsx` must be a Client Component.
|
||||||
|
|
||||||
|
### `global-error.tsx`
|
||||||
|
|
||||||
|
Catches errors in root layout:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Something went wrong!</h2>
|
||||||
|
<button onClick={() => reset()}>Try again</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Must include `<html>` and `<body>` tags.
|
||||||
|
|
||||||
|
## Server Actions: Navigation API Gotcha
|
||||||
|
|
||||||
|
**Do NOT wrap navigation APIs in try-catch.** They throw special errors that Next.js handles internally.
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/api-reference/functions/redirect#behavior
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
|
// Bad: try-catch catches the navigation "error"
|
||||||
|
async function createPost(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const post = await db.post.create({ ... })
|
||||||
|
redirect(`/posts/${post.id}`) // This throws!
|
||||||
|
} catch (error) {
|
||||||
|
// redirect() throw is caught here - navigation fails!
|
||||||
|
return { error: 'Failed to create post' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Call navigation APIs outside try-catch
|
||||||
|
async function createPost(formData: FormData) {
|
||||||
|
let post
|
||||||
|
try {
|
||||||
|
post = await db.post.create({ ... })
|
||||||
|
} catch (error) {
|
||||||
|
return { error: 'Failed to create post' }
|
||||||
|
}
|
||||||
|
redirect(`/posts/${post.id}`) // Outside try-catch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Re-throw navigation errors
|
||||||
|
async function createPost(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const post = await db.post.create({ ... })
|
||||||
|
redirect(`/posts/${post.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
|
||||||
|
throw error // Re-throw navigation errors
|
||||||
|
}
|
||||||
|
return { error: 'Failed to create post' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same applies to:
|
||||||
|
- `redirect()` - 307 temporary redirect
|
||||||
|
- `permanentRedirect()` - 308 permanent redirect
|
||||||
|
- `notFound()` - 404 not found
|
||||||
|
- `forbidden()` - 403 forbidden
|
||||||
|
- `unauthorized()` - 401 unauthorized
|
||||||
|
|
||||||
|
Use `unstable_rethrow()` to re-throw these errors in catch blocks:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { unstable_rethrow } from 'next/navigation'
|
||||||
|
|
||||||
|
async function action() {
|
||||||
|
try {
|
||||||
|
// ...
|
||||||
|
redirect('/success')
|
||||||
|
} catch (error) {
|
||||||
|
unstable_rethrow(error) // Re-throws Next.js internal errors
|
||||||
|
return { error: 'Something went wrong' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Redirects
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { redirect, permanentRedirect } from 'next/navigation'
|
||||||
|
|
||||||
|
// 307 Temporary - use for most cases
|
||||||
|
redirect('/new-path')
|
||||||
|
|
||||||
|
// 308 Permanent - use for URL migrations (cached by browsers)
|
||||||
|
permanentRedirect('/new-url')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Errors
|
||||||
|
|
||||||
|
Trigger auth-related error pages:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { forbidden, unauthorized } from 'next/navigation'
|
||||||
|
|
||||||
|
async function Page() {
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
unauthorized() // Renders unauthorized.tsx (401)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.hasAccess) {
|
||||||
|
forbidden() // Renders forbidden.tsx (403)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Dashboard />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create corresponding error pages:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/forbidden.tsx
|
||||||
|
export default function Forbidden() {
|
||||||
|
return <div>You don't have access to this resource</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/unauthorized.tsx
|
||||||
|
export default function Unauthorized() {
|
||||||
|
return <div>Please log in to continue</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Not Found
|
||||||
|
|
||||||
|
### `not-found.tsx`
|
||||||
|
|
||||||
|
Custom 404 page for a route segment:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Not Found</h2>
|
||||||
|
<p>Could not find the requested resource</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Triggering Not Found
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
const post = await getPost(id)
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
notFound() // Renders closest not-found.tsx
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>{post.title}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Hierarchy
|
||||||
|
|
||||||
|
Errors bubble up to the nearest error boundary:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── error.tsx # Catches errors from all children
|
||||||
|
├── blog/
|
||||||
|
│ ├── error.tsx # Catches errors in /blog/*
|
||||||
|
│ └── [slug]/
|
||||||
|
│ ├── error.tsx # Catches errors in /blog/[slug]
|
||||||
|
│ └── page.tsx
|
||||||
|
└── layout.tsx # Errors here go to global-error.tsx
|
||||||
|
```
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# File Conventions
|
||||||
|
|
||||||
|
Next.js App Router uses file-based routing with special file conventions.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/getting-started/project-structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── layout.tsx # Root layout (required)
|
||||||
|
├── page.tsx # Home page (/)
|
||||||
|
├── loading.tsx # Loading UI
|
||||||
|
├── error.tsx # Error UI
|
||||||
|
├── not-found.tsx # 404 UI
|
||||||
|
├── global-error.tsx # Global error UI
|
||||||
|
├── route.ts # API endpoint
|
||||||
|
├── template.tsx # Re-rendered layout
|
||||||
|
├── default.tsx # Parallel route fallback
|
||||||
|
├── blog/
|
||||||
|
│ ├── page.tsx # /blog
|
||||||
|
│ └── [slug]/
|
||||||
|
│ └── page.tsx # /blog/:slug
|
||||||
|
└── (group)/ # Route group (no URL impact)
|
||||||
|
└── page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Special Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `page.tsx` | UI for a route segment |
|
||||||
|
| `layout.tsx` | Shared UI for segment and children |
|
||||||
|
| `loading.tsx` | Loading UI (Suspense boundary) |
|
||||||
|
| `error.tsx` | Error UI (Error boundary) |
|
||||||
|
| `not-found.tsx` | 404 UI |
|
||||||
|
| `route.ts` | API endpoint |
|
||||||
|
| `template.tsx` | Like layout but re-renders on navigation |
|
||||||
|
| `default.tsx` | Fallback for parallel routes |
|
||||||
|
|
||||||
|
## Route Segments
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── blog/ # Static segment: /blog
|
||||||
|
├── [slug]/ # Dynamic segment: /:slug
|
||||||
|
├── [...slug]/ # Catch-all: /a/b/c
|
||||||
|
├── [[...slug]]/ # Optional catch-all: / or /a/b/c
|
||||||
|
└── (marketing)/ # Route group (ignored in URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── @analytics/
|
||||||
|
│ └── page.tsx
|
||||||
|
├── @sidebar/
|
||||||
|
│ └── page.tsx
|
||||||
|
└── layout.tsx # Receives { analytics, sidebar } as props
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intercepting Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── feed/
|
||||||
|
│ └── page.tsx
|
||||||
|
├── @modal/
|
||||||
|
│ └── (.)photo/[id]/ # Intercepts /photo/[id] from /feed
|
||||||
|
│ └── page.tsx
|
||||||
|
└── photo/[id]/
|
||||||
|
└── page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Conventions:
|
||||||
|
- `(.)` - same level
|
||||||
|
- `(..)` - one level up
|
||||||
|
- `(..)(..)` - two levels up
|
||||||
|
- `(...)` - from root
|
||||||
|
|
||||||
|
## Private Folders
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── _components/ # Private folder (not a route)
|
||||||
|
│ └── Button.tsx
|
||||||
|
└── page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefix with `_` to exclude from routing.
|
||||||
|
|
||||||
|
## Middleware / Proxy
|
||||||
|
|
||||||
|
### Next.js 14-15: `middleware.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// middleware.ts (root of project)
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
// Auth, redirects, rewrites, etc.
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/dashboard/:path*', '/api/:path*'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next.js 16+: `proxy.ts`
|
||||||
|
|
||||||
|
Renamed for clarity - same capabilities, different names:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// proxy.ts (root of project)
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
// Same logic as middleware
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const proxyConfig = {
|
||||||
|
matcher: ['/dashboard/:path*', '/api/:path*'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
| Version | File | Export | Config |
|
||||||
|
|---------|------|--------|--------|
|
||||||
|
| v14-15 | `middleware.ts` | `middleware()` | `config` |
|
||||||
|
| v16+ | `proxy.ts` | `proxy()` | `proxyConfig` |
|
||||||
|
|
||||||
|
**Migration**: Run `npx @next/codemod@latest upgrade` to auto-rename.
|
||||||
|
|
||||||
|
## File Conventions Reference
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/api-reference/file-conventions
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# Font Optimization
|
||||||
|
|
||||||
|
Use `next/font` for automatic font optimization with zero layout shift.
|
||||||
|
|
||||||
|
## Google Fonts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/layout.tsx
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={inter.className}>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Fonts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Inter, Roboto_Mono } from 'next/font/google'
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-inter',
|
||||||
|
})
|
||||||
|
|
||||||
|
const robotoMono = Roboto_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-roboto-mono',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use in CSS:
|
||||||
|
```css
|
||||||
|
body {
|
||||||
|
font-family: var(--font-inter);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-roboto-mono);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Font Weights and Styles
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single weight
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: '400',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Multiple weights
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['400', '500', '700'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Variable font (recommended) - includes all weights
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
// No weight needed - variable fonts support all weights
|
||||||
|
})
|
||||||
|
|
||||||
|
// With italic
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
style: ['normal', 'italic'],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Fonts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import localFont from 'next/font/local'
|
||||||
|
|
||||||
|
const myFont = localFont({
|
||||||
|
src: './fonts/MyFont.woff2',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Multiple files for different weights
|
||||||
|
const myFont = localFont({
|
||||||
|
src: [
|
||||||
|
{
|
||||||
|
path: './fonts/MyFont-Regular.woff2',
|
||||||
|
weight: '400',
|
||||||
|
style: 'normal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: './fonts/MyFont-Bold.woff2',
|
||||||
|
weight: '700',
|
||||||
|
style: 'normal',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Variable font
|
||||||
|
const myFont = localFont({
|
||||||
|
src: './fonts/MyFont-Variable.woff2',
|
||||||
|
variable: '--font-my-font',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tailwind CSS Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/layout.tsx
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-inter',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={inter.variable}>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tailwind.config.js
|
||||||
|
module.exports = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['var(--font-inter)'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preloading Subsets
|
||||||
|
|
||||||
|
Only load needed character subsets:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Latin only (most common)
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
// Multiple subsets
|
||||||
|
const inter = Inter({ subsets: ['latin', 'latin-ext', 'cyrillic'] })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Display Strategy
|
||||||
|
|
||||||
|
Control font loading behavior:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap', // Default - shows fallback, swaps when loaded
|
||||||
|
})
|
||||||
|
|
||||||
|
// Options:
|
||||||
|
// 'auto' - browser decides
|
||||||
|
// 'block' - short block period, then swap
|
||||||
|
// 'swap' - immediate fallback, swap when ready (recommended)
|
||||||
|
// 'fallback' - short block, short swap, then fallback
|
||||||
|
// 'optional' - short block, no swap (use if font is optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Don't Use Manual Font Links
|
||||||
|
|
||||||
|
Always use `next/font` instead of `<link>` tags for Google Fonts.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Manual link tag (blocks rendering, no optimization)
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
|
||||||
|
|
||||||
|
// Bad: Missing display and preconnect
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
|
||||||
|
|
||||||
|
// Good: Use next/font (self-hosted, zero layout shift)
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Importing font in every component
|
||||||
|
// components/Button.tsx
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
const inter = Inter({ subsets: ['latin'] }) // Creates new instance each time!
|
||||||
|
|
||||||
|
// Good: Import once in layout, use CSS variable
|
||||||
|
// app/layout.tsx
|
||||||
|
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
||||||
|
|
||||||
|
// Bad: Using @import in CSS (blocks rendering)
|
||||||
|
/* globals.css */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter');
|
||||||
|
|
||||||
|
// Good: Use next/font (self-hosted, no network request)
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
|
||||||
|
// Bad: Loading all weights when only using a few
|
||||||
|
const inter = Inter({ subsets: ['latin'] }) // Loads all weights
|
||||||
|
|
||||||
|
// Good: Specify only needed weights (for non-variable fonts)
|
||||||
|
const inter = Inter({ subsets: ['latin'], weight: ['400', '700'] })
|
||||||
|
|
||||||
|
// Bad: Missing subset - loads all characters
|
||||||
|
const inter = Inter({})
|
||||||
|
|
||||||
|
// Good: Always specify subset
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Font in Specific Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// For component-specific fonts, export from a shared file
|
||||||
|
// lib/fonts.ts
|
||||||
|
import { Inter, Playfair_Display } from 'next/font/google'
|
||||||
|
|
||||||
|
export const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
||||||
|
export const playfair = Playfair_Display({ subsets: ['latin'], variable: '--font-playfair' })
|
||||||
|
|
||||||
|
// components/Heading.tsx
|
||||||
|
import { playfair } from '@/lib/fonts'
|
||||||
|
|
||||||
|
export function Heading({ children }) {
|
||||||
|
return <h1 className={playfair.className}>{children}</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Functions
|
||||||
|
|
||||||
|
Next.js function APIs.
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/api-reference/functions
|
||||||
|
|
||||||
|
## Navigation Hooks (Client)
|
||||||
|
|
||||||
|
| Hook | Purpose | Reference |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| `useRouter` | Programmatic navigation (`push`, `replace`, `back`, `refresh`) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-router) |
|
||||||
|
| `usePathname` | Get current pathname | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-pathname) |
|
||||||
|
| `useSearchParams` | Read URL search parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-search-params) |
|
||||||
|
| `useParams` | Access dynamic route parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-params) |
|
||||||
|
| `useSelectedLayoutSegment` | Active child segment (one level) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment) |
|
||||||
|
| `useSelectedLayoutSegments` | All active segments below layout | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segments) |
|
||||||
|
| `useLinkStatus` | Check link prefetch status | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-link-status) |
|
||||||
|
| `useReportWebVitals` | Report Core Web Vitals metrics | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals) |
|
||||||
|
|
||||||
|
## Server Functions
|
||||||
|
|
||||||
|
| Function | Purpose | Reference |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| `cookies` | Read/write cookies | [Docs](https://nextjs.org/docs/app/api-reference/functions/cookies) |
|
||||||
|
| `headers` | Read request headers | [Docs](https://nextjs.org/docs/app/api-reference/functions/headers) |
|
||||||
|
| `draftMode` | Enable preview of unpublished CMS content | [Docs](https://nextjs.org/docs/app/api-reference/functions/draft-mode) |
|
||||||
|
| `after` | Run code after response finishes streaming | [Docs](https://nextjs.org/docs/app/api-reference/functions/after) |
|
||||||
|
| `connection` | Wait for connection before dynamic rendering | [Docs](https://nextjs.org/docs/app/api-reference/functions/connection) |
|
||||||
|
| `userAgent` | Parse User-Agent header | [Docs](https://nextjs.org/docs/app/api-reference/functions/userAgent) |
|
||||||
|
|
||||||
|
## Generate Functions
|
||||||
|
|
||||||
|
| Function | Purpose | Reference |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| `generateStaticParams` | Pre-render dynamic routes at build time | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) |
|
||||||
|
| `generateMetadata` | Dynamic metadata | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) |
|
||||||
|
| `generateViewport` | Dynamic viewport config | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-viewport) |
|
||||||
|
| `generateSitemaps` | Multiple sitemaps for large sites | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps) |
|
||||||
|
| `generateImageMetadata` | Multiple OG images per route | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata) |
|
||||||
|
|
||||||
|
## Request/Response
|
||||||
|
|
||||||
|
| Function | Purpose | Reference |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| `NextRequest` | Extended Request with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-request) |
|
||||||
|
| `NextResponse` | Extended Response with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-response) |
|
||||||
|
| `ImageResponse` | Generate OG images | [Docs](https://nextjs.org/docs/app/api-reference/functions/image-response) |
|
||||||
|
|
||||||
|
## Common Examples
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
Use `next/link` for internal navigation instead of `<a>` tags.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Plain anchor tag
|
||||||
|
<a href="/about">About</a>
|
||||||
|
|
||||||
|
// Good: Next.js Link
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
<Link href="/about">About</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
Active link styling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
|
export function NavLink({ href, children }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} className={pathname === href ? 'active' : ''}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Static Generation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/blog/[slug]/page.tsx
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const posts = await getPosts()
|
||||||
|
return posts.map((post) => ({ slug: post.slug }))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Response
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { after } from 'next/server'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const data = await processRequest(request)
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await logAnalytics(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ success: true })
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Hydration Errors
|
||||||
|
|
||||||
|
Diagnose and fix React hydration mismatch errors.
|
||||||
|
|
||||||
|
## Error Signs
|
||||||
|
|
||||||
|
- "Hydration failed because the initial UI does not match"
|
||||||
|
- "Text content does not match server-rendered HTML"
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
In development, click the hydration error to see the server/client diff.
|
||||||
|
|
||||||
|
## Common Causes and Fixes
|
||||||
|
|
||||||
|
### Browser-only APIs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Causes mismatch - window doesn't exist on server
|
||||||
|
<div>{window.innerWidth}</div>
|
||||||
|
|
||||||
|
// Good: Use client component with mounted check
|
||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function ClientOnly({ children }: { children: React.ReactNode }) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => setMounted(true), [])
|
||||||
|
return mounted ? children : null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date/Time Rendering
|
||||||
|
|
||||||
|
Server and client may be in different timezones:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Causes mismatch
|
||||||
|
<span>{new Date().toLocaleString()}</span>
|
||||||
|
|
||||||
|
// Good: Render on client only
|
||||||
|
'use client'
|
||||||
|
const [time, setTime] = useState<string>()
|
||||||
|
useEffect(() => setTime(new Date().toLocaleString()), [])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Random Values or IDs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Random values differ between server and client
|
||||||
|
<div id={Math.random().toString()}>
|
||||||
|
|
||||||
|
// Good: Use useId hook
|
||||||
|
import { useId } from 'react'
|
||||||
|
|
||||||
|
function Input() {
|
||||||
|
const id = useId()
|
||||||
|
return <input id={id} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalid HTML Nesting
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Invalid - div inside p
|
||||||
|
<p><div>Content</div></p>
|
||||||
|
|
||||||
|
// Bad: Invalid - p inside p
|
||||||
|
<p><p>Nested</p></p>
|
||||||
|
|
||||||
|
// Good: Valid nesting
|
||||||
|
<div><p>Content</p></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Third-party Scripts
|
||||||
|
|
||||||
|
Scripts that modify DOM during hydration.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Use next/script with afterInteractive
|
||||||
|
import Script from 'next/script'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
src="https://example.com/script.js"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Image Optimization
|
||||||
|
|
||||||
|
Use `next/image` for automatic image optimization.
|
||||||
|
|
||||||
|
## Always Use next/image
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Avoid native img
|
||||||
|
<img src="/hero.png" alt="Hero" />
|
||||||
|
|
||||||
|
// Good: Use next/image
|
||||||
|
import Image from 'next/image'
|
||||||
|
<Image src="/hero.png" alt="Hero" width={800} height={400} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Props
|
||||||
|
|
||||||
|
Images need explicit dimensions to prevent layout shift:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Local images - dimensions inferred automatically
|
||||||
|
import heroImage from './hero.png'
|
||||||
|
<Image src={heroImage} alt="Hero" />
|
||||||
|
|
||||||
|
// Remote images - must specify width/height
|
||||||
|
<Image src="https://example.com/image.jpg" alt="Hero" width={800} height={400} />
|
||||||
|
|
||||||
|
// Or use fill for parent-relative sizing
|
||||||
|
<div style={{ position: 'relative', width: '100%', height: 400 }}>
|
||||||
|
<Image src="/hero.png" alt="Hero" fill style={{ objectFit: 'cover' }} />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote Images Configuration
|
||||||
|
|
||||||
|
Remote domains must be configured in `next.config.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'example.com',
|
||||||
|
pathname: '/images/**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '*.cdn.com', // Wildcard subdomain
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Images
|
||||||
|
|
||||||
|
Use `sizes` to tell the browser which size to download:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Full-width hero
|
||||||
|
<Image
|
||||||
|
src="/hero.png"
|
||||||
|
alt="Hero"
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Responsive grid (3 columns on desktop, 1 on mobile)
|
||||||
|
<Image
|
||||||
|
src="/card.png"
|
||||||
|
alt="Card"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Fixed sidebar image
|
||||||
|
<Image
|
||||||
|
src="/avatar.png"
|
||||||
|
alt="Avatar"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
sizes="200px"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Blur Placeholder
|
||||||
|
|
||||||
|
Prevent layout shift with placeholders:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Local images - automatic blur hash
|
||||||
|
import heroImage from './hero.png'
|
||||||
|
<Image src={heroImage} alt="Hero" placeholder="blur" />
|
||||||
|
|
||||||
|
// Remote images - provide blurDataURL
|
||||||
|
<Image
|
||||||
|
src="https://example.com/image.jpg"
|
||||||
|
alt="Hero"
|
||||||
|
width={800}
|
||||||
|
height={400}
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Or use color placeholder
|
||||||
|
<Image
|
||||||
|
src="https://example.com/image.jpg"
|
||||||
|
alt="Hero"
|
||||||
|
width={800}
|
||||||
|
height={400}
|
||||||
|
placeholder="empty"
|
||||||
|
style={{ backgroundColor: '#e0e0e0' }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Priority Loading
|
||||||
|
|
||||||
|
Use `priority` for above-the-fold images (LCP):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Hero image - loads immediately
|
||||||
|
<Image src="/hero.png" alt="Hero" fill priority />
|
||||||
|
|
||||||
|
// Below-fold images - lazy loaded by default (no priority needed)
|
||||||
|
<Image src="/card.png" alt="Card" width={400} height={300} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Missing sizes with fill - downloads largest image
|
||||||
|
<Image src="/hero.png" alt="Hero" fill />
|
||||||
|
|
||||||
|
// Good: Add sizes for proper responsive behavior
|
||||||
|
<Image src="/hero.png" alt="Hero" fill sizes="100vw" />
|
||||||
|
|
||||||
|
// Bad: Using width/height for aspect ratio only
|
||||||
|
<Image src="/hero.png" alt="Hero" width={16} height={9} />
|
||||||
|
|
||||||
|
// Good: Use actual display dimensions or fill with sizes
|
||||||
|
<Image src="/hero.png" alt="Hero" fill sizes="100vw" style={{ objectFit: 'cover' }} />
|
||||||
|
|
||||||
|
// Bad: Remote image without config
|
||||||
|
<Image src="https://untrusted.com/image.jpg" alt="Image" width={400} height={300} />
|
||||||
|
// Error: Invalid src prop, hostname not configured
|
||||||
|
|
||||||
|
// Good: Add hostname to next.config.js remotePatterns
|
||||||
|
```
|
||||||
|
|
||||||
|
## Static Export
|
||||||
|
|
||||||
|
When using `output: 'export'`, use `unoptimized` or custom loader:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Option 1: Disable optimization
|
||||||
|
<Image src="/hero.png" alt="Hero" width={800} height={400} unoptimized />
|
||||||
|
|
||||||
|
// Option 2: Global config
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
output: 'export',
|
||||||
|
images: { unoptimized: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 3: Custom loader (Cloudinary, Imgix, etc.)
|
||||||
|
const cloudinaryLoader = ({ src, width, quality }) => {
|
||||||
|
return `https://res.cloudinary.com/demo/image/upload/w_${width},q_${quality || 75}/${src}`
|
||||||
|
}
|
||||||
|
|
||||||
|
<Image loader={cloudinaryLoader} src="sample.jpg" alt="Sample" width={800} height={400} />
|
||||||
|
```
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# Metadata
|
||||||
|
|
||||||
|
Add SEO metadata to Next.js pages using the Metadata API.
|
||||||
|
|
||||||
|
## Important: Server Components Only
|
||||||
|
|
||||||
|
The `metadata` object and `generateMetadata` function are **only supported in Server Components**. They cannot be used in Client Components.
|
||||||
|
|
||||||
|
If the target page has `'use client'`:
|
||||||
|
1. Remove `'use client'` if possible, move client logic to child components
|
||||||
|
2. Or extract metadata to a parent Server Component layout
|
||||||
|
3. Or split the file: Server Component with metadata imports Client Components
|
||||||
|
|
||||||
|
## Static Metadata
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Page Title',
|
||||||
|
description: 'Page description for search engines',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Metadata
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { slug } = await params
|
||||||
|
const post = await getPost(slug)
|
||||||
|
return { title: post.title, description: post.description }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Avoid Duplicate Fetches
|
||||||
|
|
||||||
|
Use React `cache()` when the same data is needed for both metadata and page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cache } from 'react'
|
||||||
|
|
||||||
|
export const getPost = cache(async (slug: string) => {
|
||||||
|
return await db.posts.findFirst({ where: { slug } })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Viewport
|
||||||
|
|
||||||
|
Separate from metadata for streaming support:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Viewport } from 'next'
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
themeColor: '#000000',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or dynamic
|
||||||
|
export function generateViewport({ params }): Viewport {
|
||||||
|
return { themeColor: getThemeColor(params) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Title Templates
|
||||||
|
|
||||||
|
In root layout for consistent naming:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: { default: 'Site Name', template: '%s | Site Name' },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metadata File Conventions
|
||||||
|
|
||||||
|
Reference: https://nextjs.org/docs/app/getting-started/project-structure#metadata-file-conventions
|
||||||
|
|
||||||
|
Place these files in `app/` directory (or route segments):
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `favicon.ico` | Favicon |
|
||||||
|
| `icon.png` / `icon.svg` | App icon |
|
||||||
|
| `apple-icon.png` | Apple app icon |
|
||||||
|
| `opengraph-image.png` | OG image |
|
||||||
|
| `twitter-image.png` | Twitter card image |
|
||||||
|
| `sitemap.ts` / `sitemap.xml` | Sitemap (use `generateSitemaps` for multiple) |
|
||||||
|
| `robots.ts` / `robots.txt` | Robots directives |
|
||||||
|
| `manifest.ts` / `manifest.json` | Web app manifest |
|
||||||
|
|
||||||
|
## SEO Best Practice: Static Files Are Often Enough
|
||||||
|
|
||||||
|
For most sites, **static metadata files provide excellent SEO coverage**:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── favicon.ico
|
||||||
|
├── opengraph-image.png # Works for both OG and Twitter
|
||||||
|
├── sitemap.ts
|
||||||
|
├── robots.ts
|
||||||
|
└── layout.tsx # With title/description metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
- A single `opengraph-image.png` covers both Open Graph and Twitter (Twitter falls back to OG)
|
||||||
|
- Static `title` and `description` in layout metadata is sufficient for most pages
|
||||||
|
- Only use dynamic `generateMetadata` when content varies per page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# OG Image Generation
|
||||||
|
|
||||||
|
Generate dynamic Open Graph images using `next/og`.
|
||||||
|
|
||||||
|
## Important Rules
|
||||||
|
|
||||||
|
1. **Use `next/og`** - not `@vercel/og` (it's built into Next.js)
|
||||||
|
2. **No searchParams** - OG images can't access search params, use route params instead
|
||||||
|
3. **Avoid Edge runtime** - Use default Node.js runtime
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
// import { ImageResponse } from '@vercel/og'
|
||||||
|
// export const runtime = 'edge'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic OG Image
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/opengraph-image.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
|
||||||
|
export const alt = 'Site Name'
|
||||||
|
export const size = { width: 1200, height: 630 }
|
||||||
|
export const contentType = 'image/png'
|
||||||
|
|
||||||
|
export default function Image() {
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 128,
|
||||||
|
background: 'white',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hello World
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic OG Image
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/blog/[slug]/opengraph-image.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
|
||||||
|
export const alt = 'Blog Post'
|
||||||
|
export const size = { width: 1200, height: 630 }
|
||||||
|
export const contentType = 'image/png'
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
|
export default async function Image({ params }: Props) {
|
||||||
|
const { slug } = await params
|
||||||
|
const post = await getPost(slug)
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
background: 'linear-gradient(to bottom, #1a1a1a, #333)',
|
||||||
|
color: 'white',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 64, fontWeight: 'bold' }}>{post.title}</div>
|
||||||
|
<div style={{ marginTop: 24, opacity: 0.8 }}>{post.description}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Fonts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
const fontPath = join(process.cwd(), 'assets/fonts/Inter-Bold.ttf')
|
||||||
|
const fontData = await readFile(fontPath)
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div style={{ fontFamily: 'Inter', fontSize: 64 }}>
|
||||||
|
Custom Font Text
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: [{ name: 'Inter', data: fontData, style: 'normal' }],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
- `opengraph-image.tsx` - Open Graph (Facebook, LinkedIn)
|
||||||
|
- `twitter-image.tsx` - Twitter/X cards (optional, falls back to OG)
|
||||||
|
|
||||||
|
## Styling Notes
|
||||||
|
|
||||||
|
ImageResponse uses Flexbox layout:
|
||||||
|
- Use `display: 'flex'`
|
||||||
|
- No CSS Grid support
|
||||||
|
- Styles must be inline objects
|
||||||
|
|
||||||
|
## Multiple OG Images
|
||||||
|
|
||||||
|
Use `generateImageMetadata` for multiple images per route:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/blog/[slug]/opengraph-image.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
|
||||||
|
export async function generateImageMetadata({ params }) {
|
||||||
|
const images = await getPostImages(params.slug)
|
||||||
|
return images.map((img, idx) => ({
|
||||||
|
id: idx,
|
||||||
|
alt: img.alt,
|
||||||
|
size: { width: 1200, height: 630 },
|
||||||
|
contentType: 'image/png',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Image({ params, id }) {
|
||||||
|
const images = await getPostImages(params.slug)
|
||||||
|
const image = images[id]
|
||||||
|
return new ImageResponse(/* ... */)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Sitemaps
|
||||||
|
|
||||||
|
Use `generateSitemaps` for large sites:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/sitemap.ts
|
||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
|
||||||
|
export async function generateSitemaps() {
|
||||||
|
// Return array of sitemap IDs
|
||||||
|
return [{ id: 0 }, { id: 1 }, { id: 2 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function sitemap({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number
|
||||||
|
}): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const start = id * 50000
|
||||||
|
const end = start + 50000
|
||||||
|
const products = await getProducts(start, end)
|
||||||
|
|
||||||
|
return products.map((product) => ({
|
||||||
|
url: `https://example.com/product/${product.id}`,
|
||||||
|
lastModified: product.updatedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generates `/sitemap/0.xml`, `/sitemap/1.xml`, etc.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Parallel & Intercepting Routes
|
||||||
|
|
||||||
|
Parallel routes render multiple pages in the same layout. Intercepting routes show a different UI when navigating from within your app vs direct URL access. Together they enable modal patterns.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── @modal/ # Parallel route slot
|
||||||
|
│ ├── default.tsx # Required! Returns null
|
||||||
|
│ ├── (.)photos/ # Intercepts /photos/*
|
||||||
|
│ │ └── [id]/
|
||||||
|
│ │ └── page.tsx # Modal content
|
||||||
|
│ └── [...]catchall/ # Optional: catch unmatched
|
||||||
|
│ └── page.tsx
|
||||||
|
├── photos/
|
||||||
|
│ └── [id]/
|
||||||
|
│ └── page.tsx # Full page (direct access)
|
||||||
|
├── layout.tsx # Renders both children and @modal
|
||||||
|
└── page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Root Layout with Slot
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/layout.tsx
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
modal,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
modal: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
{modal}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Default File (Critical!)
|
||||||
|
|
||||||
|
**Every parallel route slot MUST have a `default.tsx`** to prevent 404s on hard navigation.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/@modal/default.tsx
|
||||||
|
export default function Default() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this file, refreshing any page will 404 because Next.js can't determine what to render in the `@modal` slot.
|
||||||
|
|
||||||
|
## Step 3: Intercepting Route (Modal)
|
||||||
|
|
||||||
|
The `(.)` prefix intercepts routes at the same level.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/@modal/(.)photos/[id]/page.tsx
|
||||||
|
import { Modal } from '@/components/modal';
|
||||||
|
|
||||||
|
export default async function PhotoModal({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const photo = await getPhoto(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal>
|
||||||
|
<img src={photo.url} alt={photo.title} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Full Page (Direct Access)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/photos/[id]/page.tsx
|
||||||
|
export default async function PhotoPage({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const photo = await getPhoto(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="full-page">
|
||||||
|
<img src={photo.url} alt={photo.title} />
|
||||||
|
<h1>{photo.title}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Modal Component with Correct Closing
|
||||||
|
|
||||||
|
**Critical: Use `router.back()` to close modals, NOT `router.push()` or `<Link>`.**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/modal.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function Modal({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close on escape key
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
router.back(); // Correct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// Close on overlay click
|
||||||
|
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.target === overlayRef.current) {
|
||||||
|
router.back(); // Correct
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()} // Correct!
|
||||||
|
className="absolute top-4 right-4"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why NOT `router.push('/')` or `<Link href="/">`?
|
||||||
|
|
||||||
|
Using `push` or `Link` to "close" a modal:
|
||||||
|
1. Adds a new history entry (back button shows modal again)
|
||||||
|
2. Doesn't properly clear the intercepted route
|
||||||
|
3. Can cause the modal to flash or persist unexpectedly
|
||||||
|
|
||||||
|
`router.back()` correctly:
|
||||||
|
1. Removes the intercepted route from history
|
||||||
|
2. Returns to the previous page
|
||||||
|
3. Properly unmounts the modal
|
||||||
|
|
||||||
|
## Route Matcher Reference
|
||||||
|
|
||||||
|
Matchers match **route segments**, not filesystem paths:
|
||||||
|
|
||||||
|
| Matcher | Matches | Example |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `(.)` | Same level | `@modal/(.)photos` intercepts `/photos` |
|
||||||
|
| `(..)` | One level up | `@modal/(..)settings` from `/dashboard/@modal` intercepts `/settings` |
|
||||||
|
| `(..)(..)` | Two levels up | Rarely used |
|
||||||
|
| `(...)` | From root | `@modal/(...)photos` intercepts `/photos` from anywhere |
|
||||||
|
|
||||||
|
**Common mistake**: Thinking `(..)` means "parent folder" - it means "parent route segment".
|
||||||
|
|
||||||
|
## Handling Hard Navigation
|
||||||
|
|
||||||
|
When users directly visit `/photos/123` (bookmark, refresh, shared link):
|
||||||
|
- The intercepting route is bypassed
|
||||||
|
- The full `photos/[id]/page.tsx` renders
|
||||||
|
- Modal doesn't appear (expected behavior)
|
||||||
|
|
||||||
|
If you want the modal to appear on direct access too, you need additional logic:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/photos/[id]/page.tsx
|
||||||
|
import { Modal } from '@/components/modal';
|
||||||
|
|
||||||
|
export default async function PhotoPage({ params }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const photo = await getPhoto(id);
|
||||||
|
|
||||||
|
// Option: Render as modal on direct access too
|
||||||
|
return (
|
||||||
|
<Modal>
|
||||||
|
<img src={photo.url} alt={photo.title} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
### 1. Missing `default.tsx` → 404 on Refresh
|
||||||
|
|
||||||
|
Every `@slot` folder needs a `default.tsx` that returns `null` (or appropriate content).
|
||||||
|
|
||||||
|
### 2. Modal Persists After Navigation
|
||||||
|
|
||||||
|
You're using `router.push()` instead of `router.back()`.
|
||||||
|
|
||||||
|
### 3. Nested Parallel Routes Need Defaults Too
|
||||||
|
|
||||||
|
If you have `@modal` inside a route group, each level needs its own `default.tsx`:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── (marketing)/
|
||||||
|
│ ├── @modal/
|
||||||
|
│ │ └── default.tsx # Needed!
|
||||||
|
│ └── layout.tsx
|
||||||
|
└── layout.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Intercepted Route Shows Wrong Content
|
||||||
|
|
||||||
|
Check your matcher:
|
||||||
|
- `(.)photos` intercepts `/photos` from the same route level
|
||||||
|
- If your `@modal` is in `app/dashboard/@modal`, use `(.)photos` to intercept `/dashboard/photos`, not `/photos`
|
||||||
|
|
||||||
|
### 5. TypeScript Errors with `params`
|
||||||
|
|
||||||
|
In Next.js 15+, `params` is a Promise:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Correct
|
||||||
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example: Photo Gallery Modal
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── @modal/
|
||||||
|
│ ├── default.tsx
|
||||||
|
│ └── (.)photos/
|
||||||
|
│ └── [id]/
|
||||||
|
│ └── page.tsx
|
||||||
|
├── photos/
|
||||||
|
│ ├── page.tsx # Gallery grid
|
||||||
|
│ └── [id]/
|
||||||
|
│ └── page.tsx # Full photo page
|
||||||
|
├── layout.tsx
|
||||||
|
└── page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Links in the gallery:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/photos/page.tsx
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default async function Gallery() {
|
||||||
|
const photos = await getPhotos();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{photos.map(photo => (
|
||||||
|
<Link key={photo.id} href={`/photos/${photo.id}`}>
|
||||||
|
<img src={photo.thumbnail} alt={photo.title} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clicking a photo → Modal opens (intercepted)
|
||||||
|
Direct URL → Full page renders
|
||||||
|
Refresh while modal open → Full page renders
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Route Handlers
|
||||||
|
|
||||||
|
Create API endpoints with `route.ts` files.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/api/users/route.ts
|
||||||
|
export async function GET() {
|
||||||
|
const users = await getUsers()
|
||||||
|
return Response.json(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.json()
|
||||||
|
const user = await createUser(body)
|
||||||
|
return Response.json(user, { status: 201 })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Methods
|
||||||
|
|
||||||
|
`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`
|
||||||
|
|
||||||
|
## GET Handler Conflicts with page.tsx
|
||||||
|
|
||||||
|
**A `route.ts` and `page.tsx` cannot coexist in the same folder.**
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── api/
|
||||||
|
│ └── users/
|
||||||
|
│ └── route.ts # /api/users
|
||||||
|
└── users/
|
||||||
|
├── page.tsx # /users (page)
|
||||||
|
└── route.ts # Warning: Conflicts with page.tsx!
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need both a page and an API at the same path, use different paths:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── users/
|
||||||
|
│ └── page.tsx # /users (page)
|
||||||
|
└── api/
|
||||||
|
└── users/
|
||||||
|
└── route.ts # /api/users (API)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Behavior
|
||||||
|
|
||||||
|
Route handlers run in a **Server Component-like environment**:
|
||||||
|
|
||||||
|
- Yes: Can use `async/await`
|
||||||
|
- Yes: Can access `cookies()`, `headers()`
|
||||||
|
- Yes: Can use Node.js APIs
|
||||||
|
- No: Cannot use React hooks
|
||||||
|
- No: Cannot use React DOM APIs
|
||||||
|
- No: Cannot use browser APIs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: This won't work - no React DOM in route handlers
|
||||||
|
import { renderToString } from 'react-dom/server'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const html = renderToString(<Component />) // Error!
|
||||||
|
return new Response(html)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Route Handlers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/api/users/[id]/route.ts
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const user = await getUser(id)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(user)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Helpers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// URL and search params
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const query = searchParams.get('q')
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
||||||
|
// Cookies (Next.js helper)
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const token = cookieStore.get('token')
|
||||||
|
|
||||||
|
return Response.json({ query, token })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Helpers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// JSON response
|
||||||
|
return Response.json({ data })
|
||||||
|
|
||||||
|
// With status
|
||||||
|
return Response.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
// With headers
|
||||||
|
return Response.json(data, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'max-age=3600',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect
|
||||||
|
return Response.redirect(new URL('/login', request.url))
|
||||||
|
|
||||||
|
// Stream
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use Route Handlers vs Server Actions
|
||||||
|
|
||||||
|
| Use Case | Route Handlers | Server Actions |
|
||||||
|
|----------|----------------|----------------|
|
||||||
|
| Form submissions | No | Yes |
|
||||||
|
| Data mutations from UI | No | Yes |
|
||||||
|
| Third-party webhooks | Yes | No |
|
||||||
|
| External API consumption | Yes | No |
|
||||||
|
| Public REST API | Yes | No |
|
||||||
|
| File uploads | Both work | Both work |
|
||||||
|
|
||||||
|
**Prefer Server Actions** for mutations triggered from your UI.
|
||||||
|
**Use Route Handlers** for external integrations and public APIs.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# RSC Boundaries
|
||||||
|
|
||||||
|
Detect and prevent invalid patterns when crossing Server/Client component boundaries.
|
||||||
|
|
||||||
|
## Detection Rules
|
||||||
|
|
||||||
|
### 1. Async Client Components Are Invalid
|
||||||
|
|
||||||
|
Client components **cannot** be async functions. Only Server Components can be async.
|
||||||
|
|
||||||
|
**Detect:** File has `'use client'` AND component is `async function` or returns `Promise`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: async client component
|
||||||
|
'use client'
|
||||||
|
export default async function UserProfile() {
|
||||||
|
const user = await getUser() // Cannot await in client component
|
||||||
|
return <div>{user.name}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Remove async, fetch data in parent server component
|
||||||
|
// page.tsx (server component - no 'use client')
|
||||||
|
export default async function Page() {
|
||||||
|
const user = await getUser()
|
||||||
|
return <UserProfile user={user} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProfile.tsx (client component)
|
||||||
|
'use client'
|
||||||
|
export function UserProfile({ user }: { user: User }) {
|
||||||
|
return <div>{user.name}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: async arrow function client component
|
||||||
|
'use client'
|
||||||
|
const Dashboard = async () => {
|
||||||
|
const data = await fetchDashboard()
|
||||||
|
return <div>{data}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Fetch in server component, pass data down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Non-Serializable Props to Client Components
|
||||||
|
|
||||||
|
Props passed from Server → Client must be JSON-serializable.
|
||||||
|
|
||||||
|
**Detect:** Server component passes these to a client component:
|
||||||
|
- Functions (except Server Actions with `'use server'`)
|
||||||
|
- `Date` objects
|
||||||
|
- `Map`, `Set`, `WeakMap`, `WeakSet`
|
||||||
|
- Class instances
|
||||||
|
- `Symbol` (unless globally registered)
|
||||||
|
- Circular references
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Function prop
|
||||||
|
// page.tsx (server)
|
||||||
|
export default function Page() {
|
||||||
|
const handleClick = () => console.log('clicked')
|
||||||
|
return <ClientButton onClick={handleClick} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Define function inside client component
|
||||||
|
// ClientButton.tsx
|
||||||
|
'use client'
|
||||||
|
export function ClientButton() {
|
||||||
|
const handleClick = () => console.log('clicked')
|
||||||
|
return <button onClick={handleClick}>Click</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Date object (silently becomes string, then crashes)
|
||||||
|
// page.tsx (server)
|
||||||
|
export default async function Page() {
|
||||||
|
const post = await getPost()
|
||||||
|
return <PostCard createdAt={post.createdAt} /> // Date object
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostCard.tsx (client) - will crash on .getFullYear()
|
||||||
|
'use client'
|
||||||
|
export function PostCard({ createdAt }: { createdAt: Date }) {
|
||||||
|
return <span>{createdAt.getFullYear()}</span> // Runtime error!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good: Serialize to string on server
|
||||||
|
// page.tsx (server)
|
||||||
|
export default async function Page() {
|
||||||
|
const post = await getPost()
|
||||||
|
return <PostCard createdAt={post.createdAt.toISOString()} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostCard.tsx (client)
|
||||||
|
'use client'
|
||||||
|
export function PostCard({ createdAt }: { createdAt: string }) {
|
||||||
|
const date = new Date(createdAt)
|
||||||
|
return <span>{date.getFullYear()}</span>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Class instance
|
||||||
|
const user = new UserModel(data)
|
||||||
|
<ClientProfile user={user} /> // Methods will be stripped
|
||||||
|
|
||||||
|
// Good: Pass plain object
|
||||||
|
const user = await getUser()
|
||||||
|
<ClientProfile user={{ id: user.id, name: user.name }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Map/Set
|
||||||
|
<ClientComponent items={new Map([['a', 1]])} />
|
||||||
|
|
||||||
|
// Good: Convert to array/object
|
||||||
|
<ClientComponent items={Object.fromEntries(map)} />
|
||||||
|
<ClientComponent items={Array.from(set)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Server Actions Are the Exception
|
||||||
|
|
||||||
|
Functions marked with `'use server'` CAN be passed to client components.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Valid: Server Action can be passed
|
||||||
|
// actions.ts
|
||||||
|
'use server'
|
||||||
|
export async function submitForm(formData: FormData) {
|
||||||
|
// server-side logic
|
||||||
|
}
|
||||||
|
|
||||||
|
// page.tsx (server)
|
||||||
|
import { submitForm } from './actions'
|
||||||
|
export default function Page() {
|
||||||
|
return <ClientForm onSubmit={submitForm} /> // OK!
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientForm.tsx (client)
|
||||||
|
'use client'
|
||||||
|
export function ClientForm({ onSubmit }: { onSubmit: (data: FormData) => Promise<void> }) {
|
||||||
|
return <form action={onSubmit}>...</form>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Pattern | Valid? | Fix |
|
||||||
|
|---------|--------|-----|
|
||||||
|
| `'use client'` + `async function` | No | Fetch in server parent, pass data |
|
||||||
|
| Pass `() => {}` to client | No | Define in client or use server action |
|
||||||
|
| Pass `new Date()` to client | No | Use `.toISOString()` |
|
||||||
|
| Pass `new Map()` to client | No | Convert to object/array |
|
||||||
|
| Pass class instance to client | No | Pass plain object |
|
||||||
|
| Pass server action to client | Yes | - |
|
||||||
|
| Pass `string/number/boolean` | Yes | - |
|
||||||
|
| Pass plain object/array | Yes | - |
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Runtime Selection
|
||||||
|
|
||||||
|
## Use Node.js Runtime by Default
|
||||||
|
|
||||||
|
Use the default Node.js runtime for new routes and pages. Only use Edge runtime if the project already uses it or there's a specific requirement.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Default - no runtime config needed (uses Node.js)
|
||||||
|
export default function Page() { ... }
|
||||||
|
|
||||||
|
// Caution: Only if already used in project or specifically required
|
||||||
|
export const runtime = 'edge'
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use Each
|
||||||
|
|
||||||
|
### Node.js Runtime (Default)
|
||||||
|
|
||||||
|
- Full Node.js API support
|
||||||
|
- File system access (`fs`)
|
||||||
|
- Full `crypto` support
|
||||||
|
- Database connections
|
||||||
|
- Most npm packages work
|
||||||
|
|
||||||
|
### Edge Runtime
|
||||||
|
|
||||||
|
- Only for specific edge-location latency requirements
|
||||||
|
- Limited API (no `fs`, limited `crypto`)
|
||||||
|
- Smaller cold start
|
||||||
|
- Geographic distribution needs
|
||||||
|
|
||||||
|
## Detection
|
||||||
|
|
||||||
|
**Before adding `runtime = 'edge'`**, check:
|
||||||
|
1. Does the project already use Edge runtime?
|
||||||
|
2. Is there a specific latency requirement?
|
||||||
|
3. Are all dependencies Edge-compatible?
|
||||||
|
|
||||||
|
If unsure, use Node.js runtime.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Scripts
|
||||||
|
|
||||||
|
Loading third-party scripts in Next.js.
|
||||||
|
|
||||||
|
## Use next/script
|
||||||
|
|
||||||
|
Always use `next/script` instead of native `<script>` tags for better performance.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Native script tag
|
||||||
|
<script src="https://example.com/script.js"></script>
|
||||||
|
|
||||||
|
// Good: Next.js Script component
|
||||||
|
import Script from 'next/script'
|
||||||
|
|
||||||
|
<Script src="https://example.com/script.js" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inline Scripts Need ID
|
||||||
|
|
||||||
|
Inline scripts require an `id` attribute for Next.js to track them.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Missing id
|
||||||
|
<Script dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} />
|
||||||
|
|
||||||
|
// Good: Has id
|
||||||
|
<Script id="my-script" dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} />
|
||||||
|
|
||||||
|
// Good: Inline with id
|
||||||
|
<Script id="show-banner">
|
||||||
|
{`document.getElementById('banner').classList.remove('hidden')`}
|
||||||
|
</Script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Don't Put Script in Head
|
||||||
|
|
||||||
|
`next/script` should not be placed inside `next/head`. It handles its own positioning.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Script inside Head
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Script from 'next/script'
|
||||||
|
|
||||||
|
<Head>
|
||||||
|
<Script src="/analytics.js" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
// Good: Script outside Head
|
||||||
|
<Head>
|
||||||
|
<title>Page</title>
|
||||||
|
</Head>
|
||||||
|
<Script src="/analytics.js" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading Strategies
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// afterInteractive (default) - Load after page is interactive
|
||||||
|
<Script src="/analytics.js" strategy="afterInteractive" />
|
||||||
|
|
||||||
|
// lazyOnload - Load during idle time
|
||||||
|
<Script src="/widget.js" strategy="lazyOnload" />
|
||||||
|
|
||||||
|
// beforeInteractive - Load before page is interactive (use sparingly)
|
||||||
|
// Only works in app/layout.tsx or pages/_document.js
|
||||||
|
<Script src="/critical.js" strategy="beforeInteractive" />
|
||||||
|
|
||||||
|
// worker - Load in web worker (experimental)
|
||||||
|
<Script src="/heavy.js" strategy="worker" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Google Analytics
|
||||||
|
|
||||||
|
Use `@next/third-parties` instead of inline GA scripts.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Inline GA script
|
||||||
|
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX" />
|
||||||
|
<Script id="ga-init">
|
||||||
|
{`window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-XXXXX');`}
|
||||||
|
</Script>
|
||||||
|
|
||||||
|
// Good: Next.js component
|
||||||
|
import { GoogleAnalytics } from '@next/third-parties/google'
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>{children}</body>
|
||||||
|
<GoogleAnalytics gaId="G-XXXXX" />
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Google Tag Manager
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { GoogleTagManager } from '@next/third-parties/google'
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<GoogleTagManager gtmId="GTM-XXXXX" />
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other Third-Party Scripts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// YouTube embed
|
||||||
|
import { YouTubeEmbed } from '@next/third-parties/google'
|
||||||
|
|
||||||
|
<YouTubeEmbed videoid="dQw4w9WgXcQ" />
|
||||||
|
|
||||||
|
// Google Maps
|
||||||
|
import { GoogleMapsEmbed } from '@next/third-parties/google'
|
||||||
|
|
||||||
|
<GoogleMapsEmbed
|
||||||
|
apiKey="YOUR_API_KEY"
|
||||||
|
mode="place"
|
||||||
|
q="Brooklyn+Bridge,New+York,NY"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Pattern | Issue | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| `<script src="...">` | No optimization | Use `next/script` |
|
||||||
|
| `<Script>` without id | Can't track inline scripts | Add `id` attribute |
|
||||||
|
| `<Script>` inside `<Head>` | Wrong placement | Move outside Head |
|
||||||
|
| Inline GA/GTM scripts | No optimization | Use `@next/third-parties` |
|
||||||
|
| `strategy="beforeInteractive"` outside layout | Won't work | Only use in root layout |
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# Self-Hosting Next.js
|
||||||
|
|
||||||
|
Deploy Next.js outside of Vercel with confidence.
|
||||||
|
|
||||||
|
## Quick Start: Standalone Output
|
||||||
|
|
||||||
|
For Docker or any containerized deployment, use standalone output:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a minimal `standalone` folder with only production dependencies:
|
||||||
|
|
||||||
|
```
|
||||||
|
.next/
|
||||||
|
├── standalone/
|
||||||
|
│ ├── server.js # Entry point
|
||||||
|
│ ├── node_modules/ # Only production deps
|
||||||
|
│ └── .next/ # Build output
|
||||||
|
└── static/ # Must be copied separately
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Build
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy standalone output
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## PM2 Deployment
|
||||||
|
|
||||||
|
For traditional server deployments:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ecosystem.config.js
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'nextjs',
|
||||||
|
script: '.next/standalone/server.js',
|
||||||
|
instances: 'max',
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## ISR and Cache Handlers
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
ISR (Incremental Static Regeneration) uses filesystem caching by default. This **breaks with multiple instances**:
|
||||||
|
|
||||||
|
- Instance A regenerates page → saves to its local disk
|
||||||
|
- Instance B serves stale page → doesn't see Instance A's cache
|
||||||
|
- Load balancer sends users to random instances → inconsistent content
|
||||||
|
|
||||||
|
### Solution: Custom Cache Handler
|
||||||
|
|
||||||
|
Next.js 14+ supports custom cache handlers for shared storage:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
cacheHandler: require.resolve('./cache-handler.js'),
|
||||||
|
cacheMaxMemorySize: 0, // Disable in-memory cache
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Redis Cache Handler Example
|
||||||
|
|
||||||
|
```js
|
||||||
|
// cache-handler.js
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
const redis = new Redis(process.env.REDIS_URL);
|
||||||
|
const CACHE_PREFIX = 'nextjs:';
|
||||||
|
|
||||||
|
module.exports = class CacheHandler {
|
||||||
|
constructor(options) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key) {
|
||||||
|
const data = await redis.get(CACHE_PREFIX + key);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
return {
|
||||||
|
value: parsed.value,
|
||||||
|
lastModified: parsed.lastModified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key, data, ctx) {
|
||||||
|
const cacheData = {
|
||||||
|
value: data,
|
||||||
|
lastModified: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set TTL based on revalidate option
|
||||||
|
if (ctx?.revalidate) {
|
||||||
|
await redis.setex(
|
||||||
|
CACHE_PREFIX + key,
|
||||||
|
ctx.revalidate,
|
||||||
|
JSON.stringify(cacheData)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await redis.set(CACHE_PREFIX + key, JSON.stringify(cacheData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revalidateTag(tags) {
|
||||||
|
// Implement tag-based invalidation
|
||||||
|
// This requires tracking which keys have which tags
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### S3 Cache Handler Example
|
||||||
|
|
||||||
|
```js
|
||||||
|
// cache-handler.js
|
||||||
|
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
|
||||||
|
const s3 = new S3Client({ region: process.env.AWS_REGION });
|
||||||
|
const BUCKET = process.env.CACHE_BUCKET;
|
||||||
|
|
||||||
|
module.exports = class CacheHandler {
|
||||||
|
async get(key) {
|
||||||
|
try {
|
||||||
|
const response = await s3.send(new GetObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: `cache/${key}`,
|
||||||
|
}));
|
||||||
|
const body = await response.Body.transformToString();
|
||||||
|
return JSON.parse(body);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NoSuchKey') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key, data, ctx) {
|
||||||
|
await s3.send(new PutObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: `cache/${key}`,
|
||||||
|
Body: JSON.stringify({
|
||||||
|
value: data,
|
||||||
|
lastModified: Date.now(),
|
||||||
|
}),
|
||||||
|
ContentType: 'application/json',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Works vs What Needs Setup
|
||||||
|
|
||||||
|
| Feature | Single Instance | Multi-Instance | Notes |
|
||||||
|
|---------|----------------|----------------|-------|
|
||||||
|
| SSR | Yes | Yes | No special setup |
|
||||||
|
| SSG | Yes | Yes | Built at deploy time |
|
||||||
|
| ISR | Yes | Needs cache handler | Filesystem cache breaks |
|
||||||
|
| Image Optimization | Yes | Yes | CPU-intensive, consider CDN |
|
||||||
|
| Middleware | Yes | Yes | Runs on Node.js |
|
||||||
|
| Edge Runtime | Limited | Limited | Some features Node-only |
|
||||||
|
| `revalidatePath/Tag` | Yes | Needs cache handler | Must share cache |
|
||||||
|
| `next/font` | Yes | Yes | Fonts bundled at build |
|
||||||
|
| Draft Mode | Yes | Yes | Cookie-based |
|
||||||
|
|
||||||
|
## Image Optimization
|
||||||
|
|
||||||
|
Next.js Image Optimization works out of the box but is CPU-intensive.
|
||||||
|
|
||||||
|
### Option 1: Built-in (Simple)
|
||||||
|
|
||||||
|
Works automatically, but consider:
|
||||||
|
- Set `deviceSizes` and `imageSizes` in config to limit variants
|
||||||
|
- Use `minimumCacheTTL` to reduce regeneration
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
images: {
|
||||||
|
minimumCacheTTL: 60 * 60 * 24, // 24 hours
|
||||||
|
deviceSizes: [640, 750, 1080, 1920], // Limit sizes
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: External Loader (Recommended for Scale)
|
||||||
|
|
||||||
|
Offload to Cloudinary, Imgix, or similar:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
images: {
|
||||||
|
loader: 'custom',
|
||||||
|
loaderFile: './lib/image-loader.js',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// lib/image-loader.js
|
||||||
|
export default function cloudinaryLoader({ src, width, quality }) {
|
||||||
|
const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`];
|
||||||
|
return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Build-time vs Runtime
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Available at build time only (baked into bundle)
|
||||||
|
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||||
|
|
||||||
|
// Available at runtime (server-side only)
|
||||||
|
DATABASE_URL=postgresql://...
|
||||||
|
API_SECRET=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Configuration
|
||||||
|
|
||||||
|
For truly dynamic config, don't use `NEXT_PUBLIC_*`. Instead:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/api/config/route.ts
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({
|
||||||
|
apiUrl: process.env.API_URL,
|
||||||
|
features: process.env.FEATURES?.split(','),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenNext: Serverless Without Vercel
|
||||||
|
|
||||||
|
[OpenNext](https://open-next.js.org/) adapts Next.js for AWS Lambda, Cloudflare Workers, etc.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx create-sst@latest
|
||||||
|
# or
|
||||||
|
npx @opennextjs/aws build
|
||||||
|
```
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- AWS Lambda + CloudFront
|
||||||
|
- Cloudflare Workers
|
||||||
|
- Netlify Functions
|
||||||
|
- Deno Deploy
|
||||||
|
|
||||||
|
## Health Check Endpoint
|
||||||
|
|
||||||
|
Always include a health check for load balancers:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/api/health/route.ts
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Optional: check database connection
|
||||||
|
// await db.$queryRaw`SELECT 1`;
|
||||||
|
|
||||||
|
return Response.json({ status: 'healthy' }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ status: 'unhealthy' }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-Deployment Checklist
|
||||||
|
|
||||||
|
1. **Build locally first**: `npm run build` - catch errors before deploy
|
||||||
|
2. **Test standalone output**: `node .next/standalone/server.js`
|
||||||
|
3. **Set `output: 'standalone'`** for Docker
|
||||||
|
4. **Configure cache handler** for multi-instance ISR
|
||||||
|
5. **Set `HOSTNAME="0.0.0.0"`** for containers
|
||||||
|
6. **Copy `public/` and `.next/static/`** - not included in standalone
|
||||||
|
7. **Add health check endpoint**
|
||||||
|
8. **Test ISR revalidation** after deployment
|
||||||
|
9. **Monitor memory usage** - Node.js defaults may need tuning
|
||||||
|
|
||||||
|
## Testing Cache Handler
|
||||||
|
|
||||||
|
**Critical**: Test your cache handler on every Next.js upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start multiple instances
|
||||||
|
PORT=3001 node .next/standalone/server.js &
|
||||||
|
PORT=3002 node .next/standalone/server.js &
|
||||||
|
|
||||||
|
# Trigger ISR revalidation
|
||||||
|
curl http://localhost:3001/api/revalidate?path=/posts
|
||||||
|
|
||||||
|
# Verify both instances see the update
|
||||||
|
curl http://localhost:3001/posts
|
||||||
|
curl http://localhost:3002/posts
|
||||||
|
# Should return identical content
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Suspense Boundaries
|
||||||
|
|
||||||
|
Client hooks that cause CSR bailout without Suspense boundaries.
|
||||||
|
|
||||||
|
## useSearchParams
|
||||||
|
|
||||||
|
Always requires Suspense boundary in static routes. Without it, the entire page becomes client-side rendered.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad: Entire page becomes CSR
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function SearchBar() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
return <div>Query: {searchParams.get('q')}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Wrap in Suspense
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import SearchBar from './search-bar'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<SearchBar />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## usePathname
|
||||||
|
|
||||||
|
Requires Suspense boundary when route has dynamic parameters.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In dynamic route [slug]
|
||||||
|
// Bad: No Suspense
|
||||||
|
'use client'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
|
export function Breadcrumb() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
return <nav>{pathname}</nav>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Wrap in Suspense
|
||||||
|
<Suspense fallback={<BreadcrumbSkeleton />}>
|
||||||
|
<Breadcrumb />
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use `generateStaticParams`, Suspense is optional.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Hook | Suspense Required |
|
||||||
|
|------|-------------------|
|
||||||
|
| `useSearchParams()` | Yes |
|
||||||
|
| `usePathname()` | Yes (dynamic routes) |
|
||||||
|
| `useParams()` | No |
|
||||||
|
| `useRouter()` | No |
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"registry": "https://clawhub.ai",
|
||||||
|
"slug": "nextjs-patterns",
|
||||||
|
"installedVersion": "1.0.0",
|
||||||
|
"installedAt": 1779235115388,
|
||||||
|
"fingerprint": "bc26850b7f1dac7a77618c50aab5563296d26699e6b00b8a31bab25e950849b4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
name: nextjs-patterns
|
||||||
|
description: >
|
||||||
|
Apply Next.js 15 best practices and modern patterns including App Router,
|
||||||
|
Server Components, Server Actions, caching strategies, and performance
|
||||||
|
optimization. Use when building or reviewing Next.js 15 applications to
|
||||||
|
ensure idiomatic, production-ready code.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Next.js 15 Best Practices
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1. **Default to Server Components** — only add `"use client"` when you need interactivity or browser APIs
|
||||||
|
2. **Colocate by feature** — keep components, hooks, and utils near the routes that use them
|
||||||
|
3. **Type everything** — leverage TypeScript with strict mode enabled
|
||||||
|
4. **Cache deliberately** — understand the four caching layers and opt in/out explicitly
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
(marketing)/ # route group: no URL segment
|
||||||
|
page.tsx
|
||||||
|
(dashboard)/
|
||||||
|
layout.tsx
|
||||||
|
[id]/
|
||||||
|
page.tsx
|
||||||
|
api/
|
||||||
|
route.ts
|
||||||
|
components/
|
||||||
|
ui/ # shared, "dumb" UI components
|
||||||
|
features/ # feature-specific components
|
||||||
|
lib/
|
||||||
|
db.ts # database client (singleton)
|
||||||
|
auth.ts # auth helpers
|
||||||
|
utils.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server vs. Client Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ Server Component (default) — runs on server, no JS sent to client
|
||||||
|
export default async function ProductList() {
|
||||||
|
const products = await db.product.findMany()
|
||||||
|
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Client Component — only when needed
|
||||||
|
"use client"
|
||||||
|
import { useState } from "react"
|
||||||
|
export function Counter() {
|
||||||
|
const [n, setN] = useState(0)
|
||||||
|
return <button onClick={() => setN(n + 1)}>{n}</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Fetching Patterns
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Parallel fetching (avoid sequential waterfalls)
|
||||||
|
export default async function Dashboard() {
|
||||||
|
const [user, stats] = await Promise.all([
|
||||||
|
fetchUser(),
|
||||||
|
fetchStats(),
|
||||||
|
])
|
||||||
|
return <View user={user} stats={stats} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch with cache control
|
||||||
|
const data = await fetch("https://api.example.com/data", {
|
||||||
|
next: { revalidate: 60 }, // ISR: revalidate every 60s
|
||||||
|
// cache: "no-store" // always fresh
|
||||||
|
// cache: "force-cache" // static, until manual revalidation
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Actions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/actions.ts
|
||||||
|
"use server"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
export async function createPost(formData: FormData) {
|
||||||
|
const title = formData.get("title") as string
|
||||||
|
await db.post.create({ data: { title } })
|
||||||
|
revalidatePath("/posts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/posts/new/page.tsx
|
||||||
|
import { createPost } from "../actions"
|
||||||
|
export default function NewPost() {
|
||||||
|
return (
|
||||||
|
<form action={createPost}>
|
||||||
|
<input name="title" />
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metadata & SEO
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Static metadata
|
||||||
|
export const metadata = {
|
||||||
|
title: "My App",
|
||||||
|
description: "...",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic metadata
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const post = await fetchPost(params.slug)
|
||||||
|
return { title: post.title, description: post.excerpt }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error & Loading States
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/posts/loading.tsx — automatic Suspense boundary
|
||||||
|
export default function Loading() {
|
||||||
|
return <Skeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/posts/error.tsx — automatic error boundary
|
||||||
|
"use client"
|
||||||
|
export default function Error({ error, reset }) {
|
||||||
|
return <button onClick={reset}>Retry: {error.message}</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Checklist
|
||||||
|
|
||||||
|
- [ ] Images use `next/image` with explicit `width`/`height`
|
||||||
|
- [ ] Fonts use `next/font` (zero layout shift)
|
||||||
|
- [ ] Dynamic imports for heavy client components: `dynamic(() => import(...))`
|
||||||
|
- [ ] `generateStaticParams` for known dynamic routes
|
||||||
|
- [ ] Bundle analyzer run: `ANALYZE=true next build`
|
||||||
|
- [ ] Partial Prerendering (PPR) considered for mixed static/dynamic pages
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
See `references/` folder for routing patterns, caching deep-dive, and migration guide.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn753sxg7hag86tcm2dtgzyhns84chhv",
|
||||||
|
"slug": "nextjs-patterns",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishedAt": 1776047751623
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Next.js 15 Caching Deep Dive
|
||||||
|
|
||||||
|
## The Four Caching Layers
|
||||||
|
|
||||||
|
### 1. Request Memoization (in-memory, per request)
|
||||||
|
Automatically deduplicates identical `fetch()` calls within a single render tree.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Both components call fetchUser(id) — only ONE network request is made
|
||||||
|
async function Avatar({ id }) { const u = await fetchUser(id); return <img src={u.avatar} /> }
|
||||||
|
async function Name({ id }) { const u = await fetchUser(id); return <span>{u.name}</span> }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Data Cache (persistent, cross-request)
|
||||||
|
`fetch()` responses are stored on the server and reused across requests and deployments.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Static — cached indefinitely
|
||||||
|
fetch(url)
|
||||||
|
fetch(url, { cache: "force-cache" })
|
||||||
|
|
||||||
|
// Time-based revalidation (ISR)
|
||||||
|
fetch(url, { next: { revalidate: 3600 } }) // revalidate every hour
|
||||||
|
|
||||||
|
// Always fresh — no caching
|
||||||
|
fetch(url, { cache: "no-store" })
|
||||||
|
fetch(url, { next: { revalidate: 0 } })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Full Route Cache (build-time static rendering)
|
||||||
|
Pages rendered at build time are stored as static HTML+RSC payload.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Force dynamic rendering for this route
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
// Custom revalidation period for the whole route
|
||||||
|
export const revalidate = 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Router Cache (client-side, per session)
|
||||||
|
Browser caches RSC payloads for instant back/forward navigation.
|
||||||
|
- Static routes: cached for 5 minutes
|
||||||
|
- Dynamic routes: cached for 30 seconds
|
||||||
|
|
||||||
|
## On-Demand Revalidation
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Revalidate by path
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
revalidatePath("/blog")
|
||||||
|
revalidatePath("/blog/[slug]", "page")
|
||||||
|
|
||||||
|
// Revalidate by cache tag
|
||||||
|
import { revalidateTag } from "next/cache"
|
||||||
|
revalidateTag("posts")
|
||||||
|
|
||||||
|
// Tagging fetches
|
||||||
|
fetch(url, { next: { tags: ["posts"] } })
|
||||||
|
```
|
||||||
|
|
||||||
|
## unstable_cache for Non-Fetch Data
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { unstable_cache } from "next/cache"
|
||||||
|
|
||||||
|
const getCachedUser = unstable_cache(
|
||||||
|
async (id: string) => db.user.findUnique({ where: { id } }),
|
||||||
|
["user"],
|
||||||
|
{ revalidate: 300, tags: ["users"] }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Using `fetch` inside `useEffect` | Move to Server Component or use React Query |
|
||||||
|
| Forgetting `revalidatePath` after mutations | Call in Server Action after every write |
|
||||||
|
| Over-caching user-specific data | Add `cache: "no-store"` or check `cookies()`/`headers()` |
|
||||||
|
| Sequential awaits | Use `Promise.all()` for parallel fetching |
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Next.js 15 Migration & Configuration Guide
|
||||||
|
|
||||||
|
## Upgrading to Next.js 15
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @next/codemod@canary upgrade latest
|
||||||
|
# or manual:
|
||||||
|
npm install next@latest react@latest react-dom@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Breaking Changes (14 → 15)
|
||||||
|
|
||||||
|
### 1. Async Request APIs (Breaking)
|
||||||
|
`cookies()`, `headers()`, `params`, `searchParams` are now async:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (Next.js 14)
|
||||||
|
export default function Page({ params }) {
|
||||||
|
const { id } = params
|
||||||
|
}
|
||||||
|
|
||||||
|
// After (Next.js 15)
|
||||||
|
export default async function Page({ params }) {
|
||||||
|
const { id } = await params
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookies and headers
|
||||||
|
import { cookies, headers } from "next/headers"
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const headersList = await headers()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Caching Defaults Changed
|
||||||
|
`fetch()` no longer caches by default (was `force-cache`, now `no-store`).
|
||||||
|
Add `{ cache: "force-cache" }` or set route-level `export const fetchCache = "default-cache"` to restore.
|
||||||
|
|
||||||
|
### 3. React 19 Compatibility
|
||||||
|
Next.js 15 supports React 19. New hooks available:
|
||||||
|
- `useActionState` (replaces `useFormState`)
|
||||||
|
- `useFormStatus`
|
||||||
|
- `use()` for reading promises/context
|
||||||
|
|
||||||
|
## next.config.ts (TypeScript config)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
experimental: {
|
||||||
|
ppr: "incremental", // Partial Prerendering
|
||||||
|
reactCompiler: true, // React Compiler (auto-memoization)
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: "https", hostname: "images.example.com" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
fetches: { fullUrl: true }, // Log all fetch calls in dev
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local
|
||||||
|
DATABASE_URL="postgresql://..."
|
||||||
|
NEXT_PUBLIC_API_URL="https://api.example.com" # exposed to browser
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Access server-side
|
||||||
|
process.env.DATABASE_URL
|
||||||
|
|
||||||
|
// Access client-side (only NEXT_PUBLIC_ prefix)
|
||||||
|
process.env.NEXT_PUBLIC_API_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Turbopack (Default in Dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
next dev # uses Turbopack by default in Next.js 15
|
||||||
|
next dev --turbopack # explicit flag (same behavior)
|
||||||
|
next build # Webpack still default for production builds
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Path Aliases
|
||||||
|
|
||||||
|
```json
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"],
|
||||||
|
"@/components/*": ["./components/*"],
|
||||||
|
"@/lib/*": ["./lib/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Next.js 15 Routing Patterns
|
||||||
|
|
||||||
|
## App Router Fundamentals
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---------------|----------------------------------------------|
|
||||||
|
| `page.tsx` | Unique UI for a route, makes it publicly accessible |
|
||||||
|
| `layout.tsx` | Shared UI; does NOT re-render on navigation |
|
||||||
|
| `template.tsx`| Like layout but re-renders on navigation |
|
||||||
|
| `loading.tsx` | Instant loading state (React Suspense) |
|
||||||
|
| `error.tsx` | Error UI boundary (must be Client Component) |
|
||||||
|
| `not-found.tsx`| 404 UI |
|
||||||
|
| `route.ts` | API endpoint (GET, POST, etc.) |
|
||||||
|
|
||||||
|
## Route Groups
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
(auth)/
|
||||||
|
login/page.tsx → /login
|
||||||
|
register/page.tsx → /register
|
||||||
|
(app)/
|
||||||
|
layout.tsx ← shared auth-required layout
|
||||||
|
dashboard/page.tsx → /dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Route groups `(name)` organize routes without affecting the URL.
|
||||||
|
|
||||||
|
## Dynamic Routes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/blog/[slug]/page.tsx
|
||||||
|
export default function Post({ params }: { params: { slug: string } }) {
|
||||||
|
return <h1>{params.slug}</h1>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate static routes at build time
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const posts = await fetchAllPosts()
|
||||||
|
return posts.map(p => ({ slug: p.slug }))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Catch-All & Optional Catch-All
|
||||||
|
|
||||||
|
```
|
||||||
|
app/shop/[...categories]/page.tsx → /shop/a/b/c
|
||||||
|
app/shop/[[...categories]]/page.tsx → /shop AND /shop/a/b/c
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Routes (Advanced)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
@team/page.tsx
|
||||||
|
@analytics/page.tsx
|
||||||
|
layout.tsx ← receives { team, analytics } props
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// layout.tsx
|
||||||
|
export default function Layout({ children, team, analytics }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{children}
|
||||||
|
<aside>{team}</aside>
|
||||||
|
<aside>{analytics}</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intercepting Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
feed/page.tsx
|
||||||
|
(..)photo/[id]/page.tsx ← intercepts /photo/[id] when navigated from /feed
|
||||||
|
photo/[id]/page.tsx ← full page on direct URL visit
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful for modals that maintain background context.
|
||||||
|
|
||||||
|
## Middleware
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// middleware.ts (project root)
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const token = request.cookies.get("token")
|
||||||
|
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url))
|
||||||
|
}
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/dashboard/:path*", "/api/protected/:path*"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Routes (Route Handlers)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/posts/route.ts
|
||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const page = searchParams.get("page") ?? "1"
|
||||||
|
const posts = await fetchPosts(parseInt(page))
|
||||||
|
return NextResponse.json({ posts })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json()
|
||||||
|
const post = await createPost(body)
|
||||||
|
return NextResponse.json(post, { status: 201 })
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"registry": "https://clawhub.ai",
|
||||||
|
"slug": "uncle-bob",
|
||||||
|
"installedVersion": "1.0.0",
|
||||||
|
"installedAt": 1779235184660,
|
||||||
|
"fingerprint": "1156424cd7832ef251f075af039aa673dcde72b57dce28c50517af1b1fc7f3fa"
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
name: uncle-bob
|
||||||
|
description: 'Apply Robert C. Martin (Uncle Bob) principles for clean code, SOLID design, and clean architecture. Use when: (1) reviewing or refactoring code for quality, (2) designing modules, classes, or functions, (3) asked to "clean up" or improve code structure, (4) evaluating architectural boundaries, (5) naming things, (6) reducing coupling or increasing cohesion. Triggers on phrases like "clean code", "SOLID", "uncle bob", "clean architecture", "refactor for quality", "code smells", "single responsibility", "dependency inversion".'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Uncle Bob — Clean Code & Architecture Principles
|
||||||
|
|
||||||
|
Apply these principles when writing, reviewing, or refactoring code. They are not rules to follow blindly — use judgment, but default to clean.
|
||||||
|
|
||||||
|
## The Boy Scout Rule
|
||||||
|
|
||||||
|
Leave the code cleaner than you found it. Every commit should improve the codebase, even if slightly.
|
||||||
|
|
||||||
|
## Clean Code Fundamentals
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- Names reveal intent. If a name requires a comment, the name is wrong.
|
||||||
|
- Use pronounceable, searchable names. Avoid abbreviations, single letters (except loop counters), and prefixes.
|
||||||
|
- Classes/types: noun or noun phrase (`AccountManager`, `OrderRepository`).
|
||||||
|
- Functions/methods: verb or verb phrase (`calculateTotal`, `fetchUser`, `isValid`).
|
||||||
|
- Booleans: read as a question (`isActive`, `hasPermission`, `canExecute`).
|
||||||
|
- Avoid mental mapping. `r` is not a URL. Say `url`.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
- Small. Then smaller. A function does **one thing**.
|
||||||
|
- Ideally 0-2 arguments. 3+ is a smell — extract an options object or rethink the design.
|
||||||
|
- No side effects. A function named `checkPassword` must not also initialize a session.
|
||||||
|
- Command-Query Separation: a function either does something (command) or answers something (query), never both.
|
||||||
|
- Don't Repeat Yourself (DRY) — but don't abstract prematurely. Three instances of duplication is the threshold.
|
||||||
|
- Extract till you drop: if you can extract a meaningful sub-function, do it.
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
- Good code is self-documenting. Comments compensate for failure to express in code.
|
||||||
|
- Legal, informative, clarifying intent, warning of consequences, and TODO comments are acceptable.
|
||||||
|
- Delete commented-out code. Version control remembers.
|
||||||
|
- Never write comments that restate what the code does (`// increment i` before `i++`).
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
- Vertical: newspaper metaphor — high-level summary at top, details below.
|
||||||
|
- Related functions stay close. Caller above callee.
|
||||||
|
- Horizontal: avoid scrolling. Keep lines short.
|
||||||
|
- Consistent formatting across the team trumps personal preference.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Prefer exceptions/Result types over error codes.
|
||||||
|
- Don't return null. Don't pass null.
|
||||||
|
- Write try-catch at the top level of a function, not scattered throughout.
|
||||||
|
- Error handling is **one thing** — a function that handles errors should do little else.
|
||||||
|
- Define exception classes in terms of the caller's needs, not the thrower's implementation.
|
||||||
|
|
||||||
|
### Objects vs. Data Structures
|
||||||
|
|
||||||
|
- Objects hide data, expose behavior. Data structures expose data, have no behavior.
|
||||||
|
- Don't mix them. A class with public fields AND business methods is the worst of both worlds.
|
||||||
|
- Law of Demeter: a method should only call methods on its own object, its parameters, objects it creates, or its direct dependencies. No `a.getB().getC().doThing()`.
|
||||||
|
|
||||||
|
## SOLID Principles
|
||||||
|
|
||||||
|
For detailed explanations and examples, see [references/solid.md](references/solid.md).
|
||||||
|
|
||||||
|
- **S — Single Responsibility**: A class has one reason to change. One actor, one responsibility.
|
||||||
|
- **O — Open/Closed**: Open for extension, closed for modification. Use polymorphism, not conditionals.
|
||||||
|
- **L — Liskov Substitution**: Subtypes must be substitutable for their base types without breaking behavior.
|
||||||
|
- **I — Interface Segregation**: Many specific interfaces beat one general-purpose interface. Clients should not depend on methods they don't use.
|
||||||
|
- **D — Dependency Inversion**: Depend on abstractions, not concretions. High-level modules must not depend on low-level modules.
|
||||||
|
|
||||||
|
## Clean Architecture
|
||||||
|
|
||||||
|
For the full architecture guide, see [references/clean-architecture.md](references/clean-architecture.md).
|
||||||
|
|
||||||
|
### The Dependency Rule
|
||||||
|
|
||||||
|
Source code dependencies must point **inward** — toward higher-level policies.
|
||||||
|
|
||||||
|
```
|
||||||
|
Frameworks & Drivers → Interface Adapters → Use Cases → Entities
|
||||||
|
(outer) (inner)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Entities**: enterprise business rules, pure domain objects.
|
||||||
|
- **Use Cases**: application-specific business rules (orchestrate entities).
|
||||||
|
- **Interface Adapters**: convert between use case format and external format (controllers, presenters, gateways).
|
||||||
|
- **Frameworks & Drivers**: the outermost layer (DB, web framework, UI). Details. Replaceable.
|
||||||
|
|
||||||
|
### Key Rules
|
||||||
|
|
||||||
|
- Nothing in an inner circle knows about anything in an outer circle.
|
||||||
|
- Data crossing boundaries is simple DTOs or value objects — never framework-specific types.
|
||||||
|
- The database is a detail. The web is a detail. Frameworks are details.
|
||||||
|
|
||||||
|
## Component Principles
|
||||||
|
|
||||||
|
- **Common Closure Principle (CCP)**: classes that change together belong together.
|
||||||
|
- **Common Reuse Principle (CRP)**: don't force users to depend on things they don't use.
|
||||||
|
- **Stable Dependencies Principle**: depend in the direction of stability.
|
||||||
|
- **Stable Abstractions Principle**: stable components should be abstract.
|
||||||
|
|
||||||
|
## Code Smells (Red Flags)
|
||||||
|
|
||||||
|
- Rigidity: small change causes cascade of changes elsewhere.
|
||||||
|
- Fragility: change in one place breaks unrelated code.
|
||||||
|
- Immobility: can't reuse a module without dragging its dependencies.
|
||||||
|
- Needless complexity: speculative generality, premature abstraction.
|
||||||
|
- Needless repetition: copy-paste code (DRY violation).
|
||||||
|
- Opacity: code is hard to understand.
|
||||||
|
- Long functions, large classes, long parameter lists, boolean flags, switch/case on type.
|
||||||
|
|
||||||
|
## Testing (TDD)
|
||||||
|
|
||||||
|
- **Three Laws of TDD**: (1) Write a failing test first. (2) Write only enough test to fail. (3) Write only enough production code to pass.
|
||||||
|
- Tests are first-class code. Keep them clean, readable, fast.
|
||||||
|
- One assert per test (guideline, not dogma). One concept per test.
|
||||||
|
- F.I.R.S.T.: Fast, Independent, Repeatable, Self-validating, Timely.
|
||||||
|
- Test boundaries, not implementations. Test behavior, not methods.
|
||||||
|
|
||||||
|
## Applying These Principles
|
||||||
|
|
||||||
|
When reviewing or writing code, check in this order:
|
||||||
|
|
||||||
|
1. **Readability**: Can someone understand this in 30 seconds?
|
||||||
|
2. **Naming**: Do names reveal intent?
|
||||||
|
3. **Function size**: Can anything be extracted?
|
||||||
|
4. **Single Responsibility**: Does each unit have one reason to change?
|
||||||
|
5. **Dependencies**: Do they point toward stability/abstraction?
|
||||||
|
6. **Coupling**: Is anything unnecessarily coupled?
|
||||||
|
7. **Error handling**: Is it clean and consistent?
|
||||||
|
8. **Tests**: Are they present, clean, and testing behavior?
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn76xwsm851ppe4hybyhnjd4mh811cng",
|
||||||
|
"slug": "uncle-bob",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishedAt": 1773171309969
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Clean Architecture — Detailed Guide
|
||||||
|
|
||||||
|
## The Core Idea
|
||||||
|
|
||||||
|
Separate the software into layers. Each layer has a specific role. Dependencies point inward.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Frameworks & Drivers │ ← DB, Web, UI, devices
|
||||||
|
│ ┌────────────────────────────────────┐ │
|
||||||
|
│ │ Interface Adapters │ │ ← Controllers, Gateways, Presenters
|
||||||
|
│ │ ┌──────────────────────────────┐ │ │
|
||||||
|
│ │ │ Use Cases │ │ │ ← Application business rules
|
||||||
|
│ │ │ ┌────────────────────────┐ │ │ │
|
||||||
|
│ │ │ │ Entities │ │ │ │ ← Enterprise business rules
|
||||||
|
│ │ │ └────────────────────────┘ │ │ │
|
||||||
|
│ │ └──────────────────────────────┘ │ │
|
||||||
|
│ └────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Dependency Rule
|
||||||
|
|
||||||
|
Source code dependencies must only point **inward**. Nothing in an inner ring can know anything about an outer ring. This includes functions, classes, variables, types, or any named entity.
|
||||||
|
|
||||||
|
## Layer Details
|
||||||
|
|
||||||
|
### Entities (Innermost)
|
||||||
|
|
||||||
|
- Encapsulate enterprise-wide business rules.
|
||||||
|
- Could be used by many applications in the enterprise.
|
||||||
|
- Least likely to change when something external changes.
|
||||||
|
- Pure domain objects with business logic. No framework dependencies.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pure entity — no imports from outer layers
|
||||||
|
class Account {
|
||||||
|
constructor(
|
||||||
|
readonly id: string,
|
||||||
|
private balance: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
deposit(amount: number) {
|
||||||
|
if (amount <= 0) throw new DomainError('Amount must be positive')
|
||||||
|
this.balance += amount
|
||||||
|
}
|
||||||
|
|
||||||
|
withdraw(amount: number) {
|
||||||
|
if (amount > this.balance) throw new InsufficientFundsError()
|
||||||
|
this.balance -= amount
|
||||||
|
}
|
||||||
|
|
||||||
|
getBalance() { return this.balance }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
- Application-specific business rules.
|
||||||
|
- Orchestrate the flow of data to and from entities.
|
||||||
|
- Direct entities to use their enterprise-wide business rules.
|
||||||
|
- Changes to this layer should not affect entities.
|
||||||
|
- Changes to external layers (DB, UI) should not affect use cases.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Use case — depends on entities and port interfaces, nothing else
|
||||||
|
class TransferFundsUseCase {
|
||||||
|
constructor(
|
||||||
|
private accountRepo: AccountRepository, // Port (interface)
|
||||||
|
private notifier: TransferNotifier, // Port (interface)
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(fromId: string, toId: string, amount: number) {
|
||||||
|
const from = await this.accountRepo.findById(fromId)
|
||||||
|
const to = await this.accountRepo.findById(toId)
|
||||||
|
|
||||||
|
from.withdraw(amount)
|
||||||
|
to.deposit(amount)
|
||||||
|
|
||||||
|
await this.accountRepo.save(from)
|
||||||
|
await this.accountRepo.save(to)
|
||||||
|
await this.notifier.notify(fromId, toId, amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interface Adapters
|
||||||
|
|
||||||
|
- Convert data between the format most convenient for use cases/entities and the format most convenient for external things (DB, web).
|
||||||
|
- Controllers, presenters, gateways live here.
|
||||||
|
- No business logic — only translation.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Controller (adapter) — converts HTTP to use case input
|
||||||
|
class TransferController {
|
||||||
|
constructor(private useCase: TransferFundsUseCase) {}
|
||||||
|
|
||||||
|
async handle(req: HttpRequest): Promise<HttpResponse> {
|
||||||
|
const { fromId, toId, amount } = req.body
|
||||||
|
await this.useCase.execute(fromId, toId, amount)
|
||||||
|
return { status: 200, body: { success: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository implementation (adapter) — converts use case port to DB
|
||||||
|
class PostgresAccountRepository implements AccountRepository {
|
||||||
|
async findById(id: string): Promise<Account> {
|
||||||
|
const row = await this.db.query('SELECT * FROM accounts WHERE id = $1', [id])
|
||||||
|
return new Account(row.id, row.balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(account: Account): Promise<void> {
|
||||||
|
await this.db.query('UPDATE accounts SET balance = $1 WHERE id = $2',
|
||||||
|
[account.getBalance(), account.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frameworks & Drivers (Outermost)
|
||||||
|
|
||||||
|
- Glue code. Minimal.
|
||||||
|
- Web framework config, database drivers, HTTP server setup.
|
||||||
|
- This is where all the details go. Keep them out of the inner circles.
|
||||||
|
|
||||||
|
## Ports and Adapters (Hexagonal Architecture)
|
||||||
|
|
||||||
|
Clean Architecture is compatible with hexagonal architecture:
|
||||||
|
|
||||||
|
- **Ports**: interfaces defined by the use case layer (what it needs from the outside).
|
||||||
|
- **Adapters**: implementations in the outer layer that fulfill ports.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PORT — defined in use case layer
|
||||||
|
interface AccountRepository {
|
||||||
|
findById(id: string): Promise<Account>
|
||||||
|
save(account: Account): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADAPTER — defined in infrastructure layer
|
||||||
|
class DrizzleAccountRepository implements AccountRepository {
|
||||||
|
// Implementation using Drizzle ORM
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Crossing Boundaries
|
||||||
|
|
||||||
|
When data crosses a boundary, it should be in the form most convenient for the **inner** circle. Never pass database rows or HTTP request objects into use cases.
|
||||||
|
|
||||||
|
Use simple DTOs or value objects:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Input DTO for use case
|
||||||
|
interface TransferInput {
|
||||||
|
fromAccountId: string
|
||||||
|
toAccountId: string
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output DTO from use case
|
||||||
|
interface TransferResult {
|
||||||
|
success: boolean
|
||||||
|
newBalance: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Composition Root
|
||||||
|
|
||||||
|
All dependency wiring happens at the outermost layer — the "main" or "composition root":
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// main.ts — the only place that knows about ALL concrete implementations
|
||||||
|
const db = new PostgresDatabase(config.dbUrl)
|
||||||
|
const accountRepo = new PostgresAccountRepository(db)
|
||||||
|
const notifier = new EmailTransferNotifier(config.smtp)
|
||||||
|
const transferUseCase = new TransferFundsUseCase(accountRepo, notifier)
|
||||||
|
const controller = new TransferController(transferUseCase)
|
||||||
|
|
||||||
|
app.post('/transfer', (req, res) => controller.handle(req, res))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Benefits
|
||||||
|
|
||||||
|
Each layer can be tested independently:
|
||||||
|
|
||||||
|
- **Entities**: pure unit tests, no mocks needed.
|
||||||
|
- **Use Cases**: mock the ports (repositories, services).
|
||||||
|
- **Adapters**: integration tests against real infrastructure.
|
||||||
|
- **End-to-end**: full stack through the composition root.
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
- Letting entities import from frameworks (ORM decorators on domain objects).
|
||||||
|
- Putting business logic in controllers.
|
||||||
|
- Use cases that know about HTTP status codes or database queries.
|
||||||
|
- Skipping the adapter layer and having use cases talk directly to the DB.
|
||||||
|
- Over-engineering: not every project needs all four layers. Scale the architecture to the complexity.
|
||||||
|
|
||||||
|
## Pragmatic Application
|
||||||
|
|
||||||
|
- Start with two layers (domain + infrastructure) for small projects.
|
||||||
|
- Add use case and adapter layers as complexity grows.
|
||||||
|
- The dependency rule is the non-negotiable part. Everything else is negotiable.
|
||||||
|
- Frameworks are details. Design your system so switching a framework is possible (even if you never do).
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# SOLID Principles — Detailed Guide
|
||||||
|
|
||||||
|
## S — Single Responsibility Principle (SRP)
|
||||||
|
|
||||||
|
> "A module should have one, and only one, reason to change."
|
||||||
|
|
||||||
|
More precisely: a module should be responsible to one, and only one, actor (stakeholder).
|
||||||
|
|
||||||
|
### Violation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class Employee {
|
||||||
|
calculatePay() // CFO's team cares about this
|
||||||
|
reportHours() // COO's team cares about this
|
||||||
|
save() // CTO's team cares about this
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three actors, three reasons to change. A change for payroll could break hour reporting.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Separate into three classes, each responsible to one actor. Use a facade if you need a single entry point.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class PayCalculator { calculatePay(employee: Employee) {} }
|
||||||
|
class HourReporter { reportHours(employee: Employee) {} }
|
||||||
|
class EmployeeSaver { save(employee: Employee) {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heuristic
|
||||||
|
|
||||||
|
If you describe a class and use "and" — it probably has multiple responsibilities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O — Open/Closed Principle (OCP)
|
||||||
|
|
||||||
|
> "Software entities should be open for extension, closed for modification."
|
||||||
|
|
||||||
|
Add new behavior by adding new code, not changing existing code.
|
||||||
|
|
||||||
|
### Violation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function calculateArea(shape: Shape) {
|
||||||
|
if (shape.type === 'circle') return Math.PI * shape.radius ** 2
|
||||||
|
if (shape.type === 'rectangle') return shape.width * shape.height
|
||||||
|
// Every new shape = modify this function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Use polymorphism:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Shape { area(): number }
|
||||||
|
|
||||||
|
class Circle implements Shape {
|
||||||
|
constructor(private radius: number) {}
|
||||||
|
area() { return Math.PI * this.radius ** 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Rectangle implements Shape {
|
||||||
|
constructor(private width: number, private height: number) {}
|
||||||
|
area() { return this.width * this.height }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New shapes extend the system without modifying `calculateArea`.
|
||||||
|
|
||||||
|
### Heuristic
|
||||||
|
|
||||||
|
If adding a feature requires modifying a switch/case or if-else chain, consider OCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L — Liskov Substitution Principle (LSP)
|
||||||
|
|
||||||
|
> "Subtypes must be substitutable for their base types."
|
||||||
|
|
||||||
|
If `S` extends `T`, anywhere you use `T` you should be able to use `S` without surprises.
|
||||||
|
|
||||||
|
### Classic Violation: Square/Rectangle
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class Rectangle {
|
||||||
|
setWidth(w: number) { this.width = w }
|
||||||
|
setHeight(h: number) { this.height = h }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Square extends Rectangle {
|
||||||
|
setWidth(w: number) { this.width = w; this.height = w }
|
||||||
|
setHeight(h: number) { this.width = h; this.height = h }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breaks expectations:
|
||||||
|
function resize(r: Rectangle) {
|
||||||
|
r.setWidth(5)
|
||||||
|
r.setHeight(10)
|
||||||
|
assert(r.area() === 50) // Fails for Square!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Don't model Square as a subtype of Rectangle. Use composition or separate types.
|
||||||
|
|
||||||
|
### Heuristic
|
||||||
|
|
||||||
|
If a subclass overrides a method to do something the caller wouldn't expect, it violates LSP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I — Interface Segregation Principle (ISP)
|
||||||
|
|
||||||
|
> "Clients should not be forced to depend on methods they don't use."
|
||||||
|
|
||||||
|
### Violation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Worker {
|
||||||
|
work(): void
|
||||||
|
eat(): void
|
||||||
|
sleep(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Robot worker doesn't eat or sleep
|
||||||
|
class Robot implements Worker {
|
||||||
|
work() { /* ... */ }
|
||||||
|
eat() { throw new Error('Robots do not eat') }
|
||||||
|
sleep() { throw new Error('Robots do not sleep') }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Split into focused interfaces:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Workable { work(): void }
|
||||||
|
interface Feedable { eat(): void }
|
||||||
|
interface Restable { sleep(): void }
|
||||||
|
|
||||||
|
class Human implements Workable, Feedable, Restable { /* ... */ }
|
||||||
|
class Robot implements Workable { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heuristic
|
||||||
|
|
||||||
|
If implementing an interface forces you to write empty methods or throw "not supported", the interface is too fat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D — Dependency Inversion Principle (DIP)
|
||||||
|
|
||||||
|
> "Depend on abstractions, not concretions."
|
||||||
|
|
||||||
|
High-level modules (policy) must not depend on low-level modules (details). Both should depend on abstractions.
|
||||||
|
|
||||||
|
### Violation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class OrderService {
|
||||||
|
private db = new PostgresDatabase() // Concrete dependency
|
||||||
|
|
||||||
|
createOrder(order: Order) {
|
||||||
|
this.db.insert('orders', order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Depend on an abstraction; inject the implementation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface OrderRepository {
|
||||||
|
save(order: Order): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderService {
|
||||||
|
constructor(private repository: OrderRepository) {}
|
||||||
|
|
||||||
|
createOrder(order: Order) {
|
||||||
|
this.repository.save(order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject at composition root:
|
||||||
|
const service = new OrderService(new PostgresOrderRepository())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heuristic
|
||||||
|
|
||||||
|
If a class instantiates its own dependencies with `new`, it's likely violating DIP. Inject dependencies through the constructor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Applying SOLID Together
|
||||||
|
|
||||||
|
These principles reinforce each other:
|
||||||
|
|
||||||
|
- SRP keeps classes focused → easier to apply OCP
|
||||||
|
- OCP uses polymorphism → requires LSP-compliant subtypes
|
||||||
|
- ISP keeps interfaces thin → makes DIP practical
|
||||||
|
- DIP enables testing → which validates LSP
|
||||||
|
|
||||||
|
Don't apply them dogmatically. They're tools for managing complexity. A simple script doesn't need SOLID. A growing system does.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"registry": "https://clawhub.ai",
|
||||||
|
"slug": "vite",
|
||||||
|
"installedVersion": "1.0.0",
|
||||||
|
"installedAt": 1779235114856,
|
||||||
|
"fingerprint": "bf3b9389d49ed3ef7939236105adaabe3174b7f78931388325ae30160870152b"
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
name: Vite
|
||||||
|
description: Configure and optimize Vite for development, production builds, and library bundling.
|
||||||
|
metadata: {"clawdbot":{"emoji":"⚡","requires":{"bins":["node"]},"os":["linux","darwin","win32"]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vite Patterns
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
- Only `VITE_` prefixed vars are exposed to client code — `DB_PASSWORD` stays server-side, `VITE_API_URL` is bundled
|
||||||
|
- Access via `import.meta.env.VITE_*` not `process.env` — process.env is Node-only and undefined in browser
|
||||||
|
- `.env.local` overrides `.env` and is gitignored by default — use for local secrets
|
||||||
|
- `import.meta.env.MODE` is `development` or `production` — use for conditional logic, not `NODE_ENV`
|
||||||
|
|
||||||
|
## CommonJS Compatibility
|
||||||
|
- Pure ESM by default — CommonJS packages need `optimizeDeps.include` for pre-bundling
|
||||||
|
- `require()` doesn't work in Vite — use `import` or `createRequire` from `module` for dynamic requires
|
||||||
|
- Some packages ship broken ESM — add to `ssr.noExternal` or `optimizeDeps.exclude` and let Vite transform them
|
||||||
|
- Named exports from CommonJS may fail — use default import and destructure: `import pkg from 'pkg'; const { method } = pkg`
|
||||||
|
|
||||||
|
## Dependency Pre-bundling
|
||||||
|
- Vite pre-bundles dependencies on first run — delete `node_modules/.vite` to force rebuild after package changes
|
||||||
|
- Large dependencies slow down dev server start — add rarely-changing ones to `optimizeDeps.include` for persistent cache
|
||||||
|
- Linked local packages (`npm link`) aren't pre-bundled — add to `optimizeDeps.include` explicitly
|
||||||
|
- `optimizeDeps.force: true` rebuilds every time — only for debugging, kills dev performance
|
||||||
|
|
||||||
|
## Path Aliases
|
||||||
|
- Configure in both `vite.config.ts` AND `tsconfig.json` — Vite uses its own, TypeScript uses tsconfig
|
||||||
|
- Use `path.resolve(__dirname, './src')` not relative paths — relative breaks depending on working directory
|
||||||
|
- `@/` alias is not built-in — must configure manually unlike some frameworks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// vite.config.ts
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, './src') }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev Server Proxy
|
||||||
|
- Proxy only works in dev — production needs actual CORS config or reverse proxy
|
||||||
|
- `changeOrigin: true` rewrites Host header — required for most APIs that check origin
|
||||||
|
- WebSocket proxy needs explicit `ws: true` — HTTP proxy doesn't forward WS by default
|
||||||
|
- Trailing slashes matter: `/api` proxies `/api/users`, `/api/` only proxies `/api//users`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: path => path.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Static Assets
|
||||||
|
- `public/` files served at root, not processed — use for favicons, robots.txt, files that need exact paths
|
||||||
|
- `src/assets/` files are processed, hashed, can be imported — use for images, fonts referenced in code
|
||||||
|
- Import assets to get resolved URL: `import logo from './logo.png'` — hardcoded paths break after build
|
||||||
|
- `new URL('./img.png', import.meta.url)` for dynamic paths — template literals with variables don't work
|
||||||
|
|
||||||
|
## Build Optimization
|
||||||
|
- `build.rollupOptions.output.manualChunks` for code splitting — without it, one giant bundle
|
||||||
|
- Analyze bundle with `rollup-plugin-visualizer` — find unexpected large dependencies
|
||||||
|
- `build.target` defaults to modern browsers — set `'es2015'` for legacy support, but increases bundle size
|
||||||
|
- `build.cssCodeSplit: true` (default) — each async chunk gets its own CSS file
|
||||||
|
|
||||||
|
## Library Mode
|
||||||
|
- `build.lib` for npm packages — different config from app mode
|
||||||
|
- Set `external` for peer dependencies — don't bundle React/Vue into your library
|
||||||
|
- Generate types separately with `tsc` — Vite doesn't emit `.d.ts` files
|
||||||
|
- Both ESM and CJS outputs: `formats: ['es', 'cjs']` — some consumers still need require()
|
||||||
|
|
||||||
|
## HMR Issues
|
||||||
|
- Circular imports break HMR — refactor to break the cycle or full reload triggers
|
||||||
|
- State lost on HMR means component isn't accepting updates — check for `import.meta.hot.accept()`
|
||||||
|
- CSS changes trigger full reload if imported in JS that doesn't accept HMR — import CSS in components that do
|
||||||
|
- `server.hmr.overlay: false` hides error overlay — useful for custom error handling but hides issues
|
||||||
|
|
||||||
|
## SSR Configuration
|
||||||
|
- `ssr.external` for Node-only packages — prevents bundling node_modules in SSR build
|
||||||
|
- `ssr.noExternal` forces bundling — needed for packages with browser-specific imports
|
||||||
|
- CSS imports fail in SSR by default — use `?inline` suffix or configure `css.postcss` for SSR
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn73vp5rarc3b14rc7wjcw8f8580t5d1",
|
||||||
|
"slug": "vite",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishedAt": 1770856042773
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"registry": "https://clawhub.ai",
|
||||||
|
"slug": "vue-composition-api-best-practices",
|
||||||
|
"installedVersion": "1.0.0",
|
||||||
|
"installedAt": 1779235185180,
|
||||||
|
"fingerprint": "b117fb9fc7693ff411a53c62a54f5989e882495193430abfb8f27e5b3ed08021"
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: vue-composition-api-best-practices
|
||||||
|
description: "Vue 3 组合式 API 与 <script setup> 最佳实践。涵盖代码组织、useXxx 模式、组合式函数设计、Store 集成、响应性优化及功能提取。"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: github.com/acanzaima
|
||||||
|
version: 1.0.0
|
||||||
|
tags: [vue3, composition-api, script-setup, typescript, pinia, composables, reactivity, performance]
|
||||||
|
---
|
||||||
|
|
||||||
|
Vue 3 组合式 API 与 `<script setup>`、TypeScript 集成及代码组织模式的最佳实践。
|
||||||
|
|
||||||
|
## 版本兼容性
|
||||||
|
|
||||||
|
本技能面向 **Vue 3.3+**,部分功能需要更高的次版本号:
|
||||||
|
|
||||||
|
| 特性 | 最低版本 | 参考 |
|
||||||
|
|---------|:---:|---|
|
||||||
|
| `defineOptions` | 3.3+ | [script-setup](reference/script-setup-best-practices.md) |
|
||||||
|
| `toValue()` | 3.3+ | [script-setup](reference/script-setup-best-practices.md) |
|
||||||
|
| `defineModel` | 3.4+ | [script-setup](reference/script-setup-best-practices.md) |
|
||||||
|
| `watch` 搭配 `once` 选项 | 3.4+ | [reactivity](reference/reactivity-performance.md) |
|
||||||
|
| `useTemplateRef()` | 3.5+ | [script-setup](reference/script-setup-best-practices.md) |
|
||||||
|
| `useId()` | 3.5+ | [script-setup](reference/script-setup-best-practices.md) |
|
||||||
|
| `onWatcherCleanup()` | 3.5+ | [reactivity](reference/reactivity-performance.md) |
|
||||||
|
|
||||||
|
## 快速决策表
|
||||||
|
|
||||||
|
| 问题 | 参考阅读 |
|
||||||
|
|---------|-------------|
|
||||||
|
| 我的 `<script setup>` 很乱,难以浏览 | [SFC 代码组织](reference/sfc-code-organization.md) |
|
||||||
|
| 某个功能的逻辑分散在很多行中 | [useXxx 函数模式](reference/use-function-pattern.md) |
|
||||||
|
| 相同逻辑在多个组件中重复 | [功能提取](reference/feature-extraction.md) |
|
||||||
|
| 两个功能相互影响,但不知道正确的处理模式 | [跨功能依赖](reference/cross-feature-dependencies.md) |
|
||||||
|
| Props/emits 类型安全、`defineModel` 使用 | [script setup 最佳实践](reference/script-setup-best-practices.md) |
|
||||||
|
| 在 hooks/工具函数/插件中无法访问 Store | [组件外 Store 访问](reference/store-without-pattern.md) |
|
||||||
|
| 如何组织新的组合式函数文件 | [组合式函数设计模式](reference/composable-design-patterns.md) |
|
||||||
|
| 页面感觉慢,可能是响应性问题 | [响应性与性能](reference/reactivity-performance.md) |
|
||||||
|
| 如何测试组合式函数 | [组合式函数测试](reference/composable-design-patterns.md#9-testing-composables) |
|
||||||
|
| 类型安全的 `provide`/`inject` | [script setup - Provide/Inject](reference/script-setup-best-practices.md#provideinject-with-typescript) |
|
||||||
|
|
||||||
|
### 代码组织
|
||||||
|
- SFC 代码缺乏清晰的组织结构 → 参见 [sfc-code-organization](reference/sfc-code-organization.md)
|
||||||
|
- 功能逻辑分散在脚本各处 → 参见 [use-function-pattern](reference/use-function-pattern.md)
|
||||||
|
- 需要将可复用逻辑提取为组合式函数 → 参见 [feature-extraction](reference/feature-extraction.md)
|
||||||
|
- 跨功能依赖导致混乱 → 参见 [cross-feature-dependencies](reference/cross-feature-dependencies.md)
|
||||||
|
|
||||||
|
### TypeScript 与 Script Setup
|
||||||
|
- 需要 script setup 的 TypeScript 最佳实践 → 参见 [script-setup-best-practices](reference/script-setup-best-practices.md)
|
||||||
|
|
||||||
|
### Store 集成
|
||||||
|
- 在 Vue 组件外部访问 Pinia store → 参见 [store-without-pattern](reference/store-without-pattern.md)
|
||||||
|
|
||||||
|
### 组合式函数设计
|
||||||
|
- 设计健壮、可复用的组合式函数 → 参见 [composable-design-patterns](reference/composable-design-patterns.md)
|
||||||
|
|
||||||
|
### 响应性与性能
|
||||||
|
- 优化响应性以获得更好性能 → 参见 [reactivity-performance](reference/reactivity-performance.md)
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
- 使用 Vitest 测试组合式函数 → 参见 [composable-design-patterns](reference/composable-design-patterns.md#9-testing-composables)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 速查表
|
||||||
|
|
||||||
|
### SFC 代码组织顺序(11 步)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. defineOptions → 组件名称
|
||||||
|
2. defineProps → Props 类型声明
|
||||||
|
3. defineModel → 双向绑定(3.4+)
|
||||||
|
4. inject → 注入依赖
|
||||||
|
5. defineEmits → 事件类型声明
|
||||||
|
6. Store 声明 → useXxxStore()
|
||||||
|
7. 外部 hooks → useI18n()、useDesign() 等
|
||||||
|
8. 功能声明 → const { ... } = useFeature()
|
||||||
|
9. provide → 提供依赖
|
||||||
|
10. defineExpose → 暴露公共 API
|
||||||
|
11. 功能实现 → function useFeature() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应式 API 选择
|
||||||
|
|
||||||
|
```
|
||||||
|
基本类型 → ref
|
||||||
|
需要深层响应 → ref
|
||||||
|
大型对象/动态组件 → shallowRef
|
||||||
|
不需要重新赋值 → reactive(谨慎使用)
|
||||||
|
永不响应式 → markRaw + shallowRef
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store 访问规则
|
||||||
|
|
||||||
|
```
|
||||||
|
组件内 (<script setup>) → useAppStore()
|
||||||
|
组件外 (hooks/utils/plugins) → useAppStoreWithOut()
|
||||||
|
解构保持响应式 → storeToRefs(store)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 反模式 TOP 5
|
||||||
|
|
||||||
|
| # | 反模式 | 正确做法 |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 1 | 解构 props → 丢失响应式 | `toRefs(props)` 或直接用 `props.xxx` |
|
||||||
|
| 2 | 组件外用 `useXxxStore()` | 用 `useXxxStoreWithOut()` |
|
||||||
|
| 3 | `ref` 用于动态组件/大对象 | 用 `shallowRef` |
|
||||||
|
| 4 | 混用 Options API + script setup | 只选一种风格 |
|
||||||
|
| 5 | 事件监听不清理 | `onUnmounted` 中移除 / 使用 VueUse 的 `useEventListener` |
|
||||||
|
|
||||||
|
### 依赖模式速查
|
||||||
|
|
||||||
|
| 场景 | 推荐模式 |
|
||||||
|
|------|---------|
|
||||||
|
| 父子组件通信 | Props + Emits |
|
||||||
|
| 兄弟功能交互 | 回调参数(`onXxxChange`) |
|
||||||
|
| 跨层级多对多 | 事件总线(`useEmitt`) |
|
||||||
|
| 共享状态 | Store 桥接组合式函数 |
|
||||||
|
| 功能编排 | 组合式函数编排模式 |
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn73f0csgtdgbgjvxyzpvfp32h86bs77",
|
||||||
|
"slug": "vue-composition-api-best-practices",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishedAt": 1778222320482
|
||||||
|
}
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
# 组合式函数设计模式
|
||||||
|
|
||||||
|
基于 Vue 3 Composition API 的组合式函数(composable)设计模式总结。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目录结构规范
|
||||||
|
|
||||||
|
```
|
||||||
|
src/hooks/
|
||||||
|
├── web/ # 业务相关 hooks
|
||||||
|
│ ├── useDesign.ts # 命名空间/样式前缀
|
||||||
|
│ ├── useEmitt.ts # 事件总线
|
||||||
|
│ ├── useEngine.ts # 搜索引擎
|
||||||
|
│ ├── useI18n.ts # 国际化
|
||||||
|
│ ├── useLocalForage.ts # IndexedDB 存储
|
||||||
|
│ ├── useLocale.ts # 语言切换
|
||||||
|
│ ├── useNetwork.ts # 网络状态
|
||||||
|
│ ├── usePageIcon.ts # 页面图标
|
||||||
|
│ ├── useSideCategory.ts # 侧边栏分类
|
||||||
|
│ ├── useSuggestion.ts # 搜索建议
|
||||||
|
│ └── useTimeAgo.ts # 时间格式化
|
||||||
|
└── event/ # DOM 事件相关 hooks
|
||||||
|
└── useScrollTo.ts # 滚动定位
|
||||||
|
```
|
||||||
|
|
||||||
|
**规则**:
|
||||||
|
- 按功能域划分子目录(`web/`、`event/`)
|
||||||
|
- 每个文件一个 composable,文件名即函数名
|
||||||
|
- 函数名以 `use` 开头
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 五种设计模式
|
||||||
|
|
||||||
|
### 模式 1:有状态服务
|
||||||
|
|
||||||
|
封装独立的响应式状态和操作逻辑。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useNetwork.ts
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export function useNetwork() {
|
||||||
|
const isOnline = ref(navigator.onLine)
|
||||||
|
|
||||||
|
const updateOnline = () => (isOnline.value = navigator.onLine)
|
||||||
|
|
||||||
|
window.addEventListener('online', updateOnline)
|
||||||
|
window.addEventListener('offline', updateOnline)
|
||||||
|
|
||||||
|
// 注意:此处未自动清理,因为网络状态是全局性的
|
||||||
|
// 如果需要组件级清理,参考 Pattern 3
|
||||||
|
|
||||||
|
return { isOnline }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**特征**:维护全局状态,通常不需要组件级清理。
|
||||||
|
|
||||||
|
### 模式 2:Store 桥接
|
||||||
|
|
||||||
|
用 composable 封装 store 访问,隐藏 store 内部实现细节。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/usePageIcon.ts
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useBusinessStoreWithOut } from '@/store/modules/business'
|
||||||
|
|
||||||
|
export function usePageIcon() {
|
||||||
|
const businessStore = useBusinessStoreWithOut()
|
||||||
|
|
||||||
|
const pageIcon = computed(() => businessStore.getPageIcon)
|
||||||
|
|
||||||
|
function addPageIcon(icon: IconItem) {
|
||||||
|
businessStore.addPageIcon(icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePageIcon(id: string) {
|
||||||
|
businessStore.removePageIcon(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pageIcon, addPageIcon, removePageIcon }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**特征**:
|
||||||
|
- 使用 `useXxxStoreWithOut` 访问 store(因为 hook 可能在组件外使用)
|
||||||
|
- 对外暴露语义化接口,隐藏 store action 细节
|
||||||
|
- 不维护自身状态,仅转发 store 数据
|
||||||
|
|
||||||
|
**何时使用**:当多个组件需要以相同方式访问同一 store 数据时。
|
||||||
|
|
||||||
|
### 模式 3:生命周期感知
|
||||||
|
|
||||||
|
自动在组件卸载时清理副作用。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useEmitt.ts
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
import { mittBus } from '@/utils/mitt'
|
||||||
|
|
||||||
|
export function useEmitt() {
|
||||||
|
const listeners: Array<{ event: string; handler: (...args: any[]) => void }> = []
|
||||||
|
|
||||||
|
function on(event: string, handler: (...args: any[]) => void) {
|
||||||
|
mittBus.on(event, handler)
|
||||||
|
listeners.push({ event, handler })
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(event: string, ...args: any[]) {
|
||||||
|
mittBus.emit(event, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载时自动解绑所有通过此 hook 注册的事件
|
||||||
|
onUnmounted(() => {
|
||||||
|
listeners.forEach(({ event, handler }) => {
|
||||||
|
mittBus.off(event, handler)
|
||||||
|
})
|
||||||
|
listeners.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return { on, emit }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**特征**:
|
||||||
|
- 使用 `onUnmounted` 自动清理
|
||||||
|
- 内部维护清理队列
|
||||||
|
- 防止内存泄漏
|
||||||
|
|
||||||
|
**何时使用**:涉及事件监听、定时器、DOM 事件等需要清理的副作用。
|
||||||
|
|
||||||
|
> 另见:[跨功能依赖 - 模式 4:事件总线模式](cross-feature-dependencies.md#模式-4事件总线模式适用于复杂的多对多场景) 了解此模式在跨组件通信中的实际应用。
|
||||||
|
|
||||||
|
### 模式 4:异步资源
|
||||||
|
|
||||||
|
封装异步资源加载,提供加载状态。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useLocalForage.ts
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import localforage from 'localforage'
|
||||||
|
|
||||||
|
export function useLocalForage(storeName: string) {
|
||||||
|
const store = localforage.createInstance({ name: storeName })
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function getItem<T>(key: string): Promise<T | null> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
return await store.getItem<T>(key)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setItem<T>(key: string, value: T): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await store.setItem(key, value)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, getItem, setItem }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**特征**:
|
||||||
|
- 提供 `loading` 状态
|
||||||
|
- `try/finally` 保证状态重置
|
||||||
|
- 支持泛型返回值
|
||||||
|
|
||||||
|
**何时使用**:封装 IndexedDB、fetch、文件读取等异步操作。
|
||||||
|
|
||||||
|
### 模式 5:参数化工具
|
||||||
|
|
||||||
|
接收参数,返回计算结果或操作函数,不维护持久状态。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useDesign.ts
|
||||||
|
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||||
|
|
||||||
|
export function useDesign(scope: string) {
|
||||||
|
const appStore = useAppStoreWithOut()
|
||||||
|
|
||||||
|
const prefixCls = computed(() => `${appStore.getPrefixCls}-${scope}`)
|
||||||
|
const variables = computed(() => ({
|
||||||
|
'--prefix-cls': prefixCls.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { prefixCls, variables }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/event/useScrollTo.ts
|
||||||
|
export function useScrollTo() {
|
||||||
|
function scrollTo(target: HTMLElement, options?: ScrollToOptions) {
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scrollTo }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**特征**:
|
||||||
|
- 纯函数式,无副作用
|
||||||
|
- 参数决定返回值
|
||||||
|
- 不需要清理
|
||||||
|
|
||||||
|
**何时使用**:工具类逻辑,如样式计算、DOM 操作、格式化函数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 参数设计原则
|
||||||
|
|
||||||
|
### 单一职责参数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 每个参数职责明确
|
||||||
|
export function useLocalForage(storeName: string) { ... }
|
||||||
|
|
||||||
|
// ✅ GOOD: 可选参数用 Options 模式
|
||||||
|
export function useSuggestion(engine: string, options?: SuggestionOptions) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options 模式
|
||||||
|
|
||||||
|
当参数超过 2 个时,使用 Options 对象:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SuggestionOptions {
|
||||||
|
timeout?: number
|
||||||
|
maxResults?: number
|
||||||
|
callbackName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuggestion(engine: string, options?: SuggestionOptions) {
|
||||||
|
const { timeout = 5000, maxResults = 10, callbackName } = options ?? {}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 返回值设计原则
|
||||||
|
|
||||||
|
### 最小暴露原则
|
||||||
|
|
||||||
|
只返回外部真正需要的:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 只暴露必要的接口
|
||||||
|
export function usePageIcon() {
|
||||||
|
const businessStore = useBusinessStoreWithOut()
|
||||||
|
const pageIcon = computed(() => businessStore.getPageIcon)
|
||||||
|
|
||||||
|
function addPageIcon(icon: IconItem) { businessStore.addPageIcon(icon) }
|
||||||
|
function removePageIcon(id: string) { businessStore.removePageIcon(id) }
|
||||||
|
|
||||||
|
return { pageIcon, addPageIcon, removePageIcon }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ BAD: 暴露了整个 store
|
||||||
|
export function usePageIcon() {
|
||||||
|
const businessStore = useBusinessStoreWithOut()
|
||||||
|
return { businessStore } // 调用方可随意修改 store
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ref vs Computed
|
||||||
|
|
||||||
|
| 返回类型 | 使用场景 | 特征 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| `computed` | 派生状态,依赖其他响应式源 | 只读,自动更新,有缓存 |
|
||||||
|
| `ref` | 独立状态 | 可读写 |
|
||||||
|
| `readonly(ref)` | 只读状态,内部可修改 | 防止外部篡改 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useSideCategory() {
|
||||||
|
// 派生自 store → computed
|
||||||
|
const categories = computed(() => businessStore.getSideCategory)
|
||||||
|
|
||||||
|
// 独立状态 → ref
|
||||||
|
const activeId = ref<string>('')
|
||||||
|
|
||||||
|
// 只读暴露 → readonly
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editingState = readonly(isEditing)
|
||||||
|
|
||||||
|
return { categories, activeId, editingState }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 类型设计原则
|
||||||
|
|
||||||
|
### 泛型约束
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 泛型支持不同数据类型
|
||||||
|
export function useLocalForage(storeName: string) {
|
||||||
|
async function getItem<T>(key: string): Promise<T | null> { ... }
|
||||||
|
async function setItem<T>(key: string, value: T): Promise<void> { ... }
|
||||||
|
return { getItem, setItem }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 输入类型严格
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 参数类型精确
|
||||||
|
export function useEngine(engineType: SearchEngineType) { ... }
|
||||||
|
|
||||||
|
// ❌ BAD: 参数类型过于宽泛
|
||||||
|
export function useEngine(engineType: string) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回类型推断
|
||||||
|
|
||||||
|
让 TypeScript 自动推断返回类型,除非需要导出:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 一般不需要显式声明返回类型
|
||||||
|
export function usePageIcon() {
|
||||||
|
// TypeScript 自动推断返回 { pageIcon: ComputedRef<...>, addPageIcon: (...) => void, ... }
|
||||||
|
return { pageIcon, addPageIcon, removePageIcon }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果其他模块需要使用返回值类型,用 Extract 类型工具
|
||||||
|
export type PageIconReturn = ReturnType<typeof usePageIcon>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 错误处理原则
|
||||||
|
|
||||||
|
### 静默失败 vs 抛出异常
|
||||||
|
|
||||||
|
| 场景 | 策略 | 原因 |
|
||||||
|
|------|------|------|
|
||||||
|
| 数据获取 | 静默失败 + 降级 | 不应阻塞 UI |
|
||||||
|
| 关键操作 | 抛出异常 | 必须让调用方感知 |
|
||||||
|
| 生命周期清理 | 静默失败 | 卸载时不应抛错 |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 数据获取:静默失败 + 降级
|
||||||
|
export function useSuggestion(engine: string) {
|
||||||
|
const suggestions = ref<string[]>([])
|
||||||
|
|
||||||
|
async function fetchSuggestion(keyword: string) {
|
||||||
|
try {
|
||||||
|
suggestions.value = await doFetch(keyword)
|
||||||
|
} catch {
|
||||||
|
suggestions.value = [] // 降级为空列表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suggestions, fetchSuggestion }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键操作:抛出异常
|
||||||
|
export function useBackup() {
|
||||||
|
async function exportData(): Promise<Blob> {
|
||||||
|
const data = await collectData()
|
||||||
|
if (!data) throw new Error('No data to export')
|
||||||
|
return packZip(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exportData }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Composable 与组件的边界
|
||||||
|
|
||||||
|
### 放在 Composable 中
|
||||||
|
|
||||||
|
- 可复用的状态逻辑
|
||||||
|
- 与特定 UI 无关的数据转换
|
||||||
|
- Store 访问桥接
|
||||||
|
- 浏览器 API 封装
|
||||||
|
|
||||||
|
### 放在组件中
|
||||||
|
|
||||||
|
- 模板渲染相关计算
|
||||||
|
- 仅当前组件使用的 UI 状态(如弹窗开关)
|
||||||
|
- DOM 直接操作(通过 ref)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 放 composable:可复用的搜索引擎逻辑
|
||||||
|
// hooks/web/useEngine.ts
|
||||||
|
export function useEngine() {
|
||||||
|
const businessStore = useBusinessStoreWithOut()
|
||||||
|
const currentEngine = computed(() => businessStore.getSearchEngine)
|
||||||
|
function switchEngine() { ... }
|
||||||
|
return { currentEngine, switchEngine }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 放组件:仅当前组件使用的弹窗状态
|
||||||
|
<script setup lang="ts">
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const openDialog = () => (dialogVisible.value = true)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 完整示例:生产级 Composable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useSuggestion.ts
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import { useEngine } from './useEngine'
|
||||||
|
import { SUGGESTION_TIMEOUT } from '@/constants'
|
||||||
|
|
||||||
|
interface SuggestionOptions {
|
||||||
|
timeout?: number
|
||||||
|
maxResults?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuggestion(options?: SuggestionOptions) {
|
||||||
|
const { currentEngine } = useEngine()
|
||||||
|
const { timeout = SUGGESTION_TIMEOUT, maxResults = 10 } = options ?? {}
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const suggestions = ref<string[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 清理:JSONP 脚本和超时定时器
|
||||||
|
let scriptEl: HTMLScriptElement | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (scriptEl) {
|
||||||
|
scriptEl.remove()
|
||||||
|
scriptEl = null
|
||||||
|
}
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetch(keyword: string) {
|
||||||
|
if (!keyword.trim()) {
|
||||||
|
suggestions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const callbackName = `suggestion_${Date.now()}`
|
||||||
|
|
||||||
|
// JSONP 回调
|
||||||
|
;(window as any)[callbackName] = (data: string[]) => {
|
||||||
|
suggestions.value = data.slice(0, maxResults)
|
||||||
|
loading.value = false
|
||||||
|
cleanup()
|
||||||
|
delete (window as any)[callbackName]
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时处理
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
suggestions.value = []
|
||||||
|
loading.value = false
|
||||||
|
cleanup()
|
||||||
|
delete (window as any)[callbackName]
|
||||||
|
resolve()
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
// 注入脚本
|
||||||
|
const url = currentEngine.value.suggestionUrl(keyword, callbackName)
|
||||||
|
scriptEl = document.createElement('script')
|
||||||
|
scriptEl.src = url
|
||||||
|
document.head.appendChild(scriptEl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动清理
|
||||||
|
onUnmounted(cleanup)
|
||||||
|
|
||||||
|
return { suggestions, loading, fetch }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这个示例综合了多种模式:
|
||||||
|
- **Options 模式**:可配置超时和最大结果数
|
||||||
|
- **异步资源**:loading 状态管理
|
||||||
|
- **生命周期感知**:onUnmounted 自动清理
|
||||||
|
- **最小暴露**:只返回 suggestions、loading、fetch
|
||||||
|
- **错误处理**:超时降级为空列表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 测试 Composable
|
||||||
|
|
||||||
|
Composable 是纯函数(返回响应式状态 + 方法),非常适合单元测试。推荐使用 **Vitest + @vue/test-utils**。
|
||||||
|
|
||||||
|
### 测试纯计算型 Composable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/__tests__/useDesign.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { useDesign } from '../web/useDesign'
|
||||||
|
|
||||||
|
describe('useDesign', () => {
|
||||||
|
it('should generate correct prefix class', () => {
|
||||||
|
const { getPrefixCls } = useDesign()
|
||||||
|
expect(getPrefixCls('layout')).toBe('mi-layout')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expose namespace variables', () => {
|
||||||
|
const { variables, simplePrefixCls } = useDesign()
|
||||||
|
expect(variables.namespace).toBeDefined()
|
||||||
|
expect(simplePrefixCls).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试有状态 Composable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/__tests__/useEngine.test.ts
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useEngine } from '../web/useEngine'
|
||||||
|
|
||||||
|
describe('useEngine', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia()) // 每次测试前创建新的 pinia 实例
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return current search engine', () => {
|
||||||
|
const { selectEngine, engineInfo } = useEngine()
|
||||||
|
expect(selectEngine.value).toBe('baidu')
|
||||||
|
expect(engineInfo.value.label).toBe('百度')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch to next engine', () => {
|
||||||
|
const { selectEngine, nextEngine } = useEngine()
|
||||||
|
nextEngine()
|
||||||
|
expect(selectEngine.value).toBe('google')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cycle back to first engine', () => {
|
||||||
|
const { selectEngine, updateSelectEngine, nextEngine } = useEngine()
|
||||||
|
// 切换到最后一个
|
||||||
|
updateSelectEngine('sogou')
|
||||||
|
nextEngine()
|
||||||
|
expect(selectEngine.value).toBe('baidu')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试含生命周期的 Composable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/__tests__/useNetwork.test.ts
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { useNetwork } from '../web/useNetwork'
|
||||||
|
|
||||||
|
describe('useNetwork', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reflect online status', async () => {
|
||||||
|
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||||
|
const { isOnline } = useNetwork()
|
||||||
|
expect(isOnline.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update when going offline', async () => {
|
||||||
|
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||||
|
const { isOnline } = useNetwork()
|
||||||
|
expect(isOnline.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试异步 Composable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/__tests__/useLocalForage.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { useLocalForage } from '../web/useLocalForage'
|
||||||
|
|
||||||
|
describe('useLocalForage', () => {
|
||||||
|
it('should get and set items', async () => {
|
||||||
|
const { setItem, getItem, loading } = useLocalForage('test')
|
||||||
|
|
||||||
|
await setItem('key1', { name: 'test' })
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
|
||||||
|
const result = await getItem<{ name: string }>('key1')
|
||||||
|
expect(result?.name).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing items', async () => {
|
||||||
|
const { getItem } = useLocalForage('test')
|
||||||
|
const result = await getItem('nonexistent')
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试 Store Bridge Composable
|
||||||
|
|
||||||
|
Store Bridge 模式的 composable 测试关键是初始化 pinia:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/__tests__/usePageIcon.test.ts
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { usePageIcon } from '../web/usePageIcon'
|
||||||
|
|
||||||
|
describe('usePageIcon', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add page icon', () => {
|
||||||
|
const { addPageIcon, curPageIcons } = usePageIcon()
|
||||||
|
|
||||||
|
addPageIcon({
|
||||||
|
label: '测试',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'test-icon',
|
||||||
|
iconType: 'online',
|
||||||
|
type: 'icon'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(curPageIcons.value.length).toBe(1)
|
||||||
|
expect(curPageIcons.value[0].label).toBe('测试')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试原则
|
||||||
|
|
||||||
|
| 原则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 每个 test 独立 | 用 `beforeEach` + `setActivePinia(createPinia())` 重置状态 |
|
||||||
|
| 只测试公开接口 | 只测 `return` 的值和方法,不测内部实现 |
|
||||||
|
| Mock 副作用 | 网络请求、浏览器 API 用 `vi.spyOn` / `vi.mock` 隔离 |
|
||||||
|
| 覆盖边界情况 | 空输入、异常路径、极限值 |
|
||||||
|
| 测试异步行为 | 用 `async/await` + 断言 `loading` 状态变化 |
|
||||||
|
|
||||||
|
**目录结构建议:**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/hooks/
|
||||||
|
├── web/
|
||||||
|
│ ├── __tests__/ # 测试文件目录
|
||||||
|
│ │ ├── useDesign.test.ts
|
||||||
|
│ │ ├── useEngine.test.ts
|
||||||
|
│ │ ├── useNetwork.test.ts
|
||||||
|
│ │ ├── usePageIcon.test.ts
|
||||||
|
│ │ └── useLocalForage.test.ts
|
||||||
|
│ ├── useDesign.ts
|
||||||
|
│ ├── useEngine.ts
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
---
|
||||||
|
title: Handling Cross-Feature Dependencies
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: 跨功能依赖管理不当会导致紧密耦合、不可预测的行为,以及功能交互时难以调试
|
||||||
|
type: best-practice
|
||||||
|
tags: [vue3, composition-api, dependencies, coupling, architecture, event-bus]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 处理跨功能依赖
|
||||||
|
|
||||||
|
**影响级别:MEDIUM** - 当功能需要交互时,合理的依赖管理可确保行为可预测、代码可维护。
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
- [ ] 通过函数参数显式传递依赖
|
||||||
|
- [ ] 避免通过外层作用域闭包产生隐式依赖
|
||||||
|
- [ ] 使用回调函数进行跨功能通信
|
||||||
|
- [ ] 对于多对多通信,使用事件总线并自动清理
|
||||||
|
- [ ] 考虑依赖方向(单向,避免循环)
|
||||||
|
- [ ] 优先使用 Store 桥接组合式函数访问共享状态
|
||||||
|
|
||||||
|
## 问题所在
|
||||||
|
|
||||||
|
功能之间经常需要交互,但隐式依赖会使代码难以理解和测试。
|
||||||
|
|
||||||
|
**BAD - 通过外层作用域产生隐式依赖:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
// 功能 1:搜索
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchResults = ref([])
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
searchResults.value = await fetchResults(searchQuery.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 功能 2:分页 — 隐式依赖 handleSearch
|
||||||
|
const currentPage = ref(1)
|
||||||
|
|
||||||
|
watch(currentPage, () => {
|
||||||
|
handleSearch() // 这属于哪个功能?
|
||||||
|
})
|
||||||
|
|
||||||
|
// 功能 3:筛选 — 也依赖搜索
|
||||||
|
const activeFilter = ref('all')
|
||||||
|
|
||||||
|
watch(activeFilter, () => {
|
||||||
|
handleSearch() // 又一个隐式依赖
|
||||||
|
})
|
||||||
|
|
||||||
|
// 问题:更改筛选条件会重置页码,但顺序很重要!
|
||||||
|
// 哪个 watch 先触发?不清楚!
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**GOOD - 通过参数显式传递依赖:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
// 功能声明
|
||||||
|
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
searchResults,
|
||||||
|
handleSearch
|
||||||
|
} = useSearch()
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentPage,
|
||||||
|
changePage
|
||||||
|
} = usePagination({
|
||||||
|
onPageChange: handleSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeFilter,
|
||||||
|
setFilter
|
||||||
|
} = useFilter({
|
||||||
|
onFilterChange: () => {
|
||||||
|
changePage(1)
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============ 功能实现 ============
|
||||||
|
|
||||||
|
function useSearch() {
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchResults = ref<SearchResult[]>([])
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
searchResults.value = await fetchResults(searchQuery.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { searchQuery, searchResults, handleSearch }
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePagination(options: { onPageChange: () => void }) {
|
||||||
|
const currentPage = ref(1)
|
||||||
|
|
||||||
|
const changePage = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
options.onPageChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentPage, changePage }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFilter(options: { onFilterChange: () => void }) {
|
||||||
|
const activeFilter = ref('all')
|
||||||
|
|
||||||
|
const setFilter = (filter: string) => {
|
||||||
|
activeFilter.value = filter
|
||||||
|
options.onFilterChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activeFilter, setFilter }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖模式
|
||||||
|
|
||||||
|
### 模式 1:回调模式(推荐用于简单通信)
|
||||||
|
|
||||||
|
通过回调进行直接的跨功能通信:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function usePagination(options: {
|
||||||
|
onPageChange?: (page: number) => void
|
||||||
|
}) {
|
||||||
|
const currentPage = ref(1)
|
||||||
|
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
options.onPageChange?.(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentPage, goToPage }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用法
|
||||||
|
const { handleSearch } = useSearch()
|
||||||
|
const { currentPage, goToPage } = usePagination({
|
||||||
|
onPageChange: () => handleSearch()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 2:Ref 注入模式
|
||||||
|
|
||||||
|
传递响应式 ref 实现共享状态:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function useSearch(query: Ref<string>) {
|
||||||
|
const results = ref([])
|
||||||
|
|
||||||
|
watch(query, async (q) => {
|
||||||
|
results.value = await fetchResults(q)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { results }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSearchInput() {
|
||||||
|
const query = ref('')
|
||||||
|
const debouncedQuery = refDebounced(query, 300)
|
||||||
|
|
||||||
|
return { query, debouncedQuery }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用法 — 显式依赖
|
||||||
|
const { query, debouncedQuery } = useSearchInput()
|
||||||
|
const { results } = useSearch(debouncedQuery)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 3:组合式函数编排模式
|
||||||
|
|
||||||
|
创建更高层级的组合式函数来组合多个功能:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function useSearchWithPagination() {
|
||||||
|
const { searchQuery, searchResults, handleSearch } = useSearch()
|
||||||
|
const { currentPage, pageSize, changePage } = usePagination({
|
||||||
|
onPageChange: handleSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchWithParams = () => {
|
||||||
|
return handleSearch({
|
||||||
|
query: searchQuery.value,
|
||||||
|
page: currentPage.value,
|
||||||
|
size: pageSize.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery, searchResults,
|
||||||
|
currentPage, pageSize,
|
||||||
|
searchWithParams, changePage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 4:事件总线模式(适用于复杂的多对多场景)
|
||||||
|
|
||||||
|
使用 `mitt` 并自动清理,实现解耦通信:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useEmitt.ts
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
import { mittBus } from '@/utils/mitt'
|
||||||
|
|
||||||
|
export function useEmitt() {
|
||||||
|
const listeners: Array<{ event: string; handler: (...args: any[]) => void }> = []
|
||||||
|
|
||||||
|
function on(event: string, handler: (...args: any[]) => void) {
|
||||||
|
mittBus.on(event, handler)
|
||||||
|
listeners.push({ event, handler })
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(event: string, ...args: any[]) {
|
||||||
|
mittBus.emit(event, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载时自动解绑所有通过此 hook 注册的事件
|
||||||
|
onUnmounted(() => {
|
||||||
|
listeners.forEach(({ event, handler }) => {
|
||||||
|
mittBus.off(event, handler)
|
||||||
|
})
|
||||||
|
listeners.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return { on, emit }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**用法 — 发送方:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Layout.vue - 发送事件
|
||||||
|
const { emit } = useEmitt()
|
||||||
|
emit('open-contextmenu', { event: e })
|
||||||
|
```
|
||||||
|
|
||||||
|
**用法 — 接收方(自动清理):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// MiContextMenu.vue - 监听事件
|
||||||
|
const { on } = useEmitt()
|
||||||
|
|
||||||
|
on('open-contextmenu', (data) => {
|
||||||
|
// 在事件位置处理上下文菜单
|
||||||
|
})
|
||||||
|
// 无需手动 off,组件卸载时自动清理
|
||||||
|
// 多次调用 on() 注册多个监听器,全部会在卸载时清理
|
||||||
|
```
|
||||||
|
|
||||||
|
> 另见:[组合式函数设计模式 - 模式 3:生命周期感知](composable-design-patterns.md#模式-3生命周期感知) 了解 `useEmitt` 作为 Lifecycle-Aware 模式的完整设计原理。
|
||||||
|
|
||||||
|
**何时使用事件总线 vs 回调:**
|
||||||
|
|
||||||
|
| 场景 | 模式 | 原因 |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| 直接父子关系 | 回调/Props | 简单、显式、类型安全 |
|
||||||
|
| 同一组件内的兄弟功能 | 回调 | 依赖流清晰 |
|
||||||
|
| 不同层级树的跨组件通信 | 事件总线 | 需要解耦 |
|
||||||
|
| 一个事件多个监听器 | 事件总线 | 一对多关系 |
|
||||||
|
| 功能需要响应 store 变化 | Store 桥接 | 单一数据源 |
|
||||||
|
|
||||||
|
### 模式 5:Store 桥接用于共享状态
|
||||||
|
|
||||||
|
当多个功能需要相同的 store 数据时,使用 Store 桥接组合式函数,而不是直接访问 store:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good — Store 桥接提供统一接口
|
||||||
|
// hooks/web/useSideCategory.ts
|
||||||
|
export const useSideCategory = () => {
|
||||||
|
const appStore = useAppStoreWithOut()
|
||||||
|
|
||||||
|
const selectCategory = computed(() => appStore.selectCategory)
|
||||||
|
const sidebarCategories = computed(() => appStore.sidebarCategories)
|
||||||
|
|
||||||
|
const removeCategory = (val: string) => {
|
||||||
|
// 复杂的业务逻辑集中在这里
|
||||||
|
const idx = sidebarCategories.value.findIndex((s) => s.key === val)
|
||||||
|
if (idx !== -1) {
|
||||||
|
const newCategories = [...sidebarCategories.value]
|
||||||
|
newCategories.splice(idx, 1)
|
||||||
|
appStore.deletePageIconInfo(val) // 同时清理相关数据
|
||||||
|
// 处理选中状态...
|
||||||
|
appStore.setSidebarCategories(newCategories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { selectCategory, sidebarCategories, removeCategory }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad — 组件中分散的直接 store 访问
|
||||||
|
// ComponentA.vue
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const idx = appStore.sidebarCategories.findIndex(...)
|
||||||
|
appStore.deletePageIconInfo(val)
|
||||||
|
appStore.setSidebarCategories(...)
|
||||||
|
|
||||||
|
// ComponentB.vue - 重复的逻辑!
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const idx = appStore.sidebarCategories.findIndex(...)
|
||||||
|
appStore.deletePageIconInfo(val)
|
||||||
|
appStore.setSidebarCategories(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖方向规则
|
||||||
|
|
||||||
|
### ✅ Good — 单向依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ 父组件 │
|
||||||
|
│ Component │
|
||||||
|
└──────┬──────┘
|
||||||
|
│ 传递依赖
|
||||||
|
▼
|
||||||
|
┌─────────────┐ ┌─────────────┐
|
||||||
|
│ 功能 A │────▶│ 功能 B │
|
||||||
|
│ (搜索) │ │ (分页) │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Good — 跨树通信使用事件总线
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ emit ┌──────────────┐
|
||||||
|
│ Layout │───────────────▶│ ContextMenu │
|
||||||
|
└──────────┘ └──────────────┘
|
||||||
|
|
||||||
|
┌──────────┐ emit ┌──────────────┐
|
||||||
|
│ Search │───────────────▶│ Suggestion │
|
||||||
|
└──────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 避免 — 循环依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐
|
||||||
|
│ 功能 A │◀───▶│ 功能 B │
|
||||||
|
│ │ │ │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
循环依赖!
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见场景
|
||||||
|
|
||||||
|
### 搜索 + 筛选 + 分页
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 清晰的依赖链
|
||||||
|
const { searchQuery } = useSearchInput()
|
||||||
|
const { activeFilter } = useFilter()
|
||||||
|
const { currentPage, pageSize } = usePagination()
|
||||||
|
|
||||||
|
// 数据获取整合所有参数
|
||||||
|
const { data, loading, refetch } = useDataFetch({
|
||||||
|
query: searchQuery,
|
||||||
|
filter: activeFilter,
|
||||||
|
page: currentPage,
|
||||||
|
size: pageSize
|
||||||
|
})
|
||||||
|
|
||||||
|
// 筛选变化时重置页码
|
||||||
|
watch(activeFilter, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
refetch()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通过事件总线跨组件通信
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Layout.vue - 触发 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { emit } = useEmitt()
|
||||||
|
|
||||||
|
const openContextmenu = (e: PointerEvent) => {
|
||||||
|
emit('open-contextmenu', { event: e })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- MiContextMenu.vue - 监听 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { on } = useEmitt()
|
||||||
|
|
||||||
|
on('open-contextmenu', ({ event }) => {
|
||||||
|
// 在事件位置显示上下文菜单
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 表单 + 验证 + 提交
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { fields, updateField } = useFormFields({
|
||||||
|
name: '',
|
||||||
|
email: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const { errors, validate } = useFormValidation(fields, {
|
||||||
|
name: { required: true },
|
||||||
|
email: { required: true, email: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { submit, isSubmitting } = useFormSubmit({
|
||||||
|
onSubmit: async () => {
|
||||||
|
if (!validate()) return
|
||||||
|
await submitForm(fields)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||||
|
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
|
||||||
|
- [mitt - Tiny Event Emitter](https://github.com/developit/mitt)
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
---
|
||||||
|
title: Feature Extraction to Composables
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: 未能提取可复用逻辑将导致代码重复、组件间行为不一致以及更高的维护成本
|
||||||
|
type: best-practice
|
||||||
|
tags: [vue3, composition-api, composables, reusability, dry]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 将功能提取为组合式函数
|
||||||
|
|
||||||
|
**影响等级:MEDIUM** - 将通用功能提取到外部组合式函数中可以促进代码复用、保持一致性和简化测试。
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
- [ ] 识别多个组件中使用的逻辑
|
||||||
|
- [ ] 提取到 `composables/` 或 `hooks/` 目录
|
||||||
|
- [ ] 保持组合式函数专注于单一职责
|
||||||
|
- [ ] 使用参数进行配置和依赖注入
|
||||||
|
- [ ] 返回响应式引用和方法
|
||||||
|
- [ ] 考虑使用 Store 桥接模式抽象 store 访问
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
当相似逻辑在多个组件中重复时,任何 bug 修复或功能增强都必须在多处应用,增加了维护负担和不一致的风险。
|
||||||
|
|
||||||
|
**BAD - 组件间重复的逻辑:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ComponentA.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const width = ref(window.innerWidth)
|
||||||
|
const height = ref(window.innerHeight)
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
width.value = window.innerWidth
|
||||||
|
height.value = window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('resize', handleResize))
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ComponentB.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const width = ref(window.innerWidth)
|
||||||
|
const height = ref(window.innerHeight)
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
width.value = window.innerWidth
|
||||||
|
height.value = window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('resize', handleResize))
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||||
|
|
||||||
|
// 使用 width/height 的其他逻辑...
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**GOOD - 提取到组合式函数:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useWindowSize.ts
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export function useWindowSize() {
|
||||||
|
const width = ref(window.innerWidth)
|
||||||
|
const height = ref(window.innerHeight)
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
width.value = window.innerWidth
|
||||||
|
height.value = window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('resize', handleResize))
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||||
|
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ComponentA.vue / ComponentB.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useWindowSize } from '@/hooks/web/useWindowSize'
|
||||||
|
|
||||||
|
const { width, height } = useWindowSize()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 何时提取
|
||||||
|
|
||||||
|
| 信号 | 示例 | 操作 |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| 在 2+ 组件中使用 | 窗口大小、认证状态 | 提取到组合式函数 |
|
||||||
|
| 复杂逻辑 | 表单验证、分页 | 提取以提高清晰度 |
|
||||||
|
| 需要测试 | API 调用、状态机 | 提取以实现隔离 |
|
||||||
|
| 第三方集成 | 数据分析、WebSocket | 提取以实现抽象 |
|
||||||
|
| Store 访问模式 | 页面图标、侧边栏分类 | 提取为 Store 桥接组合式函数 |
|
||||||
|
| 横切关注点 | 事件总线、网络状态 | 提取以保持一致性 |
|
||||||
|
|
||||||
|
## 提取模式
|
||||||
|
|
||||||
|
### 模式 1:简单工具组合式函数
|
||||||
|
|
||||||
|
无状态或最小状态,单一用途:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useDesign.ts
|
||||||
|
import variables from '@/styles/global.module.less'
|
||||||
|
|
||||||
|
export const useDesign = () => {
|
||||||
|
const lessVariables = variables
|
||||||
|
|
||||||
|
const getPrefixCls = (scope: string) => {
|
||||||
|
return `${lessVariables.namespace}-${scope}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
variables: lessVariables,
|
||||||
|
simplePrefixCls: lessVariables.miNamespace,
|
||||||
|
getPrefixCls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 2:Store 桥接组合式函数
|
||||||
|
|
||||||
|
将 store 访问封装在清晰的 API 之后。这是实际项目中最具影响力的提取模式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useEngine.ts
|
||||||
|
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||||
|
import { SEARCH_ENGINE_INFO, SEARCH_ENGINE_ORDER } from '@/config/setting'
|
||||||
|
|
||||||
|
export const useEngine = () => {
|
||||||
|
const appStore = useAppStoreWithOut()
|
||||||
|
|
||||||
|
// 当前选中搜索引擎
|
||||||
|
const selectEngine = computed(() => appStore.selectEngine)
|
||||||
|
|
||||||
|
// 当前设定搜索引擎列表(过滤后)
|
||||||
|
const searchEngine = computed(() =>
|
||||||
|
SEARCH_ENGINE_ORDER.filter((engine) => appStore.searchEngine.includes(engine))
|
||||||
|
)
|
||||||
|
|
||||||
|
// 当前选中搜索引擎详细信息
|
||||||
|
const engineInfo = computed(() => SEARCH_ENGINE_INFO[selectEngine.value])
|
||||||
|
|
||||||
|
// 更新搜索引擎
|
||||||
|
const updateSelectEngine = (val: SearchEngine) => {
|
||||||
|
appStore.setSelectEngine(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一个搜索引擎
|
||||||
|
const nextEngine = () => {
|
||||||
|
const idx = appStore.searchEngine.indexOf(selectEngine.value)
|
||||||
|
if (idx === appStore.searchEngine.length - 1) {
|
||||||
|
appStore.setSelectEngine(appStore.searchEngine[0])
|
||||||
|
} else {
|
||||||
|
appStore.setSelectEngine(appStore.searchEngine[idx + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { selectEngine, engineInfo, searchEngine, nextEngine, updateSelectEngine }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么 Store 桥接很重要:**
|
||||||
|
- 组件不需要 `import { useAppStore }` 和了解 store 结构
|
||||||
|
- 业务规则(例如 `SEARCH_ENGINE_ORDER.filter`)集中在一处
|
||||||
|
- 可以轻松替换 store 实现,无需修改组件
|
||||||
|
- 通过 `useXxxStoreWithOut` 可在组件外使用
|
||||||
|
|
||||||
|
### 模式 3:基于事件的组合式函数
|
||||||
|
|
||||||
|
管理副作用并自动清理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useNetwork.ts
|
||||||
|
import { ref, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
|
export const useNetwork = () => {
|
||||||
|
const online = ref(true)
|
||||||
|
|
||||||
|
const updateNetwork = () => {
|
||||||
|
online.value = navigator.onLine
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', updateNetwork)
|
||||||
|
window.addEventListener('offline', updateNetwork)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('online', updateNetwork)
|
||||||
|
window.removeEventListener('offline', updateNetwork)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { online }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 4:参数化组合式函数
|
||||||
|
|
||||||
|
接受配置以实现灵活性:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useCoordinateArea.ts
|
||||||
|
interface Coordinate { x1: number; y1: number; x2: number; y2: number }
|
||||||
|
type DirectionX = 'ltr' | 'rtl'
|
||||||
|
type DirectionY = 'ttb' | 'btt'
|
||||||
|
|
||||||
|
export const useCoordinateArea = (
|
||||||
|
coordinate: Coordinate,
|
||||||
|
direction: DirectionX = 'ltr',
|
||||||
|
directionY: DirectionY = 'ttb'
|
||||||
|
) => {
|
||||||
|
const { width, height } = useWindowSize()
|
||||||
|
const { x, y } = useMouse()
|
||||||
|
|
||||||
|
const { x1, y1, x2, y2 } = coordinate
|
||||||
|
|
||||||
|
const inCoordinateX = computed(() =>
|
||||||
|
direction === 'ltr'
|
||||||
|
? x.value > x1 && x.value < x2
|
||||||
|
: x.value > width.value - x2 && x.value < width.value - x1
|
||||||
|
)
|
||||||
|
|
||||||
|
const inCoordinateY = computed(() =>
|
||||||
|
directionY === 'ttb'
|
||||||
|
? y.value > y1 && y.value < y2
|
||||||
|
: y.value > height.value - y2 && y.value < height.value - y1
|
||||||
|
)
|
||||||
|
|
||||||
|
const inCoordinate = computed(() => inCoordinateX.value && inCoordinateY.value)
|
||||||
|
|
||||||
|
return { inCoordinate }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 5:第三方集成组合式函数
|
||||||
|
|
||||||
|
用 Vue 友好的 API 封装第三方库:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useCache.ts
|
||||||
|
import WebStorageCacheCrypto from 'web-storage-cache-crypto'
|
||||||
|
import sm4 from '@/utils/cipher/sm4'
|
||||||
|
|
||||||
|
type CacheType = 'localStorage' | 'sessionStorage'
|
||||||
|
|
||||||
|
export const CACHE_KEY = {
|
||||||
|
LANG: 'miao-lang',
|
||||||
|
DICT_CACHE: 'dictCache',
|
||||||
|
MIAOWING_APP: 'miaowing-app',
|
||||||
|
MIAOWING_BUSINESS: 'miaowing-business'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCache = (type: CacheType = 'localStorage', crypt: boolean = true) => {
|
||||||
|
const wsCache = new WebStorageCacheCrypto({
|
||||||
|
storage: type,
|
||||||
|
crypt: Boolean(crypt),
|
||||||
|
encrypt: sm4.encrypt,
|
||||||
|
decrypt: sm4.decrypt
|
||||||
|
})
|
||||||
|
|
||||||
|
return { wsCache }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── hooks/ # 组合式函数目录
|
||||||
|
│ ├── event/ # 事件相关的组合式函数
|
||||||
|
│ │ └── useScrollTo.ts # 平滑滚动
|
||||||
|
│ └── web/ # Web API 与业务逻辑组合式函数
|
||||||
|
│ ├── useCache.ts # 加密存储
|
||||||
|
│ ├── useCoordinateArea.ts # 鼠标位置检测
|
||||||
|
│ ├── useDesign.ts # CSS 命名空间
|
||||||
|
│ ├── useEmitt.ts # 事件总线
|
||||||
|
│ ├── useEngine.ts # 搜索引擎(Store 桥接)
|
||||||
|
│ ├── useI18n.ts # i18n 命名空间封装
|
||||||
|
│ ├── useLocale.ts # 语言切换
|
||||||
|
│ ├── useLocalForage.ts # IndexedDB 存储
|
||||||
|
│ ├── useNetwork.ts # 网络状态
|
||||||
|
│ ├── usePageIcon.ts # 页面图标(Store 桥接)
|
||||||
|
│ ├── useSideCategory.ts # 侧边栏分类(Store 桥接)
|
||||||
|
│ ├── useSuggestion.ts # 搜索建议 JSONP
|
||||||
|
│ └── useTimeAgo.ts # 相对时间
|
||||||
|
```
|
||||||
|
|
||||||
|
**命名规范:**
|
||||||
|
- 文件名与函数名一致:`useEngine.ts` → `export const useEngine = () => {}`
|
||||||
|
- 按领域分组:`event/` 用于 DOM 事件,`web/` 用于 Web API 和业务逻辑
|
||||||
|
- 每个文件一个组合式函数
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 单一职责
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 好 — 只专注一件事
|
||||||
|
export function useLocalStorage<T>(key: string, defaultValue: T) {
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)
|
||||||
|
|
||||||
|
watch(data, (newValue) => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(newValue))
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
return { data }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 差 — 混合了多个关注点
|
||||||
|
export function useUserStorageAndAuth() {
|
||||||
|
// 太多职责混在一起
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意:** 上述 `useLocalStorage` 直接使用了浏览器 API。如果你的项目需要支持 SSR,应从 `@vueuse/core` 引入 `useStorage`,它会自动处理非浏览器环境。
|
||||||
|
|
||||||
|
### 2. 接受 Ref 以保持响应性
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 好 — 同时接受原始值和 ref
|
||||||
|
export function useSearch(query: MaybeRef<string>) {
|
||||||
|
const results = ref([])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => toValue(query),
|
||||||
|
async (q) => {
|
||||||
|
results.value = await searchAPI(q)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { results }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用法
|
||||||
|
const query = ref('')
|
||||||
|
const { results } = useSearch(query) // 响应式!
|
||||||
|
const { results } = useSearch('static') // 也可以工作
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 返回响应式引用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 好 — 返回 ref 用于模板绑定
|
||||||
|
export function useTimer() {
|
||||||
|
const seconds = ref(0)
|
||||||
|
const isRunning = ref(false)
|
||||||
|
return { seconds, isRunning, start, stop }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 差 — 返回普通值,丢失响应性
|
||||||
|
export function useTimer() {
|
||||||
|
let seconds = 0
|
||||||
|
return { seconds } // 不是响应式的!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 自动清理
|
||||||
|
|
||||||
|
始终在 `onBeforeUnmount` 中清理副作用:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 好 — 卸载时清理
|
||||||
|
export const useNetwork = () => {
|
||||||
|
const online = ref(true)
|
||||||
|
const update = () => { online.value = navigator.onLine }
|
||||||
|
|
||||||
|
window.addEventListener('online', update)
|
||||||
|
window.addEventListener('offline', update)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('online', update)
|
||||||
|
window.removeEventListener('offline', update)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { online }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||||
|
- [VueUse - Collection of Vue Composition Utilities](https://vueuse.org/)
|
||||||
|
- [Vue.js Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
# 响应性与性能
|
||||||
|
|
||||||
|
Vue 3 Composition API 响应式与性能优化最佳实践。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ref vs shallowRef vs reactive
|
||||||
|
|
||||||
|
### 选择决策树
|
||||||
|
|
||||||
|
```
|
||||||
|
需要响应式?
|
||||||
|
├── 是 → 数据是基本类型?
|
||||||
|
│ ├── 是 → ref
|
||||||
|
│ └── 否 → 数据层级深?
|
||||||
|
│ ├── 浅层即可 → shallowRef
|
||||||
|
│ └── 需要深层 → ref(或 reactive)
|
||||||
|
└── 否 → 不需要响应式 → 普通变量 / shallowRef
|
||||||
|
```
|
||||||
|
|
||||||
|
### 对比表
|
||||||
|
|
||||||
|
| API | 响应深度 | 触发更新方式 | 适用场景 |
|
||||||
|
|-----|---------|-------------|---------|
|
||||||
|
| `ref` | 深层 | 自动 | 通用场景,对象属性变更需触发更新 |
|
||||||
|
| `shallowRef` | 浅层(.value) | 手动 `triggerRef` | 大型对象、动态组件、性能敏感场景 |
|
||||||
|
| `reactive` | 深层 | 自动 | 不需要重新赋值的对象 |
|
||||||
|
|
||||||
|
### ⚠️ reactive 的局限性
|
||||||
|
|
||||||
|
虽然 `reactive` 在某些场景下很方便,但 Vue 3 官方更推荐用 `ref` 作为主要响应式 API:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 1. 不能重新赋值 -- 整个替换会丢失响应式
|
||||||
|
let state = reactive({ count: 0 })
|
||||||
|
state = reactive({ count: 1 }) // 响应式连接断开!
|
||||||
|
|
||||||
|
// ✅ 用 ref 没问题
|
||||||
|
const state = ref({ count: 0 })
|
||||||
|
state.value = { count: 1 } // 正常触发更新
|
||||||
|
|
||||||
|
// ❌ 2. 解构丢失响应式
|
||||||
|
const { count } = reactive({ count: 0 }) // count 变成了普通数字
|
||||||
|
|
||||||
|
// ✅ 用 toRefs 保持响应式
|
||||||
|
const { count } = toRefs(reactive({ count: 0 }))
|
||||||
|
|
||||||
|
// ❌ 3. 不支持基本类型
|
||||||
|
const count = reactive(0) // 类型错误!
|
||||||
|
|
||||||
|
// ✅ 基本类型用 ref
|
||||||
|
const count = ref(0)
|
||||||
|
```
|
||||||
|
|
||||||
|
**经验法则:** 新代码优先使用 `ref`,仅在明确需要"对象属性级响应式且确定不会重新赋值"时使用 `reactive`。
|
||||||
|
|
||||||
|
### 实际案例:动态组件切换
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 使用 shallowRef 避免组件对象的深层响应式开销
|
||||||
|
function usePage() {
|
||||||
|
const activeCom = shallowRef()
|
||||||
|
const isPure = computed(() => appStore.pure)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
// 组件对象不需要深层响应式,shallowRef 足矣
|
||||||
|
activeCom.value = isPure.value ? PureMode : HomeMode
|
||||||
|
})
|
||||||
|
|
||||||
|
return { activeCom }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: 使用 ref 对组件对象做深层响应式,无意义且浪费性能
|
||||||
|
function usePage() {
|
||||||
|
const activeCom = ref() // 会递归遍历组件对象的所有属性
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shallowRef 手动触发更新
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const list = shallowRef<string[]>(['a', 'b', 'c'])
|
||||||
|
|
||||||
|
// ❌ 不会触发更新:修改数组内部不会被追踪
|
||||||
|
list.value.push('d')
|
||||||
|
|
||||||
|
// ✅ 触发更新:替换整个 .value
|
||||||
|
list.value = [...list.value, 'd']
|
||||||
|
|
||||||
|
// ✅ 触发更新:使用 triggerRef
|
||||||
|
list.value.push('d')
|
||||||
|
triggerRef(list)
|
||||||
|
```
|
||||||
|
|
||||||
|
### markRaw — 标记对象永不转为响应式
|
||||||
|
|
||||||
|
当确定某个对象不需要响应式时(如第三方库实例、大型静态数据),用 `markRaw` 标记它。这可以防止 Vue 的响应式系统意外地将其深层代理,避免性能浪费:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { markRaw, reactive, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
// ❌ BAD: 第三方库实例被意外代理
|
||||||
|
const mapInstance = new Map() // 被 reactive 包装,产生大量 proxy 开销
|
||||||
|
|
||||||
|
// ✅ GOOD: 标记为永不代理
|
||||||
|
const mapInstance = markRaw(new Map())
|
||||||
|
const state = reactive({
|
||||||
|
map: mapInstance // map 本身不会被代理
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ GOOD: 标记大型静态数据
|
||||||
|
const largeStaticConfig = markRaw({
|
||||||
|
// 数千行配置数据...
|
||||||
|
})
|
||||||
|
const appState = shallowRef({
|
||||||
|
config: largeStaticConfig // config 不会被 deep-track
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**何时使用 `markRaw`:**
|
||||||
|
- 第三方库实例(如 Leaflet 地图、Monaco Editor、ECharts 实例)
|
||||||
|
- 大型静态数据对象(如国家/地区列表、字典数据)
|
||||||
|
- 已经冻结的数据(`Object.freeze`)
|
||||||
|
- 在 `pinia` persist 中不需要持久化的运行时对象
|
||||||
|
|
||||||
|
**⚠️ 注意:** `markRaw` 是永久性的,标记后无法撤销。被标记的对象在 `reactive`/`ref` 中会被视为非响应式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. computed 缓存优化
|
||||||
|
|
||||||
|
### computed 的缓存特性
|
||||||
|
|
||||||
|
- 只在依赖变化时重新计算
|
||||||
|
- 多次访问只计算一次
|
||||||
|
- 适合派生状态和昂贵计算
|
||||||
|
|
||||||
|
### 何时用 computed vs 方法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 派生状态用 computed,有缓存
|
||||||
|
const filteredList = computed(() =>
|
||||||
|
list.value.filter(item => item.active)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ❌ BAD: 用方法返回派生值,每次调用都重新计算
|
||||||
|
function getFilteredList() {
|
||||||
|
return list.value.filter(item => item.active)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### computed 写入(双向绑定)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const keyword = computed({
|
||||||
|
get: () => searchStore.keyword,
|
||||||
|
set: (val: string) => { searchStore.keyword = val }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 避免在 computed 中产生副作用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: computed 中有副作用
|
||||||
|
const userInfo = computed(() => {
|
||||||
|
fetchUserInfo() // 每次依赖变化都会请求
|
||||||
|
return userStore.info
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ GOOD: 用 watch 处理副作用
|
||||||
|
const userInfo = computed(() => userStore.info)
|
||||||
|
watch(userId, (newId) => {
|
||||||
|
fetchUserInfo(newId)
|
||||||
|
}, { immediate: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. watch 优化
|
||||||
|
|
||||||
|
### watch vs watchEffect
|
||||||
|
|
||||||
|
| API | 依赖追踪 | 访问旧值 | 精确控制 | 适用场景 |
|
||||||
|
|-----|---------|---------|---------|---------|
|
||||||
|
| `watch` | 显式指定 | ✅ | ✅ | 需要旧值对比、精确监听 |
|
||||||
|
| `watchEffect` | 自动追踪 | ❌ | ❌ | 副作用与响应式源直接关联 |
|
||||||
|
|
||||||
|
### watch 的精确控制
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 精确监听特定属性
|
||||||
|
watch(
|
||||||
|
() => appStore.theme,
|
||||||
|
(newTheme) => { applyTheme(newTheme) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ❌ BAD: 监听整个 store,任何变化都触发
|
||||||
|
watch(
|
||||||
|
() => appStore,
|
||||||
|
() => { applyTheme(appStore.theme) },
|
||||||
|
{ deep: true } // 深层监听开销大
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用选项
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
watch(source, callback, {
|
||||||
|
immediate: true, // 创建时立即执行一次
|
||||||
|
deep: false, // 避免深层监听(默认 false)
|
||||||
|
once: true, // 只触发一次后自动停止(Vue 3.4+)
|
||||||
|
flush: 'post', // DOM 更新后执行(需要访问更新后的 DOM 时使用)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### watch 中清理副作用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
watch(id, (newId, oldId, onCleanup) => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
fetch(`/api/user/${newId}`, { signal: controller.signal })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => { user.value = data })
|
||||||
|
|
||||||
|
// id 变化时取消上一次请求
|
||||||
|
onCleanup(() => controller.abort())
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### onWatcherCleanup (Vue 3.5+)
|
||||||
|
|
||||||
|
**Vue 3.5 引入了 `onWatcherCleanup()`** — 可以在 `watchEffect` 内部调用的清理函数(之前 `watchEffect` 不支持 `onCleanup` 回调参数):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { watchEffect, onWatcherCleanup } from 'vue'
|
||||||
|
|
||||||
|
// ✅ Vue 3.5+: watchEffect 内部也能注册清理函数
|
||||||
|
watchEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
fetch(`/api/user/${userId.value}`, { signal: controller.signal })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => { user.value = data })
|
||||||
|
|
||||||
|
// userId 变化或组件卸载时自动取消请求
|
||||||
|
onWatcherCleanup(() => controller.abort())
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**对比 `watch` 的 `onCleanup` 参数:**
|
||||||
|
|
||||||
|
| 特性 | `watch(fn, (_, __, onCleanup) => {})` | `watchEffect(() => { onWatcherCleanup(...) })` |
|
||||||
|
|------|--------------------------------------|----------------------------------------------|
|
||||||
|
| 可用性 | Vue 3.0+ | Vue 3.5+ |
|
||||||
|
| 清理触发时机 | 下次执行前 + 卸载时 | 下次执行前 + 卸载时 |
|
||||||
|
| 使用方式 | 回调参数 | 独立函数调用 |
|
||||||
|
| 适用场景 | 精确监听 + 清理 | 自动追踪 + 清理 |
|
||||||
|
|
||||||
|
**为什么需要 `onWatcherCleanup`:** 之前 `watchEffect` 无法注册清理函数,导致在 effect 中发起的异步请求无法在新请求发起前自动取消,容易产生竞态条件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 事件监听清理
|
||||||
|
|
||||||
|
### 组件级自动清理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 在 composable 中使用生命周期钩子自动清理
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
import { mittBus } from '@/utils/mitt'
|
||||||
|
|
||||||
|
export function useEmitt() {
|
||||||
|
const listeners: Array<{ event: string; handler: Function }> = []
|
||||||
|
|
||||||
|
function on(event: string, handler: Function) {
|
||||||
|
mittBus.on(event, handler as any)
|
||||||
|
listeners.push({ event, handler })
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
listeners.forEach(({ event, handler }) => {
|
||||||
|
mittBus.off(event, handler as any)
|
||||||
|
})
|
||||||
|
listeners.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return { on, emit: mittBus.emit }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 另见:[组合式函数设计模式 - 模式 3](composable-design-patterns.md#模式-3生命周期感知) 和 [跨功能依赖 - 模式 4](cross-feature-dependencies.md#模式-4事件总线模式适用于复杂的多对多场景) 了解 `useEmitt` 的更多使用场景。
|
||||||
|
|
||||||
|
### DOM 事件清理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 使用 VueUse 的 useEventListener 自动清理
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
|
||||||
|
export function useNetwork() {
|
||||||
|
const isOnline = ref(navigator.onLine)
|
||||||
|
// 自动在卸载时移除监听
|
||||||
|
useEventListener(window, 'online', () => (isOnline.value = true))
|
||||||
|
useEventListener(window, 'offline', () => (isOnline.value = false))
|
||||||
|
return { isOnline }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动清理模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 对于不支持生命周期钩子的场景,提供 stop 函数
|
||||||
|
export function useInterval(fn: () => void, delay: number) {
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
stop()
|
||||||
|
timer = setInterval(fn, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(stop)
|
||||||
|
|
||||||
|
return { start, stop }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 组件懒加载
|
||||||
|
|
||||||
|
### defineAsyncComponent
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
|
// ✅ GOOD: 懒加载重型组件
|
||||||
|
const HeavyChart = defineAsyncComponent(() =>
|
||||||
|
import('@/components/Chart/src/HeavyChart.vue')
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动态 import + shallowRef
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: 条件加载组件
|
||||||
|
const activeCom = shallowRef<Component>()
|
||||||
|
|
||||||
|
watchEffect(async () => {
|
||||||
|
if (condition.value) {
|
||||||
|
const mod = await import('./HeavyComponent.vue')
|
||||||
|
activeCom.value = mod.default
|
||||||
|
} else {
|
||||||
|
activeCom.value = LightComponent
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Suspense 配合
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Suspense>
|
||||||
|
<template #default>
|
||||||
|
<AsyncComponent />
|
||||||
|
</template>
|
||||||
|
<template #fallback>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. v-once 与 v-memo
|
||||||
|
|
||||||
|
### v-once:只渲染一次
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- 只在首次渲染时求值,后续更新跳过 -->
|
||||||
|
<div v-once>{{ staticContent }}</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### v-memo:条件记忆
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- 仅当 item.id 变化时重新渲染 -->
|
||||||
|
<div v-memo="[item.id]">
|
||||||
|
<ExpensiveComponent :item="item" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 列表渲染优化
|
||||||
|
|
||||||
|
### key 的正确使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ✅ GOOD: 使用唯一 ID -->
|
||||||
|
<div v-for="item in list" :key="item.id">
|
||||||
|
|
||||||
|
<!-- ❌ BAD: 使用 index -->
|
||||||
|
<div v-for="(item, index) in list" :key="index">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 虚拟列表
|
||||||
|
|
||||||
|
当列表项超过 100 个时,使用虚拟滚动:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 推荐 vueuse/useVirtualList 或第三方库
|
||||||
|
import { useVirtualList } from '@vueuse/core'
|
||||||
|
|
||||||
|
const { list, containerProps, wrapperProps } = useVirtualList(
|
||||||
|
largeList,
|
||||||
|
{ itemHeight: 48, overscan: 10 }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 响应式解包注意事项
|
||||||
|
|
||||||
|
### 模板自动解包
|
||||||
|
|
||||||
|
在模板中,`ref` 自动解包,不需要 `.value`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- ✅ 自动解包 -->
|
||||||
|
<div>{{ count }}</div>
|
||||||
|
|
||||||
|
<!-- ❌ 不需要 .value -->
|
||||||
|
<div>{{ count.value }}</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### reactive 内的 ref 自动解包
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const state = reactive({
|
||||||
|
count: ref(0),
|
||||||
|
name: 'test'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ reactive 对象中 ref 自动解包
|
||||||
|
console.log(state.count) // 0,不需要 state.count.value
|
||||||
|
```
|
||||||
|
|
||||||
|
### 非响应式对象中的 ref 不解包
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const map = new Map<string, Ref<number>>()
|
||||||
|
map.set('a', ref(1))
|
||||||
|
|
||||||
|
// ❌ 非 reactive 对象,不会自动解包
|
||||||
|
console.log(map.get('a')) // Ref 对象,需要 .value
|
||||||
|
console.log(map.get('a')!.value) // 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. effectScope — 管理多个 Composable 的生命周期
|
||||||
|
|
||||||
|
当多个 composable 需要同时创建和销毁时,`effectScope` 可以批量管理它们的响应式 effect:
|
||||||
|
|
||||||
|
### 基础用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { effectScope, ref, watchEffect, onScopeDispose } from 'vue'
|
||||||
|
|
||||||
|
// 创建独立作用域
|
||||||
|
const scope = effectScope()
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
// 在这个作用域内创建的所有 effect、watch、computed
|
||||||
|
// 都会关联到此 scope
|
||||||
|
const count = ref(0)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
console.log(`Count: ${count.value}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 注册作用域销毁时的清理函数
|
||||||
|
onScopeDispose(() => {
|
||||||
|
console.log('Scope disposed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 一次性停止作用域内的所有 effect
|
||||||
|
scope.stop()
|
||||||
|
// 输出: "Scope disposed"
|
||||||
|
// 所有 watchEffect 停止
|
||||||
|
```
|
||||||
|
|
||||||
|
### 实际场景:Composable 工厂
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/useControlledEffects.ts
|
||||||
|
import { effectScope, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export function useControlledEffects() {
|
||||||
|
let scope: ReturnType<typeof effectScope> | null = effectScope()
|
||||||
|
const isActive = ref(true)
|
||||||
|
|
||||||
|
function run(setup: () => void) {
|
||||||
|
scope?.run(() => {
|
||||||
|
setup()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
scope?.stop()
|
||||||
|
scope = effectScope()
|
||||||
|
isActive.value = false
|
||||||
|
nextTick(() => (isActive.value = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
scope?.stop()
|
||||||
|
scope = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return { run, restart, isActive }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**何时使用 `effectScope`:**
|
||||||
|
- 需要在组件外手动管理多个 effect 的生命周期(如插件、指令)
|
||||||
|
- 实现"批量创建/销毁"模式(如路由切换时清理上一页所有 effect)
|
||||||
|
- 编写 composable 测试时隔离 effect
|
||||||
|
|
||||||
|
**何时不需要:**
|
||||||
|
- 直接在组件内使用 composable — 组件卸载时自动清理
|
||||||
|
- 单个 `watch` / `watchEffect` — 返回的 `stop` 函数足矣
|
||||||
|
|
||||||
|
> 另见:[组合式函数测试](composable-design-patterns.md#9-测试-composable) 了解如何在测试中使用 `effectScope` 隔离 effect。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Store 性能优化
|
||||||
|
|
||||||
|
### storeToRefs 避免额外响应式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
const store = useAppStore()
|
||||||
|
|
||||||
|
// ✅ GOOD: storeToRefs 只提取响应式属性,不触发额外响应式包装
|
||||||
|
const { theme, pure } = storeToRefs(store)
|
||||||
|
|
||||||
|
// ❌ BAD: 解构丢失响应式
|
||||||
|
const { theme, pure } = store // 失去响应式
|
||||||
|
|
||||||
|
// ❌ BAD: toRefs 对 store 实例做额外包装
|
||||||
|
const { theme, pure } = toRefs(store) // 不必要,用 storeToRefs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 按需访问 Store 属性
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD: computed 精确追踪
|
||||||
|
const theme = computed(() => appStore.theme)
|
||||||
|
|
||||||
|
// ❌ BAD: 解构整个 store 导致所有属性变化都触发重渲染
|
||||||
|
const store = useAppStore()
|
||||||
|
const { theme, pure, layout, ... } = storeToRefs(store) // 过度解构
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 性能检查清单
|
||||||
|
|
||||||
|
### 组件级
|
||||||
|
|
||||||
|
- [ ] 大型数据使用 `shallowRef` 而非 `ref`
|
||||||
|
- [ ] 动态组件使用 `shallowRef`
|
||||||
|
- [ ] 派生状态用 `computed`,不用方法
|
||||||
|
- [ ] 避免在 `computed` 中产生副作用
|
||||||
|
- [ ] 列表使用唯一 `key`
|
||||||
|
- [ ] 重型组件懒加载
|
||||||
|
- [ ] 非响应式对象使用 `markRaw` 标记
|
||||||
|
|
||||||
|
### 副作用清理
|
||||||
|
|
||||||
|
- [ ] 事件监听器在 `onUnmounted` 中移除
|
||||||
|
- [ ] 定时器在 `onUnmounted` 中清除
|
||||||
|
- [ ] JSONP 脚本在完成/超时后移除
|
||||||
|
- [ ] `watch` 返回的 `stop` 函数在适当时机调用
|
||||||
|
- [ ] `watchEffect` 中使用 `onWatcherCleanup()` 清理异步请求(3.5+)
|
||||||
|
|
||||||
|
### Store 使用
|
||||||
|
|
||||||
|
- [ ] 组件内用 `useXxxStore()`,组件外用 `useXxxStoreWithOut()`
|
||||||
|
- [ ] 解构 store 用 `storeToRefs`
|
||||||
|
- [ ] 避免深层 `watch` store
|
||||||
|
- [ ] 不暴露整个 store 实例
|
||||||
|
|
||||||
|
### 响应式选择
|
||||||
|
|
||||||
|
- [ ] 基本类型用 `ref`
|
||||||
|
- [ ] 不需要深层响应的大型对象用 `shallowRef`
|
||||||
|
- [ ] 需要旧值对比用 `watch`,否则用 `watchEffect`
|
||||||
|
- [ ] 模板中不加 `.value`
|
||||||
|
- [ ] 多个 composable 需统一生命周期管理时考虑 `effectScope`
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
---
|
||||||
|
title: script setup Best Practices
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: 滥用 script setup 特性会导致类型安全问题、运行时错误以及更难维护的代码
|
||||||
|
type: best-practice
|
||||||
|
tags: [vue3, composition-api, script-setup, typescript, best-practices]
|
||||||
|
---
|
||||||
|
|
||||||
|
# script setup 最佳实践
|
||||||
|
|
||||||
|
**影响程度:高** — `<script setup>` 是编写 Vue 3 组件的推荐方式。理解其模式可确保类型安全和可维护性。
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
- [ ] 使用 `<script setup lang="ts">` 以支持 TypeScript
|
||||||
|
- [ ] 使用 `defineOptions` 设置组件名称(Vue 3.3+)—— 务必始终使用
|
||||||
|
- [ ] 使用类型声明式的 `defineProps` 和 `defineEmits`
|
||||||
|
- [ ] 使用 `defineModel` 实现双向绑定(Vue 3.4+)
|
||||||
|
- [ ] 使用 `useTemplateRef()` 获取类型安全的模板引用(Vue 3.5+)
|
||||||
|
- [ ] 使用 `useId()` 生成无障碍友好的唯一 ID(Vue 3.5+)
|
||||||
|
- [ ] 优先使用 `toValue()` 而非 `unref()` 来解包 MaybeRef(Vue 3.3+)
|
||||||
|
- [ ] 使用 `InjectionKey` 为 `provide`/`inject` 添加类型
|
||||||
|
- [ ] 避免将 `<script setup>` 与 Options API 混用
|
||||||
|
- [ ] 在组件内和组件外使用正确的 Store 访问模式
|
||||||
|
|
||||||
|
## 问题所在
|
||||||
|
|
||||||
|
使用 `<script setup>` 时的常见错误可能导致类型问题、响应性丢失或令人困惑的代码模式。
|
||||||
|
|
||||||
|
## defineOptions — 务必始终使用
|
||||||
|
|
||||||
|
`defineOptions` 对于 DevTools 识别、`keep-alive` 的 include/exclude 以及递归组件至关重要:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ✅ Good - 始终设置组件名称
|
||||||
|
defineOptions({
|
||||||
|
name: 'MiSearch'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**在真实项目中的重要性:**
|
||||||
|
- Vue DevTools 显示组件名称而非 `<Anonymous>`
|
||||||
|
- `<keep-alive :include="['MiSearch']">` 可以正常工作
|
||||||
|
- 递归组件可以引用自身
|
||||||
|
- 调试堆栈信息具有可读性
|
||||||
|
- 更易于在代码库中按组件名称搜索
|
||||||
|
|
||||||
|
## TypeScript 集成
|
||||||
|
|
||||||
|
### 带类型的 Props
|
||||||
|
|
||||||
|
**BAD — 运行时声明:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// props 使用时没有类型推导
|
||||||
|
const props = defineProps({
|
||||||
|
title: String,
|
||||||
|
count: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// props.title 的类型是 string | undefined,而不是 string
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**GOOD — 基于类型的声明:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
count: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// props.title 的类型是 string(必填、已定义)
|
||||||
|
// props.count 的类型是 number(可选、有默认值)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 带类型的 Emits
|
||||||
|
|
||||||
|
**BAD — 无类型安全:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits(['update', 'delete'])
|
||||||
|
// 没有参数类型检查
|
||||||
|
emit('update', { any: 'thing' })
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**GOOD — 类型安全的 emits:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Emits {
|
||||||
|
update: [value: { id: string; name: string }]
|
||||||
|
delete: [id: string]
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 类型检查!
|
||||||
|
emit('update', { id: '1', name: 'Test' }) // ✅
|
||||||
|
emit('update', { wrong: 'type' }) // ❌ Error
|
||||||
|
emit('delete', '123') // ✅
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## defineModel 实现双向绑定
|
||||||
|
|
||||||
|
**Vue 3.4+ 特性:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 简单 model
|
||||||
|
const model = defineModel<string>()
|
||||||
|
model.value = 'new value' // 更新父组件
|
||||||
|
|
||||||
|
// 带选项
|
||||||
|
const count = defineModel<number>({
|
||||||
|
default: 0,
|
||||||
|
required: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 命名 model
|
||||||
|
const title = defineModel<string>('title')
|
||||||
|
const description = defineModel<string>('description')
|
||||||
|
|
||||||
|
// 带校验器
|
||||||
|
const email = defineModel<string>({
|
||||||
|
default: '',
|
||||||
|
validator: (value) => value.includes('@')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input v-model="model" />
|
||||||
|
<input v-model:title="title" v-model:description="description" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vue 3.4 之前(手动实现):**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputValue = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Store 访问模式
|
||||||
|
|
||||||
|
### 在 Vue 组件内 — 使用 `useXxxStore()`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ✅ Good - 在组件内,使用标准的 store 访问方式
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const businessStore = useBusinessStore()
|
||||||
|
|
||||||
|
// 在组件中直接访问
|
||||||
|
const isDark = computed(() => appStore.getIsDark)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在 Vue 组件外 — 使用 `useXxxStoreWithOut()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - 在 utils、hooks、plugins 中使用 WithOut 版本
|
||||||
|
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||||
|
|
||||||
|
export const useEngine = () => {
|
||||||
|
const appStore = useAppStoreWithOut() // 传入全局 pinia 实例
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - 在 utils/migration.ts 中
|
||||||
|
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||||
|
|
||||||
|
export async function migrateOnlineIcons() {
|
||||||
|
const appStore = useAppStoreWithOut()
|
||||||
|
// 可以在组件上下文之外访问 store
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - 在 plugins/vue-i18n/index.ts 中
|
||||||
|
import { useLocaleStoreWithOut } from '@/store/modules/locale'
|
||||||
|
|
||||||
|
export async function setupI18n(app: App) {
|
||||||
|
const localeStore = useLocaleStoreWithOut()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**区分两者的原因:**
|
||||||
|
- `useXxxStore()` 依赖 Vue 的 `inject`/`provide`,仅在组件上下文中可用
|
||||||
|
- `useXxxStoreWithOut(store)` 显式传入 pinia 实例,可在任意位置使用
|
||||||
|
- 使用错误会导致运行时错误:`getActivePinia was called with no active Pinia`
|
||||||
|
|
||||||
|
详见 [store-without-pattern](store-without-pattern.md)。
|
||||||
|
|
||||||
|
## 常见模式
|
||||||
|
|
||||||
|
### 带默认值的响应式 Props
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
items: string[]
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
pageSize: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用 toRefs 获取响应式 props
|
||||||
|
const { pageSize } = toRefs(props)
|
||||||
|
|
||||||
|
// 或使用 computed 获取派生值
|
||||||
|
const totalPages = computed(() =>
|
||||||
|
Math.ceil(props.items.length / pageSize.value)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 暴露组件方法
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
inputRef.value!.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法供父组件调用
|
||||||
|
defineExpose({
|
||||||
|
focus,
|
||||||
|
clear
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板引用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 元素引用
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// 组件引用
|
||||||
|
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)
|
||||||
|
|
||||||
|
// v-for 中的引用数组
|
||||||
|
const itemRefs = ref<HTMLDivElement[]>([])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input ref="inputRef" />
|
||||||
|
<MyComponent ref="componentRef" />
|
||||||
|
<div v-for="item in items" :key="item.id" ref="itemRefs">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useTemplateRef(Vue 3.5+)
|
||||||
|
|
||||||
|
**Vue 3.5 引入了 `useTemplateRef()`** — 一种类型安全的替代方案,用于替代普通的 `ref()` 来获取模板引用。它解决了尴尬的 `null` 初始化问题,并提供更好的类型推导:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ✅ Vue 3.5+:无需 null 初始值,类型从模板中推导
|
||||||
|
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
|
||||||
|
const componentRef = useTemplateRef<InstanceType<typeof MyComponent>>('componentRef')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
inputRef.value?.focus() // HTMLInputElement | undefined
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input ref="inputRef" />
|
||||||
|
<MyComponent ref="componentRef" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**与 `ref<T | null>` 的关键区别:**
|
||||||
|
|
||||||
|
| 特性 | `ref<T \| null>` | `useTemplateRef<T>()` |
|
||||||
|
|---------|-------------------|-----------------------|
|
||||||
|
| 初始值 | 必须指定 `null` | 自动推导,无需手动设置 null |
|
||||||
|
| 挂载后的类型 | `T \| null` | `T \| undefined` |
|
||||||
|
| 需要 ref 名称匹配 | 手动(靠约定) | 通过字符串参数强制匹配 |
|
||||||
|
| 支持 v-for | ✅ `ref<T[]>([])` | ✅ `useTemplateRef<T[]>('list')` |
|
||||||
|
|
||||||
|
### useId(Vue 3.5+)
|
||||||
|
|
||||||
|
**Vue 3.5 引入了 `useId()`**,用于生成唯一的、SSR 安全的 ID。对于无障碍访问(`aria-labelledby`、`for`/`id` 关联)和避免 ID 冲突至关重要:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const inputId = useId()
|
||||||
|
const labelId = useId()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label :for="inputId" :id="labelId">Email</label>
|
||||||
|
<input :id="inputId" :aria-labelledby="labelId" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么不使用 `Math.random()` 或计数器?**
|
||||||
|
- `useId()` 是 SSR 安全的 — 服务端和客户端生成匹配的 ID
|
||||||
|
- 不会在组件实例之间产生冲突
|
||||||
|
- 在客户端导航(SPA 路由切换)之间会清除
|
||||||
|
|
||||||
|
### Provide/Inject 与 TypeScript
|
||||||
|
|
||||||
|
`provide`/`inject` 是避免 props 逐层传递的强大工具,但类型安全需要明确的模式:
|
||||||
|
|
||||||
|
**步骤 1:定义 `InjectionKey`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types/injection-keys.ts
|
||||||
|
import type { InjectionKey, Ref } from 'vue'
|
||||||
|
|
||||||
|
// 类型化的 injection key
|
||||||
|
export const THEME_KEY: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
|
||||||
|
export const CONFIG_KEY: InjectionKey<AppConfig> = Symbol('config')
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 2:带类型安全地 provide**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { provide } from 'vue'
|
||||||
|
import { THEME_KEY, CONFIG_KEY } from '@/types/injection-keys'
|
||||||
|
|
||||||
|
const theme = ref<'light' | 'dark'>('light')
|
||||||
|
const appConfig = { apiUrl: 'https://api.example.com', timeout: 5000 }
|
||||||
|
|
||||||
|
provide(THEME_KEY, theme) // ✅ 类型检查通过
|
||||||
|
provide(CONFIG_KEY, appConfig) // ✅ 类型检查通过
|
||||||
|
|
||||||
|
// ❌ Error:类型错误
|
||||||
|
provide(THEME_KEY, 'blue')
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 3:带类型安全地 inject**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { THEME_KEY, CONFIG_KEY } from '@/types/injection-keys'
|
||||||
|
|
||||||
|
// ✅ 使用 InjectionKey — 完全类型化,无需默认值
|
||||||
|
const theme = inject(THEME_KEY) // Ref<'light' | 'dark'>
|
||||||
|
|
||||||
|
// ✅ 带默认值 — 类型推导,不会是 undefined
|
||||||
|
const config = inject(CONFIG_KEY, { apiUrl: '/fallback', timeout: 3000 })
|
||||||
|
|
||||||
|
// ⚠️ 不使用 key 或默认值 — 返回 T | undefined
|
||||||
|
const maybeConfig = inject<AppConfig>('config') // AppConfig | undefined
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 模式 | 返回类型 | 使用场景 |
|
||||||
|
|---------|------------|-------------|
|
||||||
|
| `inject(key)` 配合 `InjectionKey` | `T`(不可为 null) | 祖先组件中存在 provider |
|
||||||
|
| `inject(key, default)` | `T`(不可为 null) | provider 可能不存在 |
|
||||||
|
| `inject<string>('key')` | `T \| undefined` | 旧的字符串 key 模式 |
|
||||||
|
|
||||||
|
**⚠️ 在 TypeScript 中不要使用纯字符串进行 provide/inject** — 你会失去所有类型安全和 IDE 自动补全。
|
||||||
|
|
||||||
|
### toValue() vs unref()
|
||||||
|
|
||||||
|
**Vue 3.3 引入了 `toValue()`**,作为解包 "MaybeRef" 值的首选方式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toValue, unref } from 'vue'
|
||||||
|
import type { MaybeRef } from 'vue'
|
||||||
|
|
||||||
|
// 两者都可以标准化 ref 和普通值
|
||||||
|
const a = ref(42)
|
||||||
|
const b = 100
|
||||||
|
|
||||||
|
toValue(a) // 42 — 解包 ref,透传普通值
|
||||||
|
toValue(b) // 100
|
||||||
|
|
||||||
|
unref(a) // 42 — 行为相同
|
||||||
|
unref(b) // 100
|
||||||
|
|
||||||
|
// 但 toValue() 有一个关键区别:
|
||||||
|
// toValue() 还可以解包 getter(返回值的函数)
|
||||||
|
const getter = () => 42
|
||||||
|
toValue(getter) // 42 ✅
|
||||||
|
unref(getter) // () => 42 ❌(不会调用 getter)
|
||||||
|
```
|
||||||
|
|
||||||
|
**经验法则:**
|
||||||
|
- 如果你的组合式函数接受 `MaybeRef<T>` — 使用 `toValue()` 来标准化
|
||||||
|
- 如果你只处理 `Ref` 对象 — `unref()` 也可以
|
||||||
|
- 新代码应优先使用 `toValue()` 以保持向前兼容
|
||||||
|
|
||||||
|
## 应避免的反模式
|
||||||
|
|
||||||
|
### ❌ 不要与 Options API 混用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- BAD - 混合风格 -->
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
count: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 无法在这里访问 Options API 的 data!
|
||||||
|
const double = computed(() => this.count * 2) // Error!
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 不要解构 Props
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ count: number }>()
|
||||||
|
|
||||||
|
// BAD - 丢失响应性
|
||||||
|
const { count } = props
|
||||||
|
|
||||||
|
// GOOD - 使用 toRefs
|
||||||
|
const { count } = toRefs(props)
|
||||||
|
|
||||||
|
// 或者直接使用 props
|
||||||
|
const double = computed(() => props.count * 2)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 不要在 script setup 中使用 `this`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ❌ Error:'this' 是 undefined
|
||||||
|
console.log(this.$router)
|
||||||
|
|
||||||
|
// ✅ 改用组合式函数
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
const router = useRouter()
|
||||||
|
console.log(router)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 不要忘记 ref 的 `.value`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const count = ref(0)
|
||||||
|
|
||||||
|
// ❌ 不会触发响应性
|
||||||
|
count = 1
|
||||||
|
|
||||||
|
// ✅ 正确方式
|
||||||
|
count.value = 1
|
||||||
|
|
||||||
|
// 模板中不需要 .value
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 模板中自动解包 -->
|
||||||
|
<div>{{ count }}</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 不要在组件外使用 `useXxxStore()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - 在 utils/hooks/plugins 中会抛出错误
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
export function someUtil() {
|
||||||
|
const store = useAppStore() // Error: pinia is not defined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good - 使用 WithOut 版本
|
||||||
|
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||||
|
export function someUtil() {
|
||||||
|
const store = useAppStoreWithOut() // 正常工作!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能提示
|
||||||
|
|
||||||
|
### 对大型对象和动态组件使用 shallowRef
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 动态组件切换 — 使用 shallowRef 避免深层响应性
|
||||||
|
const activeCom = shallowRef()
|
||||||
|
watchEffect(() => {
|
||||||
|
activeCom.value = isPure.value ? PureMode : HomeMode
|
||||||
|
})
|
||||||
|
|
||||||
|
// 大型数据对象 — 替换整个值时使用 shallowRef
|
||||||
|
const largeData = shallowRef<BigObject>({})
|
||||||
|
largeData.value = { /* 新对象 */ } // 仅在引用变化时触发更新
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 对派生状态使用 computed
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const items = ref<string[]>([])
|
||||||
|
|
||||||
|
// ✅ 高效 - 缓存直到 items 变化
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...items.value].sort()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ❌ 低效 - 每次渲染都重新创建
|
||||||
|
const getSortedItems = () => [...items.value].sort()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
- [Vue.js script setup](https://vuejs.org/api/sfc-script-setup.html)
|
||||||
|
- [Vue.js TypeScript with Composition API](https://vuejs.org/guide/typescript/composition-api.html)
|
||||||
|
- [Vue.js defineModel](https://vuejs.org/api/sfc-script-setup.html#definemodel)
|
||||||
|
- [Vue.js defineOptions](https://vuejs.org/api/sfc-script-setup.html#defineoptions)
|
||||||
|
- [Vue.js useTemplateRef](https://vuejs.org/api/composition-api-helpers.html#usetemplateref)
|
||||||
|
- [Vue.js useId](https://vuejs.org/api/composition-api-helpers.html#useid)
|
||||||
|
- [Vue.js provide/inject](https://vuejs.org/guide/components/provide-inject.html#working-with-reactivity)
|
||||||
|
- [Vue.js toValue](https://vuejs.org/api/reactivity-utilities.html#tovalue)
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
---
|
||||||
|
title: SFC Code Organization Order
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: 代码组织混乱会导致维护困难、难以理解组件结构,以及团队成员之间代码风格不一致
|
||||||
|
type: best-practice
|
||||||
|
tags: [vue3, composition-api, script-setup, code-organization, maintainability]
|
||||||
|
---
|
||||||
|
|
||||||
|
# SFC 代码组织顺序
|
||||||
|
|
||||||
|
**影响等级:高** - 良好组织的 SFC(单文件组件)对可维护性和团队协作至关重要。遵循一致的顺序使代码可预测且易于导航。
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
- [ ] 遵循标准的 SFC 代码组织顺序
|
||||||
|
- [ ] 使用 useXxx 函数按功能分组相关代码
|
||||||
|
- [ ] 将 Vue 公共项(options、props、emits 等)放在顶部
|
||||||
|
- [ ] 将功能实现放在底部,IDE 中默认折叠
|
||||||
|
- [ ] 使用清晰的区块注释进行分隔
|
||||||
|
|
||||||
|
## 问题所在
|
||||||
|
|
||||||
|
`<script setup>` 带来了自由和灵活性,但如果没有约定,每个 SFC 文件可能看起来完全不同,使得维护和重构变得困难。
|
||||||
|
|
||||||
|
**BAD - 组织混乱的代码:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, provide, inject } from 'vue'
|
||||||
|
|
||||||
|
// 分散的状态
|
||||||
|
const count = ref(0)
|
||||||
|
const user = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 函数随意穿插在中间
|
||||||
|
function fetchUser() {
|
||||||
|
loading.value = true
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算属性混在其中
|
||||||
|
const doubleCount = computed(() => count.value * 2)
|
||||||
|
|
||||||
|
// 又一个状态
|
||||||
|
const theme = ref('dark')
|
||||||
|
|
||||||
|
// props 定义在很后面
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// watch 散落在某处
|
||||||
|
watch(() => props.id, fetchUser)
|
||||||
|
|
||||||
|
// 生命周期钩子
|
||||||
|
onMounted(() => {
|
||||||
|
fetchUser()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更多函数...
|
||||||
|
function increment() {
|
||||||
|
count.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
// emits 放在底部
|
||||||
|
const emit = defineEmits(['update'])
|
||||||
|
|
||||||
|
// provide 分散在各处
|
||||||
|
provide('theme', theme)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**GOOD - 组织良好的代码:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, provide, inject } from 'vue'
|
||||||
|
|
||||||
|
// 组件名
|
||||||
|
defineOptions({
|
||||||
|
name: 'UserComponent'
|
||||||
|
})
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// model
|
||||||
|
const model = defineModel<string>()
|
||||||
|
|
||||||
|
// inject
|
||||||
|
const globalConfig = inject('config')
|
||||||
|
|
||||||
|
// emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// store
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// 外部 hooks
|
||||||
|
const { user, loading, fetchUser } = useUser()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
// 功能声明
|
||||||
|
const { count, doubleCount, increment } = useCounter()
|
||||||
|
|
||||||
|
// provide
|
||||||
|
provide('theme', theme)
|
||||||
|
|
||||||
|
// expose
|
||||||
|
defineExpose({
|
||||||
|
increment,
|
||||||
|
fetchUser
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============ 功能实现 ============
|
||||||
|
|
||||||
|
// 用户管理功能
|
||||||
|
function useUser() {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
user.value = await fetchUserData(props.id)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, fetchUser, { immediate: true })
|
||||||
|
onMounted(fetchUser)
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
fetchUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题功能
|
||||||
|
function useTheme() {
|
||||||
|
const theme = ref<'light' | 'dark'>('dark')
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
toggleTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计数器功能
|
||||||
|
function useCounter() {
|
||||||
|
const count = ref(0)
|
||||||
|
|
||||||
|
const doubleCount = computed(() => count.value * 2)
|
||||||
|
|
||||||
|
const increment = () => {
|
||||||
|
count.value++
|
||||||
|
emit('update', count.value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
doubleCount,
|
||||||
|
increment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 标准组织顺序
|
||||||
|
|
||||||
|
| 顺序 | 区块 | 是否必须 | 描述 |
|
||||||
|
|-------|---------|----------|-------------|
|
||||||
|
| 1 | `defineOptions` | ✅ 推荐 | 组件名称(DevTools、keep-alive、递归组件) |
|
||||||
|
| 2 | `defineProps` | 可选 | 带类型声明的组件 props |
|
||||||
|
| 3 | `defineModel` | 可选 | 双向绑定 model(Vue 3.4+) |
|
||||||
|
| 4 | `inject` | 可选 | 注入的依赖 |
|
||||||
|
| 5 | `defineEmits` | 可选 | 带类型声明的组件事件 |
|
||||||
|
| 6 | Store 声明 | 可选 | Pinia store 实例(`useXxxStore()`) |
|
||||||
|
| 7 | 外部 hooks | 可选 | 导入的组合式函数 |
|
||||||
|
| 8 | 功能声明 | 可选 | `const { ... } = useFeature()` |
|
||||||
|
| 9 | `provide` | 可选 | 提供的依赖 |
|
||||||
|
| 10 | `defineExpose` | 可选 | 暴露的公共 API |
|
||||||
|
| 11 | 功能实现 | 按需 | `function useFeature() {}` |
|
||||||
|
|
||||||
|
## 区块注释风格
|
||||||
|
|
||||||
|
使用清晰、简洁的区块注释。两种常见风格:
|
||||||
|
|
||||||
|
### 风格一:简洁中文注释(推荐中文团队使用)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 组件名
|
||||||
|
defineOptions({ name: 'Layout' })
|
||||||
|
|
||||||
|
// props
|
||||||
|
const props = defineProps<{ id: string }>()
|
||||||
|
|
||||||
|
// emits
|
||||||
|
const emit = defineEmits<{ update: [value: string] }>()
|
||||||
|
|
||||||
|
// store
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// 外部 hooks
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 功能声明
|
||||||
|
const { imageBgUrl, videoBgUrl } = useBackground()
|
||||||
|
|
||||||
|
// ============ 功能实现 ============
|
||||||
|
|
||||||
|
function useBackground() { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 风格二:分隔线风格(适用于大型组件)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ============ Vue 公共项 ============
|
||||||
|
|
||||||
|
defineOptions({ name: 'UserComponent' })
|
||||||
|
|
||||||
|
// ============ Store ============
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// ============ 外部 Hooks ============
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// ============ 功能声明 ============
|
||||||
|
|
||||||
|
const { search, results } = useSearch()
|
||||||
|
|
||||||
|
// ============ 功能实现 ============
|
||||||
|
|
||||||
|
function useSearch() { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 收益
|
||||||
|
|
||||||
|
1. **结构可预测**:团队成员知道在哪里找到特定代码
|
||||||
|
2. **快速概览**:顶部区域让组件接口一目了然
|
||||||
|
3. **IDE 导航**:点击声明中的函数名即可跳转到实现
|
||||||
|
4. **默认折叠**:功能实现保持折叠状态,减少视觉干扰
|
||||||
|
5. **依赖清晰**:一眼看清每个功能返回和消费了什么
|
||||||
|
6. **Store 聚合**:所有 store 实例集中声明,便于识别
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- **始终遵循**:对所有 `<script setup>` 组件使用此组织方式
|
||||||
|
- **小型组件**:即使区块较少,仍应遵循此顺序
|
||||||
|
- **大型组件**:功能较多时,对保持可读性至关重要
|
||||||
|
- **团队项目**:通过代码审查和 lint 规则强制保持一致性
|
||||||
|
|
||||||
|
## 真实案例
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
|
||||||
|
// 组件名
|
||||||
|
defineOptions({
|
||||||
|
name: 'MiSearch'
|
||||||
|
})
|
||||||
|
|
||||||
|
// store
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const businessStore = useBusinessStore()
|
||||||
|
|
||||||
|
// 多语言
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 简单前缀
|
||||||
|
const { getPrefixCls } = useDesign()
|
||||||
|
|
||||||
|
// useEngine
|
||||||
|
const { engineInfo, nextEngine } = useEngine()
|
||||||
|
|
||||||
|
// useSearchInput
|
||||||
|
const {
|
||||||
|
searchContent,
|
||||||
|
handleChange,
|
||||||
|
handleSearch,
|
||||||
|
isComposing,
|
||||||
|
searchHistoryRef,
|
||||||
|
searchSuggestionRef,
|
||||||
|
operaHistoryOrSuggestion,
|
||||||
|
searchInputRef
|
||||||
|
} = useSearchInput()
|
||||||
|
|
||||||
|
// 输入框
|
||||||
|
function useSearchInput() {
|
||||||
|
const searchContent = ref('')
|
||||||
|
const isComposing = ref(false)
|
||||||
|
|
||||||
|
const handleSearch = (val) => {
|
||||||
|
if (isComposing.value) return
|
||||||
|
const useContent = encodeURIComponent(val)
|
||||||
|
if (!useContent) return
|
||||||
|
businessStore.updateHistoryList(val)
|
||||||
|
window.open(`${engineInfo.value.url}${useContent}`, appStore.openType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 更多逻辑
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchContent,
|
||||||
|
isComposing,
|
||||||
|
handleSearch,
|
||||||
|
handleChange,
|
||||||
|
searchHistoryRef,
|
||||||
|
searchSuggestionRef,
|
||||||
|
operaHistoryOrSuggestion,
|
||||||
|
searchInputRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- [Vue.js Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||||
|
- [Vue.js script setup](https://vuejs.org/api/sfc-script-setup.html)
|
||||||
|
- [官方示例:FileExplorer.vue](https://github.com/vuejs-translations/docs-zh-cn/blob/main/assets/FileExplorer.vue)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Store Without 模式
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
Pinia 的 `useStore()` 默认依赖 Vue 组件上下文(inject/provide)。在组件外(hooks、utils、plugins、路由守卫、axios 拦截器)直接调用会抛出错误:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: "getActivePinia()" was called but there was no active Pinia.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 解决方案:Store Without 模式
|
||||||
|
|
||||||
|
每个 store 模块额外导出一个 `useXxxStoreWithOut` 函数,接收全局 pinia 实例作为参数,使 store 可在任意上下文中安全访问。
|
||||||
|
|
||||||
|
### 模式定义
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// store/modules/app.ts
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { store } from '@/store'
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', {
|
||||||
|
// ... store 定义
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在组件外使用时,传入全局 pinia 实例
|
||||||
|
export const useAppStoreWithOut = () => {
|
||||||
|
return useAppStore(store)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 全局 Pinia 实例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// store/index.ts
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
export default pinia
|
||||||
|
|
||||||
|
// 导出 store 供 Without 函数使用
|
||||||
|
export const store = pinia
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
|
||||||
|
### 何时使用哪个
|
||||||
|
|
||||||
|
| 函数 | 使用场景 | 原因 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `useAppStore()` | Vue 组件 `<script setup>` 内 | 自动从组件上下文获取 pinia |
|
||||||
|
| `useAppStoreWithOut()` | hooks、utils、plugins、路由守卫等 | 组件上下文不可用,需显式传入 pinia |
|
||||||
|
|
||||||
|
### 在 Vue 组件中(始终使用标准方式)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD:组件内使用标准方式
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD:组件内使用 WithOut 是多余的
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||||
|
|
||||||
|
const appStore = useAppStoreWithOut() // 可以工作但不必要
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在 Hooks / Utils / Plugins 中(必须使用 WithOut)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD:hooks 中使用 WithOut
|
||||||
|
// hooks/web/useSideCategory.ts
|
||||||
|
import { useBusinessStoreWithOut } from '@/store/modules/business'
|
||||||
|
|
||||||
|
export function useSideCategory() {
|
||||||
|
const businessStore = useBusinessStoreWithOut()
|
||||||
|
const categories = computed(() => businessStore.getSideCategory)
|
||||||
|
return { categories }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD:utils 中使用 WithOut
|
||||||
|
// utils/migration.ts
|
||||||
|
import { useBusinessStoreWithOut } from '@/store/modules/business'
|
||||||
|
|
||||||
|
export async function migrateOnlineIcons() {
|
||||||
|
const businessStore = useBusinessStoreWithOut()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD:组件外直接使用标准方式会报错
|
||||||
|
// utils/migration.ts
|
||||||
|
import { useBusinessStore } from '@/store/modules/business'
|
||||||
|
|
||||||
|
export async function migrateOnlineIcons() {
|
||||||
|
const businessStore = useBusinessStore() // Error: no active Pinia
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命名规范
|
||||||
|
|
||||||
|
所有模块遵循统一命名规范:
|
||||||
|
|
||||||
|
| Store 模块 | 标准函数 | WithOut 函数 |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `app.ts` | `useAppStore` | `useAppStoreWithOut` |
|
||||||
|
| `business.ts` | `useBusinessStore` | `useBusinessStoreWithOut` |
|
||||||
|
| `dict.ts` | `useDictStore` | `useDictStoreWithOut` |
|
||||||
|
| `locale.ts` | `useLocaleStore` | `useLocaleStoreWithOut` |
|
||||||
|
|
||||||
|
**规则**:`use{ModuleName}StoreWithOut` — 模块名首字母大写 + Store + WithOut(注意大小写)。
|
||||||
|
|
||||||
|
## 实现清单
|
||||||
|
|
||||||
|
每个 store 模块必须:
|
||||||
|
|
||||||
|
- [ ] 导出标准 `useXxxStore` 函数(`defineStore` 的返回值)
|
||||||
|
- [ ] 导出 `useXxxStoreWithOut` 函数,内部调用 `useXxxStore(store)`
|
||||||
|
- [ ] 从 `@/store` 导入全局 `store` 实例
|
||||||
|
- [ ] WithOut 函数放在文件底部,紧跟标准函数之后
|
||||||
|
|
||||||
|
## 为什么不直接使用 `useXxxStore(pinia)`?
|
||||||
|
|
||||||
|
理论上可以直接调用 `useAppStore(pinia)`,但 `WithOut` 函数提供了:
|
||||||
|
|
||||||
|
1. **语义明确** — 函数名直接表达"在组件外使用"的意图
|
||||||
|
2. **统一入口** — 不需要每个调用方都 import `store`,减少依赖
|
||||||
|
3. **集中管理** — 如果 pinia 实例获取方式变更,只需改 WithOut 函数
|
||||||
|
4. **可搜索** — 搜索 `WithOut` 即可找到所有组件外使用 store 的地方
|
||||||
|
|
||||||
|
## 替代方案:`storeToRefs` 注意事项
|
||||||
|
|
||||||
|
注意 `useXxxStoreWithOut()` 返回的是 store 实例,如需解构响应式属性,仍需使用 `storeToRefs`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||||
|
|
||||||
|
export function useAppInfo() {
|
||||||
|
const appStore = useAppStoreWithOut()
|
||||||
|
const { pure, theme } = storeToRefs(appStore) // 保持响应式
|
||||||
|
return { pure, theme }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
| 优势 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 解决组件外访问 | 核心价值,让 store 可在任意上下文使用 |
|
||||||
|
| 命名约定清晰 | `WithOut` 后缀一目了然 |
|
||||||
|
| 减少样板代码 | 调用方无需 import store |
|
||||||
|
| 易于维护 | pinia 实例变更只需改一处 |
|
||||||
|
| 可追溯 | 搜索 `WithOut` 可定位所有组件外使用 |
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
---
|
||||||
|
title: UseXxx Function Pattern for Feature Encapsulation
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: 如果不使用 useXxx 模式,功能逻辑会变得分散,难以理解每个功能暴露了什么,也难以追踪功能之间的依赖关系
|
||||||
|
type: best-practice
|
||||||
|
tags: [vue3, composition-api, script-setup, use-pattern, code-organization]
|
||||||
|
---
|
||||||
|
|
||||||
|
# UseXxx 函数模式:功能封装
|
||||||
|
|
||||||
|
**影响等级:高** - useXxx 模式将相关逻辑封装到自包含的函数中,使功能易于理解、测试和复用。
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
- [ ] 将功能逻辑封装在 `useFeatureName()` 函数中
|
||||||
|
- [ ] 仅返回外部需要的值和方法
|
||||||
|
- [ ] 将功能实现放在 script 底部
|
||||||
|
- [ ] 在顶部使用解构声明功能使用
|
||||||
|
- [ ] 功能函数命名清晰,能反映其用途
|
||||||
|
|
||||||
|
## 问题所在
|
||||||
|
|
||||||
|
没有封装时,相关的变量、计算属性、监听器和方法散落在代码各处,难以理解哪些代码属于哪个功能。
|
||||||
|
|
||||||
|
**BAD - 功能逻辑分散:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
// 搜索功能分散
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchResults = ref([])
|
||||||
|
const isSearching = ref(false)
|
||||||
|
|
||||||
|
// 分页功能混在一起
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const totalItems = ref(0)
|
||||||
|
|
||||||
|
// 随意放置的 computed
|
||||||
|
const hasResults = computed(() => searchResults.value.length > 0)
|
||||||
|
|
||||||
|
// 另一个功能开始
|
||||||
|
const selectedItem = ref(null)
|
||||||
|
|
||||||
|
// 搜索函数
|
||||||
|
async function handleSearch() {
|
||||||
|
isSearching.value = true
|
||||||
|
searchResults.value = await fetchResults(searchQuery.value)
|
||||||
|
isSearching.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页函数
|
||||||
|
function changePage(page: number) {
|
||||||
|
currentPage.value = page
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch 分散
|
||||||
|
watch(searchQuery, handleSearch)
|
||||||
|
|
||||||
|
// 选择函数
|
||||||
|
function selectItem(item: any) {
|
||||||
|
selectedItem.value = item
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**GOOD - 使用 useXxx 模式封装:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
// 功能声明
|
||||||
|
|
||||||
|
// 搜索功能 - 一目了然的接口
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
searchResults,
|
||||||
|
isSearching,
|
||||||
|
hasResults,
|
||||||
|
handleSearch
|
||||||
|
} = useSearch()
|
||||||
|
|
||||||
|
// 分页功能 - 自包含
|
||||||
|
const {
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
changePage
|
||||||
|
} = usePagination({ onPageChange: handleSearch })
|
||||||
|
|
||||||
|
// 选择功能 - 独立
|
||||||
|
const {
|
||||||
|
selectedItem,
|
||||||
|
selectItem
|
||||||
|
} = useSelection()
|
||||||
|
|
||||||
|
// ============ 功能实现 ============
|
||||||
|
|
||||||
|
function useSearch() {
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchResults = ref<SearchResult[]>([])
|
||||||
|
const isSearching = ref(false)
|
||||||
|
|
||||||
|
const hasResults = computed(() => searchResults.value.length > 0)
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
isSearching.value = true
|
||||||
|
try {
|
||||||
|
searchResults.value = await fetchResults(searchQuery.value)
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(searchQuery, handleSearch)
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery,
|
||||||
|
searchResults,
|
||||||
|
isSearching,
|
||||||
|
hasResults,
|
||||||
|
handleSearch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePagination(options: { onPageChange: () => void }) {
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const totalItems = ref(0)
|
||||||
|
|
||||||
|
const changePage = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
options.onPageChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
changePage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSelection() {
|
||||||
|
const selectedItem = ref<SearchResult | null>(null)
|
||||||
|
|
||||||
|
const selectItem = (item: SearchResult) => {
|
||||||
|
selectedItem.value = item
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedItem,
|
||||||
|
selectItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
### 1. 清晰的返回接口
|
||||||
|
|
||||||
|
return 语句记录了该功能暴露了什么:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function useSearch() {
|
||||||
|
// 内部状态 - 不返回
|
||||||
|
const abortController = ref<AbortController | null>(null)
|
||||||
|
|
||||||
|
// 公共状态
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchResults = ref<SearchResult[]>([])
|
||||||
|
|
||||||
|
// 公共方法
|
||||||
|
const handleSearch = async () => { /* ... */ }
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 只暴露需要的内容
|
||||||
|
searchQuery,
|
||||||
|
searchResults,
|
||||||
|
handleSearch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 自包含的逻辑
|
||||||
|
|
||||||
|
每个 useXxx 函数包含所有相关的内容:
|
||||||
|
- 状态(ref、reactive)
|
||||||
|
- 计算属性
|
||||||
|
- 监听器
|
||||||
|
- 生命周期钩子
|
||||||
|
- 方法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function useSearch() {
|
||||||
|
// 状态
|
||||||
|
const query = ref('')
|
||||||
|
const results = ref([])
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isEmpty = computed(() => results.value.length === 0)
|
||||||
|
|
||||||
|
// 监听器
|
||||||
|
watch(query, debounce(search, 300))
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
if (query.value) search()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
async function search() { /* ... */ }
|
||||||
|
|
||||||
|
return { query, results, isEmpty, search }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 通过参数进行依赖注入
|
||||||
|
|
||||||
|
将依赖作为参数传递,实现跨功能通信:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function usePagination(options: {
|
||||||
|
onPageChange?: () => void
|
||||||
|
initialPage?: number
|
||||||
|
} = {}) {
|
||||||
|
const currentPage = ref(options.initialPage ?? 1)
|
||||||
|
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
options.onPageChange?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentPage, goToPage }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用方式
|
||||||
|
const { handleSearch } = useSearch()
|
||||||
|
const { currentPage, goToPage } = usePagination({
|
||||||
|
onPageChange: handleSearch
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Store 桥接模式
|
||||||
|
|
||||||
|
当组合式函数封装 store 访问时,需提供干净的接口来隐藏 store 实现细节:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 页面图标管理 hooks - 封装 store 访问
|
||||||
|
export const usePageIcon = () => {
|
||||||
|
const appStore = useAppStoreWithOut()
|
||||||
|
|
||||||
|
// 当前 page
|
||||||
|
const curPage = computed(() => appStore.selectCategory.key)
|
||||||
|
|
||||||
|
// 当前 page icons
|
||||||
|
const curPageIcons = computed(() => appStore.pageIconMap[curPage.value] || [])
|
||||||
|
|
||||||
|
// 新增 page icon
|
||||||
|
const addPageIcon = (icon: PageItemWithOptionalKey, page?: string) => {
|
||||||
|
if (!appStore.pageIconMap[page || curPage.value]) {
|
||||||
|
appStore.addPageIconInfo(page || curPage.value)
|
||||||
|
}
|
||||||
|
const icons = appStore.pageIconMap[page || curPage.value]
|
||||||
|
if (!icon.key) {
|
||||||
|
icon.key = `${page || curPage.value}-icon-${icon.type}-${icons.length}`
|
||||||
|
}
|
||||||
|
appStore.updatePageIconInfo(page || curPage.value, icons.concat(icon as PageItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 page icon
|
||||||
|
const updatePageIcon = (icon: PageItem, page: string) => {
|
||||||
|
const icons = appStore.pageIconMap[page]
|
||||||
|
const targetIdx = icons.findIndex((i) => i.key === icon.key)
|
||||||
|
if (targetIdx !== -1) {
|
||||||
|
icons[targetIdx] = icon
|
||||||
|
appStore.updatePageIconInfo(page, icons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 page icon
|
||||||
|
const removePageIcon = (icon: PageItem, page?: string) => {
|
||||||
|
const icons = appStore.pageIconMap[page || curPage.value]
|
||||||
|
if (icons) {
|
||||||
|
appStore.updatePageIconInfo(
|
||||||
|
page || curPage.value,
|
||||||
|
icons.filter((item) => item.key !== icon.key)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
curPage,
|
||||||
|
curPageIcons,
|
||||||
|
addPageIcon,
|
||||||
|
updatePageIcon,
|
||||||
|
removePageIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Store 桥接模式的收益:**
|
||||||
|
- 组件无需了解 store 内部结构
|
||||||
|
- 业务逻辑集中在一处
|
||||||
|
- 便于在无需更新所有组件的情况下更改 store 结构
|
||||||
|
- 与 `useXxxStoreWithOut` 无缝配合,适用于非组件场景
|
||||||
|
|
||||||
|
## 命名约定
|
||||||
|
|
||||||
|
| 模式 | 示例 | 使用场景 |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| `useXxx` | `useSearch()` | 组件内部的功能封装 |
|
||||||
|
| `useXxx` | `useUserStore()` | 外部组合式函数导入 |
|
||||||
|
| `useXxxStoreWithOut` | `useAppStoreWithOut()` | 组件外部的 store 访问 |
|
||||||
|
| `useXxx`(桥接) | `usePageIcon()` | Store 桥接组合式函数 |
|
||||||
|
|
||||||
|
## 何时提取到外部文件
|
||||||
|
|
||||||
|
满足以下条件时移至外部文件:
|
||||||
|
- 跨多个组件使用
|
||||||
|
- 复杂度足够高,需要单独测试
|
||||||
|
- 不依赖父组件状态
|
||||||
|
- 作为 store 数据的桥接层
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/web/usePageIcon.ts
|
||||||
|
export const usePageIcon = () => {
|
||||||
|
const appStore = useAppStoreWithOut()
|
||||||
|
// ...
|
||||||
|
return { curPageIcons, addPageIcon, updatePageIcon, removePageIcon }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在组件中
|
||||||
|
import { usePageIcon } from '@/hooks/web/usePageIcon'
|
||||||
|
const { curPageIcons, addPageIcon } = usePageIcon()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能实现检查清单
|
||||||
|
|
||||||
|
编写 `useXxx` 函数时:
|
||||||
|
|
||||||
|
- [ ] 所有相关状态都在函数内部(不散落在外部)
|
||||||
|
- [ ] 从状态派生的计算属性都在函数内部
|
||||||
|
- [ ] 响应状态变化的监听器都在函数内部
|
||||||
|
- [ ] 生命周期钩子(`onMounted`、`onBeforeUnmount`)都在函数内部
|
||||||
|
- [ ] 清理逻辑(`removeEventListener`、`off`)在 `onBeforeUnmount` 内
|
||||||
|
- [ ] 只返回组件需要的内容
|
||||||
|
- [ ] 内部实现细节被隐藏
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||||
|
- [Vue.js Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"registry": "https://clawhub.ai",
|
||||||
|
"slug": "vue",
|
||||||
|
"installedVersion": "1.0.1",
|
||||||
|
"installedAt": 1779235184751,
|
||||||
|
"fingerprint": "14a2c1a8f3397ee5141f374e3f3858af15293d50cac50820ae3ea3437b63f36b"
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: Vue
|
||||||
|
slug: vue
|
||||||
|
version: 1.0.1
|
||||||
|
description: Build Vue 3 applications with Composition API, proper reactivity patterns, and production-ready components.
|
||||||
|
metadata: {"clawdbot":{"emoji":"💚","requires":{"bins":["node"]},"os":["linux","darwin","win32"]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
User needs Vue expertise — from Composition API patterns to production optimization. Agent handles reactivity, component design, state management, and performance.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Topic | File |
|
||||||
|
|-------|------|
|
||||||
|
| Reactivity patterns | `reactivity.md` |
|
||||||
|
| Component patterns | `components.md` |
|
||||||
|
| Composables design | `composables.md` |
|
||||||
|
| Performance optimization | `performance.md` |
|
||||||
|
|
||||||
|
## Composition API Philosophy
|
||||||
|
|
||||||
|
- Composition API is not about replacing Options API—it's about better code organization
|
||||||
|
- Group code by feature, not by option type—related logic stays together
|
||||||
|
- Extract reusable logic into composables—the main win of Composition API
|
||||||
|
- `<script setup>` is the recommended syntax—cleaner and better performance
|
||||||
|
|
||||||
|
## Reactivity Traps
|
||||||
|
|
||||||
|
- `ref` for primitives—access with `.value` in script, auto-unwrapped in template
|
||||||
|
- `reactive` can't reassign whole object—`state = {...}` breaks reactivity
|
||||||
|
- Destructuring `reactive` loses reactivity—use `toRefs(state)` to preserve
|
||||||
|
- Array index assignment reactive in Vue 3—`arr[0] = x` works, unlike Vue 2
|
||||||
|
- Nested refs unwrap inside reactive—`reactive({count: ref(0)}).count` is number, not ref
|
||||||
|
|
||||||
|
## Watch vs Computed
|
||||||
|
|
||||||
|
- `computed` for derived state—cached, recalculates only when dependencies change
|
||||||
|
- `watch` for side effects—when you need to DO something in response to changes
|
||||||
|
- `computed` should be pure—no side effects, no async
|
||||||
|
- `watchEffect` for immediate reaction with auto-tracked dependencies
|
||||||
|
|
||||||
|
## Watch Traps
|
||||||
|
|
||||||
|
- Watching reactive object needs `deep: true`—or watch a getter function
|
||||||
|
- `watch` is lazy by default—use `immediate: true` for initial run
|
||||||
|
- Watch callback receives old/new—`watch(source, (newVal, oldVal) => {})`
|
||||||
|
- `watchEffect` can't access old value—use `watch` if you need old/new comparison
|
||||||
|
- Stop watchers with returned function—`const stop = watch(...); stop()`
|
||||||
|
|
||||||
|
## Props and Emits Traps
|
||||||
|
|
||||||
|
- `defineProps` for type-safe props—`defineProps<{ msg: string }>()`
|
||||||
|
- Props are readonly—don't mutate, emit event to parent
|
||||||
|
- `defineEmits` for type-safe events—`defineEmits<{ (e: 'update', val: string): void }>()`
|
||||||
|
- `v-model` is `:modelValue` + `@update:modelValue`—custom v-model with `defineModel()`
|
||||||
|
- Default value for objects must be factory function—`default: () => ({})`
|
||||||
|
|
||||||
|
## Template Ref Traps
|
||||||
|
|
||||||
|
- `ref="name"` + `const name = ref(null)`—names must match exactly
|
||||||
|
- Template refs available after mount—access in `onMounted`, not during setup
|
||||||
|
- `ref` on component gives component instance—`ref` on element gives DOM element
|
||||||
|
- Template ref with `v-for` becomes array of refs
|
||||||
|
|
||||||
|
## Lifecycle Traps
|
||||||
|
|
||||||
|
- `onMounted` for DOM access—component mounted to DOM
|
||||||
|
- `onUnmounted` for cleanup—subscriptions, timers, event listeners
|
||||||
|
- `onBeforeMount` runs before DOM insert—rarely needed but exists
|
||||||
|
- Hooks must be called synchronously in setup—not inside callbacks or conditionals
|
||||||
|
- Async setup needs `<Suspense>` wrapper
|
||||||
|
|
||||||
|
## Provide/Inject Traps
|
||||||
|
|
||||||
|
- `provide('key', value)` in parent—`inject('key')` in any descendant
|
||||||
|
- Reactive if value is ref/reactive—otherwise static snapshot
|
||||||
|
- Default value: `inject('key', defaultVal)`—third param for factory function
|
||||||
|
- Symbol keys for type safety—avoid string key collisions
|
||||||
|
|
||||||
|
## Vue Router Traps
|
||||||
|
|
||||||
|
- `useRoute` for current route—reactive, use in setup
|
||||||
|
- `useRouter` for navigation—`router.push('/path')`
|
||||||
|
- Navigation guards: `beforeEach`, `beforeResolve`, `afterEach`—return `false` to cancel
|
||||||
|
- `<RouterView>` with named views—multiple views per route
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
- `v-if` vs `v-show`—v-if removes from DOM, v-show toggles display
|
||||||
|
- Key on `v-for` required—`v-for="item in items" :key="item.id"`
|
||||||
|
- Event modifiers order matters—`.prevent.stop` vs `.stop.prevent`
|
||||||
|
- Teleport for modals—`<Teleport to="body">` renders outside component tree
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn73vp5rarc3b14rc7wjcw8f8580t5d1",
|
||||||
|
"slug": "vue",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"publishedAt": 1771075866839
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Component Patterns
|
||||||
|
|
||||||
|
## Props Best Practices
|
||||||
|
|
||||||
|
- Type your props: `defineProps<{msg: string, count?: number}>()`
|
||||||
|
- Required props have no `?`—TypeScript enforces
|
||||||
|
- Default values: `withDefaults(defineProps<Props>(), {count: 0})`
|
||||||
|
- Props are readonly—never mutate, emit event to parent instead
|
||||||
|
|
||||||
|
## Emit Patterns
|
||||||
|
|
||||||
|
- Type your emits: `defineEmits<{(e: 'update', value: string): void}>()`
|
||||||
|
- Emit returns boolean if event handler returns false
|
||||||
|
- Vue 3.3+ shorthand: `defineEmits<{update: [value: string]}>()`
|
||||||
|
- Always emit for changes—maintain one-way data flow
|
||||||
|
|
||||||
|
## v-model Patterns
|
||||||
|
|
||||||
|
- `v-model` = `:modelValue` + `@update:modelValue`
|
||||||
|
- Multiple v-model: `v-model:title`, `v-model:content`—different props
|
||||||
|
- `defineModel()` (3.4+) creates ref that syncs automatically—much simpler
|
||||||
|
- v-model modifiers: `.trim`, `.number`, `.lazy`—access via `modelModifiers`
|
||||||
|
|
||||||
|
## Slots Patterns
|
||||||
|
|
||||||
|
- Default slot: `<slot></slot>`—or `<slot>fallback content</slot>`
|
||||||
|
- Named slots: `<slot name="header">` + `<template #header>`
|
||||||
|
- Scoped slots: `<slot :item="item">` + `<template #default="{item}">`
|
||||||
|
- `$slots.header?.()` to check if slot provided
|
||||||
|
|
||||||
|
## Dynamic Components
|
||||||
|
|
||||||
|
- `<component :is="componentName">`—switch between components
|
||||||
|
- `<KeepAlive>` preserves state when switching—cached in memory
|
||||||
|
- `include`/`exclude` props on KeepAlive—control which are cached
|
||||||
|
- `max` prop limits cache size—LRU eviction
|
||||||
|
|
||||||
|
## Async Components
|
||||||
|
|
||||||
|
- `defineAsyncComponent(() => import('./Comp.vue'))`—lazy loading
|
||||||
|
- Loading component: `loadingComponent` option—shown while loading
|
||||||
|
- Error component: `errorComponent` option—shown on load failure
|
||||||
|
- Combine with `<Suspense>` for coordinated loading states
|
||||||
|
|
||||||
|
## Teleport
|
||||||
|
|
||||||
|
- `<Teleport to="body">`—render content outside component tree
|
||||||
|
- Useful for modals, dropdowns, tooltips—need to escape overflow/z-index
|
||||||
|
- `disabled` prop for conditional teleporting
|
||||||
|
- Events still bubble in Vue component tree—not DOM tree
|
||||||
|
|
||||||
|
## Provide/Inject
|
||||||
|
|
||||||
|
- `provide('key', value)` in ancestor—available to all descendants
|
||||||
|
- `inject('key', defaultValue)` to receive
|
||||||
|
- Provide reactive value for reactive inject—`provide('count', ref(0))`
|
||||||
|
- Symbol keys for type safety—avoid string collisions
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Composables Design
|
||||||
|
|
||||||
|
## Composable Philosophy
|
||||||
|
|
||||||
|
- Composables are functions that encapsulate stateful logic
|
||||||
|
- Name with `use` prefix—`useMouse`, `useAuth`, `useFetch`
|
||||||
|
- Return refs/reactive, not plain values—caller needs reactivity
|
||||||
|
- Composables are like custom hooks in React
|
||||||
|
|
||||||
|
## Basic Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export function useCounter(initial = 0) {
|
||||||
|
const count = ref(initial)
|
||||||
|
const increment = () => count.value++
|
||||||
|
const decrement = () => count.value--
|
||||||
|
return { count, increment, decrement }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Input Flexibility
|
||||||
|
|
||||||
|
- Accept ref OR plain value as input—support both use cases
|
||||||
|
- `toValue()` (3.3+) unwraps ref or returns value—flexible API
|
||||||
|
- For reactive objects, consider accepting getter function
|
||||||
|
- Document what inputs are accepted
|
||||||
|
|
||||||
|
## Return Value Patterns
|
||||||
|
|
||||||
|
- Return object with named properties—allows destructuring what's needed
|
||||||
|
- Return refs, not reactive—easier to destructure
|
||||||
|
- Return methods along with state—complete API
|
||||||
|
- Consider returning readonly for state—prevent external mutation
|
||||||
|
|
||||||
|
## Lifecycle in Composables
|
||||||
|
|
||||||
|
- `onMounted`, `onUnmounted` work inside composables—attached to calling component
|
||||||
|
- Cleanup with `onUnmounted`—unsubscribe, clear timers
|
||||||
|
- Composable must be called synchronously in setup—not in callbacks
|
||||||
|
- Multiple components using same composable = separate instances
|
||||||
|
|
||||||
|
## Side Effects
|
||||||
|
|
||||||
|
- Start effects in composable—they run when component mounts
|
||||||
|
- Clean up in `onUnmounted`—or use cleanup function in watchEffect
|
||||||
|
- Consider `onScopeDispose` for effect scope cleanup
|
||||||
|
- Return stop functions for manual control
|
||||||
|
|
||||||
|
## Async Composables
|
||||||
|
|
||||||
|
- Return reactive state that updates when async completes
|
||||||
|
- Include loading and error states—`{data, isLoading, error}`
|
||||||
|
- Consider `useFetch` pattern from VueUse
|
||||||
|
- For SSR, handle hydration mismatch
|
||||||
|
|
||||||
|
## Composition Patterns
|
||||||
|
|
||||||
|
- Composables can use other composables—build on abstractions
|
||||||
|
- Avoid deep nesting—keep composables focused
|
||||||
|
- Share types between composables—consistent API
|
||||||
|
- Consider extracting to separate packages—if truly reusable
|
||||||
|
|
||||||
|
## VueUse Reference
|
||||||
|
|
||||||
|
- Rich composable library—200+ utilities
|
||||||
|
- Study patterns in VueUse—excellent examples
|
||||||
|
- Don't reinvent—check if VueUse has it
|
||||||
|
- Extend VueUse composables rather than replacing
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Performance Optimization
|
||||||
|
|
||||||
|
## Render Performance
|
||||||
|
|
||||||
|
- Vue tracks dependencies automatically—only re-renders what changes
|
||||||
|
- Keys on `v-for` are critical—use stable, unique ID, not array index
|
||||||
|
- `v-if` vs `v-show`: if unmounts, show toggles CSS—show better for frequent toggle
|
||||||
|
- `v-once` for static content—renders once, never updates
|
||||||
|
|
||||||
|
## Computed Optimization
|
||||||
|
|
||||||
|
- Computed values are cached—use for expensive derivations
|
||||||
|
- Computed only recalculates when dependencies change
|
||||||
|
- Avoid side effects in computed—can't rely on when they run
|
||||||
|
- Chain computed values—each caches independently
|
||||||
|
|
||||||
|
## Component Optimization
|
||||||
|
|
||||||
|
- Define components outside `<script setup>`—or use `defineComponent`
|
||||||
|
- Avoid inline handlers creating new functions: `:click="() => handle(item)"`
|
||||||
|
- For large lists, use virtual scrolling—vue-virtual-scroller
|
||||||
|
- Code split with `defineAsyncComponent`—load on demand
|
||||||
|
|
||||||
|
## Props Stability
|
||||||
|
|
||||||
|
- Object/array props create new reference each render
|
||||||
|
- Use `computed` or `toRef` for derived props—stable references
|
||||||
|
- `shallowRef` for large objects not deeply reactive
|
||||||
|
- `v-memo` for memoizing parts of template
|
||||||
|
|
||||||
|
## v-memo
|
||||||
|
|
||||||
|
- `v-memo="[dependency]"` caches template fragment
|
||||||
|
- Re-renders only when dependency changes
|
||||||
|
- Useful for large lists with selectable items
|
||||||
|
- More granular than component-level memoization
|
||||||
|
|
||||||
|
## List Optimization
|
||||||
|
|
||||||
|
- `key` must be stable and unique—not array index if list reorders
|
||||||
|
- `key` change triggers full component recreation—use for forced re-render
|
||||||
|
- Virtual scrolling for 1000+ items—don't render off-screen
|
||||||
|
- Paginate if possible—show 50, load more on demand
|
||||||
|
|
||||||
|
## Async Components
|
||||||
|
|
||||||
|
- `defineAsyncComponent` for lazy loading—reduces initial bundle
|
||||||
|
- Route-level code splitting—each route loads its components
|
||||||
|
- `<Suspense>` for coordinated async loading—show loading state
|
||||||
|
- Prefetch likely-needed components—on hover or visibility
|
||||||
|
|
||||||
|
## DevTools Profiling
|
||||||
|
|
||||||
|
- Vue DevTools has component inspector—see props, state
|
||||||
|
- Performance timeline shows render time
|
||||||
|
- Highlight updates option—visualize what re-renders
|
||||||
|
- Production build for accurate performance—dev mode adds overhead
|
||||||
|
|
||||||
|
## Build Optimization
|
||||||
|
|
||||||
|
- Tree-shaking removes unused code—don't import entire libraries
|
||||||
|
- `vue` runtime only build—smaller if not using template compiler
|
||||||
|
- `vite build` handles most optimizations automatically
|
||||||
|
- Analyze bundle: `vite-plugin-visualizer`—find heavy dependencies
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Reactivity Patterns
|
||||||
|
|
||||||
|
## ref vs reactive Decision
|
||||||
|
|
||||||
|
- `ref` for primitives (string, number, boolean)—`.value` access
|
||||||
|
- `ref` also works for objects—sometimes clearer than reactive
|
||||||
|
- `reactive` for objects when you want direct property access
|
||||||
|
- Consistency wins: pick one style for your team
|
||||||
|
|
||||||
|
## ref Patterns
|
||||||
|
|
||||||
|
- Forgot `.value` in script—logs Ref object instead of value
|
||||||
|
- `.value` in template—unnecessary, auto-unwrapped
|
||||||
|
- `ref` with object value—still reactive, but `.value` for whole object
|
||||||
|
- `unref()` helper—returns value if ref, value itself if not
|
||||||
|
|
||||||
|
## reactive Patterns
|
||||||
|
|
||||||
|
- Reassigning whole object breaks reactivity—`state = newObj` doesn't work
|
||||||
|
- Destructuring loses reactivity—`const {count} = state` is not reactive
|
||||||
|
- `toRefs(state)` converts each property to ref—preserves reactivity
|
||||||
|
- `toRef(state, 'prop')` for single property to ref
|
||||||
|
|
||||||
|
## Shallow Reactivity
|
||||||
|
|
||||||
|
- `shallowRef` only tracks `.value` changes—nested changes not reactive
|
||||||
|
- `shallowReactive` only tracks root-level properties
|
||||||
|
- Use for performance with large objects—when deep reactivity not needed
|
||||||
|
- `triggerRef(ref)` manually triggers update for shallowRef
|
||||||
|
|
||||||
|
## readonly Protection
|
||||||
|
|
||||||
|
- `readonly(state)` creates read-only proxy—mutations silently fail
|
||||||
|
- Props are already readonly—no need to wrap
|
||||||
|
- Good for exposing state from composables—prevent external mutation
|
||||||
|
- `isReadonly()` to check if value is readonly
|
||||||
|
|
||||||
|
## Raw Values
|
||||||
|
|
||||||
|
- `toRaw(proxy)` gets original object from reactive proxy
|
||||||
|
- Useful for third-party libraries that don't handle Proxy
|
||||||
|
- Mutations on raw object won't trigger updates
|
||||||
|
- `markRaw(obj)` prevents object from ever becoming reactive
|
||||||
|
|
||||||
|
## Computed Patterns
|
||||||
|
|
||||||
|
- Computed with setter: `computed({ get, set })`—for two-way derived state
|
||||||
|
- Computed is cached—expensive calculations only run when dependencies change
|
||||||
|
- Avoid side effects in computed—use `watch` or `watchEffect` instead
|
||||||
|
- Computed ref unwraps in template—same as regular ref
|
||||||
|
|
||||||
|
## Effect Cleanup
|
||||||
|
|
||||||
|
- `watchEffect` receives cleanup function: `watchEffect((onCleanup) => {})`
|
||||||
|
- Clean up timers, subscriptions, event listeners
|
||||||
|
- Cleanup runs before effect re-runs AND on unmount
|
||||||
|
- Same pattern in `watch` callback—third argument
|
||||||
Reference in New Issue
Block a user