feat(appendix): 重构工程实践章节,添加交互式演示组件
## 新增组件 (14个) - CodeSmellDemo.vue: 代码异味识别演示 - DecisionMatrixDemo.vue: 决策矩阵工具 - DesignPatternCatalogDemo.vue: 设计模式目录 - DocStructureDemo.vue: 文档结构示例 - LicenseComparisonDemo.vue: 开源许可证对比 - OpenSourceWorkflowDemo.vue: 开源协作流程 - PatternPlaygroundDemo.vue: 设计模式演练场 - RefactoringDemo.vue: 重构实战演示 - SecurityChecklistDemo.vue: 安全检查清单 - TDDCycleDemo.vue: TDD 循环演示 - TechRadarDemo.vue: 技术雷达图 - TechWritingPracticeDemo.vue: 技术写作实践 - TestPyramidDemo.vue: 测试金字塔 - WebSecurityDemo.vue: Web 安全演示 ## 文档更新 (7篇) - code-quality-refactoring.md: 代码质量与重构 - design-patterns.md: 设计模式 - open-source-collaboration.md: 开源协作 - security-thinking.md: 安全思维 - technical-writing.md: 技术写作 - technology-selection.md: 技术选型 - testing-strategies.md: 测试策略 ## 其他变更 - 将 browser-as-os.md 内容合并到 computer-networks.md - 更新 .gitignore 和 theme/index.js
This commit is contained in:
@@ -16,3 +16,5 @@ REFACTORING_PLAN.md
|
||||
.gitignore
|
||||
.gitignore
|
||||
REFACTORING_REPORT.md
|
||||
docs/.vitepress/.temp/*
|
||||
.temp/*
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="code-smell-demo">
|
||||
<div class="demo-label">代码坏味道识别器 ── 点击切换不同示例</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(item, i) in smells"
|
||||
:key="i"
|
||||
class="tab"
|
||||
:class="{ active: current === i }"
|
||||
@click="current = i"
|
||||
>
|
||||
{{ item.icon }} {{ item.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="code-panel">
|
||||
<div class="panel-title">问题代码</div>
|
||||
<pre><code>{{ smells[current].bad }}</code></pre>
|
||||
</div>
|
||||
<div class="info-panel" :class="smells[current].cls">
|
||||
<h4>{{ smells[current].icon }} {{ smells[current].name }}</h4>
|
||||
<p class="desc">{{ smells[current].desc }}</p>
|
||||
<div class="suggestion">
|
||||
<strong>改进建议:</strong>{{ smells[current].fix }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const current = ref(0)
|
||||
|
||||
const smells = [
|
||||
{
|
||||
name: '过长函数',
|
||||
icon: '📏',
|
||||
cls: 'red',
|
||||
desc: '一个函数超过 50 行,做了太多事情,难以理解和测试。',
|
||||
bad: `function processOrder(order) {
|
||||
// 验证订单... (20行)
|
||||
// 计算价格... (15行)
|
||||
// 检查库存... (10行)
|
||||
// 发送通知... (15行)
|
||||
// 更新数据库... (10行)
|
||||
// 生成报表... (10行)
|
||||
// 总计 80+ 行!
|
||||
}`,
|
||||
fix: '将大函数拆分为多个职责单一的小函数:validateOrder()、calculatePrice()、checkInventory() 等。'
|
||||
},
|
||||
{
|
||||
name: '魔法数字',
|
||||
icon: '🔢',
|
||||
cls: 'orange',
|
||||
desc: '代码中直接使用含义不明的数字字面量,阅读者无法理解其含义。',
|
||||
bad: `if (user.age >= 18) { ... }
|
||||
if (password.length < 8) { ... }
|
||||
if (retryCount > 3) { ... }
|
||||
setTimeout(fn, 86400000)`,
|
||||
fix: '用命名常量替代:const ADULT_AGE = 18、const MIN_PASSWORD_LENGTH = 8、const ONE_DAY_MS = 86400000。'
|
||||
},
|
||||
{
|
||||
name: '重复代码',
|
||||
icon: '📋',
|
||||
cls: 'yellow',
|
||||
desc: '相同或相似的代码出现在多处,修改时容易遗漏。',
|
||||
bad: `// 文件 A
|
||||
const tax = price * 0.13
|
||||
const total = price + tax
|
||||
|
||||
// 文件 B(几乎一样)
|
||||
const tax = amount * 0.13
|
||||
const sum = amount + tax`,
|
||||
fix: '提取公共函数 calculateTax(amount),在多处复用,修改只需改一处。'
|
||||
},
|
||||
{
|
||||
name: '过深嵌套',
|
||||
icon: '🪆',
|
||||
cls: 'purple',
|
||||
desc: '多层 if/for 嵌套导致代码难以阅读,逻辑像迷宫。',
|
||||
bad: `if (user) {
|
||||
if (user.isActive) {
|
||||
if (user.hasPermission) {
|
||||
if (order.isValid) {
|
||||
// 终于到了真正的逻辑...
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
fix: '使用卫语句(Guard Clause)提前返回:if (!user) return; if (!user.isActive) return; ...'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-smell-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.code-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.72rem;
|
||||
padding: 4px 10px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-3);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-panel pre {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.info-panel.red { background: #fef2f2; border: 1px solid #fecaca; }
|
||||
.info-panel.orange { background: #fff7ed; border: 1px solid #fed7aa; }
|
||||
.info-panel.yellow { background: #fefce8; border: 1px solid #fde68a; }
|
||||
.info-panel.purple { background: #faf5ff; border: 1px solid #e9d5ff; }
|
||||
|
||||
:root.dark .info-panel.red { background: #1c0606; border-color: #7f1d1d; }
|
||||
:root.dark .info-panel.orange { background: #1c0f03; border-color: #9a3412; }
|
||||
:root.dark .info-panel.yellow { background: #1c1a03; border-color: #854d0e; }
|
||||
:root.dark .info-panel.purple { background: #1a0a2e; border-color: #6b21a8; }
|
||||
|
||||
.info-panel h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
font-size: 0.83rem;
|
||||
padding: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
<template>
|
||||
<div class="decision-matrix-demo">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📊</span>
|
||||
<span class="title">决策矩阵</span>
|
||||
<span class="subtitle">量化对比,科学选型</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h6 class="section-title">待比较技术</h6>
|
||||
<div class="options-row">
|
||||
<span
|
||||
v-for="opt in options"
|
||||
:key="opt"
|
||||
class="option-tag"
|
||||
>
|
||||
{{ opt }}
|
||||
<button class="remove-btn" @click="removeOption(opt)">×</button>
|
||||
</span>
|
||||
<div v-if="options.length < 5" class="add-option">
|
||||
<input
|
||||
v-model="newOption"
|
||||
placeholder="添加技术..."
|
||||
class="add-input"
|
||||
@keyup.enter="addOption"
|
||||
/>
|
||||
<button class="add-btn" @click="addOption">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h6 class="section-title">评估维度与权重</h6>
|
||||
<div class="dimensions-list">
|
||||
<div
|
||||
v-for="dim in dimensions"
|
||||
:key="dim.key"
|
||||
class="dim-row"
|
||||
>
|
||||
<span class="dim-name">{{ dim.label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
:value="weights[dim.key]"
|
||||
class="weight-slider"
|
||||
@input="weights[dim.key] = Number($event.target.value)"
|
||||
/>
|
||||
<span class="weight-val">{{ weights[dim.key] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h6 class="section-title">打分(1-5)</h6>
|
||||
<div class="score-table-wrapper">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>维度</th>
|
||||
<th v-for="opt in options" :key="opt">{{ opt }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="dim in dimensions" :key="dim.key">
|
||||
<td class="dim-cell">{{ dim.label }}</td>
|
||||
<td v-for="opt in options" :key="opt">
|
||||
<div class="score-btns">
|
||||
<button
|
||||
v-for="s in 5"
|
||||
:key="s"
|
||||
class="score-btn"
|
||||
:class="{ active: scores[opt]?.[dim.key] >= s }"
|
||||
@click="setScore(opt, dim.key, s)"
|
||||
>
|
||||
{{ s }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAllScores" class="section results">
|
||||
<h6 class="section-title">加权总分排名</h6>
|
||||
<div class="bar-chart">
|
||||
<div
|
||||
v-for="(r, i) in ranked"
|
||||
:key="r.name"
|
||||
class="bar-row"
|
||||
>
|
||||
<span class="bar-rank" :class="{ first: i === 0 }">
|
||||
{{ i === 0 ? '🏆' : `#${i + 1}` }}
|
||||
</span>
|
||||
<span class="bar-name">{{ r.name }}</span>
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-fill"
|
||||
:style="{
|
||||
width: (r.score / maxScore) * 100 + '%',
|
||||
background: barColors[i % barColors.length]
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="bar-score">{{ r.score.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="reset-btn" @click="resetAll">重置全部</button>
|
||||
<button class="preset-btn" @click="loadPreset">加载预设</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>使用方法:</strong>
|
||||
调整权重反映你的项目优先级,为每个技术在各维度打分,系统自动计算加权总分。权重越高的维度对最终结果影响越大。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
|
||||
const barColors = ['#22c55e', '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899']
|
||||
|
||||
const dimensions = [
|
||||
{ key: 'learning', label: '学习曲线' },
|
||||
{ key: 'ecosystem', label: '生态系统' },
|
||||
{ key: 'performance', label: '性能' },
|
||||
{ key: 'community', label: '社区活跃度' },
|
||||
{ key: 'hiring', label: '招聘难度' }
|
||||
]
|
||||
|
||||
const options = ref(['React', 'Vue', 'Svelte'])
|
||||
const newOption = ref('')
|
||||
const weights = reactive({
|
||||
learning: 3,
|
||||
ecosystem: 4,
|
||||
performance: 3,
|
||||
community: 3,
|
||||
hiring: 2
|
||||
})
|
||||
|
||||
const scores = reactive({
|
||||
React: { learning: 3, ecosystem: 5, performance: 4, community: 5, hiring: 4 },
|
||||
Vue: { learning: 4, ecosystem: 4, performance: 4, community: 4, hiring: 3 },
|
||||
Svelte: { learning: 5, ecosystem: 2, performance: 5, community: 3, hiring: 1 }
|
||||
})
|
||||
|
||||
const addOption = () => {
|
||||
const name = newOption.value.trim()
|
||||
if (name && !options.value.includes(name) && options.value.length < 5) {
|
||||
options.value.push(name)
|
||||
scores[name] = {}
|
||||
newOption.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeOption = (opt) => {
|
||||
if (options.value.length <= 2) return
|
||||
options.value = options.value.filter((o) => o !== opt)
|
||||
delete scores[opt]
|
||||
}
|
||||
|
||||
const setScore = (opt, dim, val) => {
|
||||
if (!scores[opt]) scores[opt] = {}
|
||||
scores[opt][dim] = val
|
||||
}
|
||||
|
||||
const hasAllScores = computed(() => {
|
||||
return options.value.every((opt) =>
|
||||
dimensions.every((dim) => scores[opt]?.[dim.key])
|
||||
)
|
||||
})
|
||||
|
||||
const ranked = computed(() => {
|
||||
return options.value
|
||||
.map((opt) => {
|
||||
let total = 0
|
||||
dimensions.forEach((dim) => {
|
||||
total += (scores[opt]?.[dim.key] || 0) * weights[dim.key]
|
||||
})
|
||||
return { name: opt, score: total }
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
})
|
||||
|
||||
const maxScore = computed(() => {
|
||||
return Math.max(...ranked.value.map((r) => r.score), 1)
|
||||
})
|
||||
|
||||
const resetAll = () => {
|
||||
options.value = ['React', 'Vue', 'Svelte']
|
||||
Object.keys(scores).forEach((k) => delete scores[k])
|
||||
Object.assign(weights, { learning: 3, ecosystem: 4, performance: 3, community: 3, hiring: 2 })
|
||||
}
|
||||
|
||||
const loadPreset = () => {
|
||||
options.value = ['React', 'Vue', 'Svelte']
|
||||
Object.keys(scores).forEach((k) => delete scores[k])
|
||||
Object.assign(scores, {
|
||||
React: { learning: 3, ecosystem: 5, performance: 4, community: 5, hiring: 4 },
|
||||
Vue: { learning: 4, ecosystem: 4, performance: 4, community: 4, hiring: 3 },
|
||||
Svelte: { learning: 5, ecosystem: 2, performance: 5, community: 3, hiring: 1 }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.decision-matrix-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.25rem }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem }
|
||||
|
||||
.section {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.options-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.option-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.7);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
.remove-btn:hover { color: #fff }
|
||||
|
||||
.add-option {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.add-input {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
width: 120px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dimensions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dim-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
width: 80px;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weight-slider {
|
||||
flex: 1;
|
||||
accent-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.weight-val {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.score-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.score-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.score-table th,
|
||||
.score-table td {
|
||||
padding: 0.4rem 0.5rem;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.score-table th {
|
||||
background: var(--vp-c-bg-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dim-cell {
|
||||
text-align: left !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.score-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.score-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 3px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: all 0.15s;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.score-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.results {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-rank {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.bar-rank.first {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bar-name {
|
||||
width: 60px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 22px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.bar-score {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.reset-btn,
|
||||
.preset-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.reset-btn:hover { background: var(--vp-c-bg-alt) }
|
||||
.preset-btn:hover { opacity: 0.9 }
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem }
|
||||
</style>
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="pattern-catalog-demo">
|
||||
<div class="demo-label">设计模式图鉴 ── 点击分类查看常用模式</div>
|
||||
|
||||
<div class="categories">
|
||||
<div
|
||||
v-for="(cat, i) in categories"
|
||||
:key="cat.name"
|
||||
class="cat-card"
|
||||
:class="[cat.cls, { active: selected === i }]"
|
||||
@click="selected = selected === i ? -1 : i"
|
||||
>
|
||||
<span class="cat-icon">{{ cat.icon }}</span>
|
||||
<span class="cat-name">{{ cat.name }}</span>
|
||||
<span class="cat-count">{{ cat.patterns.length }} 个模式</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="selected >= 0" class="patterns-list">
|
||||
<div
|
||||
v-for="p in categories[selected].patterns"
|
||||
:key="p.name"
|
||||
class="pattern-item"
|
||||
:class="categories[selected].cls"
|
||||
>
|
||||
<div class="pattern-header" @click="expanded = expanded === p.name ? '' : p.name">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="toggle">{{ expanded === p.name ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div class="pattern-intent">{{ p.intent }}</div>
|
||||
<Transition name="fade">
|
||||
<div v-if="expanded === p.name" class="pattern-detail">
|
||||
<div class="detail-label">适用场景</div>
|
||||
<div class="detail-text">{{ p.when }}</div>
|
||||
<div class="detail-label">代码示例</div>
|
||||
<pre><code>{{ p.code }}</code></pre>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const selected = ref(-1)
|
||||
const expanded = ref('')
|
||||
|
||||
const categories = [
|
||||
{
|
||||
name: '创建型',
|
||||
icon: '🏗️',
|
||||
cls: 'create',
|
||||
patterns: [
|
||||
{
|
||||
name: '单例模式 Singleton',
|
||||
intent: '确保一个类只有一个实例,并提供全局访问点。',
|
||||
when: '数据库连接池、全局配置管理、日志记录器。',
|
||||
code: `class Database {
|
||||
static instance = null
|
||||
static getInstance() {
|
||||
if (!this.instance) {
|
||||
this.instance = new Database()
|
||||
}
|
||||
return this.instance
|
||||
}
|
||||
}`
|
||||
},
|
||||
{
|
||||
name: '工厂模式 Factory',
|
||||
intent: '定义创建对象的接口,让子类决定实例化哪个类。',
|
||||
when: '需要根据条件创建不同类型对象时。',
|
||||
code: `function createNotification(type) {
|
||||
switch (type) {
|
||||
case 'email': return new EmailNotify()
|
||||
case 'sms': return new SmsNotify()
|
||||
case 'push': return new PushNotify()
|
||||
}
|
||||
}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '结构型',
|
||||
icon: '🧱',
|
||||
cls: 'structure',
|
||||
patterns: [
|
||||
{
|
||||
name: '装饰器模式 Decorator',
|
||||
intent: '动态地给对象添加额外职责,比继承更灵活。',
|
||||
when: '需要在不修改原有代码的情况下扩展功能。',
|
||||
code: `function withLogging(fn) {
|
||||
return function(...args) {
|
||||
console.log('调用:', fn.name)
|
||||
return fn.apply(this, args)
|
||||
}
|
||||
}
|
||||
const save = withLogging(saveUser)`
|
||||
},
|
||||
{
|
||||
name: '适配器模式 Adapter',
|
||||
intent: '将一个接口转换成客户端期望的另一个接口。',
|
||||
when: '对接第三方 API、兼容旧系统接口。',
|
||||
code: `class OldApi { getData() { ... } }
|
||||
|
||||
class ApiAdapter {
|
||||
constructor(old) { this.old = old }
|
||||
fetch() { return this.old.getData() }
|
||||
}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '行为型',
|
||||
icon: '🎭',
|
||||
cls: 'behavior',
|
||||
patterns: [
|
||||
{
|
||||
name: '观察者模式 Observer',
|
||||
intent: '定义一对多依赖,当状态变化时自动通知所有依赖者。',
|
||||
when: '事件系统、状态管理、消息推送。',
|
||||
code: `class EventBus {
|
||||
listeners = {}
|
||||
on(event, fn) {
|
||||
(this.listeners[event] ||= []).push(fn)
|
||||
}
|
||||
emit(event, data) {
|
||||
this.listeners[event]?.forEach(fn => fn(data))
|
||||
}
|
||||
}`
|
||||
},
|
||||
{
|
||||
name: '策略模式 Strategy',
|
||||
intent: '定义一系列算法,使它们可以互相替换。',
|
||||
when: '排序策略、支付方式、验证规则的切换。',
|
||||
code: `const strategies = {
|
||||
bubble: arr => { /* 冒泡排序 */ },
|
||||
quick: arr => { /* 快速排序 */ },
|
||||
merge: arr => { /* 归并排序 */ }
|
||||
}
|
||||
function sort(arr, type) {
|
||||
return strategies[type](arr)
|
||||
}`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pattern-catalog-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.categories { display: flex; gap: 8px; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
|
||||
.cat-card {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cat-card:hover { transform: scale(1.03); }
|
||||
.cat-card.active { box-shadow: 0 0 0 2px var(--vp-c-brand); transform: scale(1.05); }
|
||||
|
||||
.cat-card.create { background: #dbeafe; color: #1e40af; }
|
||||
.cat-card.structure { background: #d1fae5; color: #065f46; }
|
||||
.cat-card.behavior { background: #fef3c7; color: #92400e; }
|
||||
|
||||
:root.dark .cat-card.create { background: #172554; color: #93c5fd; }
|
||||
:root.dark .cat-card.structure { background: #022c22; color: #6ee7b7; }
|
||||
:root.dark .cat-card.behavior { background: #451a03; color: #fcd34d; }
|
||||
|
||||
.cat-icon { display: block; font-size: 1.5rem; margin-bottom: 4px; }
|
||||
.cat-name { display: block; font-weight: 600; font-size: 0.9rem; }
|
||||
.cat-count { display: block; font-size: 0.72rem; opacity: 0.7; }
|
||||
|
||||
.patterns-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.pattern-item {
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.pattern-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toggle { font-size: 0.7rem; color: var(--vp-c-text-3); }
|
||||
.pattern-intent { font-size: 0.82rem; color: var(--vp-c-text-2); margin-top: 4px; }
|
||||
|
||||
.pattern-detail { margin-top: 8px; }
|
||||
.detail-label { font-size: 0.75rem; font-weight: 600; color: var(--vp-c-text-3); margin-top: 6px; }
|
||||
.detail-text { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.pattern-detail pre {
|
||||
margin: 4px 0 0;
|
||||
padding: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="doc-structure-demo">
|
||||
<div class="demo-label">文档结构模板 ── 点击切换文档类型</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(doc, i) in docs"
|
||||
:key="i"
|
||||
class="tab"
|
||||
:class="{ active: current === i }"
|
||||
@click="current = i"
|
||||
>{{ doc.icon }} {{ doc.name }}</button>
|
||||
</div>
|
||||
|
||||
<div class="structure-card">
|
||||
<div class="section-list">
|
||||
<div
|
||||
v-for="(sec, j) in docs[current].sections"
|
||||
:key="j"
|
||||
class="section-item"
|
||||
:class="{ active: selectedSec === j }"
|
||||
@click="selectedSec = selectedSec === j ? -1 : j"
|
||||
>
|
||||
<div class="sec-header">
|
||||
<span class="sec-num">{{ j + 1 }}</span>
|
||||
<span class="sec-name">{{ sec.name }}</span>
|
||||
<span class="sec-toggle">{{ selectedSec === j ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<Transition name="fade">
|
||||
<div v-if="selectedSec === j" class="sec-detail">
|
||||
<p>{{ sec.desc }}</p>
|
||||
<pre v-if="sec.example"><code>{{ sec.example }}</code></pre>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
const current = ref(0)
|
||||
const selectedSec = ref(-1)
|
||||
watch(current, () => { selectedSec.value = -1 })
|
||||
|
||||
const docs = [
|
||||
{
|
||||
name: 'README',
|
||||
icon: '📖',
|
||||
sections: [
|
||||
{ name: '项目名称 + 一句话描述', desc: '让读者在 3 秒内知道这个项目是什么。', example: '# MyApp\n> 一个轻量级的任务管理工具' },
|
||||
{ name: '快速开始', desc: '最短路径让用户跑起来,通常是安装 + 运行命令。', example: 'npm install myapp\nnpx myapp init' },
|
||||
{ name: '功能特性', desc: '用列表列出核心功能,让用户判断是否满足需求。', example: '- ✅ 任务看板\n- ✅ 团队协作\n- ✅ 数据导出' },
|
||||
{ name: '使用示例', desc: '展示典型用法的代码片段,比文字描述更直观。', example: null },
|
||||
{ name: '贡献指南 + 许可证', desc: '说明如何参与贡献,以及项目的开源许可证。', example: null }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'API 文档',
|
||||
icon: '🔌',
|
||||
sections: [
|
||||
{ name: '接口概述', desc: '说明 API 的基础 URL、认证方式、通用参数。', example: 'Base URL: https://api.example.com/v1\nAuth: Bearer Token' },
|
||||
{ name: '请求参数', desc: '用表格列出每个参数的名称、类型、是否必填、说明。', example: '| 参数 | 类型 | 必填 | 说明 |\n| name | string | 是 | 用户名 |' },
|
||||
{ name: '响应格式', desc: '展示成功和失败的 JSON 响应示例。', example: '{ "code": 200, "data": { ... } }' },
|
||||
{ name: '错误码说明', desc: '列出所有可能的错误码及其含义。', example: '401 - 未授权\n404 - 资源不存在\n429 - 请求过于频繁' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '架构文档',
|
||||
icon: '🏛️',
|
||||
sections: [
|
||||
{ name: '系统概述', desc: '用一段话说明系统的目标、边界和核心约束。', example: null },
|
||||
{ name: '架构图', desc: '展示系统的整体架构,包括各模块和它们之间的关系。', example: '[客户端] → [API 网关] → [微服务集群]\n ↓\n [数据库集群]' },
|
||||
{ name: '技术选型', desc: '说明关键技术的选择理由和替代方案的对比。', example: null },
|
||||
{ name: '部署架构', desc: '说明生产环境的部署方式、扩容策略。', example: null }
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-structure-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
|
||||
.tabs { display: flex; gap: 6px; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
|
||||
.tab.active { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
|
||||
|
||||
.section-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.section-item { border: 1px solid var(--vp-c-divider); border-radius: 6px; background: var(--vp-c-bg); overflow: hidden; }
|
||||
.sec-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; }
|
||||
.sec-num { width: 22px; height: 22px; border-radius: 50%; background: var(--vp-c-brand); color: #fff; font-size: 0.72rem; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.sec-name { flex: 1; font-size: 0.88rem; font-weight: 600; }
|
||||
.sec-toggle { font-size: 0.7rem; color: var(--vp-c-text-3); }
|
||||
.section-item.active { border-color: var(--vp-c-brand); }
|
||||
|
||||
.sec-detail { padding: 0 12px 10px; }
|
||||
.sec-detail p { font-size: 0.83rem; color: var(--vp-c-text-2); margin: 0 0 6px; }
|
||||
.sec-detail pre { margin: 0; padding: 8px; background: var(--vp-c-bg-soft); border-radius: 4px; font-size: 0.78rem; line-height: 1.5; overflow-x: auto; }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="lc-root">
|
||||
<h4 class="lc-title">开源许可证对比工具</h4>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="lc-filter">
|
||||
<span class="lc-filter-label">我的需求:</span>
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.id"
|
||||
:class="['lc-tag', { 'lc-tag--on': activeFilters.includes(f.id) }]"
|
||||
@click="toggle(f.id)"
|
||||
>{{ f.label }}</button>
|
||||
<button v-if="activeFilters.length" class="lc-tag lc-tag--clear" @click="activeFilters = []">清除筛选</button>
|
||||
</div>
|
||||
|
||||
<!-- Recommendation -->
|
||||
<div v-if="recommended" class="lc-recommend">
|
||||
推荐许可证:<strong>{{ recommended.name }}</strong> — {{ recommended.summary }}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="lc-table-wrap">
|
||||
<table class="lc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>许可证</th>
|
||||
<th v-for="p in permissions" :key="p.id">{{ p.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="l in licenses"
|
||||
:key="l.id"
|
||||
:class="{ 'lc-row--hl': recommended && recommended.id === l.id }"
|
||||
>
|
||||
<td class="lc-name-cell">
|
||||
<strong>{{ l.name }}</strong>
|
||||
<span class="lc-desc">{{ l.summary }}</span>
|
||||
</td>
|
||||
<td v-for="p in permissions" :key="p.id" class="lc-cell">
|
||||
<span v-if="l.perms[p.id] === true" class="lc-yes">✓</span>
|
||||
<span v-else-if="l.perms[p.id] === false" class="lc-no">✗</span>
|
||||
<span v-else class="lc-cond">⚠</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="lc-legend">
|
||||
<span><span class="lc-yes">✓</span> 允许</span>
|
||||
<span><span class="lc-no">✗</span> 不允许/限制</span>
|
||||
<span><span class="lc-cond">⚠</span> 有条件</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const permissions = [
|
||||
{ id: 'commercial', label: '商用' },
|
||||
{ id: 'modify', label: '修改' },
|
||||
{ id: 'distribute', label: '分发' },
|
||||
{ id: 'patent', label: '专利授权' },
|
||||
{ id: 'private', label: '私用' },
|
||||
{ id: 'copyleft', label: '需开源衍生' },
|
||||
{ id: 'liability', label: '免责' }
|
||||
]
|
||||
|
||||
const licenses = [
|
||||
{
|
||||
id: 'mit', name: 'MIT', summary: '最宽松,几乎无限制',
|
||||
perms: { commercial: true, modify: true, distribute: true, patent: false, private: true, copyleft: false, liability: true },
|
||||
tags: ['commercial', 'simple', 'private']
|
||||
},
|
||||
{
|
||||
id: 'apache2', name: 'Apache 2.0', summary: '宽松 + 专利保护',
|
||||
perms: { commercial: true, modify: true, distribute: true, patent: true, private: true, copyleft: false, liability: true },
|
||||
tags: ['commercial', 'patent', 'private']
|
||||
},
|
||||
{
|
||||
id: 'gpl3', name: 'GPL 3.0', summary: '强 Copyleft,衍生必须开源',
|
||||
perms: { commercial: true, modify: true, distribute: true, patent: true, private: true, copyleft: true, liability: true },
|
||||
tags: ['copyleft', 'patent']
|
||||
},
|
||||
{
|
||||
id: 'bsd2', name: 'BSD 2-Clause', summary: '类似 MIT,极简宽松',
|
||||
perms: { commercial: true, modify: true, distribute: true, patent: false, private: true, copyleft: false, liability: true },
|
||||
tags: ['commercial', 'simple', 'private']
|
||||
},
|
||||
{
|
||||
id: 'mpl2', name: 'MPL 2.0', summary: '文件级 Copyleft,折中方案',
|
||||
perms: { commercial: true, modify: true, distribute: true, patent: true, private: true, copyleft: 'cond', liability: true },
|
||||
tags: ['commercial', 'patent', 'copyleft']
|
||||
}
|
||||
]
|
||||
|
||||
const filters = [
|
||||
{ id: 'commercial', label: '允许商用' },
|
||||
{ id: 'patent', label: '需要专利保护' },
|
||||
{ id: 'simple', label: '尽量简单' },
|
||||
{ id: 'copyleft', label: '要求衍生开源' },
|
||||
{ id: 'private', label: '允许闭源使用' }
|
||||
]
|
||||
|
||||
const activeFilters = ref([])
|
||||
|
||||
function toggle(id) {
|
||||
const idx = activeFilters.value.indexOf(id)
|
||||
if (idx >= 0) activeFilters.value.splice(idx, 1)
|
||||
else activeFilters.value.push(id)
|
||||
}
|
||||
|
||||
const recommended = computed(() => {
|
||||
if (!activeFilters.value.length) return null
|
||||
let best = null
|
||||
let bestScore = -1
|
||||
for (const l of licenses) {
|
||||
const score = activeFilters.value.filter(f => l.tags.includes(f)).length
|
||||
if (score > bestScore) { bestScore = score; best = l }
|
||||
}
|
||||
return bestScore > 0 ? best : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lc-root {
|
||||
margin: 1.5em 0;
|
||||
padding: 1.2em;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
.lc-title {
|
||||
margin: 0 0 1em;
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Filter */
|
||||
.lc-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.lc-filter-label {
|
||||
font-size: 0.9em;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
.lc-tag {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 16px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.lc-tag:hover { border-color: var(--vp-c-brand-1); }
|
||||
.lc-tag--on {
|
||||
background: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
color: #fff;
|
||||
}
|
||||
.lc-tag--clear {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
/* Recommend */
|
||||
.lc-recommend {
|
||||
padding: 0.6em 1em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-brand-soft);
|
||||
font-size: 0.9em;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.lc-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
.lc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.lc-table th,
|
||||
.lc-table td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
text-align: center;
|
||||
}
|
||||
.lc-table th {
|
||||
background: var(--vp-c-bg);
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lc-name-cell {
|
||||
text-align: left !important;
|
||||
min-width: 130px;
|
||||
}
|
||||
.lc-desc {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--vp-c-text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.lc-row--hl {
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
.lc-cell { font-size: 1.1em; }
|
||||
.lc-yes { color: #10b981; font-weight: 700; }
|
||||
.lc-no { color: #ef4444; font-weight: 700; }
|
||||
.lc-cond { color: #f59e0b; }
|
||||
|
||||
/* Legend */
|
||||
.lc-legend {
|
||||
display: flex;
|
||||
gap: 1.5em;
|
||||
font-size: 0.8em;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
</style>
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="os-workflow-demo">
|
||||
<div class="demo-label">开源贡献流程 ── 点击步骤查看详情</div>
|
||||
|
||||
<div class="steps-bar">
|
||||
<div
|
||||
v-for="(s, i) in steps"
|
||||
:key="i"
|
||||
class="step"
|
||||
:class="{ active: current === i, done: i < current }"
|
||||
@click="current = i"
|
||||
>
|
||||
<span class="step-dot">{{ i < current ? '✓' : i + 1 }}</span>
|
||||
<span class="step-label">{{ s.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h4>{{ steps[current].icon }} {{ steps[current].name }}</h4>
|
||||
<p>{{ steps[current].desc }}</p>
|
||||
<div class="cmd-block" v-if="steps[current].cmd">
|
||||
<div class="cmd-title">对应命令</div>
|
||||
<pre><code>{{ steps[current].cmd }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" :disabled="current === 0" @click="current--">上一步</button>
|
||||
<button class="btn primary" :disabled="current === steps.length - 1" @click="current++">下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const current = ref(0)
|
||||
|
||||
const steps = [
|
||||
{ name: 'Fork', icon: '🍴', desc: '在 GitHub 上 Fork 目标仓库到自己的账号下,获得一份完整的副本。', cmd: '# 在 GitHub 页面点击 Fork 按钮' },
|
||||
{ name: 'Clone', icon: '📥', desc: '将 Fork 后的仓库克隆到本地开发环境。', cmd: 'git clone https://github.com/你的用户名/项目.git\ncd 项目' },
|
||||
{ name: 'Branch', icon: '🌿', desc: '创建功能分支,不要直接在 main 上开发。分支名应描述你要做的事。', cmd: 'git checkout -b fix/login-bug' },
|
||||
{ name: 'Commit', icon: '💾', desc: '完成修改后提交,写清晰的 commit message。遵循项目的提交规范。', cmd: 'git add .\ngit commit -m "fix: 修复登录页白屏问题"' },
|
||||
{ name: 'Push', icon: '🚀', desc: '将本地分支推送到你 Fork 的远程仓库。', cmd: 'git push origin fix/login-bug' },
|
||||
{ name: 'PR', icon: '📬', desc: '在 GitHub 上创建 Pull Request,描述你的改动、关联的 Issue、测试方法。', cmd: '# 在 GitHub 页面点击 "New Pull Request"' },
|
||||
{ name: 'Review', icon: '👀', desc: '维护者会审查你的代码,可能提出修改建议。根据反馈修改后再次 push 即可。', cmd: 'git add . && git commit -m "fix: 根据 review 反馈调整"\ngit push' },
|
||||
{ name: 'Merge', icon: '🎉', desc: '审查通过后,维护者会合并你的 PR。恭喜,你成为了项目贡献者!', cmd: '# 维护者操作:Merge Pull Request' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.os-workflow-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
|
||||
|
||||
.steps-bar { display: flex; gap: 2px; margin-bottom: 1rem; overflow-x: auto; }
|
||||
.step { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 6px 8px; border-radius: 6px; cursor: pointer; min-width: 56px; transition: all 0.2s; }
|
||||
.step:hover { background: var(--vp-c-bg); }
|
||||
.step.active { background: var(--vp-c-brand-soft); }
|
||||
.step-dot { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--vp-c-divider); display: flex; align-items: center; justify-content: center; font-size: 0.72rem; font-weight: 600; transition: all 0.2s; }
|
||||
.step.active .step-dot { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
|
||||
.step.done .step-dot { background: #10b981; color: #fff; border-color: #10b981; }
|
||||
.step-label { font-size: 0.7rem; color: var(--vp-c-text-3); white-space: nowrap; }
|
||||
.step.active .step-label { color: var(--vp-c-brand); font-weight: 600; }
|
||||
|
||||
.detail-card { border: 1px solid var(--vp-c-divider); border-radius: 8px; padding: 1rem; background: var(--vp-c-bg); margin-bottom: 1rem; }
|
||||
.detail-card h4 { margin: 0 0 0.5rem; font-size: 1rem; }
|
||||
.detail-card p { font-size: 0.85rem; color: var(--vp-c-text-2); margin: 0 0 0.6rem; }
|
||||
.cmd-block { border-radius: 6px; overflow: hidden; }
|
||||
.cmd-title { font-size: 0.72rem; padding: 4px 10px; background: var(--vp-c-bg-soft); color: var(--vp-c-text-3); border-bottom: 1px solid var(--vp-c-divider); }
|
||||
.cmd-block pre { margin: 0; padding: 8px; font-size: 0.8rem; line-height: 1.5; overflow-x: auto; background: var(--vp-c-bg-soft); }
|
||||
|
||||
.controls { display: flex; gap: 8px; justify-content: center; }
|
||||
.btn { padding: 6px 16px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); color: var(--vp-c-text-1); cursor: pointer; font-size: 0.85rem; }
|
||||
.btn:hover:not(:disabled) { background: var(--vp-c-bg-soft); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
|
||||
</style>
|
||||
+476
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="pattern-playground">
|
||||
<div class="demo-header">
|
||||
<span class="icon">🎮</span>
|
||||
<span class="title">设计模式演练场</span>
|
||||
<span class="subtitle">选择模式,动手体验</span>
|
||||
</div>
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.key"
|
||||
:class="['mode-btn', { active: activeMode === m.key }]"
|
||||
@click="activeMode = m.key; resetState()"
|
||||
>
|
||||
{{ m.icon }} {{ m.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 观察者模式演练 -->
|
||||
<div v-if="activeMode === 'observer'" class="playground-area">
|
||||
<div class="playground-desc">
|
||||
模拟事件发布/订阅:添加订阅者,发布事件,观察通知如何传播。
|
||||
</div>
|
||||
|
||||
<div class="observer-layout">
|
||||
<div class="publisher-panel">
|
||||
<div class="panel-title">📡 发布者 (Publisher)</div>
|
||||
<div class="event-input">
|
||||
<input
|
||||
v-model="eventMessage"
|
||||
placeholder="输入事件消息..."
|
||||
@keyup.enter="publishEvent"
|
||||
/>
|
||||
<button class="action-btn publish" @click="publishEvent">发布事件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subscribers-panel">
|
||||
<div class="panel-title">
|
||||
👥 订阅者
|
||||
<button class="action-btn add" @click="addSubscriber">+ 添加</button>
|
||||
</div>
|
||||
<div v-if="subscribers.length === 0" class="empty-hint">暂无订阅者,点击"添加"按钮</div>
|
||||
<div
|
||||
v-for="sub in subscribers"
|
||||
:key="sub.id"
|
||||
:class="['subscriber-card', { notified: sub.notified }]"
|
||||
>
|
||||
<span class="sub-name">{{ sub.name }}</span>
|
||||
<span v-if="sub.lastMsg" class="sub-msg">收到: {{ sub.lastMsg }}</span>
|
||||
<button class="remove-btn" @click="removeSubscriber(sub.id)">移除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="observerLog.length" class="event-log">
|
||||
<div class="log-title">📋 事件日志</div>
|
||||
<div v-for="(log, i) in observerLog" :key="i" class="log-item">{{ log }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 策略模式演练 -->
|
||||
<div v-if="activeMode === 'strategy'" class="playground-area">
|
||||
<div class="playground-desc">
|
||||
选择不同的排序策略,观察同一组数据如何被不同算法处理。
|
||||
</div>
|
||||
|
||||
<div class="strategy-layout">
|
||||
<div class="data-panel">
|
||||
<div class="panel-title">📊 待排序数据</div>
|
||||
<div class="data-bars">
|
||||
<div
|
||||
v-for="(v, i) in strategyData"
|
||||
:key="i"
|
||||
class="bar"
|
||||
:style="{ height: v * 3 + 'px', background: barColor(i) }"
|
||||
>
|
||||
<span class="bar-label">{{ v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn" @click="shuffleData" style="margin-top: 10px">🔀 打乱数据</button>
|
||||
</div>
|
||||
|
||||
<div class="strategy-panel">
|
||||
<div class="panel-title">⚙️ 选择策略</div>
|
||||
<div class="strategy-options">
|
||||
<button
|
||||
v-for="s in sortStrategies"
|
||||
:key="s.key"
|
||||
:class="['strategy-btn', { active: activeStrategy === s.key }]"
|
||||
@click="activeStrategy = s.key"
|
||||
>
|
||||
{{ s.name }}
|
||||
<span class="strategy-complexity">{{ s.complexity }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="action-btn publish" @click="executeSort" :disabled="sorting">
|
||||
{{ sorting ? '排序中...' : '▶ 执行排序' }}
|
||||
</button>
|
||||
<div v-if="sortSteps.length" class="steps-info">
|
||||
步骤数: {{ sortSteps.length }} | 策略: {{ sortStrategies.find(s => s.key === activeStrategy)?.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeMode = ref('observer')
|
||||
const modes = [
|
||||
{ key: 'observer', name: '观察者模式', icon: '📡' },
|
||||
{ key: 'strategy', name: '策略模式', icon: '⚙️' }
|
||||
]
|
||||
|
||||
// === 观察者模式状态 ===
|
||||
let subIdCounter = 0
|
||||
const subscribers = ref([])
|
||||
const eventMessage = ref('')
|
||||
const observerLog = ref([])
|
||||
const subNames = ['小明', '小红', '小刚', '小美', '小李', '小王', '小张', '小赵']
|
||||
|
||||
function addSubscriber() {
|
||||
const name = subNames[subIdCounter % subNames.length]
|
||||
subscribers.value.push({
|
||||
id: ++subIdCounter,
|
||||
name: name + '#' + subIdCounter,
|
||||
lastMsg: '',
|
||||
notified: false
|
||||
})
|
||||
observerLog.value.unshift(`[订阅] ${name}#${subIdCounter} 加入了订阅列表`)
|
||||
}
|
||||
|
||||
function removeSubscriber(id) {
|
||||
const sub = subscribers.value.find(s => s.id === id)
|
||||
subscribers.value = subscribers.value.filter(s => s.id !== id)
|
||||
if (sub) observerLog.value.unshift(`[取消订阅] ${sub.name} 离开了`)
|
||||
}
|
||||
|
||||
function publishEvent() {
|
||||
const msg = eventMessage.value.trim() || '默认事件'
|
||||
observerLog.value.unshift(`[发布] 事件: "${msg}" → 通知 ${subscribers.value.length} 个订阅者`)
|
||||
subscribers.value.forEach((sub, i) => {
|
||||
setTimeout(() => {
|
||||
sub.lastMsg = msg
|
||||
sub.notified = true
|
||||
setTimeout(() => { sub.notified = false }, 600)
|
||||
}, i * 150)
|
||||
})
|
||||
eventMessage.value = ''
|
||||
}
|
||||
|
||||
// === 策略模式状态 ===
|
||||
const strategyData = ref([38, 15, 72, 46, 91, 23, 64, 8, 55, 30])
|
||||
const activeStrategy = ref('bubble')
|
||||
const sorting = ref(false)
|
||||
const sortSteps = ref([])
|
||||
const highlightIdx = ref(-1)
|
||||
|
||||
const sortStrategies = [
|
||||
{ key: 'bubble', name: '冒泡排序', complexity: 'O(n²)' },
|
||||
{ key: 'selection', name: '选择排序', complexity: 'O(n²)' },
|
||||
{ key: 'insertion', name: '插入排序', complexity: 'O(n²)' }
|
||||
]
|
||||
|
||||
function shuffleData() {
|
||||
const arr = [...strategyData.value]
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]]
|
||||
}
|
||||
strategyData.value = arr
|
||||
sortSteps.value = []
|
||||
}
|
||||
|
||||
function barColor(i) {
|
||||
if (i === highlightIdx.value) return '#f59e0b'
|
||||
return `hsl(${strategyData.value[i] * 2.5}, 65%, 55%)`
|
||||
}
|
||||
|
||||
async function executeSort() {
|
||||
sorting.value = true
|
||||
sortSteps.value = []
|
||||
const arr = [...strategyData.value]
|
||||
const steps = []
|
||||
|
||||
if (activeStrategy.value === 'bubble') {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = 0; j < arr.length - i - 1; j++) {
|
||||
if (arr[j] > arr[j + 1]) {
|
||||
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
|
||||
steps.push({ arr: [...arr], idx: j + 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (activeStrategy.value === 'selection') {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
let min = i
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
if (arr[j] < arr[min]) min = j
|
||||
}
|
||||
if (min !== i) {
|
||||
[arr[i], arr[min]] = [arr[min], arr[i]]
|
||||
steps.push({ arr: [...arr], idx: i })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const key = arr[i]
|
||||
let j = i - 1
|
||||
while (j >= 0 && arr[j] > key) {
|
||||
arr[j + 1] = arr[j]
|
||||
j--
|
||||
}
|
||||
arr[j + 1] = key
|
||||
steps.push({ arr: [...arr], idx: j + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
sortSteps.value = steps
|
||||
for (const step of steps) {
|
||||
strategyData.value = step.arr
|
||||
highlightIdx.value = step.idx
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
}
|
||||
highlightIdx.value = -1
|
||||
sorting.value = false
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
observerLog.value = []
|
||||
subscribers.value = []
|
||||
subIdCounter = 0
|
||||
eventMessage.value = ''
|
||||
strategyData.value = [38, 15, 72, 46, 91, 23, 64, 8, 55, 30]
|
||||
sortSteps.value = []
|
||||
sorting.value = false
|
||||
highlightIdx.value = -1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pattern-playground {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin: 16px 0;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.demo-header .icon { font-size: 24px }
|
||||
.demo-header .subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mode-btn.active {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: rgba(100, 108, 255, 0.08);
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
.playground-area { margin-top: 8px }
|
||||
.playground-desc {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.action-btn:hover { background: var(--vp-c-bg-soft) }
|
||||
.action-btn.publish {
|
||||
background: var(--vp-c-brand-1);
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
.action-btn.publish:hover { opacity: 0.85 }
|
||||
.action-btn.add {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
/* Observer */
|
||||
.observer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.publisher-panel, .subscribers-panel {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
.event-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.event-input input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
.subscriber-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.subscriber-card.notified {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.sub-name { font-weight: 500 }
|
||||
.sub-msg {
|
||||
flex: 1;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.remove-btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #f87171;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #f87171;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.event-log {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: var(--vp-c-bg);
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.log-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.log-item {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
/* Strategy */
|
||||
.strategy-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.data-panel, .strategy-panel {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
.data-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 280px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.bar {
|
||||
flex: 1;
|
||||
border-radius: 4px 4px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
transition: height 0.2s, background 0.2s;
|
||||
}
|
||||
.bar-label {
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.strategy-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.strategy-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.strategy-btn.active {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: rgba(100, 108, 255, 0.08);
|
||||
}
|
||||
.strategy-complexity {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
.steps-info {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.observer-layout, .strategy-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="refactoring-demo">
|
||||
<div class="demo-label">重构手法对比演示 ── 选择一种手法查看前后对比</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(item, i) in techniques"
|
||||
:key="i"
|
||||
:class="['tab-btn', { active: activeTab === i }]"
|
||||
@click="selectTab(i)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="desc">{{ current.description }}</div>
|
||||
|
||||
<div class="compare-area">
|
||||
<div class="compare-panel before">
|
||||
<div class="panel-header">
|
||||
<span class="dot red"></span> 重构前
|
||||
</div>
|
||||
<pre class="code-block"><template
|
||||
v-for="(seg, j) in current.before"
|
||||
:key="'b'+j"
|
||||
><span :class="{ highlight: showHighlight && seg.changed }">{{ seg.text }}</span></template></pre>
|
||||
</div>
|
||||
|
||||
<div class="arrow-col">
|
||||
<span class="arrow-icon">→</span>
|
||||
</div>
|
||||
|
||||
<div class="compare-panel after">
|
||||
<div class="panel-header">
|
||||
<span class="dot green"></span> 重构后
|
||||
</div>
|
||||
<pre class="code-block"><template
|
||||
v-for="(seg, j) in current.after"
|
||||
:key="'a'+j"
|
||||
><span :class="{ highlight: showHighlight && seg.changed }">{{ seg.text }}</span></template></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-box">
|
||||
<strong>要点:</strong>{{ current.tip }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeTab = ref(0)
|
||||
const showHighlight = ref(false)
|
||||
|
||||
function selectTab(i) {
|
||||
activeTab.value = i
|
||||
showHighlight.value = false
|
||||
setTimeout(() => { showHighlight.value = true }, 300)
|
||||
}
|
||||
|
||||
// 初始化高亮
|
||||
setTimeout(() => { showHighlight.value = true }, 500)
|
||||
|
||||
const techniques = [
|
||||
{
|
||||
name: '提炼函数',
|
||||
description: 'Extract Function:将一段代码从大函数中提取出来,放入一个命名清晰的新函数中。',
|
||||
before: [
|
||||
{ text: 'function printReport(invoice) {\n console.log("=== 账单 ===")\n' },
|
||||
{ text: ' // 计算总额\n let total = 0\n for (let item of invoice.items) {\n total += item.price * item.qty\n }\n', changed: true },
|
||||
{ text: ' console.log(`总计: ${total}`)\n}' }
|
||||
],
|
||||
after: [
|
||||
{ text: 'function printReport(invoice) {\n console.log("=== 账单 ===")\n' },
|
||||
{ text: ' const total = calcTotal(invoice.items)\n', changed: true },
|
||||
{ text: ' console.log(`总计: ${total}`)\n}\n\n' },
|
||||
{ text: 'function calcTotal(items) {\n return items.reduce(\n (s, i) => s + i.price * i.qty, 0\n )\n}', changed: true }
|
||||
],
|
||||
tip: '提炼函数是最常用的重构手法。好的函数名就是最好的注释——如果你需要写注释解释一段代码在做什么,那它就该被提炼成函数。'
|
||||
},
|
||||
{
|
||||
name: '重命名变量',
|
||||
description: 'Rename Variable:用清晰、有意义的名称替换含糊的变量名,让代码自解释。',
|
||||
before: [
|
||||
{ text: 'function calc(', changed: true },
|
||||
{ text: 'a, b, c', changed: true },
|
||||
{ text: ') {\n' },
|
||||
{ text: ' const d = a * b\n const e = d * (1 - c)\n return e\n}', changed: true }
|
||||
],
|
||||
after: [
|
||||
{ text: 'function calcOrderTotal(', changed: true },
|
||||
{ text: 'price, quantity, discountRate', changed: true },
|
||||
{ text: ') {\n' },
|
||||
{ text: ' const subtotal = price * quantity\n const total = subtotal * (1 - discountRate)\n return total\n}', changed: true }
|
||||
],
|
||||
tip: '变量命名是程序员最重要的基本功之一。好的命名让代码像散文一样可读,差的命名让代码像密码一样难解。'
|
||||
},
|
||||
{
|
||||
name: '消除重复',
|
||||
description: 'Remove Duplication:将重复的逻辑抽取为共享函数或模板,遵循 DRY 原则。',
|
||||
before: [
|
||||
{ text: '// 员工报表\nfunction empReport(emp) {\n' },
|
||||
{ text: ' return `${emp.name} | ${emp.dept} | ${emp.salary}`', changed: true },
|
||||
{ text: '\n}\n\n// 经理报表\nfunction mgrReport(mgr) {\n' },
|
||||
{ text: ' return `${mgr.name} | ${mgr.dept} | ${mgr.salary}`', changed: true },
|
||||
{ text: '\n}' }
|
||||
],
|
||||
after: [
|
||||
{ text: '' },
|
||||
{ text: 'function formatReport(person) {\n return `${person.name} | ${person.dept} | ${person.salary}`\n}', changed: true },
|
||||
{ text: '\n\n// 统一调用\n' },
|
||||
{ text: 'formatReport(employee)\nformatReport(manager)', changed: true }
|
||||
],
|
||||
tip: 'DRY(Don\'t Repeat Yourself)是软件工程的基本原则。每一处重复都是未来 bug 的温床——改了一处忘了另一处,就是典型的重复代码事故。'
|
||||
},
|
||||
{
|
||||
name: '简化条件',
|
||||
description: 'Simplify Conditional:用卫语句、策略模式等手法替代深层嵌套的 if-else,降低圈复杂度。',
|
||||
before: [
|
||||
{ text: 'function getDiscount(user) {\n' },
|
||||
{ text: ' if (user.type === "vip") {\n if (user.years > 5) {\n return 0.3\n } else {\n return 0.2\n }\n } else {\n if (user.years > 3) {\n return 0.1\n } else {\n return 0\n }\n }', changed: true },
|
||||
{ text: '\n}' }
|
||||
],
|
||||
after: [
|
||||
{ text: 'function getDiscount(user) {\n' },
|
||||
{ text: ' if (user.type === "vip" && user.years > 5) return 0.3\n if (user.type === "vip") return 0.2\n if (user.years > 3) return 0.1\n return 0', changed: true },
|
||||
{ text: '\n}' }
|
||||
],
|
||||
tip: '卫语句(Guard Clause)通过提前返回来消除嵌套。扁平的代码结构比深层嵌套更容易理解和维护。'
|
||||
}
|
||||
]
|
||||
|
||||
const current = computed(() => techniques[activeTab.value])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.refactoring-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand-1);
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.compare-area {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.compare-area {
|
||||
flex-direction: column;
|
||||
}
|
||||
.arrow-col {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.compare-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.red { background: #ef4444; }
|
||||
.dot.green { background: #22c55e; }
|
||||
|
||||
.code-block {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-radius: 2px;
|
||||
transition: background 0.6s ease;
|
||||
}
|
||||
|
||||
.before .highlight {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.arrow-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
color: var(--vp-c-text-3);
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.tip-box {
|
||||
margin-top: 0.8rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-left: 3px solid var(--vp-c-brand-1);
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="checklist-demo">
|
||||
<div class="header">
|
||||
<div class="title">项目安全检查清单</div>
|
||||
<div class="subtitle">勾选已完成的安全措施,查看项目安全评分</div>
|
||||
</div>
|
||||
|
||||
<div class="score-bar">
|
||||
<div class="score-label">安全评分</div>
|
||||
<div class="score-track">
|
||||
<div
|
||||
class="score-fill"
|
||||
:style="{ width: score + '%', background: scoreColor }"
|
||||
/>
|
||||
</div>
|
||||
<div class="score-value" :style="{ color: scoreColor }">
|
||||
{{ score }}分
|
||||
</div>
|
||||
<div class="score-level" :style="{ color: scoreColor }">
|
||||
{{ scoreLevel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(cat, ci) in categories" :key="ci" class="category">
|
||||
<div class="cat-header" @click="cat.open = !cat.open">
|
||||
<span class="cat-icon">{{ cat.icon }}</span>
|
||||
<span class="cat-name">{{ cat.name }}</span>
|
||||
<span class="cat-progress">
|
||||
{{ checkedCount(ci) }}/{{ cat.items.length }}
|
||||
</span>
|
||||
<span class="cat-arrow">{{ cat.open ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div v-if="cat.open" class="cat-items">
|
||||
<div
|
||||
v-for="(item, ii) in cat.items"
|
||||
:key="ii"
|
||||
class="check-item"
|
||||
>
|
||||
<div class="item-row" @click="item.checked = !item.checked">
|
||||
<input type="checkbox" v-model="item.checked" @click.stop />
|
||||
<span :class="['item-text', { done: item.checked }]">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="item-detail"
|
||||
v-if="item.showDetail"
|
||||
>
|
||||
{{ item.detail }}
|
||||
</div>
|
||||
<button
|
||||
class="detail-toggle"
|
||||
@click="item.showDetail = !item.showDetail"
|
||||
>
|
||||
{{ item.showDetail ? '收起' : '查看最佳实践' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue'
|
||||
|
||||
const categories = reactive([
|
||||
{
|
||||
icon: '🔍',
|
||||
name: '输入验证',
|
||||
open: true,
|
||||
items: [
|
||||
{ label: '所有用户输入在服务端进行校验', checked: false, showDetail: false, detail: '永远不要仅依赖前端校验。攻击者可以绕过浏览器直接发送请求,服务端必须对长度、类型、格式、范围做二次验证。' },
|
||||
{ label: '使用白名单而非黑名单过滤', checked: false, showDetail: false, detail: '黑名单容易遗漏。应明确定义"允许什么"而非"禁止什么",例如只允许字母数字而非试图过滤所有特殊字符。' },
|
||||
{ label: '对文件上传进行类型和大小限制', checked: false, showDetail: false, detail: '校验文件 MIME 类型和扩展名,限制文件大小,将上传文件存储在 Web 根目录之外,使用随机文件名。' }
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: '🔐',
|
||||
name: '认证授权',
|
||||
open: false,
|
||||
items: [
|
||||
{ label: '密码使用 bcrypt/argon2 哈希存储', checked: false, showDetail: false, detail: '绝不明文存储密码。使用自带盐值的慢哈希算法(bcrypt cost>=10 或 argon2id),抵御彩虹表和暴力破解。' },
|
||||
{ label: '实施多因素认证 (MFA)', checked: false, showDetail: false, detail: '在密码之外增加第二因素(TOTP、短信、硬件密钥),即使密码泄露也能阻止未授权登录。' },
|
||||
{ label: '接口实施最小权限访问控制', checked: false, showDetail: false, detail: '每个 API 端点都应检查用户角色和权限,确保用户只能访问自己有权操作的资源(RBAC / ABAC)。' },
|
||||
{ label: '会话管理安全(超时、轮换)', checked: false, showDetail: false, detail: '登录后重新生成 Session ID,设置合理的过期时间,登出时销毁服务端会话。' }
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: '🛡️',
|
||||
name: '数据保护',
|
||||
open: false,
|
||||
items: [
|
||||
{ label: '敏感数据加密存储', checked: false, showDetail: false, detail: '对数据库中的敏感字段(手机号、身份证等)使用 AES-256 等算法加密,密钥与数据分离存储。' },
|
||||
{ label: '日志中不记录敏感信息', checked: false, showDetail: false, detail: '日志中不应出现密码、Token、信用卡号等。使用脱敏处理,如只记录手机号后四位。' },
|
||||
{ label: '实施 SQL 注入防护(参数化查询)', checked: false, showDetail: false, detail: '所有数据库操作使用参数化查询或 ORM,绝不拼接 SQL 字符串。' }
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: '🌐',
|
||||
name: '通信安全',
|
||||
open: false,
|
||||
items: [
|
||||
{ label: '全站启用 HTTPS', checked: false, showDetail: false, detail: '使用 TLS 1.2+ 加密所有通信,配置 HSTS 头强制 HTTPS,防止中间人攻击和数据窃听。' },
|
||||
{ label: '设置安全响应头(CSP、X-Frame-Options)', checked: false, showDetail: false, detail: '配置 Content-Security-Policy 限制资源加载来源,X-Frame-Options 防止点击劫持,X-Content-Type-Options 防止 MIME 嗅探。' },
|
||||
{ label: 'Cookie 设置 HttpOnly / Secure / SameSite', checked: false, showDetail: false, detail: 'HttpOnly 防止 JS 读取,Secure 确保仅 HTTPS 传输,SameSite=Lax 防止 CSRF 攻击。' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const totalItems = computed(() =>
|
||||
categories.reduce((sum, c) => sum + c.items.length, 0)
|
||||
)
|
||||
|
||||
const totalChecked = computed(() =>
|
||||
categories.reduce(
|
||||
(sum, c) => sum + c.items.filter((i) => i.checked).length,
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
const score = computed(() =>
|
||||
Math.round((totalChecked.value / totalItems.value) * 100)
|
||||
)
|
||||
|
||||
const scoreColor = computed(() => {
|
||||
if (score.value >= 80) return '#27ae60'
|
||||
if (score.value >= 50) return '#f39c12'
|
||||
return '#e74c3c'
|
||||
})
|
||||
|
||||
const scoreLevel = computed(() => {
|
||||
if (score.value >= 80) return '优秀'
|
||||
if (score.value >= 50) return '及格'
|
||||
return '危险'
|
||||
})
|
||||
|
||||
const checkedCount = (ci) =>
|
||||
categories[ci].items.filter((i) => i.checked).length
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checklist-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.header { margin-bottom: 1rem; }
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.score-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s, background 0.4s;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.score-level {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cat-icon { font-size: 1rem; }
|
||||
|
||||
.cat-name {
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cat-progress {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.cat-arrow {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cat-items {
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.check-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.check-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.item-text.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.detail-toggle {
|
||||
margin-top: 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vp-c-brand);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.score-bar { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="tdd-cycle-demo">
|
||||
<div class="demo-label">TDD 红绿重构循环 ── 点击"下一步"推进</div>
|
||||
|
||||
<div class="cycle-visual">
|
||||
<div
|
||||
v-for="(phase, i) in phases"
|
||||
:key="phase.name"
|
||||
class="phase-node"
|
||||
:class="[phase.cls, { active: current === i }]"
|
||||
>
|
||||
<span class="phase-icon">{{ phase.icon }}</span>
|
||||
<span class="phase-name">{{ phase.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-card" :class="steps[step].cls">
|
||||
<div class="step-header">
|
||||
<span class="step-badge">第 {{ step + 1 }} 步 / {{ steps.length }}</span>
|
||||
<span class="step-phase">{{ steps[step].phase }}</span>
|
||||
</div>
|
||||
<div class="step-desc">{{ steps[step].desc }}</div>
|
||||
<div class="code-block">
|
||||
<div class="code-title">{{ steps[step].fileLabel }}</div>
|
||||
<pre><code>{{ steps[step].code }}</code></pre>
|
||||
</div>
|
||||
<div class="step-result" :class="steps[step].cls">
|
||||
{{ steps[step].result }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" :disabled="step === 0" @click="step--">上一步</button>
|
||||
<button class="btn primary" :disabled="step === steps.length - 1" @click="step++">下一步</button>
|
||||
<button class="btn" @click="step = 0">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const step = ref(0)
|
||||
const current = computed(() => {
|
||||
const s = steps[step.value]
|
||||
return s.cls === 'red' ? 0 : s.cls === 'green' ? 1 : 2
|
||||
})
|
||||
|
||||
const phases = [
|
||||
{ name: 'Red', icon: '🔴', cls: 'red' },
|
||||
{ name: 'Green', icon: '🟢', cls: 'green' },
|
||||
{ name: 'Refactor', icon: '🔵', cls: 'blue' }
|
||||
]
|
||||
|
||||
const steps = [
|
||||
{
|
||||
phase: '🔴 Red — 先写一个失败的测试',
|
||||
cls: 'red',
|
||||
desc: '需求:实现 add(a, b) 函数。TDD 第一步不是写实现,而是先写测试。',
|
||||
fileLabel: 'add.test.js',
|
||||
code: `test('add(1, 2) 应该返回 3', () => {
|
||||
expect(add(1, 2)).toBe(3)
|
||||
})`,
|
||||
result: '❌ 测试失败 — add is not defined'
|
||||
},
|
||||
{
|
||||
phase: '🟢 Green — 写最小实现让测试通过',
|
||||
cls: 'green',
|
||||
desc: '不追求完美,只写刚好让测试通过的代码。',
|
||||
fileLabel: 'add.js',
|
||||
code: `function add(a, b) {
|
||||
return a + b
|
||||
}`,
|
||||
result: '✅ 测试通过!'
|
||||
},
|
||||
{
|
||||
phase: '🔵 Refactor — 重构优化',
|
||||
cls: 'blue',
|
||||
desc: '测试通过后安全地改进代码,测试是你的安全网。',
|
||||
fileLabel: 'add.js',
|
||||
code: `const add = (a, b) => a + b`,
|
||||
result: '✅ 重构完成,测试仍然通过!'
|
||||
},
|
||||
{
|
||||
phase: '🔴 Red — 添加新需求的测试',
|
||||
cls: 'red',
|
||||
desc: '新需求:add 应该能处理字符串数字。继续循环!',
|
||||
fileLabel: 'add.test.js',
|
||||
code: `test('add("1", "2") 应该返回 3', () => {
|
||||
expect(add('1', '2')).toBe(3)
|
||||
})`,
|
||||
result: '❌ 测试失败 — 返回了 "12" 而不是 3'
|
||||
},
|
||||
{
|
||||
phase: '🟢 Green — 修复实现',
|
||||
cls: 'green',
|
||||
desc: '修改实现以处理字符串输入。',
|
||||
fileLabel: 'add.js',
|
||||
code: `const add = (a, b) => Number(a) + Number(b)`,
|
||||
result: '✅ 所有测试通过!'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tdd-cycle-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cycle-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.phase-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.phase-node.active { opacity: 1; transform: scale(1.15); }
|
||||
.phase-node.red { background: #fee2e2; color: #991b1b; }
|
||||
.phase-node.green { background: #d1fae5; color: #065f46; }
|
||||
.phase-node.blue { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
:root.dark .phase-node.red { background: #450a0a; color: #fca5a5; }
|
||||
:root.dark .phase-node.green { background: #022c22; color: #6ee7b7; }
|
||||
:root.dark .phase-node.blue { background: #172554; color: #93c5fd; }
|
||||
|
||||
.phase-icon { font-size: 1.4rem; }
|
||||
|
||||
.step-card {
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.step-card.red { border-color: #fca5a5; background: #fef2f2; }
|
||||
.step-card.green { border-color: #6ee7b7; background: #ecfdf5; }
|
||||
.step-card.blue { border-color: #93c5fd; background: #eff6ff; }
|
||||
|
||||
:root.dark .step-card.red { border-color: #7f1d1d; background: #1c0606; }
|
||||
:root.dark .step-card.green { border-color: #065f46; background: #031c14; }
|
||||
:root.dark .step-card.blue { border-color: #1e40af; background: #0c1529; }
|
||||
|
||||
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 0.5rem; }
|
||||
|
||||
.step-badge {
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.step-phase { font-weight: 600; font-size: 0.95rem; }
|
||||
.step-desc { font-size: 0.85rem; color: var(--vp-c-text-2); margin-bottom: 0.6rem; }
|
||||
|
||||
.code-block {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
font-size: 0.72rem;
|
||||
padding: 4px 10px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-3);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-block pre { margin: 0; padding: 10px; font-size: 0.82rem; line-height: 1.5; overflow-x: auto; }
|
||||
|
||||
.step-result { font-size: 0.85rem; font-weight: 600; padding: 6px 10px; border-radius: 4px; }
|
||||
.step-result.red { color: #dc2626; }
|
||||
.step-result.green { color: #059669; }
|
||||
.step-result.blue { color: #2563eb; }
|
||||
|
||||
.controls { display: flex; gap: 8px; justify-content: center; }
|
||||
|
||||
.btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: var(--vp-c-bg-soft); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
|
||||
.btn.primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="tech-radar-demo">
|
||||
<div class="demo-label">技术雷达 ── 点击技术点查看详情</div>
|
||||
|
||||
<div class="radar-container">
|
||||
<div class="radar-rings">
|
||||
<div v-for="ring in rings" :key="ring.name" class="ring" :class="ring.cls">
|
||||
<span class="ring-label">{{ ring.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="tech in techs"
|
||||
:key="tech.name"
|
||||
class="tech-dot"
|
||||
:class="[tech.category, { active: selected === tech.name }]"
|
||||
:style="tech.pos"
|
||||
@click="selected = selected === tech.name ? '' : tech.name"
|
||||
>
|
||||
<span class="dot-label">{{ tech.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span v-for="c in cats" :key="c.cls" class="legend-item">
|
||||
<span class="legend-dot" :class="c.cls"></span>{{ c.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="selectedTech" class="info-card">
|
||||
<h4>{{ selectedTech.name }}</h4>
|
||||
<div class="info-ring">环位:{{ selectedTech.ring }}</div>
|
||||
<p>{{ selectedTech.desc }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
const selected = ref('')
|
||||
const selectedTech = computed(() => techs.find(t => t.name === selected.value))
|
||||
|
||||
const rings = [
|
||||
{ name: '采纳', cls: 'adopt' },
|
||||
{ name: '试验', cls: 'trial' },
|
||||
{ name: '评估', cls: 'assess' },
|
||||
{ name: '暂缓', cls: 'hold' }
|
||||
]
|
||||
|
||||
const cats = [
|
||||
{ name: '语言', cls: 'lang' },
|
||||
{ name: '框架', cls: 'framework' },
|
||||
{ name: '工具', cls: 'tool' },
|
||||
{ name: '平台', cls: 'platform' }
|
||||
]
|
||||
|
||||
const techs = [
|
||||
{ name: 'TypeScript', category: 'lang', ring: '采纳', pos: { top: '42%', left: '30%' }, desc: '类型安全的 JavaScript 超集,已成为前端项目标配。' },
|
||||
{ name: 'React', category: 'framework', ring: '采纳', pos: { top: '35%', left: '55%' }, desc: '生态最丰富的前端框架,适合大型团队和复杂应用。' },
|
||||
{ name: 'Vue', category: 'framework', ring: '采纳', pos: { top: '50%', left: '45%' }, desc: '渐进式框架,学习曲线平缓,中文社区活跃。' },
|
||||
{ name: 'Go', category: 'lang', ring: '采纳', pos: { top: '55%', left: '32%' }, desc: '高并发后端首选,编译快、部署简单。' },
|
||||
{ name: 'Rust', category: 'lang', ring: '试验', pos: { top: '30%', left: '22%' }, desc: '内存安全无 GC,适合系统编程和高性能场景,学习曲线陡峭。' },
|
||||
{ name: 'Svelte', category: 'framework', ring: '试验', pos: { top: '25%', left: '60%' }, desc: '编译时框架,无虚拟 DOM,包体积极小。' },
|
||||
{ name: 'Bun', category: 'tool', ring: '评估', pos: { top: '18%', left: '42%' }, desc: '新一代 JS 运行时,速度极快但生态尚在完善。' },
|
||||
{ name: 'Deno', category: 'platform', ring: '评估', pos: { top: '15%', left: '55%' }, desc: '安全优先的 JS/TS 运行时,内置工具链。' },
|
||||
{ name: 'jQuery', category: 'framework', ring: '暂缓', pos: { top: '8%', left: '38%' }, desc: '历史功臣,但现代框架已全面替代,新项目不建议使用。' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tech-radar-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
|
||||
|
||||
.radar-container { position: relative; width: 100%; padding-top: 70%; margin-bottom: 1rem; }
|
||||
|
||||
.radar-rings { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
|
||||
.ring {
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.ring .ring-label { font-size: 0.68rem; font-weight: 600; opacity: 0.6; }
|
||||
.ring.adopt { width: 90%; height: 90%; background: #d1fae520; border: 1px dashed #6ee7b7; }
|
||||
.ring.trial { width: 66%; height: 66%; background: #dbeafe20; border: 1px dashed #93c5fd; }
|
||||
.ring.assess { width: 42%; height: 42%; background: #fef3c720; border: 1px dashed #fcd34d; }
|
||||
.ring.hold { width: 20%; height: 20%; background: #fee2e220; border: 1px dashed #fca5a5; }
|
||||
|
||||
.tech-dot {
|
||||
position: absolute;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tech-dot:hover { transform: scale(1.1); }
|
||||
.tech-dot.active { box-shadow: 0 0 0 2px var(--vp-c-brand); transform: scale(1.15); }
|
||||
|
||||
.tech-dot.lang { background: #dbeafe; color: #1e40af; }
|
||||
.tech-dot.framework { background: #d1fae5; color: #065f46; }
|
||||
.tech-dot.tool { background: #fef3c7; color: #92400e; }
|
||||
.tech-dot.platform { background: #fae8ff; color: #86198f; }
|
||||
|
||||
:root.dark .tech-dot.lang { background: #172554; color: #93c5fd; }
|
||||
:root.dark .tech-dot.framework { background: #022c22; color: #6ee7b7; }
|
||||
:root.dark .tech-dot.tool { background: #451a03; color: #fcd34d; }
|
||||
:root.dark .tech-dot.platform { background: #4a044e; color: #f0abfc; }
|
||||
|
||||
.legend { display: flex; justify-content: center; gap: 1rem; font-size: 0.75rem; color: var(--vp-c-text-3); margin-bottom: 0.8rem; flex-wrap: wrap; }
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.legend-dot.lang { background: #3b82f6; }
|
||||
.legend-dot.framework { background: #10b981; }
|
||||
.legend-dot.tool { background: #f59e0b; }
|
||||
.legend-dot.platform { background: #d946ef; }
|
||||
|
||||
.info-card { border: 1px solid var(--vp-c-divider); border-radius: 8px; padding: 1rem; background: var(--vp-c-bg); }
|
||||
.info-card h4 { margin: 0 0 4px; font-size: 1rem; }
|
||||
.info-ring { font-size: 0.75rem; color: var(--vp-c-text-3); margin-bottom: 6px; }
|
||||
.info-card p { font-size: 0.85rem; color: var(--vp-c-text-2); margin: 0; }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="tech-writing-demo">
|
||||
<div class="demo-label">技术写作对比 ── 点击切换案例</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(c, i) in cases"
|
||||
:key="i"
|
||||
class="tab"
|
||||
:class="{ active: current === i }"
|
||||
@click="current = i"
|
||||
>{{ c.icon }} {{ c.name }}</button>
|
||||
</div>
|
||||
|
||||
<div class="compare">
|
||||
<div class="col bad">
|
||||
<div class="col-title">❌ 差的写法</div>
|
||||
<pre><code>{{ cases[current].bad }}</code></pre>
|
||||
</div>
|
||||
<div class="col good">
|
||||
<div class="col-title">✅ 好的写法</div>
|
||||
<pre><code>{{ cases[current].good }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
<strong>改进要点:</strong>
|
||||
<span v-for="(t, i) in cases[current].tips" :key="i" class="tip-tag">{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const current = ref(0)
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: '函数注释',
|
||||
icon: '💬',
|
||||
bad: `// 处理数据
|
||||
function process(d) {
|
||||
// ...
|
||||
}`,
|
||||
good: `/**
|
||||
* 将原始订单数据转换为发票格式
|
||||
* @param {Order} order - 原始订单对象
|
||||
* @returns {Invoice} 格式化后的发票
|
||||
* @throws {ValidationError} 订单数据不完整时
|
||||
*/
|
||||
function toInvoice(order) {
|
||||
// ...
|
||||
}`,
|
||||
tips: ['说明"为什么"而非"是什么"', '标注参数类型和返回值', '说明异常情况']
|
||||
},
|
||||
{
|
||||
name: 'API 说明',
|
||||
icon: '🔌',
|
||||
bad: `POST /api/users
|
||||
发送用户数据创建用户。`,
|
||||
good: `POST /api/users
|
||||
创建新用户账号。
|
||||
|
||||
请求体:
|
||||
{
|
||||
"name": "张三", // 必填,2-50字符
|
||||
"email": "a@b.com" // 必填,有效邮箱
|
||||
}
|
||||
|
||||
成功响应 201:
|
||||
{ "id": "u_123", "name": "张三" }
|
||||
|
||||
错误响应 400:
|
||||
{ "error": "邮箱格式无效" }`,
|
||||
tips: ['提供完整的请求/响应示例', '标注必填/选填', '列出错误场景']
|
||||
},
|
||||
{
|
||||
name: '变更日志',
|
||||
icon: '📝',
|
||||
bad: `v2.1 - 修了一些bug,加了新功能`,
|
||||
good: `## v2.1.0 (2025-01-15)
|
||||
|
||||
### 新增
|
||||
- 支持批量导出 PDF 格式报表
|
||||
|
||||
### 修复
|
||||
- 修复登录页在 Safari 下白屏的问题 (#234)
|
||||
|
||||
### 变更
|
||||
- 最低 Node.js 版本要求从 16 升至 18`,
|
||||
tips: ['按类型分类(新增/修复/变更)', '关联 Issue 编号', '标注版本号和日期']
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tech-writing-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
|
||||
.tabs { display: flex; gap: 6px; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
|
||||
.tab.active { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
|
||||
|
||||
.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
|
||||
@media (max-width: 640px) { .compare { grid-template-columns: 1fr; } }
|
||||
.col { border-radius: 6px; overflow: hidden; }
|
||||
.col-title { font-size: 0.72rem; padding: 4px 10px; border-bottom: 1px solid var(--vp-c-divider); }
|
||||
.col.bad .col-title { background: #fef2f2; color: #991b1b; }
|
||||
.col.good .col-title { background: #ecfdf5; color: #065f46; }
|
||||
:root.dark .col.bad .col-title { background: #1c0606; color: #fca5a5; }
|
||||
:root.dark .col.good .col-title { background: #031c14; color: #6ee7b7; }
|
||||
.col pre { margin: 0; padding: 8px; font-size: 0.78rem; line-height: 1.5; overflow-x: auto; background: var(--vp-c-bg); }
|
||||
|
||||
.tips { font-size: 0.83rem; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.tip-tag { padding: 2px 8px; border-radius: 10px; background: var(--vp-c-brand-soft); font-size: 0.75rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="test-pyramid-demo">
|
||||
<div class="demo-label">交互式测试金字塔 ── 点击每一层查看详情</div>
|
||||
|
||||
<div class="pyramid-container">
|
||||
<div
|
||||
v-for="(layer, i) in layers"
|
||||
:key="layer.name"
|
||||
class="pyramid-layer"
|
||||
:class="[layer.cls, { active: selected === i }]"
|
||||
:style="{ width: layer.width }"
|
||||
@click="selected = selected === i ? -1 : i"
|
||||
>
|
||||
<span class="layer-icon">{{ layer.icon }}</span>
|
||||
<span class="layer-name">{{ layer.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="selected >= 0" class="detail-card" :class="layers[selected].cls">
|
||||
<h4>{{ layers[selected].icon }} {{ layers[selected].name }}</h4>
|
||||
<table>
|
||||
<tr v-for="row in detailRows" :key="row.key">
|
||||
<td class="row-label">{{ row.label }}</td>
|
||||
<td>{{ layers[selected][row.key] }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="example">
|
||||
<strong>示例:</strong>{{ layers[selected].example }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="pyramid-legend">
|
||||
<span class="legend-item"><span class="dot e2e"></span>越往上:越慢、越贵、越接近用户</span>
|
||||
<span class="legend-item"><span class="dot unit"></span>越往下:越快、越多、越接近代码</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const selected = ref(-1)
|
||||
|
||||
const detailRows = [
|
||||
{ key: 'count', label: '数量占比' },
|
||||
{ key: 'speed', label: '执行速度' },
|
||||
{ key: 'cost', label: '维护成本' },
|
||||
{ key: 'scope', label: '覆盖范围' },
|
||||
{ key: 'confidence', label: '信心指数' }
|
||||
]
|
||||
|
||||
const layers = [
|
||||
{
|
||||
name: 'E2E 测试',
|
||||
cls: 'e2e',
|
||||
icon: '🖥️',
|
||||
width: '40%',
|
||||
count: '约 10%',
|
||||
speed: '慢(秒~分钟级)',
|
||||
cost: '高 — 环境依赖多,易碎',
|
||||
scope: '完整用户流程',
|
||||
confidence: '最高 — 模拟真实用户操作',
|
||||
example: '用 Playwright 模拟用户登录 → 下单 → 支付的完整流程'
|
||||
},
|
||||
{
|
||||
name: '集成测试',
|
||||
cls: 'integration',
|
||||
icon: '🔗',
|
||||
width: '60%',
|
||||
count: '约 20%',
|
||||
speed: '中等(百毫秒级)',
|
||||
cost: '中 — 需要部分外部依赖',
|
||||
scope: '模块间协作',
|
||||
confidence: '较高 — 验证组件间的配合',
|
||||
example: '测试 API 接口能否正确读写数据库并返回预期 JSON'
|
||||
},
|
||||
{
|
||||
name: '单元测试',
|
||||
cls: 'unit',
|
||||
icon: '🧪',
|
||||
width: '85%',
|
||||
count: '约 70%',
|
||||
speed: '极快(毫秒级)',
|
||||
cost: '低 — 无外部依赖',
|
||||
scope: '单个函数/类',
|
||||
confidence: '基础 — 确保每个零件正常',
|
||||
example: '测试 formatPrice(100) 是否返回 "¥1.00"'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-pyramid-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pyramid-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pyramid-layer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pyramid-layer:hover { transform: scale(1.03); }
|
||||
.pyramid-layer.active { box-shadow: 0 0 0 2px var(--vp-c-brand); transform: scale(1.05); }
|
||||
|
||||
.pyramid-layer.e2e { background: #fee2e2; color: #991b1b; }
|
||||
.pyramid-layer.integration { background: #fef3c7; color: #92400e; }
|
||||
.pyramid-layer.unit { background: #d1fae5; color: #065f46; }
|
||||
|
||||
:root.dark .pyramid-layer.e2e { background: #450a0a; color: #fca5a5; }
|
||||
:root.dark .pyramid-layer.integration { background: #451a03; color: #fcd34d; }
|
||||
:root.dark .pyramid-layer.unit { background: #022c22; color: #6ee7b7; }
|
||||
|
||||
.detail-card {
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-card.e2e { background: #fef2f2; border: 1px solid #fecaca; }
|
||||
.detail-card.integration { background: #fffbeb; border: 1px solid #fde68a; }
|
||||
.detail-card.unit { background: #ecfdf5; border: 1px solid #a7f3d0; }
|
||||
|
||||
:root.dark .detail-card.e2e { background: #1c0606; border-color: #7f1d1d; }
|
||||
:root.dark .detail-card.integration { background: #1c1303; border-color: #78350f; }
|
||||
:root.dark .detail-card.unit { background: #031c14; border-color: #065f46; }
|
||||
|
||||
.detail-card h4 { margin: 0 0 0.6rem; font-size: 1rem; }
|
||||
|
||||
.detail-card table { width: 100%; font-size: 0.85rem; border-collapse: collapse; }
|
||||
.detail-card td { padding: 4px 8px; border-bottom: 1px solid var(--vp-c-divider); }
|
||||
.row-label { font-weight: 600; white-space: nowrap; width: 80px; color: var(--vp-c-text-2); }
|
||||
|
||||
.example { margin-top: 0.6rem; font-size: 0.83rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.pyramid-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.dot.e2e { background: #ef4444; }
|
||||
.dot.unit { background: #10b981; }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="web-security-demo">
|
||||
<div class="demo-label">Web 安全漏洞演示(教育用途)── 点击切换漏洞类型</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(v, i) in vulns"
|
||||
:key="i"
|
||||
class="tab"
|
||||
:class="{ active: current === i }"
|
||||
@click="current = i"
|
||||
>{{ v.icon }} {{ v.name }}</button>
|
||||
</div>
|
||||
|
||||
<div class="vuln-card">
|
||||
<div class="attack-flow">
|
||||
<div class="flow-title">攻击流程</div>
|
||||
<div class="flow-steps">
|
||||
<div v-for="(s, j) in vulns[current].flow" :key="j" class="flow-step">
|
||||
<span class="step-num">{{ j + 1 }}</span>
|
||||
<span class="step-text">{{ s }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-compare">
|
||||
<div class="code-col bad">
|
||||
<div class="col-title">❌ 有漏洞的代码</div>
|
||||
<pre><code>{{ vulns[current].bad }}</code></pre>
|
||||
</div>
|
||||
<div class="code-col good">
|
||||
<div class="col-title">✅ 修复后的代码</div>
|
||||
<pre><code>{{ vulns[current].good }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="defense-tip">
|
||||
<strong>防御要点:</strong>{{ vulns[current].defense }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const current = ref(0)
|
||||
|
||||
const vulns = [
|
||||
{
|
||||
name: 'XSS',
|
||||
icon: '💉',
|
||||
flow: [
|
||||
'攻击者在输入框提交恶意脚本',
|
||||
'服务器未过滤直接存入数据库',
|
||||
'其他用户访问页面时脚本被执行',
|
||||
'用户 Cookie/数据被窃取'
|
||||
],
|
||||
bad: '// 直接插入用户输入(危险!)\nel.innerHTML = userInput\n// 如果 userInput = \'<scr\' + \'ipt>steal(cookie)</scr\' + \'ipt>\'\n// 脚本会被执行!',
|
||||
good: `// 使用 textContent 安全插入
|
||||
el.textContent = userInput
|
||||
// 或使用框架自动转义
|
||||
// Vue: {{ userInput }} 自动转义
|
||||
// React: {userInput} 自动转义`,
|
||||
defense: '永远不要信任用户输入。使用框架自带的转义机制,避免 innerHTML,对输出进行编码。'
|
||||
},
|
||||
{
|
||||
name: 'SQL 注入',
|
||||
icon: '🗄️',
|
||||
flow: [
|
||||
'攻击者在登录框输入特殊字符串',
|
||||
'字符串被拼接进 SQL 语句',
|
||||
'数据库执行了被篡改的查询',
|
||||
'攻击者绕过认证或获取数据'
|
||||
],
|
||||
bad: `// 字符串拼接 SQL(危险!)
|
||||
const sql = "SELECT * FROM users " +
|
||||
"WHERE name='" + username + "'" +
|
||||
" AND pass='" + password + "'"
|
||||
// 输入: admin' OR '1'='1
|
||||
// 变成: WHERE name='admin' OR '1'='1'`,
|
||||
good: `// 使用参数化查询(安全)
|
||||
const sql = "SELECT * FROM users " +
|
||||
"WHERE name = ? AND pass = ?"
|
||||
db.query(sql, [username, password])
|
||||
// 参数被安全转义,无法注入`,
|
||||
defense: '始终使用参数化查询或 ORM,永远不要拼接 SQL 字符串。'
|
||||
},
|
||||
{
|
||||
name: 'CSRF',
|
||||
icon: '🎭',
|
||||
flow: [
|
||||
'用户登录了银行网站(有 Cookie)',
|
||||
'用户访问了恶意网站',
|
||||
'恶意网站自动发起转账请求',
|
||||
'浏览器自动携带 Cookie,请求成功'
|
||||
],
|
||||
bad: '<!-- 恶意网站的隐藏表单 -->\n<form action="https://bank.com/transfer"\n method="POST" id="evil">\n <input name="to" value="attacker" />\n <input name="amount" value="10000" />\n</form>\n<scr' + 'ipt>document.getElementById(\'evil\')\n .submit()</scr' + 'ipt>',
|
||||
good: `// 服务端:生成并验证 CSRF Token
|
||||
app.post('/transfer', (req, res) => {
|
||||
if (req.body.token !== req.session.csrf) {
|
||||
return res.status(403).send('拒绝')
|
||||
}
|
||||
// 执行转账...
|
||||
})
|
||||
// 同时设置 SameSite Cookie 属性`,
|
||||
defense: '使用 CSRF Token、设置 SameSite Cookie 属性、验证 Referer/Origin 头。'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.web-security-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
|
||||
.tabs { display: flex; gap: 6px; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
|
||||
.tab.active { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
|
||||
|
||||
.attack-flow { margin-bottom: 12px; }
|
||||
.flow-title { font-size: 0.8rem; font-weight: 600; color: var(--vp-c-text-2); margin-bottom: 6px; }
|
||||
.flow-steps { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.flow-step { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; padding: 4px 8px; background: var(--vp-c-bg); border-radius: 4px; }
|
||||
.flow-step::after { content: '→'; color: var(--vp-c-text-3); margin-left: 4px; }
|
||||
.flow-step:last-child::after { content: ''; }
|
||||
.step-num { width: 18px; height: 18px; border-radius: 50%; background: var(--vp-c-brand); color: #fff; font-size: 0.7rem; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
|
||||
.code-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
|
||||
@media (max-width: 640px) { .code-compare { grid-template-columns: 1fr; } }
|
||||
.code-col { border-radius: 6px; overflow: hidden; }
|
||||
.col-title { font-size: 0.72rem; padding: 4px 10px; border-bottom: 1px solid var(--vp-c-divider); }
|
||||
.code-col.bad .col-title { background: #fef2f2; color: #991b1b; }
|
||||
.code-col.good .col-title { background: #ecfdf5; color: #065f46; }
|
||||
:root.dark .code-col.bad .col-title { background: #1c0606; color: #fca5a5; }
|
||||
:root.dark .code-col.good .col-title { background: #031c14; color: #6ee7b7; }
|
||||
.code-col pre { margin: 0; padding: 8px; font-size: 0.78rem; line-height: 1.5; overflow-x: auto; background: var(--vp-c-bg); }
|
||||
|
||||
.defense-tip { font-size: 0.83rem; padding: 8px; background: var(--vp-c-bg); border-radius: 4px; border-left: 3px solid var(--vp-c-brand); }
|
||||
</style>
|
||||
@@ -645,6 +645,22 @@ import TypeInferenceDemo from './components/appendix/typescript-intro/TypeInfere
|
||||
import SerializationDemo from './components/appendix/server-backend/SerializationDemo.vue'
|
||||
import HttpProtocolDemo from './components/appendix/server-backend/HttpProtocolDemo.vue'
|
||||
|
||||
// Engineering Excellence Components
|
||||
import CodeSmellDemo from './components/appendix/engineering-excellence/CodeSmellDemo.vue'
|
||||
import RefactoringDemo from './components/appendix/engineering-excellence/RefactoringDemo.vue'
|
||||
import TestPyramidDemo from './components/appendix/engineering-excellence/TestPyramidDemo.vue'
|
||||
import TDDCycleDemo from './components/appendix/engineering-excellence/TDDCycleDemo.vue'
|
||||
import DesignPatternCatalogDemo from './components/appendix/engineering-excellence/DesignPatternCatalogDemo.vue'
|
||||
import PatternPlaygroundDemo from './components/appendix/engineering-excellence/PatternPlaygroundDemo.vue'
|
||||
import WebSecurityDemo from './components/appendix/engineering-excellence/WebSecurityDemo.vue'
|
||||
import SecurityChecklistDemo from './components/appendix/engineering-excellence/SecurityChecklistDemo.vue'
|
||||
import DocStructureDemo from './components/appendix/engineering-excellence/DocStructureDemo.vue'
|
||||
import TechWritingPracticeDemo from './components/appendix/engineering-excellence/TechWritingPracticeDemo.vue'
|
||||
import OpenSourceWorkflowDemo from './components/appendix/engineering-excellence/OpenSourceWorkflowDemo.vue'
|
||||
import LicenseComparisonDemo from './components/appendix/engineering-excellence/LicenseComparisonDemo.vue'
|
||||
import TechRadarDemo from './components/appendix/engineering-excellence/TechRadarDemo.vue'
|
||||
import DecisionMatrixDemo from './components/appendix/engineering-excellence/DecisionMatrixDemo.vue'
|
||||
|
||||
// Data Components
|
||||
import SqlDemo from './components/appendix/data/SqlDemo.vue'
|
||||
import DataModelsDemo from './components/appendix/data/DataModelsDemo.vue'
|
||||
@@ -1321,6 +1337,22 @@ export default {
|
||||
app.component('ABTestingDemo', ABTestingDemo)
|
||||
app.component('DataAnalysisDemo', DataAnalysisDemo)
|
||||
app.component('DataTrackingDemo', DataTrackingDemo)
|
||||
|
||||
// Engineering Excellence Components Registration
|
||||
app.component('CodeSmellDemo', CodeSmellDemo)
|
||||
app.component('RefactoringDemo', RefactoringDemo)
|
||||
app.component('TestPyramidDemo', TestPyramidDemo)
|
||||
app.component('TDDCycleDemo', TDDCycleDemo)
|
||||
app.component('DesignPatternCatalogDemo', DesignPatternCatalogDemo)
|
||||
app.component('PatternPlaygroundDemo', PatternPlaygroundDemo)
|
||||
app.component('WebSecurityDemo', WebSecurityDemo)
|
||||
app.component('SecurityChecklistDemo', SecurityChecklistDemo)
|
||||
app.component('DocStructureDemo', DocStructureDemo)
|
||||
app.component('TechWritingPracticeDemo', TechWritingPracticeDemo)
|
||||
app.component('OpenSourceWorkflowDemo', OpenSourceWorkflowDemo)
|
||||
app.component('LicenseComparisonDemo', LicenseComparisonDemo)
|
||||
app.component('TechRadarDemo', TechRadarDemo)
|
||||
app.component('DecisionMatrixDemo', DecisionMatrixDemo)
|
||||
},
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
|
||||
@@ -1,224 +1,544 @@
|
||||
# 计算机网络:从输入网址到返回结果的过程
|
||||
# 浏览器是一个操作系统
|
||||
|
||||
::: tip 🎯 核心问题
|
||||
**当你舒服地靠在沙发上,在手机浏览器里输入 `www.google.com` 并按下回车,为什么几百毫秒后,搜索结果就能准确无误地出现在你的屏幕上?**
|
||||
::: tip 前言
|
||||
你每天都在用浏览器——看视频、刷新闻、在线办公。但你有没有想过:**当你在地址栏输入一个网址并按下回车,背后发生了什么?**
|
||||
|
||||
在上一章中,我们知道了数据是如何被编码成 0 和 1 并通过海底光缆传输的。但这还不够。互联网上的服务器浩如烟海,你的手机是怎么在茫茫机海中精准找到 Google 的服务器,商量好暗号,并成功把页面要回来的呢?
|
||||
这篇文章会用**"网购"**的生活化比喻,配合**真实的技术过程**,带你一步步理解浏览器如何将一行网址变成丰富多彩的页面。
|
||||
|
||||
这个看似无比简单的"敲回车"动作,背后其实隐藏着一个精密到令人震撼的跨国"快递接力系统"。本章,我们不讲枯燥的八股文概念,而是顺着**"填写购物单 -> 查地址簿 -> 打电话确认 -> 寄包裹 -> 自己拆解组装"**这条主线,带你零基础看清网络世界的全貌。
|
||||
读完这篇,你就能:
|
||||
- 理解从输入网址到显示页面的完整流程
|
||||
- 掌握 URL、DNS、TCP、HTTP 等核心概念
|
||||
- 了解浏览器如何渲染页面
|
||||
- 知道静态网站和动态网站的区别
|
||||
|
||||
**无需编程基础**,只需要你平时网购的经验即可。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | URL 解析 | 网址的结构和作用 |
|
||||
| **第 2 章** | DNS 查询 | 域名如何转换成 IP 地址 |
|
||||
| **第 3 章** | TCP 握手 | 如何建立可靠的连接 |
|
||||
| **第 4 章** | HTTP 通信 | 浏览器和服务器如何对话 |
|
||||
| **第 5 章** | 浏览器渲染 | 代码如何变成画面 |
|
||||
| **第 6 章** | 静态 vs 动态 | 网页内容的生成方式 |
|
||||
|
||||
---
|
||||
|
||||
## 0. 引言:当你按下回车键的那一刻
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**当你在浏览器输入网址并按下回车,后台发生了什么?** 为什么有的网页打开很快,有的很慢?为什么有时候会出现"找不到服务器"的错误?
|
||||
:::
|
||||
|
||||
### 生活比喻:一次网购之旅
|
||||
|
||||
想象你正在进行一次**网购**。整个过程可以分为 5 个步骤:
|
||||
|
||||
<div style="display: flex; gap: 20px; margin: 20px 0;">
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**🛒 第 1 步:填写订单**
|
||||
选好商品,确认收货地址
|
||||
|
||||
</div>
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**🗺️ 第 2 步:查找仓库**
|
||||
系统找到具体的发货仓库
|
||||
|
||||
</div>
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**📞 第 3 步:建立通道**
|
||||
确认仓库营业且能发货
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 20px; margin: 20px 0;">
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**🚚 第 4 步:仓库发货**
|
||||
快递员把包裹送上门
|
||||
|
||||
</div>
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**🎁 第 5 步:拆箱体验**
|
||||
打开包裹,看到心仪的商品
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
**访问网页的过程和网购惊人地相似!**
|
||||
|
||||
当你在浏览器输入 `google.com` 并按下回车,你就是那个"买家",浏览器通过一系列操作,最终把远方服务器上的"商品"(网页内容)送到你的屏幕上。
|
||||
|
||||
<UrlToBrowserQuickStart />
|
||||
|
||||
::: info 💡 核心启示
|
||||
理解浏览器工作原理的关键是:**把复杂的技术过程映射到熟悉的生活场景**。网购的 5 个步骤完美对应了浏览器访问网页的 5 个技术阶段。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 第一步:填写购物单 (URL 解析)
|
||||
## 1. 第一步:填写"订单" —— URL 解析
|
||||
|
||||
**目标**:把人类能看懂的网址,翻译成浏览器能理解的结构化信息。
|
||||
::: tip 🤔 核心问题
|
||||
**为什么网址要写成这样?** `https://www.example.com:8080/path/page.html?id=123#section` — 这串字符到底有什么含义?
|
||||
:::
|
||||
|
||||
当你在地址栏中输入 `https://www.google.com/search` 时,浏览器第一步必须先把你输入的这段"人类文字",仔细拆解成它能看懂的标准化字段。
|
||||
### 生活比喻:填写购物单
|
||||
|
||||
这就像是你准备去商店买东西,首先要在**购物单**上写清楚:用什么交通工具去、去哪家店、拿什么货。
|
||||
假设你只在订单上写"买鞋子",仓库肯定不知道发哪双。你需要写清楚:
|
||||
|
||||
- **店铺类型**(官方旗舰店/普通店)
|
||||
- **店铺名称**(Nike 官方店)
|
||||
- **商品位置**(男鞋区/跑鞋系列)
|
||||
- **具体型号**(Air Max 90)
|
||||
- **备注信息**(我要红色的)
|
||||
|
||||
### 真实过程:浏览器解析 URL
|
||||
|
||||
**URL(Uniform Resource Locator,统一资源定位符)**就是浏览器世界的"商品定位码"。当你在地址栏输入 `https://www.example.com:8080/path/page.html?id=123#section`,浏览器会立即拆解它:
|
||||
|
||||
| URL 部分 | 示例值 | 网购类比 | 技术作用 |
|
||||
| -------------------------- | -------------------- | -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **协议** `https://` | 安全超文本传输协议 | **物流方式**:保密配送(HTTPS)vs 普通配送(HTTP) | 决定使用什么规则通信。`http` 是普通传输,`https` 是加密传输 |
|
||||
| **域名** `www.example.com` | 服务器的人类可读名字 | **店铺名称**:京东超市 | 告诉浏览器要找哪台服务器。域名是为了让人记住,最终要转换成 IP 地址 |
|
||||
| **端口** `:8080` | 服务器的具体"门牌号" | **柜台编号**:3号柜台(默认不写) | 服务器上可能有多个服务,端口指定访问哪一个。HTTP 默认 80,HTTPS 默认 443 |
|
||||
| **路径** `/path/page.html` | 服务器上的文件位置 | **货架位置**:日用品区/第三排 | 指定服务器上的具体资源位置 |
|
||||
| **查询参数** `?id=123` | 附加信息 | **订单备注**:红色、XL码 | 传递给服务器的额外数据,如搜索关键词、页码等 |
|
||||
| **锚点** `#section` | 页面内的位置 | **说明书页码**:翻到第5页 | 页面加载后自动滚动到指定位置,不发送给服务器 |
|
||||
|
||||
<UrlParserDemo />
|
||||
|
||||
**💡 核心原理解析:URL是怎么分工的?**
|
||||
|
||||
- **交通方式(Protocol/协议)**:比如开头写的 `https://`。这代表你要求坐安全级别最高的"运钞车"(加密通信)去。如果是老式的 `http://`,就相当于坐敞篷车,你一路上买什么都会被别人看光。
|
||||
- **店铺名(Host/主机名)**:比如 `www.google.com`。这就是你要去哪家店(也就是服务器的域名)。
|
||||
- **具体货架(Path/路径)**:比如后面的 `/search`。这代表进了店门之后,你要去哪个房间拿具体的哪份文件。
|
||||
|
||||
**这一步完成了什么?** 浏览器现在知道了:我要用 HTTPS 协议,去 `www.google.com` 这个域名对应的服务器,获取 `/search` 路径下的内容。
|
||||
|
||||
**但问题来了**:浏览器知道了域名,但网络世界只认数字 IP 地址。就像你知道"王府井大饭店",但司机需要 GPS 坐标。下一步,我们需要把域名转换成 IP 地址。
|
||||
::: info 💡 关键理解
|
||||
URL 的存在是为了让**人类**能记住和输入。计算机最终需要的是 **IP 地址**(就像快递员最终需要的是具体的仓库地址,而不是"Nike 官方店"这个名字)。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 第二步:查地址簿 (DNS 解析)
|
||||
## 2. 第二步:查"地址簿" —— DNS 查询
|
||||
|
||||
**上一步完成了**:浏览器拆解了 URL,知道了目标域名是 `www.google.com`。
|
||||
::: tip 🤔 核心问题
|
||||
**为什么浏览器能找到网站?** 你输入的是人类可读的域名(如 `baidu.com`),但计算机真正需要的是数字地址(IP)。这中间发生了什么?
|
||||
:::
|
||||
|
||||
**这一步要实现**:把域名转换成 IP 地址,让浏览器知道服务器的精确位置。
|
||||
### 生活比喻:查仓库地址
|
||||
|
||||
**目的**:网络世界的底层路由器(负责指路的交警)根本不懂英文,它们**只认数字**,也就是所谓的 **IP 地址(如 142.250.80.46)**。
|
||||
你下单写的是"Nike 官方店",但物流系统不知道仓库在哪。它需要查地址簿:
|
||||
|
||||
1. 先查**常用地址**(最近买过这家吗)→ 浏览器缓存
|
||||
2. 没有的话问**小区快递点**(他们知道大区域的分配)→ 本地 DNS 服务器
|
||||
3. 问**总部调度中心**(知道.com类店铺归谁管)→ 根域名服务器
|
||||
4. 问**品牌管理处**(最终找到 Nike 店铺的真实发货仓库)→ 权威域名服务器
|
||||
|
||||
### 真实过程:DNS 分层查询
|
||||
|
||||
**DNS(Domain Name System,域名系统)**是互联网的"分布式地址簿查询系统"。由于全球有数十亿个域名,采用分层架构来分散查询压力:
|
||||
|
||||
```
|
||||
你(浏览器)
|
||||
↓ 问:google.com 的 IP 是多少?
|
||||
本地 DNS 服务器(你的网络运营商,如电信/联通)
|
||||
↓ 问:.com 归谁管?
|
||||
根域名服务器(全球13组根服务器,管理所有顶级域)
|
||||
↓ 告诉:去问 .com 的管理者
|
||||
顶级域服务器(Verisign 管理 .com)
|
||||
↓ 告诉:去问 google.com 的管理者
|
||||
权威域名服务器(Google 自己的 DNS 服务器)
|
||||
↓ 告诉:google.com 的 IP 是 142.250.80.46
|
||||
返回 IP 地址给浏览器
|
||||
```
|
||||
|
||||
**查询类型说明:**
|
||||
|
||||
- **递归查询(Recursive Query)**:浏览器只发一次请求,本地 DNS 负责层层查询后返回结果
|
||||
- **迭代查询(Iterative Query)**:每一层只告诉下一层去哪查,浏览器需要多次查询
|
||||
- **缓存机制**:查询结果会被缓存,下次直接返回,大大加速访问
|
||||
|
||||
<DnsLookupDemo />
|
||||
|
||||
**💡 核心原理解析:找"114查号台"**
|
||||
::: info 💡 为什么需要这么多层?
|
||||
想象一下如果全世界只有一个地址簿,几十亿人同时查,早就崩溃了。分层设计让每个层级只管理自己的"辖区",既高效又可靠。
|
||||
|
||||
既然必须用 IP 地址,浏览器就会走一个叫做 **DNS (Domain Name System)** 的打听流程:
|
||||
|
||||
1. **翻自己的备忘录(本地缓存)**:浏览器会先翻翻自己的浏览历史,看看前几天是不是刚去过这家店,记没记过它的数字地址。如果记了,直接用。
|
||||
2. **打电话给查号台(递归查询)**:如果实在没见过,它就会向互联网的"总查号台"(通常由你的宽带运营商提供,比如联通、电信的 DNS 服务器)发请求:"你好,请帮我查一下,google.com 对应的数字坐标是几?"
|
||||
3. **拿到坐标**:查号台通过逐级查询,最终把一个准确的 IP 地址(如 `142.250.80.46`)发回给你的手机。
|
||||
|
||||
**这一步完成了什么?** 浏览器现在拿到了 Google 服务器的精确 IP 地址 `142.250.80.46`。
|
||||
|
||||
**但问题来了**:有了 IP 地址就能直接发请求了吗?万一服务器宕机了呢?万一网线断了呢?如果直接发请求,对方没收到,就成了鸡同鸭讲。下一步,我们需要先确认双方能正常通信。
|
||||
这就是互联网设计的核心思想:**分布式系统**。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 第三步:打电话确认 (TCP 三次握手)
|
||||
## 3. 第三步:打电话确认 —— TCP 三次握手
|
||||
|
||||
**上一步完成了**:浏览器通过 DNS 查询,拿到了服务器的 IP 地址 `142.250.80.46`。
|
||||
::: tip 🤔 核心问题
|
||||
**为什么需要"三次握手"?** 找到服务器地址后,为什么不能直接发送数据?为什么要先进行三次通信?
|
||||
:::
|
||||
|
||||
**这一步要实现**:建立一条可靠的通信通道,确保双方都能收发数据。
|
||||
### 生活比喻:建立物流通道
|
||||
|
||||
**目的**:在正式传输数据之前,必须先确认"对方在线"且"双方收发通道都正常"。这就像打电话前要先确认"喂,能听到吗?"
|
||||
假设物流车直接开到仓库,结果:
|
||||
|
||||
- 仓库关门了 → 白跑一趟
|
||||
- 仓库爆仓不接单 → 无法发货
|
||||
- 找不到卸货口 → 无法对接
|
||||
|
||||
**所以在真正发货之前,必须先建立可靠的运输通道**。
|
||||
|
||||
### 真实过程:TCP 三次握手
|
||||
|
||||
**TCP(Transmission Control Protocol,传输控制协议)**是确保数据可靠传输的规则。在传输商品(数据)前,必须通过"三次握手"建立连接:
|
||||
|
||||
```
|
||||
客户端(你的电脑) 服务器(商家仓库)
|
||||
| |
|
||||
|--- SYN=1 --------------------->| 第1次:你好,我在家,准备收货!(SYN)
|
||||
| |
|
||||
|<-- SYN=1, ACK=1 ---------------| 第2次:收到!我也准备好发货了,你在家吗?(SYN-ACK)
|
||||
| |
|
||||
|--- ACK=1 --------------------->| 第3次:在的!请发货吧。(ACK)
|
||||
| |
|
||||
===== 通道建立,开始发货 =====
|
||||
```
|
||||
|
||||
**为什么是三次,不是两次?**
|
||||
|
||||
- **第一次(SYN)**:客户端证明自己能发送
|
||||
- **第二次(SYN-ACK)**:服务器证明自己能接收和发送
|
||||
- **第三次(ACK)**:客户端证明自己能接收
|
||||
|
||||
三次握手确保:**双方都能发、双方都能收** —— 四个条件都满足,才能可靠传输。
|
||||
|
||||
**TCP 还负责:**
|
||||
|
||||
- **数据分包**:大数据拆成小数据包传输
|
||||
- **顺序重组**:确保数据包按正确顺序组装
|
||||
- **错误重传**:丢包后自动重新发送
|
||||
- **流量控制**:根据网络状况调整发送速度
|
||||
|
||||
<TcpHandshakeDemo />
|
||||
|
||||
**💡 核心原理解析:为什么非得是"三"次?**
|
||||
|
||||
不要被专业名词吓到,它完全可以在现实生活中还原。想象一下你给朋友打电话:
|
||||
> **HTTPS 的额外步骤**:如果是 HTTPS(安全的网站),在 TCP 握手后还会进行 **TLS 握手**(1-RTT 或 2-RTT),双方交换加密密钥,确保之后的对话内容只有双方能看懂,就像用暗语通话。
|
||||
|
||||
---
|
||||
|
||||
### 第一次握手:SYN(同步请求)
|
||||
## 4. 第四步:"买家"和"商家"的对话 —— HTTP 请求与响应
|
||||
|
||||
**浏览器发送 SYN 包**
|
||||
::: tip 🤔 核心问题
|
||||
**浏览器和服务器在说什么?** 建立连接后,浏览器如何"告诉"服务器它想要什么?服务器又如何"回应"?
|
||||
:::
|
||||
|
||||
就像你拨通朋友电话后说的第一句话:"喂,你好,能听到我说话吗?"
|
||||
### 生活比喻:仓库发货
|
||||
|
||||
- **SYN** 是 **Synchronize**(同步)的缩写
|
||||
- 浏览器生成一个随机数字(比如 `Seq = 100`),告诉服务器:"我要开始建立连接了,我的初始序号是 100"
|
||||
- 这个序号用来标记后续发送的数据顺序,防止乱序
|
||||
物流车到达仓库:"这是订单(HTTP请求),**我要取回商品(网页 HTML 源代码)!**"
|
||||
仓库管理员核对:"订单有效,这是你要的包裹(**HTML 文件**),请拿好。"
|
||||
|
||||
**这一步确认了什么?** 服务器收到了浏览器的消息 → 浏览器的**发送通道**正常。
|
||||
### 真实过程:HTTP 协议通信
|
||||
|
||||
---
|
||||
**HTTP(HyperText Transfer Protocol,超文本传输协议)**是浏览器和服务器之间的"对话规则"。通道建立后,浏览器发送**取货请求**,**核心目标是拿回网页的源代码(HTML 文件)**:
|
||||
|
||||
### 第二次握手:SYN-ACK(同步+确认)
|
||||
**HTTP 请求示例:**
|
||||
|
||||
**服务器回复 SYN-ACK 包**
|
||||
```http
|
||||
GET /index.html HTTP/1.1 ← 请求方法 + 路径 + 协议版本
|
||||
Host: www.example.com ← 目标主机(支持虚拟主机,一台服务器可托管多个网站)
|
||||
User-Agent: Chrome/120.0 ← 客户端标识(服务器可据此返回适配内容)
|
||||
Accept: text/html,application/xhtml+xml ← 可接受的响应格式
|
||||
Accept-Language: zh-CN,zh;q=0.9 ← 偏好的语言
|
||||
Accept-Encoding: gzip, deflate ← 支持的压缩格式
|
||||
Connection: keep-alive ← 保持连接(复用 TCP 连接)
|
||||
Cookie: session_id=abc123 ← 身份凭证
|
||||
```
|
||||
|
||||
就像朋友回答:"喂喂,我能听到你!你也能听到我吗?"
|
||||
::: tip 💡 开发者顿悟:这不就是 API 吗?
|
||||
**一模一样!**
|
||||
你平时写的 API 调用(`fetch` / `axios`)和浏览器访问网页,在 **HTTP 层面完全是同一个东西**。
|
||||
|
||||
- **SYN-ACK** = **Synchronize + Acknowledge**(同步+确认)
|
||||
- 服务器做两件事:
|
||||
1. **ACK**:确认收到浏览器的消息(`Ack = 101`,表示"我期待收到你序号为 101 的下一个包")
|
||||
2. **SYN**:服务器也生成自己的随机序号(比如 `Seq = 200`),告诉浏览器:"我的初始序号是 200"
|
||||
它们都是发送一个请求,服务器返回一段文本数据。
|
||||
|
||||
**这一步确认了什么?** 浏览器收到了服务器的回复 → 服务器的**发送通道**正常,浏览器的**接收通道**正常。
|
||||
- 如果服务器给的是 **HTML**,浏览器就把它**画出来**(变成网页)。
|
||||
- 如果服务器给的是 **JSON**,你的代码就把它**存起来**(用于逻辑处理)。
|
||||
|
||||
---
|
||||
**根本就没有"两种"请求,只有同一种 HTTP 请求,只是返回的数据格式(Content-Type)不同而已。**
|
||||
这也是为什么理解了 HTTP,你就理解了 90% 的后端 API 原理。
|
||||
|
||||
### 第三次握手:ACK(确认)
|
||||
如果你想深入学习 API 开发,请参考 [API 章节](./api-intro.md)。
|
||||
:::
|
||||
|
||||
**浏览器回复 ACK 包**
|
||||
**常见 HTTP 方法:**
|
||||
|
||||
就像你回答:"能听到!那我们开始聊正事吧!"
|
||||
- `GET`:获取资源(安全、幂等,可被缓存)
|
||||
- `POST`:提交数据(创建资源,如注册、登录)
|
||||
- `PUT`:更新资源(完整替换)
|
||||
- `PATCH`:部分更新资源
|
||||
- `DELETE`:删除资源
|
||||
- `HEAD`:获取响应头(不返回主体,用于检查资源是否存在)
|
||||
|
||||
- **ACK** 是 **Acknowledge**(确认)的缩写
|
||||
- 浏览器回复:`Ack = 201`,表示"我期待收到你序号为 201 的下一个包"
|
||||
**服务器返回 HTTP 响应:**
|
||||
|
||||
**这一步确认了什么?** 服务器收到了浏览器的确认 → 服务器的**接收通道**也正常。
|
||||
```http
|
||||
HTTP/1.1 200 OK ← 协议版本 + 状态码 + 状态描述
|
||||
Date: Mon, 23 May 2025 12:00:00 GMT ← 服务器时间
|
||||
Content-Type: text/html; charset=UTF-8 ← 内容类型和编码
|
||||
Content-Length: 1234 ← 内容长度(字节)
|
||||
Cache-Control: max-age=3600 ← 缓存策略
|
||||
Set-Cookie: user_id=xyz789 ← 设置 Cookie
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
**HTTP 状态码分类:**
|
||||
### 为什么必须是三次?两次行不行?
|
||||
|
||||
| 状态码 | 类别 | 含义 | 生活类比 |
|
||||
**假设只有两次握手:**
|
||||
|
||||
1. 浏览器:"喂,能听到吗?"
|
||||
2. 服务器:"能听到!"
|
||||
|
||||
这时候服务器以为连接建立了,开始发送数据。但如果服务器的回复在半路丢了,浏览器根本没收到,浏览器就不会认为连接建立成功,也不会处理服务器发来的数据。
|
||||
|
||||
**结果**:服务器单方面认为连接已建立,疯狂发数据,但浏览器全当垃圾丢弃。服务器资源被白白浪费。
|
||||
|
||||
**三次握手的精妙之处**:
|
||||
|
||||
第三次握手的 ACK 包,**证明了浏览器确实收到了服务器的回复**。只有浏览器收到了,才会回复 ACK;服务器收到了这个 ACK,才能**100%确定**双方通道都是通的。
|
||||
|
||||
这就像打电话时的完整确认:
|
||||
- 你:"喂,能听到吗?"(SYN)
|
||||
- 朋友:"能听到,你呢?"(SYN-ACK)
|
||||
- 你:"我也能听到!"(ACK)
|
||||
|
||||
**这一步完成了什么?** 浏览器和服务器都确认了:**我能发给你,我能收到你的,你也能发给我,你也能收到我的**。一条可靠的 TCP 通道正式建立!
|
||||
|
||||
**现在可以开始了吗?** 通道已建立,下一步就是正式发送请求,获取网页内容。
|
||||
|
||||
---
|
||||
|
||||
## 第四步:寄包裹 (HTTP 请求与响应)
|
||||
|
||||
**上一步完成了**:通过 TCP 三次握手,建立了可靠的通信通道。
|
||||
|
||||
**这一步要实现**:正式发送请求,获取网页内容。
|
||||
|
||||
**目的**:浏览器向服务器"下单",服务器返回"货物"(网页内容)。
|
||||
| ----------- | ---------- | ---------------- | -------------------------------- |
|
||||
| **200** | 成功 | 请求成功处理 | "订单确认,马上发货" |
|
||||
| **301/302** | 重定向 | 资源已移动 | "本店搬家了,请去新店下单" |
|
||||
| **304** | 未修改 | 缓存仍有效 | "你上次买的还能用,不用重新发货" |
|
||||
| **400** | 客户端错误 | 请求格式错误 | "订单填写模糊,看不懂" |
|
||||
| **401** | 未授权 | 需要身份验证 | "请先出示会员卡" |
|
||||
| **403** | 禁止访问 | 权限不足 | "非内部人员禁止入内" |
|
||||
| **404** | 未找到 | 资源不存在 | "仓库里没这款商品" |
|
||||
| **500** | 服务器错误 | 服务器内部错误 | "仓库起火了,暂时发不了货" |
|
||||
| **502** | 网关错误 | 上游服务器无响应 | "总仓没货了,分仓也调不到" |
|
||||
| **503** | 服务不可用 | 服务器过载或维护 | "爆单了,暂停接单" |
|
||||
|
||||
<HttpExchangeDemo />
|
||||
|
||||
---
|
||||
**💡 核心原理解析:HTTP 请求与响应的小纸条**
|
||||
|
||||
浏览器会把你刚才写好的购物单,按照一种极为规范的格式打包(这叫 **HTTP 请求头**),正式塞进刚才建立好的 TCP 通道里,发给服务器。
|
||||
|
||||
- **买方发纸条(HTTP Request)**:
|
||||
浏览器发出的包裹里,写着大写的请求指令。如果是看网页就是 `GET`,如果是提交账号密码登录就是 `POST`。不仅如此,这张纸条里还附带了一些重要情报:"嗨,我是用 Mac 电脑的 Chrome 浏览器访问的哦,另外我只能听懂中文,请把给我的货也转换成中文。"(这些补充说明就被叫做 **请求 Headers**)。
|
||||
|
||||
- **卖方发纸条(HTTP Response)**:
|
||||
位于千里之外的服务器收到这包东西后,看了一眼:"哦,他要 `GET` 这个页面啊"。于是服务器飞速在自己的硬盘里找到相应的 HTML 网页代码打包好,在包裹最外面贴上一个标签:`200 OK`(意思是交易非常成功,你要的货全齐了),然后借由同一个通道,原路寄回给你的电脑。
|
||||
|
||||
> **小科普**:如果是找不到你要找得页面,服务器就会贴个 `404 Not Found` 的悲伤标签给你退回来。如果是服务器自己代码写错了挂掉了,就会贴个 `500 Server Error` 的崩溃标签。
|
||||
|
||||
**这一步完成了什么?** 浏览器收到了服务器返回的 HTML、CSS、JavaScript 代码(也就是网页的"原材料")。
|
||||
|
||||
**但问题来了**:这些代码只是文本,还不是你能看到的网页画面。下一步,浏览器需要把这些代码"翻译"成屏幕上的像素。
|
||||
|
||||
|
||||
## 5. 第五步:拆开"包裹" —— 浏览器渲染
|
||||
## 第五步:拆解组装 (浏览器渲染)
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**上一步完成了**:通过 HTTP 请求,浏览器获取了网页的源代码(HTML、CSS、JavaScript)。
|
||||
**代码怎么变成画面?** 服务器发来的是枯燥的 HTML/CSS/JavaScript 代码,浏览器如何把它们变成丰富多彩的网页?
|
||||
:::
|
||||
|
||||
### 生活比喻:拆箱与组装
|
||||
**这一步要实现**:把代码转换成屏幕上可见的网页画面。
|
||||
|
||||
你终于收到了快递包裹(HTTP 响应),但打开一看,里面不是现成的家具,而是一堆**零件**(HTML)和一本**组装说明书**(CSS)。作为"买家"(浏览器),你需要亲自动手组装:
|
||||
**目的**:将文本代码"翻译"成像素,让用户看到最终的网页。
|
||||
|
||||
1. **拆开包装**:取出所有零件,核对清单(解析 HTML → DOM 树)。
|
||||
2. **阅读说明**:看懂说明书,知道哪个零件该装哪、什么颜色(解析 CSS → CSSOM 树)。
|
||||
3. **分类整理**:挑出需要组装的零件,扔掉包装泡沫(`display: none`),准备组装(构建渲染树)。
|
||||
4. **测量位置**:用尺子量好房间尺寸,决定每个家具具体摆在哪(布局/回流)。
|
||||
5. **上色装饰**:给家具刷漆、贴贴纸(绘制)。
|
||||
6. **最终展示**:打扫干净,开灯展示(合成)。
|
||||
|
||||
### 真实过程:浏览器渲染引擎
|
||||
|
||||
浏览器收到的是 **HTML/CSS/JavaScript 代码**(枯燥的文本),但它要变成**像素画面**(精美的网页)。这个过程叫做**渲染(Rendering)**,由浏览器的**渲染引擎**(如 Chrome 的 Blink、Safari 的 WebKit)执行。
|
||||
|
||||
#### 步骤1:解析 HTML → 构建 DOM 树 (零件清单)
|
||||
|
||||
浏览器读取 HTML 字节流,将其解析为**DOM(Document Object Model,文档对象模型)树**。这就像把一堆散乱的零件整理成一个有层级关系的清单:
|
||||
|
||||
```html
|
||||
<!-- 原始 HTML -->
|
||||
<div class="header">标题</div>
|
||||
<div class="content">内容</div>
|
||||
```
|
||||
|
||||
```text
|
||||
DOM 树结构:
|
||||
Document
|
||||
└─ html
|
||||
└─ body
|
||||
├─ div.header ("标题")
|
||||
└─ div.content ("内容")
|
||||
```
|
||||
|
||||
#### 步骤2:解析 CSS → 构建 CSSOM 树 (说明书)
|
||||
|
||||
浏览器解析所有的 CSS(内联、外部文件),构建**CSSOM(CSS Object Model)树**。这就像理解说明书上的样式规则:
|
||||
|
||||
```css
|
||||
.header {
|
||||
color: blue;
|
||||
font-size: 24px;
|
||||
} /* 标题要是蓝色的 */
|
||||
.content {
|
||||
display: none;
|
||||
} /* 内容暂时隐藏 */
|
||||
```
|
||||
|
||||
#### 步骤3:合并 → 渲染树 (准备组装)
|
||||
|
||||
DOM 树 + CSSOM 树 = **渲染树 (Render Tree)**。
|
||||
关键点:**只有"可见"的元素才会在渲染树中**。
|
||||
|
||||
- `.header`:在渲染树中(可见)。
|
||||
- `.content`:**不在**渲染树中(因为 `display: none`,就像被扔掉的包装纸,不需要组装)。
|
||||
|
||||
#### 步骤4:布局 (Layout / Reflow) —— 测量尺寸
|
||||
|
||||
浏览器计算渲染树中每个节点在屏幕上的**精确坐标和大小**。
|
||||
|
||||
- "这个标题框宽 100px,高 50px,放在屏幕左上角 (0,0) 位置。"
|
||||
- 这个过程叫**重排 (Reflow)**。如果窗口大小变了(比如手机横屏),所有元素的位置都要重新计算,非常消耗性能。
|
||||
|
||||
#### 步骤5:绘制 (Paint) —— 上色
|
||||
|
||||
知道位置后,浏览器开始填充像素:画背景色、文字颜色、边框、阴影等。
|
||||
|
||||
#### 步骤6:合成 (Composite) —— 最终展示
|
||||
|
||||
现代浏览器会将页面分成多个**图层 (Layers)** 分别绘制(比如 3D 变换、滚动条独立图层),最后由 GPU 将它们像 Photoshop 图层一样叠加在一起,呈现在屏幕上。
|
||||
|
||||
<BrowserRenderingDemo />
|
||||
|
||||
::: info 💡 你知道吗?
|
||||
**💡 核心原理解析:毫秒级的画家**
|
||||
|
||||
此时你电脑收到的,仅仅是一大串干瘪枯燥的文本代码(HTML 骨架、CSS 色彩图纸、JS 交互动效代码)。这就像你网购了一箱子乐高,它给你的只有几千个塑料零件和一本极度复杂的说明书。
|
||||
|
||||
浏览器的组装过程堪比惊心动魄的全自动工厂流水线:
|
||||
|
||||
1. **搭骨架 (DOM 解析)**:工人先把 HTML 文件通读一遍,理清楚网页的结构。比如"这里要有一个标题框,那里要有三个图片框"。这个骨架叫做 DOM 树。
|
||||
2. **上颜色 (CSS 解析)**:紧接着看 CSS 文件,"哦,老王说标题框必须是红色的,图片框必须有圆角。"
|
||||
3. **几何计算排版 (Layout)**:结合骨架和颜色后,开始拿尺子计算。因为每个人的屏幕大小不一样,同样是三个图片框,在手机上只能竖着放,在电脑上可以横着放。必须计算出每一个像素块极其精确的摆放坐标。
|
||||
4. **上色绘制 (Paint)**:最后拿起了画笔,按照前面算出来的精确设计图,把真真切切的颜色和像素渲染到了你的显示器上!
|
||||
|
||||
**这一步完成了什么?** 浏览器把代码转换成了屏幕上的像素,用户终于看到了完整的网页!
|
||||
**布局和绘制**是浏览器最忙碌的时候。网页里的元素越多、结构越复杂,浏览器就需要花更多时间来计算位置和上色。这就是为什么有的复杂网页打开会卡顿的原因。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 5.5 网页是怎么"生成"的?静态网站 vs 动态网站
|
||||
## 完整流程回顾
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
让我们把整个过程串起来:
|
||||
**网页内容从哪里来?** 前面我们讲了浏览器如何渲染页面,但服务器上的 HTML 文件是怎么来的?是提前做好还是现做?
|
||||
:::
|
||||
|
||||
前面我们讲的都是浏览器如何"拆开包裹"——把服务器发来的 HTML/CSS/JS 渲染成页面。但你有没有想过一个问题:**服务器上那个 HTML 文件是怎么来的?**
|
||||
| 步骤 | 完成了什么 | 下一步需要什么 |
|
||||
|------|-----------|---------------|
|
||||
| **1. URL 解析** | 拆解网址,知道要去哪 | 需要把域名转成 IP |
|
||||
| **2. DNS 解析** | 拿到服务器 IP 地址 | 需要确认服务器在线 |
|
||||
| **3. TCP 握手** | 建立可靠通信通道 | 需要发送正式请求 |
|
||||
| **4. HTTP 交换** | 获取网页源代码 | 需要把代码转成画面 |
|
||||
| **5. 浏览器渲染** | 把代码渲染成像素 | ✅ 用户看到网页! |
|
||||
|
||||
答案是:**有两种方式**,这就是静态网站和动态网站的区别。
|
||||
|
||||
### 静态网站:提前做好、直接给你
|
||||
|
||||
想象你去超市买饼干。货架上的饼干都是工厂已经生产好的,你直接拿走就行,不需要等。
|
||||
|
||||
**静态网站**就是这样的"成品"——网页在服务器上已经准备好了,你访问时服务器直接把现成的 HTML 文件发给你,不做任何额外处理。
|
||||
|
||||
**特点:**
|
||||
- ✅ 访问速度快(服务器直接发文件,不用计算)
|
||||
- ✅ 制作简单(写好 HTML 就能用)
|
||||
- ✅ 承载力强(可以用 CDN 分发,多少人访问都不怕)
|
||||
- ❌ 内容难更新(想改内容就要重新生成文件)
|
||||
|
||||
**常见例子:** 公司介绍页、产品文档、帮助中心、个人博客
|
||||
|
||||
### 动态网站:现点现做、每次不同
|
||||
|
||||
这次想象你去餐厅点餐。厨师根据你的订单现做,你点宫保鸡丁不会给你上糖醋里脊。
|
||||
|
||||
**动态网站**就是你访问时才"现场制作"的页面——服务器收到你的请求后,去数据库查资料、计算数据,然后生成一个全新的 HTML 发给你。
|
||||
|
||||
**特点:**
|
||||
- ✅ 内容实时(购物车显示最新库存、新闻随时更新)
|
||||
- ✅ 因人而异(登录后看到你的个人信息)
|
||||
- ✅ 功能强大(搜索、评论、推荐、支付都能实现)
|
||||
- ❌ 访问速度慢(服务器需要时间计算)
|
||||
- ❌ 服务器压力大(同时很多人访问要排队)
|
||||
|
||||
**常见例子:** 淘宝、微博、在线银行、在线文档
|
||||
|
||||
**需要服务器吗?** 动态网站确实需要某种"后端"来生成内容,但形式多样:
|
||||
- **传统服务器**:自己买/租服务器(阿里云 ECS、AWS EC2)
|
||||
- **Serverless**:不用管服务器,云厂商帮你运行代码(AWS Lambda、阿里云函数计算、Cloudflare Workers)
|
||||
- **调用第三方 API**:支付用 Stripe、天气用气象局 API,自己不写后端代码
|
||||
|
||||
::: tip 💡 静动态结合
|
||||
现在很多网站是"混合"的:网页主体是静态的,但某些部分(比如评论区、搜索框)是动态加载的。JavaScript 可以在页面加载后调用 API 获取数据,实现"静态页面 + 动态功能"。
|
||||
:::
|
||||
|
||||
### 📊 静态 vs 动态,一对比就清楚
|
||||
|
||||
| | 静态网站 | 动态网站 |
|
||||
|---|---------|---------|
|
||||
| **怎么来的** | 提前做好,存服务器上 | 访问时现做 |
|
||||
| **像什么** | 超市货架上的商品 | 餐厅现点的菜 |
|
||||
| **速度** | 快 | 慢(需要计算) |
|
||||
| **能改内容吗** | 难(要重新生成) | 容易(后台直接改) |
|
||||
| **适合做什么** | 展示型内容(介绍页、文档) | 交互型应用(购物、社交) |
|
||||
| **典型例子** | 公司官网、帮助文档 | 淘宝、微信、在线银行 |
|
||||
|
||||
### 🤔 常见疑问
|
||||
|
||||
**Q: 静态网站是不是不能用 JavaScript?**
|
||||
|
||||
当然不是!轮播图、折叠菜单、表单验证这些交互功能,静态网站都能用 JavaScript 实现。我们说的"静态""动态",是指**页面内容是不是提前准备好的**,跟有没有交互功能是两回事。
|
||||
|
||||
**Q: 动态网站一定要自己买服务器吗?**
|
||||
|
||||
不一定。除了传统服务器,你还可以用 Serverless(云函数)、或者直接调用第三方 API。现在的趋势是"能不动服务器就不动"——用静态网站 + JavaScript 调用 API 的方式,既快又省成本。
|
||||
|
||||
::: tip 💡 重要提示
|
||||
无论静态网站还是动态网站,**浏览器渲染的原理都是一样的**!服务器发来的是什么,浏览器就渲染什么。区别只在于:
|
||||
- 静态网站:服务器发来的是"成品"
|
||||
- 动态网站:服务器发来的是"现做的"
|
||||
|
||||
作为前端开发者,你主要关注的是浏览器如何处理收到的内容,而不是服务器怎么生成的。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 6. 总结:一次完整的"网购"之旅
|
||||
## 结语:0.5 秒里发生了什么
|
||||
|
||||
::: tip 🎉 学完本章,你应该能
|
||||
敲下回车,等上半秒,页面就跳出来了——我们早就习惯了这个速度,甚至觉得慢。
|
||||
- 解释从输入网址到显示页面的完整流程
|
||||
- 理解 URL、DNS、TCP、HTTP 的作用和关系
|
||||
- 知道浏览器如何渲染页面
|
||||
- 区分静态网站和动态网站
|
||||
- 用生活化比喻向他人解释浏览器工作原理
|
||||
:::
|
||||
|
||||
让我们回顾整个旅程:
|
||||
但仔细想想,就在这眨眼的功夫里:
|
||||
|
||||
| 阶段 | 技术术语 | 网购类比 | 核心任务 | 关键技术 |
|
||||
- **第一步**:浏览器把你输入的网址拆开看懂
|
||||
- **第二步**:跑去问了好多台服务器才要到 IP 地址
|
||||
- **第三步**:跟大洋彼岸的服务器来回确认了三次"能听见吗"
|
||||
- **第四步**:把请求打包发过去,再等着收回来
|
||||
- **第五步**:最后还要把成千上万行代码瞬间组装成你能看到的画面
|
||||
| ----------- | ---------- | -------- | ------------------ | ------------------------------ |
|
||||
| **1. 解析** | URL 解析 | 填写订单 | 理解买家想买什么 | 协议、域名、端口、路径、参数 |
|
||||
| **2. 查询** | DNS 查询 | 查仓库址 | 找到店铺的发货仓库 | 递归/迭代查询、缓存机制 |
|
||||
| **3. 连接** | TCP 握手 | 建立通道 | 确保物流通畅 | 三次握手、序列号、流量控制 |
|
||||
| **4. 对话** | HTTP 交换 | 仓库发货 | 提交订单并收货 | 请求方法、状态码、头部字段 |
|
||||
| **5. 展示** | 浏览器渲染 | 拆箱组装 | 把商品展示出来 | DOM、CSSOM、渲染树、布局、绘制 |
|
||||
|
||||
**整个过程通常在几百毫秒内完成** —— 想想这有多么不可思议!
|
||||
这些步骤一环扣一环,**前一步的输出是后一步的输入**,中间哪个环节出问题,页面就打不开。而那些路由器、服务器、光缆,就默默在后台 24 小时运转,保证你每次滑动手机时,内容都能准时出现。
|
||||
|
||||
你的浏览器在不到1秒的时间里:
|
||||
下次等网页加载的时候,或许可以想想:这 0.5 秒,其实挺忙的。
|
||||
|
||||
- 解析了一个复杂的地址
|
||||
- 查询了分布在全球的 DNS 服务器
|
||||
- 和千里之外的服务器建立了可靠连接
|
||||
- 进行了一次完整的 HTTP 对话
|
||||
- 把枯燥的代码变成了精美的画面
|
||||
|
||||
这就是互联网的魅力:**复杂的技术,简单的体验**。
|
||||
|
||||
::: info 💡 进阶学习
|
||||
如果你想深入了解某个环节,可以参考:
|
||||
- **API 开发**:[API 简介](./api-intro.md) - 学习如何设计和使用 API
|
||||
- **前端性能**:[前端性能优化](./frontend-performance.md) - 学习如何优化网页加载速度
|
||||
- **浏览器渲染**:[浏览器渲染管道](./browser-rendering-pipeline.md) - 深入了解渲染细节
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 7. 名词速查表 (Glossary)
|
||||
|
||||
| 名词 | 全称 | 简单解释 |
|
||||
| ----------- | ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| **URL** | Uniform Resource Locator | **统一资源定位符**。网页的"地址",告诉浏览器去哪里找资源。 |
|
||||
| **DNS** | Domain Name System | **域名系统**。互联网的"电话簿",把人类可读的域名转换成机器可读的 IP 地址。 |
|
||||
| **IP 地址** | Internet Protocol Address | **互联网协议地址**。每台联网设备的唯一"门牌号",如 `192.168.1.1`。 |
|
||||
| **TCP** | Transmission Control Protocol | **传输控制协议**。确保数据可靠传输的"规则",通过三次握手建立连接。 |
|
||||
| **HTTP** | HyperText Transfer Protocol | **超文本传输协议**。浏览器和服务器"对话"的规则。 |
|
||||
| **HTTPS** | HTTP Secure | **安全的 HTTP**。在 HTTP 基础上加了加密(TLS/SSL),保护数据安全。 |
|
||||
| **HTML** | HyperText Markup Language | **超文本标记语言**。网页的"骨架",定义内容的结构。 |
|
||||
| **CSS** | Cascading Style Sheets | **层叠样式表**。网页的"皮肤",定义内容的外观。 |
|
||||
| **DOM** | Document Object Model | **文档对象模型**。浏览器把 HTML 转换成的树形结构,方便操作。 |
|
||||
| **CSSOM** | CSS Object Model | **CSS 对象模型**。浏览器把 CSS 转换成的树形结构。 |
|
||||
| **渲染** | Rendering | 浏览器把代码转换成屏幕像素的过程。 |
|
||||
| **RTT** | Round Trip Time | **往返时间**。数据包从发送到接收确认的时间,影响网页加载速度。 |
|
||||
|
||||
---
|
||||
|
||||
::: tip 🎓 恭喜
|
||||
现在当你再次在地址栏输入网址并按下回车时,你已经能看到屏幕背后的那个忙碌而精彩的数字世界了。
|
||||
|
||||
你理解了:
|
||||
- 为什么有时候网页打不开(DNS 解析失败、服务器宕机)
|
||||
- 为什么有的网页快、有的慢(网络延迟、服务器性能、页面复杂度)
|
||||
- 浏览器是如何把代码变成画面的(渲染管道)
|
||||
|
||||
**这就是理解技术原理的价值** — 遇到问题时,你能知道从哪里找原因,而不是束手无策。
|
||||
:::
|
||||
:::
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
# 浏览器是一个操作系统
|
||||
|
||||
::: tip 前言
|
||||
你每天都在用浏览器——看视频、刷新闻、在线办公。但你有没有想过:**当你在地址栏输入一个网址并按下回车,背后发生了什么?**
|
||||
|
||||
这篇文章会用**"网购"**的生活化比喻,配合**真实的技术过程**,带你一步步理解浏览器如何将一行网址变成丰富多彩的页面。
|
||||
|
||||
读完这篇,你就能:
|
||||
- 理解从输入网址到显示页面的完整流程
|
||||
- 掌握 URL、DNS、TCP、HTTP 等核心概念
|
||||
- 了解浏览器如何渲染页面
|
||||
- 知道静态网站和动态网站的区别
|
||||
|
||||
**无需编程基础**,只需要你平时网购的经验即可。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | URL 解析 | 网址的结构和作用 |
|
||||
| **第 2 章** | DNS 查询 | 域名如何转换成 IP 地址 |
|
||||
| **第 3 章** | TCP 握手 | 如何建立可靠的连接 |
|
||||
| **第 4 章** | HTTP 通信 | 浏览器和服务器如何对话 |
|
||||
| **第 5 章** | 浏览器渲染 | 代码如何变成画面 |
|
||||
| **第 6 章** | 静态 vs 动态 | 网页内容的生成方式 |
|
||||
|
||||
---
|
||||
|
||||
## 0. 引言:当你按下回车键的那一刻
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**当你在浏览器输入网址并按下回车,后台发生了什么?** 为什么有的网页打开很快,有的很慢?为什么有时候会出现"找不到服务器"的错误?
|
||||
:::
|
||||
|
||||
### 生活比喻:一次网购之旅
|
||||
|
||||
想象你正在进行一次**网购**。整个过程可以分为 5 个步骤:
|
||||
|
||||
<div style="display: flex; gap: 20px; margin: 20px 0;">
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**🛒 第 1 步:填写订单**
|
||||
选好商品,确认收货地址
|
||||
|
||||
</div>
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**🗺️ 第 2 步:查找仓库**
|
||||
系统找到具体的发货仓库
|
||||
|
||||
</div>
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**📞 第 3 步:建立通道**
|
||||
确认仓库营业且能发货
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 20px; margin: 20px 0;">
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**🚚 第 4 步:仓库发货**
|
||||
快递员把包裹送上门
|
||||
|
||||
</div>
|
||||
<div style="flex: 1; padding: 16px; background: var(--vp-c-bg-alt); border-radius: 12px;">
|
||||
|
||||
**🎁 第 5 步:拆箱体验**
|
||||
打开包裹,看到心仪的商品
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
**访问网页的过程和网购惊人地相似!**
|
||||
|
||||
当你在浏览器输入 `google.com` 并按下回车,你就是那个"买家",浏览器通过一系列操作,最终把远方服务器上的"商品"(网页内容)送到你的屏幕上。
|
||||
|
||||
<UrlToBrowserQuickStart />
|
||||
|
||||
::: info 💡 核心启示
|
||||
理解浏览器工作原理的关键是:**把复杂的技术过程映射到熟悉的生活场景**。网购的 5 个步骤完美对应了浏览器访问网页的 5 个技术阶段。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 1. 第一步:填写"订单" —— URL 解析
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**为什么网址要写成这样?** `https://www.example.com:8080/path/page.html?id=123#section` — 这串字符到底有什么含义?
|
||||
:::
|
||||
|
||||
### 生活比喻:填写购物单
|
||||
|
||||
假设你只在订单上写"买鞋子",仓库肯定不知道发哪双。你需要写清楚:
|
||||
|
||||
- **店铺类型**(官方旗舰店/普通店)
|
||||
- **店铺名称**(Nike 官方店)
|
||||
- **商品位置**(男鞋区/跑鞋系列)
|
||||
- **具体型号**(Air Max 90)
|
||||
- **备注信息**(我要红色的)
|
||||
|
||||
### 真实过程:浏览器解析 URL
|
||||
|
||||
**URL(Uniform Resource Locator,统一资源定位符)**就是浏览器世界的"商品定位码"。当你在地址栏输入 `https://www.example.com:8080/path/page.html?id=123#section`,浏览器会立即拆解它:
|
||||
|
||||
| URL 部分 | 示例值 | 网购类比 | 技术作用 |
|
||||
| -------------------------- | -------------------- | -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **协议** `https://` | 安全超文本传输协议 | **物流方式**:保密配送(HTTPS)vs 普通配送(HTTP) | 决定使用什么规则通信。`http` 是普通传输,`https` 是加密传输 |
|
||||
| **域名** `www.example.com` | 服务器的人类可读名字 | **店铺名称**:京东超市 | 告诉浏览器要找哪台服务器。域名是为了让人记住,最终要转换成 IP 地址 |
|
||||
| **端口** `:8080` | 服务器的具体"门牌号" | **柜台编号**:3号柜台(默认不写) | 服务器上可能有多个服务,端口指定访问哪一个。HTTP 默认 80,HTTPS 默认 443 |
|
||||
| **路径** `/path/page.html` | 服务器上的文件位置 | **货架位置**:日用品区/第三排 | 指定服务器上的具体资源位置 |
|
||||
| **查询参数** `?id=123` | 附加信息 | **订单备注**:红色、XL码 | 传递给服务器的额外数据,如搜索关键词、页码等 |
|
||||
| **锚点** `#section` | 页面内的位置 | **说明书页码**:翻到第5页 | 页面加载后自动滚动到指定位置,不发送给服务器 |
|
||||
|
||||
<UrlParserDemo />
|
||||
|
||||
::: info 💡 关键理解
|
||||
URL 的存在是为了让**人类**能记住和输入。计算机最终需要的是 **IP 地址**(就像快递员最终需要的是具体的仓库地址,而不是"Nike 官方店"这个名字)。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 2. 第二步:查"地址簿" —— DNS 查询
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**为什么浏览器能找到网站?** 你输入的是人类可读的域名(如 `baidu.com`),但计算机真正需要的是数字地址(IP)。这中间发生了什么?
|
||||
:::
|
||||
|
||||
### 生活比喻:查仓库地址
|
||||
|
||||
你下单写的是"Nike 官方店",但物流系统不知道仓库在哪。它需要查地址簿:
|
||||
|
||||
1. 先查**常用地址**(最近买过这家吗)→ 浏览器缓存
|
||||
2. 没有的话问**小区快递点**(他们知道大区域的分配)→ 本地 DNS 服务器
|
||||
3. 问**总部调度中心**(知道.com类店铺归谁管)→ 根域名服务器
|
||||
4. 问**品牌管理处**(最终找到 Nike 店铺的真实发货仓库)→ 权威域名服务器
|
||||
|
||||
### 真实过程:DNS 分层查询
|
||||
|
||||
**DNS(Domain Name System,域名系统)**是互联网的"分布式地址簿查询系统"。由于全球有数十亿个域名,采用分层架构来分散查询压力:
|
||||
|
||||
```
|
||||
你(浏览器)
|
||||
↓ 问:google.com 的 IP 是多少?
|
||||
本地 DNS 服务器(你的网络运营商,如电信/联通)
|
||||
↓ 问:.com 归谁管?
|
||||
根域名服务器(全球13组根服务器,管理所有顶级域)
|
||||
↓ 告诉:去问 .com 的管理者
|
||||
顶级域服务器(Verisign 管理 .com)
|
||||
↓ 告诉:去问 google.com 的管理者
|
||||
权威域名服务器(Google 自己的 DNS 服务器)
|
||||
↓ 告诉:google.com 的 IP 是 142.250.80.46
|
||||
返回 IP 地址给浏览器
|
||||
```
|
||||
|
||||
**查询类型说明:**
|
||||
|
||||
- **递归查询(Recursive Query)**:浏览器只发一次请求,本地 DNS 负责层层查询后返回结果
|
||||
- **迭代查询(Iterative Query)**:每一层只告诉下一层去哪查,浏览器需要多次查询
|
||||
- **缓存机制**:查询结果会被缓存,下次直接返回,大大加速访问
|
||||
|
||||
<DnsLookupDemo />
|
||||
|
||||
::: info 💡 为什么需要这么多层?
|
||||
想象一下如果全世界只有一个地址簿,几十亿人同时查,早就崩溃了。分层设计让每个层级只管理自己的"辖区",既高效又可靠。
|
||||
|
||||
这就是互联网设计的核心思想:**分布式系统**。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 3. 第三步:打电话确认 —— TCP 三次握手
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**为什么需要"三次握手"?** 找到服务器地址后,为什么不能直接发送数据?为什么要先进行三次通信?
|
||||
:::
|
||||
|
||||
### 生活比喻:建立物流通道
|
||||
|
||||
假设物流车直接开到仓库,结果:
|
||||
|
||||
- 仓库关门了 → 白跑一趟
|
||||
- 仓库爆仓不接单 → 无法发货
|
||||
- 找不到卸货口 → 无法对接
|
||||
|
||||
**所以在真正发货之前,必须先建立可靠的运输通道**。
|
||||
|
||||
### 真实过程:TCP 三次握手
|
||||
|
||||
**TCP(Transmission Control Protocol,传输控制协议)**是确保数据可靠传输的规则。在传输商品(数据)前,必须通过"三次握手"建立连接:
|
||||
|
||||
```
|
||||
客户端(你的电脑) 服务器(商家仓库)
|
||||
| |
|
||||
|--- SYN=1 --------------------->| 第1次:你好,我在家,准备收货!(SYN)
|
||||
| |
|
||||
|<-- SYN=1, ACK=1 ---------------| 第2次:收到!我也准备好发货了,你在家吗?(SYN-ACK)
|
||||
| |
|
||||
|--- ACK=1 --------------------->| 第3次:在的!请发货吧。(ACK)
|
||||
| |
|
||||
===== 通道建立,开始发货 =====
|
||||
```
|
||||
|
||||
**为什么是三次,不是两次?**
|
||||
|
||||
- **第一次(SYN)**:客户端证明自己能发送
|
||||
- **第二次(SYN-ACK)**:服务器证明自己能接收和发送
|
||||
- **第三次(ACK)**:客户端证明自己能接收
|
||||
|
||||
三次握手确保:**双方都能发、双方都能收** —— 四个条件都满足,才能可靠传输。
|
||||
|
||||
**TCP 还负责:**
|
||||
|
||||
- **数据分包**:大数据拆成小数据包传输
|
||||
- **顺序重组**:确保数据包按正确顺序组装
|
||||
- **错误重传**:丢包后自动重新发送
|
||||
- **流量控制**:根据网络状况调整发送速度
|
||||
|
||||
<TcpHandshakeDemo />
|
||||
|
||||
> **HTTPS 的额外步骤**:如果是 HTTPS(安全的网站),在 TCP 握手后还会进行 **TLS 握手**(1-RTT 或 2-RTT),双方交换加密密钥,确保之后的对话内容只有双方能看懂,就像用暗语通话。
|
||||
|
||||
---
|
||||
|
||||
## 4. 第四步:"买家"和"商家"的对话 —— HTTP 请求与响应
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**浏览器和服务器在说什么?** 建立连接后,浏览器如何"告诉"服务器它想要什么?服务器又如何"回应"?
|
||||
:::
|
||||
|
||||
### 生活比喻:仓库发货
|
||||
|
||||
物流车到达仓库:"这是订单(HTTP请求),**我要取回商品(网页 HTML 源代码)!**"
|
||||
仓库管理员核对:"订单有效,这是你要的包裹(**HTML 文件**),请拿好。"
|
||||
|
||||
### 真实过程:HTTP 协议通信
|
||||
|
||||
**HTTP(HyperText Transfer Protocol,超文本传输协议)**是浏览器和服务器之间的"对话规则"。通道建立后,浏览器发送**取货请求**,**核心目标是拿回网页的源代码(HTML 文件)**:
|
||||
|
||||
**HTTP 请求示例:**
|
||||
|
||||
```http
|
||||
GET /index.html HTTP/1.1 ← 请求方法 + 路径 + 协议版本
|
||||
Host: www.example.com ← 目标主机(支持虚拟主机,一台服务器可托管多个网站)
|
||||
User-Agent: Chrome/120.0 ← 客户端标识(服务器可据此返回适配内容)
|
||||
Accept: text/html,application/xhtml+xml ← 可接受的响应格式
|
||||
Accept-Language: zh-CN,zh;q=0.9 ← 偏好的语言
|
||||
Accept-Encoding: gzip, deflate ← 支持的压缩格式
|
||||
Connection: keep-alive ← 保持连接(复用 TCP 连接)
|
||||
Cookie: session_id=abc123 ← 身份凭证
|
||||
```
|
||||
|
||||
::: tip 💡 开发者顿悟:这不就是 API 吗?
|
||||
**一模一样!**
|
||||
你平时写的 API 调用(`fetch` / `axios`)和浏览器访问网页,在 **HTTP 层面完全是同一个东西**。
|
||||
|
||||
它们都是发送一个请求,服务器返回一段文本数据。
|
||||
|
||||
- 如果服务器给的是 **HTML**,浏览器就把它**画出来**(变成网页)。
|
||||
- 如果服务器给的是 **JSON**,你的代码就把它**存起来**(用于逻辑处理)。
|
||||
|
||||
**根本就没有"两种"请求,只有同一种 HTTP 请求,只是返回的数据格式(Content-Type)不同而已。**
|
||||
这也是为什么理解了 HTTP,你就理解了 90% 的后端 API 原理。
|
||||
|
||||
如果你想深入学习 API 开发,请参考 [API 章节](./api-intro.md)。
|
||||
:::
|
||||
|
||||
**常见 HTTP 方法:**
|
||||
|
||||
- `GET`:获取资源(安全、幂等,可被缓存)
|
||||
- `POST`:提交数据(创建资源,如注册、登录)
|
||||
- `PUT`:更新资源(完整替换)
|
||||
- `PATCH`:部分更新资源
|
||||
- `DELETE`:删除资源
|
||||
- `HEAD`:获取响应头(不返回主体,用于检查资源是否存在)
|
||||
|
||||
**服务器返回 HTTP 响应:**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK ← 协议版本 + 状态码 + 状态描述
|
||||
Date: Mon, 23 May 2025 12:00:00 GMT ← 服务器时间
|
||||
Content-Type: text/html; charset=UTF-8 ← 内容类型和编码
|
||||
Content-Length: 1234 ← 内容长度(字节)
|
||||
Cache-Control: max-age=3600 ← 缓存策略
|
||||
Set-Cookie: user_id=xyz789 ← 设置 Cookie
|
||||
|
||||
```
|
||||
|
||||
**HTTP 状态码分类:**
|
||||
|
||||
| 状态码 | 类别 | 含义 | 生活类比 |
|
||||
| ----------- | ---------- | ---------------- | -------------------------------- |
|
||||
| **200** | 成功 | 请求成功处理 | "订单确认,马上发货" |
|
||||
| **301/302** | 重定向 | 资源已移动 | "本店搬家了,请去新店下单" |
|
||||
| **304** | 未修改 | 缓存仍有效 | "你上次买的还能用,不用重新发货" |
|
||||
| **400** | 客户端错误 | 请求格式错误 | "订单填写模糊,看不懂" |
|
||||
| **401** | 未授权 | 需要身份验证 | "请先出示会员卡" |
|
||||
| **403** | 禁止访问 | 权限不足 | "非内部人员禁止入内" |
|
||||
| **404** | 未找到 | 资源不存在 | "仓库里没这款商品" |
|
||||
| **500** | 服务器错误 | 服务器内部错误 | "仓库起火了,暂时发不了货" |
|
||||
| **502** | 网关错误 | 上游服务器无响应 | "总仓没货了,分仓也调不到" |
|
||||
| **503** | 服务不可用 | 服务器过载或维护 | "爆单了,暂停接单" |
|
||||
|
||||
<HttpExchangeDemo />
|
||||
|
||||
---
|
||||
|
||||
## 5. 第五步:拆开"包裹" —— 浏览器渲染
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**代码怎么变成画面?** 服务器发来的是枯燥的 HTML/CSS/JavaScript 代码,浏览器如何把它们变成丰富多彩的网页?
|
||||
:::
|
||||
|
||||
### 生活比喻:拆箱与组装
|
||||
|
||||
你终于收到了快递包裹(HTTP 响应),但打开一看,里面不是现成的家具,而是一堆**零件**(HTML)和一本**组装说明书**(CSS)。作为"买家"(浏览器),你需要亲自动手组装:
|
||||
|
||||
1. **拆开包装**:取出所有零件,核对清单(解析 HTML → DOM 树)。
|
||||
2. **阅读说明**:看懂说明书,知道哪个零件该装哪、什么颜色(解析 CSS → CSSOM 树)。
|
||||
3. **分类整理**:挑出需要组装的零件,扔掉包装泡沫(`display: none`),准备组装(构建渲染树)。
|
||||
4. **测量位置**:用尺子量好房间尺寸,决定每个家具具体摆在哪(布局/回流)。
|
||||
5. **上色装饰**:给家具刷漆、贴贴纸(绘制)。
|
||||
6. **最终展示**:打扫干净,开灯展示(合成)。
|
||||
|
||||
### 真实过程:浏览器渲染引擎
|
||||
|
||||
浏览器收到的是 **HTML/CSS/JavaScript 代码**(枯燥的文本),但它要变成**像素画面**(精美的网页)。这个过程叫做**渲染(Rendering)**,由浏览器的**渲染引擎**(如 Chrome 的 Blink、Safari 的 WebKit)执行。
|
||||
|
||||
#### 步骤1:解析 HTML → 构建 DOM 树 (零件清单)
|
||||
|
||||
浏览器读取 HTML 字节流,将其解析为**DOM(Document Object Model,文档对象模型)树**。这就像把一堆散乱的零件整理成一个有层级关系的清单:
|
||||
|
||||
```html
|
||||
<!-- 原始 HTML -->
|
||||
<div class="header">标题</div>
|
||||
<div class="content">内容</div>
|
||||
```
|
||||
|
||||
```text
|
||||
DOM 树结构:
|
||||
Document
|
||||
└─ html
|
||||
└─ body
|
||||
├─ div.header ("标题")
|
||||
└─ div.content ("内容")
|
||||
```
|
||||
|
||||
#### 步骤2:解析 CSS → 构建 CSSOM 树 (说明书)
|
||||
|
||||
浏览器解析所有的 CSS(内联、外部文件),构建**CSSOM(CSS Object Model)树**。这就像理解说明书上的样式规则:
|
||||
|
||||
```css
|
||||
.header {
|
||||
color: blue;
|
||||
font-size: 24px;
|
||||
} /* 标题要是蓝色的 */
|
||||
.content {
|
||||
display: none;
|
||||
} /* 内容暂时隐藏 */
|
||||
```
|
||||
|
||||
#### 步骤3:合并 → 渲染树 (准备组装)
|
||||
|
||||
DOM 树 + CSSOM 树 = **渲染树 (Render Tree)**。
|
||||
关键点:**只有"可见"的元素才会在渲染树中**。
|
||||
|
||||
- `.header`:在渲染树中(可见)。
|
||||
- `.content`:**不在**渲染树中(因为 `display: none`,就像被扔掉的包装纸,不需要组装)。
|
||||
|
||||
#### 步骤4:布局 (Layout / Reflow) —— 测量尺寸
|
||||
|
||||
浏览器计算渲染树中每个节点在屏幕上的**精确坐标和大小**。
|
||||
|
||||
- "这个标题框宽 100px,高 50px,放在屏幕左上角 (0,0) 位置。"
|
||||
- 这个过程叫**重排 (Reflow)**。如果窗口大小变了(比如手机横屏),所有元素的位置都要重新计算,非常消耗性能。
|
||||
|
||||
#### 步骤5:绘制 (Paint) —— 上色
|
||||
|
||||
知道位置后,浏览器开始填充像素:画背景色、文字颜色、边框、阴影等。
|
||||
|
||||
#### 步骤6:合成 (Composite) —— 最终展示
|
||||
|
||||
现代浏览器会将页面分成多个**图层 (Layers)** 分别绘制(比如 3D 变换、滚动条独立图层),最后由 GPU 将它们像 Photoshop 图层一样叠加在一起,呈现在屏幕上。
|
||||
|
||||
<BrowserRenderingDemo />
|
||||
|
||||
::: info 💡 你知道吗?
|
||||
**布局和绘制**是浏览器最忙碌的时候。网页里的元素越多、结构越复杂,浏览器就需要花更多时间来计算位置和上色。这就是为什么有的复杂网页打开会卡顿的原因。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 5.5 网页是怎么"生成"的?静态网站 vs 动态网站
|
||||
|
||||
::: tip 🤔 核心问题
|
||||
**网页内容从哪里来?** 前面我们讲了浏览器如何渲染页面,但服务器上的 HTML 文件是怎么来的?是提前做好还是现做?
|
||||
:::
|
||||
|
||||
前面我们讲的都是浏览器如何"拆开包裹"——把服务器发来的 HTML/CSS/JS 渲染成页面。但你有没有想过一个问题:**服务器上那个 HTML 文件是怎么来的?**
|
||||
|
||||
答案是:**有两种方式**,这就是静态网站和动态网站的区别。
|
||||
|
||||
### 静态网站:提前做好、直接给你
|
||||
|
||||
想象你去超市买饼干。货架上的饼干都是工厂已经生产好的,你直接拿走就行,不需要等。
|
||||
|
||||
**静态网站**就是这样的"成品"——网页在服务器上已经准备好了,你访问时服务器直接把现成的 HTML 文件发给你,不做任何额外处理。
|
||||
|
||||
**特点:**
|
||||
- ✅ 访问速度快(服务器直接发文件,不用计算)
|
||||
- ✅ 制作简单(写好 HTML 就能用)
|
||||
- ✅ 承载力强(可以用 CDN 分发,多少人访问都不怕)
|
||||
- ❌ 内容难更新(想改内容就要重新生成文件)
|
||||
|
||||
**常见例子:** 公司介绍页、产品文档、帮助中心、个人博客
|
||||
|
||||
### 动态网站:现点现做、每次不同
|
||||
|
||||
这次想象你去餐厅点餐。厨师根据你的订单现做,你点宫保鸡丁不会给你上糖醋里脊。
|
||||
|
||||
**动态网站**就是你访问时才"现场制作"的页面——服务器收到你的请求后,去数据库查资料、计算数据,然后生成一个全新的 HTML 发给你。
|
||||
|
||||
**特点:**
|
||||
- ✅ 内容实时(购物车显示最新库存、新闻随时更新)
|
||||
- ✅ 因人而异(登录后看到你的个人信息)
|
||||
- ✅ 功能强大(搜索、评论、推荐、支付都能实现)
|
||||
- ❌ 访问速度慢(服务器需要时间计算)
|
||||
- ❌ 服务器压力大(同时很多人访问要排队)
|
||||
|
||||
**常见例子:** 淘宝、微博、在线银行、在线文档
|
||||
|
||||
**需要服务器吗?** 动态网站确实需要某种"后端"来生成内容,但形式多样:
|
||||
- **传统服务器**:自己买/租服务器(阿里云 ECS、AWS EC2)
|
||||
- **Serverless**:不用管服务器,云厂商帮你运行代码(AWS Lambda、阿里云函数计算、Cloudflare Workers)
|
||||
- **调用第三方 API**:支付用 Stripe、天气用气象局 API,自己不写后端代码
|
||||
|
||||
::: tip 💡 静动态结合
|
||||
现在很多网站是"混合"的:网页主体是静态的,但某些部分(比如评论区、搜索框)是动态加载的。JavaScript 可以在页面加载后调用 API 获取数据,实现"静态页面 + 动态功能"。
|
||||
:::
|
||||
|
||||
### 📊 静态 vs 动态,一对比就清楚
|
||||
|
||||
| | 静态网站 | 动态网站 |
|
||||
|---|---------|---------|
|
||||
| **怎么来的** | 提前做好,存服务器上 | 访问时现做 |
|
||||
| **像什么** | 超市货架上的商品 | 餐厅现点的菜 |
|
||||
| **速度** | 快 | 慢(需要计算) |
|
||||
| **能改内容吗** | 难(要重新生成) | 容易(后台直接改) |
|
||||
| **适合做什么** | 展示型内容(介绍页、文档) | 交互型应用(购物、社交) |
|
||||
| **典型例子** | 公司官网、帮助文档 | 淘宝、微信、在线银行 |
|
||||
|
||||
### 🤔 常见疑问
|
||||
|
||||
**Q: 静态网站是不是不能用 JavaScript?**
|
||||
|
||||
当然不是!轮播图、折叠菜单、表单验证这些交互功能,静态网站都能用 JavaScript 实现。我们说的"静态""动态",是指**页面内容是不是提前准备好的**,跟有没有交互功能是两回事。
|
||||
|
||||
**Q: 动态网站一定要自己买服务器吗?**
|
||||
|
||||
不一定。除了传统服务器,你还可以用 Serverless(云函数)、或者直接调用第三方 API。现在的趋势是"能不动服务器就不动"——用静态网站 + JavaScript 调用 API 的方式,既快又省成本。
|
||||
|
||||
::: tip 💡 重要提示
|
||||
无论静态网站还是动态网站,**浏览器渲染的原理都是一样的**!服务器发来的是什么,浏览器就渲染什么。区别只在于:
|
||||
- 静态网站:服务器发来的是"成品"
|
||||
- 动态网站:服务器发来的是"现做的"
|
||||
|
||||
作为前端开发者,你主要关注的是浏览器如何处理收到的内容,而不是服务器怎么生成的。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 6. 总结:一次完整的"网购"之旅
|
||||
|
||||
::: tip 🎉 学完本章,你应该能
|
||||
- 解释从输入网址到显示页面的完整流程
|
||||
- 理解 URL、DNS、TCP、HTTP 的作用和关系
|
||||
- 知道浏览器如何渲染页面
|
||||
- 区分静态网站和动态网站
|
||||
- 用生活化比喻向他人解释浏览器工作原理
|
||||
:::
|
||||
|
||||
让我们回顾整个旅程:
|
||||
|
||||
| 阶段 | 技术术语 | 网购类比 | 核心任务 | 关键技术 |
|
||||
| ----------- | ---------- | -------- | ------------------ | ------------------------------ |
|
||||
| **1. 解析** | URL 解析 | 填写订单 | 理解买家想买什么 | 协议、域名、端口、路径、参数 |
|
||||
| **2. 查询** | DNS 查询 | 查仓库址 | 找到店铺的发货仓库 | 递归/迭代查询、缓存机制 |
|
||||
| **3. 连接** | TCP 握手 | 建立通道 | 确保物流通畅 | 三次握手、序列号、流量控制 |
|
||||
| **4. 对话** | HTTP 交换 | 仓库发货 | 提交订单并收货 | 请求方法、状态码、头部字段 |
|
||||
| **5. 展示** | 浏览器渲染 | 拆箱组装 | 把商品展示出来 | DOM、CSSOM、渲染树、布局、绘制 |
|
||||
|
||||
**整个过程通常在几百毫秒内完成** —— 想想这有多么不可思议!
|
||||
|
||||
你的浏览器在不到1秒的时间里:
|
||||
|
||||
- 解析了一个复杂的地址
|
||||
- 查询了分布在全球的 DNS 服务器
|
||||
- 和千里之外的服务器建立了可靠连接
|
||||
- 进行了一次完整的 HTTP 对话
|
||||
- 把枯燥的代码变成了精美的画面
|
||||
|
||||
这就是互联网的魅力:**复杂的技术,简单的体验**。
|
||||
|
||||
::: info 💡 进阶学习
|
||||
如果你想深入了解某个环节,可以参考:
|
||||
- **API 开发**:[API 简介](./api-intro.md) - 学习如何设计和使用 API
|
||||
- **前端性能**:[前端性能优化](./frontend-performance.md) - 学习如何优化网页加载速度
|
||||
- **浏览器渲染**:[浏览器渲染管道](./browser-rendering-pipeline.md) - 深入了解渲染细节
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 7. 名词速查表 (Glossary)
|
||||
|
||||
| 名词 | 全称 | 简单解释 |
|
||||
| ----------- | ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| **URL** | Uniform Resource Locator | **统一资源定位符**。网页的"地址",告诉浏览器去哪里找资源。 |
|
||||
| **DNS** | Domain Name System | **域名系统**。互联网的"电话簿",把人类可读的域名转换成机器可读的 IP 地址。 |
|
||||
| **IP 地址** | Internet Protocol Address | **互联网协议地址**。每台联网设备的唯一"门牌号",如 `192.168.1.1`。 |
|
||||
| **TCP** | Transmission Control Protocol | **传输控制协议**。确保数据可靠传输的"规则",通过三次握手建立连接。 |
|
||||
| **HTTP** | HyperText Transfer Protocol | **超文本传输协议**。浏览器和服务器"对话"的规则。 |
|
||||
| **HTTPS** | HTTP Secure | **安全的 HTTP**。在 HTTP 基础上加了加密(TLS/SSL),保护数据安全。 |
|
||||
| **HTML** | HyperText Markup Language | **超文本标记语言**。网页的"骨架",定义内容的结构。 |
|
||||
| **CSS** | Cascading Style Sheets | **层叠样式表**。网页的"皮肤",定义内容的外观。 |
|
||||
| **DOM** | Document Object Model | **文档对象模型**。浏览器把 HTML 转换成的树形结构,方便操作。 |
|
||||
| **CSSOM** | CSS Object Model | **CSS 对象模型**。浏览器把 CSS 转换成的树形结构。 |
|
||||
| **渲染** | Rendering | 浏览器把代码转换成屏幕像素的过程。 |
|
||||
| **RTT** | Round Trip Time | **往返时间**。数据包从发送到接收确认的时间,影响网页加载速度。 |
|
||||
|
||||
---
|
||||
|
||||
::: tip 🎓 恭喜
|
||||
现在当你再次在地址栏输入网址并按下回车时,你已经能看到屏幕背后的那个忙碌而精彩的数字世界了。
|
||||
|
||||
你理解了:
|
||||
- 为什么有时候网页打不开(DNS 解析失败、服务器宕机)
|
||||
- 为什么有的网页快、有的慢(网络延迟、服务器性能、页面复杂度)
|
||||
- 浏览器是如何把代码变成画面的(渲染管道)
|
||||
|
||||
**这就是理解技术原理的价值** — 遇到问题时,你能知道从哪里找原因,而不是束手无策。
|
||||
:::
|
||||
:::
|
||||
@@ -1,117 +1,94 @@
|
||||
# 图像生成原理
|
||||
> 💡 **学习指南**:提示词工程是“教 AI 说话”,而生图模型则是“教 AI 做梦”。本章节将带你拆解 AI 画笔背后的魔法——它是如何从一堆毫无意义的噪点中,变出足以乱真的艺术品的?
|
||||
|
||||
在开始之前,建议你先体验一下“神笔马良”的感觉。
|
||||
现在的 AI 绘画工具主要分为三类:
|
||||
* **聊天机器人里带的**:GPT-4o (DALL·E 3), Gemini (Imagen 3) —— 简单,听得懂人话。
|
||||
* **追求极致画质的**:Midjourney, Flux —— 审美无敌,每一张都是壁纸。
|
||||
* **能精准控制的**:Stable Diffusion (WebUI/ComfyUI) —— 指哪打哪,设计师最爱。
|
||||
|
||||
---
|
||||
|
||||
## 0. 引言:为什么电脑画画不用“像素”?
|
||||
> 💡 **学习指南**:本章节将系统探究生成式视觉大模型的工作机制。我们将从“烧显卡”的高维像素空间难题切入,详细解构变分自编码器(VAE)、扩散模型(Diffusion)以及交叉注意力(Cross-Attention)背后的严谨数学原理。同时,巧妙且生动的交互式组件将确保你——即使毫无 AI 基础,也能迅速领悟这些尖端科技!
|
||||
|
||||
<ImageGenQuickStartDemo />
|
||||
|
||||
如果我们想让电脑画一张 1024x1024 的高清图,它需要决定 **300 多万** 个像素点(红绿蓝通道)的颜色。
|
||||
如果直接在这个“像素海洋”里作画,计算量会大到把你的显卡烧穿。
|
||||
## 0. 引言:直击千万级像素的“维度灾难”
|
||||
|
||||
聪明的科学家想到了一个绝妙的办法:**“不要画照片,要画『压缩饼干』。”**
|
||||
当我们惊叹于 Midjourney 或 Stable Diffusion 生成的极致绚丽大作时,首先要理解计算机在底层所面临的数字压力。
|
||||
|
||||
这就是我们今天要学的第一个核心概念:**潜空间 (Latent Space)**。
|
||||
一张标准的 $1024 \times 1024$ 像素高清图,在标准 RGB 三通道下,需要计算和填充近 **300 多万** 个浮点数值。
|
||||
**维度灾难 (Curse of Dimensionality)** 由此而生:如果直接让深度神经网络在这样一个巨大的“欧几里得空间(Euclidean Space)”里联合估算每一颗像素的概率分布该怎么填,它带来的算力开销将是极度毁灭性的,且生成的画面极容易产生恐怖的局部畸变和语义撕裂。
|
||||
|
||||
因此,现代前沿图像生成算法找到了一个降维打击的避风港:**“不要在宏大无序的原始像素画布上硬算,去高度凝练的特征空间里精准雕刻”。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 潜空间:AI 的“压缩饼干”
|
||||
## 1. 降维基石:潜空间与 VAE 的魔法压缩
|
||||
|
||||
想象一下,你要在电话里描述蒙娜丽莎:
|
||||
* **方法 A (像素级)**:“第 1 行第 1 个点是深褐色,第 1 行第 2 个点是浅褐色……”(讲完需要一万年)
|
||||
* **方法 B (特征级)**:“一个微胖的女人,长发,没有眉毛,神秘的微笑,背景是山水。”(讲完只需要 10 秒)
|
||||
既然一幅画在宏观结构上有极多冗余连片的部分(比如一片几乎无渐变的纯蓝天空),我们便可以将这些画面特征“打包”。这就需要请出图像生成大基座中的空间转换大师——**变分自编码器 (Variational Autoencoder, VAE)**。
|
||||
|
||||
**方法 B 就是潜空间。** 它不存像素,只存“特征”。
|
||||
|
||||
### 1.1 VAE:那个把大象装进冰箱的家伙
|
||||
|
||||
AI 绘画的第一步,是把高清大图“压”进潜空间。这个工作由 **VAE (变分自编码器)** 完成。
|
||||
它把一张巨大的图片,压缩成一张只有原本 1/48 大小的“特征图”。AI 只需要在这张小图上画画,最后再由 VAE 把它“放大”回高清图。
|
||||
VAE 的职责极其单一却又至关重要:
|
||||
- **降维压缩 (Encoder)**:将庞大的数百万**像素空间 (Pixel Space)**极限浓缩,提取其长相特征与颜色结构,压进一张尺寸极小的抽象网格中。这片高密度、富含高阶语义信息的网格域,就是大名鼎鼎的 **潜空间 (Latent Space)**。
|
||||
- **作画与解压 (Decoder)**:生成神经网络实际上完全是在这张迷你“潜空间网格”中运筹帷幄的。待低维度的特征拼搭定型完毕后,VAE 会将它像泡面吸水一样无损“膨胀还原”,映射回人类肉眼能够欣赏的高清像素面孔。
|
||||
|
||||
👇 **动手点点看**:
|
||||
试着拖动滑块,感受一下“像素空间”和“潜空间”的区别。你会发现,在潜空间里移动一点点,图上的表情就会发生巨大的变化。
|
||||
拖拽下列空间平面上的红点坐标参数,去直观感受潜空间(Latent Space)里仅仅两个数学坐标维度的毫厘偏移,是如何被解码映射成截然不同的表象特征的!
|
||||
|
||||
<LatentSpaceViz />
|
||||
|
||||
---
|
||||
|
||||
## 2. 扩散模型 (Diffusion):从混沌到秩序
|
||||
## 2. 演化核心:用扩散模型 (Diffusion) 剥离迷雾
|
||||
|
||||
既然有了画布(潜空间),AI 怎么动笔呢?
|
||||
它的画法非常反直觉:**它不是在一张白纸上画,而是对着一张全是雪花点的“废纸”硬看,直到看出东西来。**
|
||||
潜空间的画布已经搭好,那模型到底该用怎样的方法凭空生成符合预期的特征?
|
||||
目前统治生成式图像领域的绝对霸主架构——**去噪扩散概率模型 (DDPM / Diffusion Model)**,使用了令人拍案叫绝的“逆向雕刻”理念。
|
||||
|
||||
### 2.1 雕刻家理论
|
||||
正如米开朗基罗所言:“雕像本来就在石头里,我只是去掉了多余的部分。”Diffusion 的学习分为极其巧妙的首尾两极:
|
||||
|
||||
米开朗基罗说过:“雕像就在石头里,我只是去掉了多余的部分。”
|
||||
**扩散模型 (Diffusion Model)** 也是这么想的。
|
||||
1. **加噪摧毁 (前向扩散过程 Forward Process)**:这在数学上被定义为一个马尔可夫链式随机破坏过程 (SDE)。系统在训练期,通过噪声调度表(Noise Schedule)向千万级好图里逐步、均匀地融合高斯白噪声,直至图片完全坍缩成失去任何特征信息的各向同性正态分布雪花点。**(模型在此刻死死记住了所有画面的破坏轨迹特征)**。
|
||||
2. **重塑秩序 (反向去噪预估 Reverse Denoising Process)**:到了推理生成阶段,我们只给 AI 提供一团纯粹的白噪声基底。强大的 U-Net 或扩散 Transformer (DiT) 估测网络开始发力。它会在每一个细微的计算时间步节点(Step)上去预测:“这堆杂乱信息中,哪一部分才是我们要剥离掉的无效噪声(Score 函数)?”并随之扣除。
|
||||
|
||||
1. **训练时 (前向过程)**:它把一张好图,一点点加上噪点,直到变成纯噪声。它记住了这个“搞破坏”的过程。
|
||||
2. **生成时 (逆向过程)**:给它一张纯噪声,它就开始回想:“这玩意儿原本应该长什么样?”然后一步步把噪点减掉。
|
||||
|
||||
👇 **动手点点看**:
|
||||
点击“开始去噪”,观察 AI 是如何像雕刻家一样,从一团混沌中把图像“挖”出来的。
|
||||
通过成败上千次的反复退火微调剥离,它硬是从一团无序的马赛克里硬生生“预测”出了一幅精美元伦的画面特征。
|
||||
|
||||
<DiffusionProcessDemo />
|
||||
|
||||
---
|
||||
|
||||
## 3. CLIP:让 AI 听懂你的话
|
||||
## 3. 多模态对齐:听懂人话的关键 (Cross-Attention)
|
||||
|
||||
AI 会画画了,但它怎么知道你要画猫还是画狗?
|
||||
这时候需要一个翻译官:**CLIP (文本编码器)**。
|
||||
AI 掌握了作画本领后,如果脱离管控,它只会随心所欲地产出千奇百怪的狂想。如果要让它按人类给定的 Prompt 提示词(“Cyberpunk cat / 赛博朋克猫”)精准作画,必须给双方配备强力的跨模态翻译及照耀枢纽。
|
||||
|
||||
它把你的文字(Prompt)变成一串数学向量,然后“注射”到 AI 的大脑里。
|
||||
当 AI 在去噪时,这些向量就像监工一样在旁边喊:
|
||||
* “这里要画成毛茸茸的!” (关注 'cat')
|
||||
* “背景要是赛博朋克的!” (关注 'cyberpunk')
|
||||
- **翻译系统 (CLIP)**:一种跨界对比语言网格。它能成功把你的每一句英语描述,对应成可以与画面产生共鸣的数百维数学向量(Embeddings)。
|
||||
- **执行指令 (交叉注意力 Cross-Attention)**:这是大模型中的神来之笔。在以上去噪步骤的每一个瞬息循环里,生成图片潜层充当 Query(查询器),向外伸出触手去匹配 CLIP 发来的文本 Key/Value(指令键值)。
|
||||
|
||||
这就是**交叉注意力 (Cross-Attention)** 机制。
|
||||
一旦系统进入到勾勒画面轮廓时,“喵星人”这个词的向量权重就会在注意力机制中被几何倍放大激活,并聚焦染色在将要形成动物身体的那片区域网格上。**此时,你的语言化为了手电筒光束,照亮了 AI 理工直男下笔该着重的那些局部细节!**
|
||||
|
||||
<PromptVisualizer />
|
||||
|
||||
---
|
||||
|
||||
## 4. 进化:从“慢慢磨”到“传送门” (Flow Matching)
|
||||
## 4. 推理质变:流匹配 (Flow Matching) 铺就的高速公路
|
||||
|
||||
早期的 Stable Diffusion 画一张图需要走 20-50 步,因为它是“盲人摸象”,在去噪的路上跌跌撞撞。
|
||||
最新的 **Flux** 和 **Stable Diffusion 3** 引入了 **Flow Matching (流匹配)** 技术。
|
||||
尽管传统的 Diffusion 理论华丽,但致命伤是**运算过慢**。
|
||||
正因为它依据高度随机的推演,相当于置于极其崎岖的迷宫内闭门摸索(随机微分推测),生成一张图通常需要模型迭代多达惊人的 50 次步长(Steps)。
|
||||
|
||||
如果说 Diffusion 是走迷宫找到出口,Flow Matching 就是直接在起点(噪声)和终点(图片)之间修了一条**直线高速公路**。
|
||||
它不需要猜,直接滑过去。所以 Flux 只需要 4-8 步就能画出极好的画。
|
||||
为了掀起性能革命,最新的顶级多模态模型(如 SD3、黑神话背后的 Flux)全面引入了新的底座核心理论:**流匹配 (Flow Matching / Continuous Normalizing Flows)**。
|
||||
|
||||
👇 **动手点点看**:
|
||||
对比一下 Diffusion 的“随机游走”和 Flow Matching 的“直线传输”。
|
||||
在解析几何思维的加持下:通过最优传输论 (Optimal Transport, OT) 的极简逻辑引导,模型不再靠纯纯的随机兜圈摸索。**算法被直接强行套入一段解算自源端纯噪声到末端数据目标点之间近似笔直的常微分方程 (ODE) 平滑矢量轨道之中!**
|
||||
不绕路了!这也使得应用 流匹配 架构的模型只需要堪称“降维式”的极低步数(仅需 4 至 8 步),即可高速渲染出惊为天人的画面结果!
|
||||
|
||||
<FlowMatchingDemo />
|
||||
|
||||
---
|
||||
|
||||
## 5. 总结:AI 绘画的三驾马车
|
||||
## 5. 架构归纳综述
|
||||
|
||||
现在,当你点击“生成”按钮时,你的电脑里正在发生一场精密的接力赛:
|
||||
至此,当你在一款 AI 应用中按下 `<Enter>` 键求取图片的短短几秒内在显卡里运转翻滚的宏大接力便大观毕现:
|
||||
|
||||
1. **CLIP (翻译官)**:听懂你的话,变成指令。
|
||||
2. **Transformer/UNet (画家)**:在 **潜空间** 里,用 **Flow/Diffusion** 的方法,把噪声变成特征图。
|
||||
3. **VAE (放大镜)**:把特征图还原成高清大图。
|
||||
|
||||
这就是从噪点中诞生艺术的全过程。
|
||||
1. **语言翻译解压桥 (CLIP / Text Encoder)**:严谨地将人类意图向量化铺开向视界输送指导锚点。
|
||||
2. **雕刻主心骨运算基盘 (DiT 等搭配 Flow Matching/Diffusion)**:在被抽空的高低频潜度网络表象上,接受交叉注意力 (CrossAttention) 干涉打磨,进行对杂乱干扰高斯信息的高并发抽除洗出工序。
|
||||
3. **压缩映射放大镜 (VAE)**:坐镇最后把门,把经过打磨成型而抽象的微小特征矩阵极速解压,最后呈现在千万极素级的大显示屏上。
|
||||
|
||||
---
|
||||
|
||||
## 附录:核心术语表
|
||||
## 6. 核心术语速查表 (Glossary)
|
||||
|
||||
| 术语 | 解释 | 比喻 |
|
||||
| 术语 | 英文全称 | 通俗释义 |
|
||||
| :--- | :--- | :--- |
|
||||
| **Latent Space** | 潜空间 | 压缩后的特征世界,AI 的工作室 |
|
||||
| **VAE** | 变分自编码器 | 负责把大图变小(Encode)和把小图变大(Decode)的搬运工 |
|
||||
| **Diffusion** | 扩散模型 | 通过“去噪”来画画的算法,像雕刻石头 |
|
||||
| **Noise** | 噪声 | 随机的雪花点,AI 的原材料 |
|
||||
| **Sampler** | 采样器 | 决定去噪具体怎么走的“导航仪” (如 Euler, DPM++) |
|
||||
| **LoRA** | 低秩适应 | 给模型打的小补丁,专门画特定风格或角色 |
|
||||
| **潜空间** | Latent Space | 大幅降低维度的数学分布空间;一张剥离无关累赘后,只有 AI 画师看得懂的高度浓缩“构图草稿”。 |
|
||||
| **VAE** | Variational Autoencoder | 极其夸张的尺寸极限转换器。担岗着把亿万像素进行降维压扁以及把完稿图样最终解压放大落位的关键功能。 |
|
||||
| **Diffusion** | 扩散概率模型 | 主流的图像特征提取破坏与逆向回归预测恢复算法;依靠逐步去除各向同性的微细随机干扰来使得图案缓慢成型涌现的骨干基建。 |
|
||||
| **CLIP** | Contrastive Language-Image Pre-Training | 利用亿万张人类给图写的批注进行对称对比训练而出,解决语言字符和色彩事物应该怎么联想挂钩互通的强力组件。 |
|
||||
| **Cross-Attention** | 交叉注意力机制 | 大模型内部进行序列特征混融的方法;通俗说即要求图像自身网格在发生计算时刻,必须以一定权重抬头核对外部下发的语言要求重点的一种照耀映射工具。 |
|
||||
| **Flow Matching** | 流匹配算法 | 基于前人随机盲跑基础重修出来的高阶优化连续映射,依靠解方程约束一条平稳的确定直线通路从而让渲染时间被数百倍节省的核心加速路线技巧。 |
|
||||
|
||||
@@ -25,28 +25,31 @@ VLM 的核心任务,就是**把“像素信号”翻译成“文字信号”**
|
||||
|
||||
### 1.1 切块 (Patchify) —— 制作视觉单词
|
||||
|
||||
LLM 习惯读单词。为了配合它,我们得把一张完整的图片切成一个个小方块(Patch)。
|
||||
我们知道,大语言模型 (LLM) 处理文本时,会把句子拆解成一个个的词元 (Token)。如果你想让 LLM “读懂”图片,最直观的方法就是把图片也变成类似 Token 的形式。
|
||||
|
||||
- **图片** = 一篇文章
|
||||
- **方块 (Patch)** = 一个单词
|
||||
为了配合大模型这种“习惯读单词”的特性,我们需要一种能将连续的二维图像转换为离散片段的技术,这就引出了**视觉流形切片 (Patchify)** 的概念:我们把一张完整的二维图片,像切豆腐一样,切成一个个固定的网格小方块(称为 Patch)。
|
||||
|
||||
通常,我们会把图片切成 $16 \times 16$ 像素的小方块。一张 $224 \times 224$ 的图片就会变成 $14 \times 14 = 196$ 个方块。
|
||||
- **原始图片** = 一篇完整的文章
|
||||
- **图片切块 (Patch)** = 文章里的一个单词 (Token)
|
||||
|
||||
> 🕹️ **交互演示**:点击下方按钮,看图片是如何被“切”成单词的。
|
||||
在工程实践中,我们通常会把图片按照固定的尺寸(比如 $16 \times 16$ 或 $14 \times 14$ 像素)进行无缝切分。例如,一张常见的 $224 \times 224$ 像素的输入图片,切分后就会变成 $14 \times 14 = 196$ 个独立的图像方块。
|
||||
通过这个操作,原本连续完整的二维像素阵列,就被物理切割成了 196 个离散的“视觉单词本”。
|
||||
|
||||
> 🕹️ **交互演示**:点击下方按钮,体验原始图像是如何被规则的网格切割成一个个独立 Patch 的。
|
||||
|
||||
<PatchifyDemo />
|
||||
|
||||
### 1.2 序列化 (Flatten) —— 排成一句话
|
||||
|
||||
切完后,我们得到的是一个 $14 \times 14$ 的方阵。但 LLM 只能读**一行字**(序列)。
|
||||
所以,我们必须把这个方阵**拍扁**,变成长长的一串。
|
||||
完成上一步切块后,我们现在手头拥有的是一个 $14 \times 14$ 的二维方阵。然而,无论是传统的 Transformer 还是现代的 LLM,它们在底层架构上大多只接受**一维的序列输入**(也就是从左到右排成一排的线性数据结构)。
|
||||
|
||||
- **原本**:二维矩阵(有行有列)。
|
||||
- **现在**:一维长条(只有前后)。
|
||||
为了兼容大模型的输入规范,我们必须进行**序列化 (Flatten) 与线性投影 (Linear Projection)**:
|
||||
1. **拍扁摊平 (Flatten)**:把多行的图像块首尾相接,将二维矩阵“拍扁”成一条只有前后顺序的一维长轴。
|
||||
2. **特征拉伸 (Projection)**:这 196 个方块目前还只是红绿蓝像素堆叠的“生肉”。我们需要用一个小型的神经网络(通常是一个全连接层)对每个方块进行处理,把它们分别压缩和转换成一段固定长度的特征向量(比如长度为 768 的数字列表)。
|
||||
|
||||
这样,图片就变成了一串“视觉单词序列”。
|
||||
经过这一步操作,一张图片才真正变成了一串“视觉单词序列”(Visual Token Sequence)。
|
||||
|
||||
下面的演示展示了:**一个 Patch** 是如何被拍扁,并变成一个**向量**(计算机能读懂的数字列表)的。
|
||||
> 🕹️ **交互演示**:观察下方动画,了解**一个单纯的像素块 (Patch)** 是如何经历矩阵拉伸,最终被映射成一个包含丰富特征维度的高维**向量 (Vector)** 的。
|
||||
|
||||
<LinearProjectionDemo />
|
||||
|
||||
@@ -54,93 +57,98 @@ LLM 习惯读单词。为了配合它,我们得把一张完整的图片切成
|
||||
|
||||
## 2. 第二步:跨物种翻译 (Projection)
|
||||
|
||||
现在我们有了一串“视觉单词”,但 LLM 还是读不懂。
|
||||
因为这些“视觉单词”是**像素特征**(比如“这里是红色”、“那里有条线”),而 LLM 懂的是**语义特征**(比如“这是猫”、“那是树”)。
|
||||
此时,虽然图片已经被转化成了一维连续的“视觉单词”序列,但这串序列对于最后的 LLM 来说,依然是一堆不可读的乱码。
|
||||
|
||||
这就需要一个翻译官:**Projector (投射器)**。
|
||||
为什么读不懂呢?因为**特征空间不同**(也就是它们说的语言不同)。
|
||||
视觉编码器(如 ViT)提取出来的是**空间像素特征**(比如它只能告诉你“这是一个由很多弯曲黑色线条组成的东西”、“这里是大片红色”);而 LLM 内部理解的是**深层语义特征**(例如概念上的“猫”、“树木”、“危险”等)。
|
||||
|
||||
### 2.1 翻译官的作用
|
||||
在这两种截然不同的话语体系之间,我们需要架设一座桥梁,也就是我们的跨模态翻译官:**Projector (投射器/适配器)**。
|
||||
|
||||
Projector 的工作就是把**视觉特征向量**(ViT 的输出)转换成**文本特征向量**(LLM 的输入)。
|
||||
### 2.1 翻译官的作用 (Latent Space Alignment)
|
||||
|
||||
你可以把它理解为**外语翻译器**:
|
||||
Projector 的学术本质是实现**特征隐空间的对齐 (Latent Space Alignment)**。这就像是现实生活中的同声传译员:
|
||||
|
||||
- **输入**:视觉语言(ViT output)
|
||||
- **处理**:翻译(矩阵变换)
|
||||
- **输出**:LLM 语言(LLM embedding)
|
||||
- **输入 (Source)**:ViT 吐出的“视觉特征”(侧重于几何、颜色、纹理规律等连续的高维特征表示)。
|
||||
- **处理 (Translation)**:Projector 利用一个神经网络结构(可能是几层简单的线性变换层,或是复杂的注意力层),在这个过程中找到两种语言之间的数学对应关系。
|
||||
- **输出 (Target)**:输出完全符合 LLM 口味和预期的“LLM 语言”(由图片特征转换而成的等价文本嵌入 Token,使得图像拥有了可以对话的意义)。
|
||||
|
||||
通过这层翻译过滤,大模型就会惊奇地发现:“咦?传进来的这段数字串,不就是我平时读的那些带有描述性质的单词组合吗!”,从而顺理成章地将图片特征与自然语言共同处理。
|
||||
|
||||
<ProjectorDemo />
|
||||
|
||||
### 2.2 不同的翻译流派
|
||||
|
||||
为了翻译得更好,科学家们发明了不同的翻译工具:
|
||||
为了让特征对齐这道“翻译工序”做得更快、更准,学术界和工业界衍生出了几种极具代表性的硬件连接设计方案:
|
||||
|
||||
1. **直译派 (Linear)**:
|
||||
- 做法:简单粗暴,通过一个矩阵乘法直接转换。
|
||||
- 特点:**保留原汁原味**,但废话多(Token 数量多)。
|
||||
- 代表:LLaVA。
|
||||
1. **直译派 (Linear Projection)**:
|
||||
- **做法**:极其简单粗暴,仅用一层或几十层多层感知机 (MLP / 线性投影层) 进行直接的数学矩阵变换透传。
|
||||
- **特点**:**信息损耗极低,保留图像原汁原味细节**;但缺陷是将刚才切分的百上千个视觉词元毫无保留地全塞给语言模型,会导致后续计算量暴增。
|
||||
- **代表**:LLaVA 系列。
|
||||
|
||||
2. **意译派 (Q-Former/Resampler)**:
|
||||
- 做法:用一个小模型先读一遍图片,总结出几十个核心要点。
|
||||
- 特点:**精简**,Token 少,但可能会漏掉细节。
|
||||
- 代表:BLIP-2, Gemini。
|
||||
2. **意译派 (Q-Former / Resampler)**:
|
||||
- **做法**:并不是原样透传,而是在中间引入一个具有抽象总结能力的“小型侦察兵网络”。这个中间代理人先全盘快速理解一遍图片,提纯出几十个高度凝缩的核心要点。
|
||||
- **特点**:**信息高度精简提纯,Token 少,大大节省 LLM 思考理解的性能算力**;缺陷是有可能会在提纯过程中抛去原始图片边缘里极其细微的观察线索。
|
||||
- **代表**:BLIP-2, Gemini (部分机制类似)。
|
||||
|
||||
3. **折中派 (C-Abstractor)**:
|
||||
- 做法:把相邻的几个方块合并成一个,既压缩了长度,又保留了空间感。
|
||||
- 代表:Qwen-VL。
|
||||
3. **折中派 (C-Abstractor / Pooling)**:
|
||||
- **做法**:借助卷积池化或局部区域重整,把相邻的 $2 \times 2$ 或更大像素块压缩打包合并重组为一个完整的表达元。
|
||||
- **特点**:既合理压缩了词元的长度上限,又依然留存了部分相互依存的局部和空间感。
|
||||
- **代表**:Qwen-VL-Max。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第三步:合体 (The Architecture)
|
||||
|
||||
现在,零件都准备好了,我们把它们组装起来,就成了一个标准的 VLM。
|
||||
有了零件、有了对接标准,接下来我们看它是如何完成全身武装的。主流的多模态视觉语言模型 (Vision-Language Model) 基本都遵循统一的**“三段式”架构模型**。
|
||||
|
||||
### 3.1 VLM 的身体结构
|
||||
|
||||
<ModelArchitectureComparisonDemo />
|
||||
|
||||
一个典型的 VLM(如 LLaVA)由三个部分组成:
|
||||
一个典型范式下的 VLM 实体,主要由以下三大部分协同运转:
|
||||
|
||||
1. **眼睛 (Vision Encoder)**:
|
||||
- 负责看图。
|
||||
- 通常直接借用现成的、训练好的视觉模型(如 CLIP, SigLIP)。
|
||||
- _它就像视网膜,负责感光。_
|
||||
1. **特征感知的“眼睛” (Vision Encoder - 视觉编码器)**:
|
||||
- **功能**:作为图片输入的第一道关卡,负责看图并抽象出高维视觉特征。
|
||||
- **选型**:大多数厂商不会从零开始训练眼睛,而是直接借用在数亿张「图像-文本配对」数据上预训练好的成熟组件(如 OpenAI 的 CLIP 模型视觉塔,或者是谷歌的 SigLIP 模型)。
|
||||
- *形象类比:这就是生物体高度特化的视网膜感光细胞区域。*
|
||||
|
||||
2. **视神经 (Projector)**:
|
||||
- 负责传输和翻译信号。
|
||||
- 这是 VLM 训练的重点。
|
||||
- _它连接眼睛和大脑。_
|
||||
2. **信号转换的“视神经” (Projector - 模态投射器)**:
|
||||
- **功能**:对接编码器和语言基座,负责信号维度的压缩、打通和多模态语义翻译。
|
||||
- **选型**:这是整个多模态系统后续训练的**重中之重**。它自身的参数量通常不大(相对 LLM 而言),但决定了“文字”和“图片”之间能否心意相通。
|
||||
- *形象类比:它就像负责将电信号转换传递到大脑皮层的视觉神经中枢。*
|
||||
|
||||
3. **大脑 (LLM)**:
|
||||
- 负责思考和回答。
|
||||
- 借用现成的强大 LLM(如 Vicuna, Qwen)。
|
||||
- _它负责理解看到了什么,并组织语言回答。_
|
||||
3. **认知引擎“大脑” (LLM Backbone - 语言模型基座)**:
|
||||
- **功能**:承担最终的观察、常识调用、深度逻辑推理以及拟人化答复的生成工作。
|
||||
- **选型**:通常采用业内智商最高的开源大语言模型作为挂载点(如 Qwen, Llama 3, Vicuna 等)。
|
||||
- *形象类比:这是具备世界知识库的大脑语言和决策中序,它对视神经传来的加工后信号做出高阶思维判读。*
|
||||
|
||||
---
|
||||
|
||||
## 4. 它是怎么学会看图的?(Training)
|
||||
|
||||
刚组装好的 VLM 其实是“瞎”的,因为视神经(Projector)还没连通。我们需要分两步教它。
|
||||
好,现在身体各部分已经缝合在一起。但是在正式接客之前,刚组装好的 VLM 实际上是处于类似于新生儿的“失明与混乱”状态的——因为新增的视神经 (Projector) 是一张白纸,里边全是没有意义的随机数值。
|
||||
|
||||
### 阶段一:认物 (Feature Alignment)
|
||||
想要让这个拼接的怪物具备看图说话的能力,科学界总结出了一套高效的**“两阶段训练法则 (Two-Stage Training)”**。
|
||||
|
||||
这一阶段就像教婴儿认卡片。
|
||||
### 阶段一:认物 (Feature Alignment —— 认物预训练)
|
||||
|
||||
- **给它看**:一张“猫”的照片。
|
||||
- **告诉它**:这是“猫”。
|
||||
- **目标**:让 Projector 学会把“猫的照片特征”翻译成“猫这个字的向量”。
|
||||
- **状态**:冻结眼睛和大脑,**只训练视神经 (Projector)**。
|
||||
这一阶段,主要任务是让随机的 Projector 建立起初步的跨模态映射关系。过程非常像教婴儿用“认知闪卡”强行记单词。
|
||||
|
||||
- **给它看 (训练输入)**:大批量(往往上亿张)包含单个突出主体的极简配对图文(例如白底的“猫”照片)。
|
||||
- **告诉它 (目标输出)**:附带简短的标签词汇(“一只橘猫”)。
|
||||
- **优化目标**:强制驱使 Projector 学会通过矩阵变化,让这只猫的对应视觉特征(经过翻译后),和自然语言里的“猫”词元向量尽可能重合对齐。
|
||||
- **参数控制状态 (Freeze Strategy)**:为了防止破坏原有模型的智慧,在这个阶段研究人员会重度**冻结 (Freeze)** “眼睛”(ViT) 和 “大脑”(LLM) 的几十上百亿参数,**仅仅只开启“视神经”(Projector) 本身的几百万参数训练**。
|
||||
|
||||
<FeatureAlignmentDemo />
|
||||
|
||||
### 阶段二:对话 (Visual Instruction Tuning)
|
||||
### 阶段二:对话 (Visual Instruction Tuning —— 对话演练)
|
||||
|
||||
这一阶段是教它根据图片回答复杂问题。
|
||||
如果第一阶段只会让模型变成报菜名似的认字机,那么第二阶段的任务就是激发它的高级智商,让它真正能根据上下文解答人类复杂的图文结合指令。
|
||||
|
||||
- **用户问**:`<图片>` 图里的猫在干什么?
|
||||
- **教它答**:它在睡觉。
|
||||
- **目标**:让大脑 (LLM) 学会处理视觉信息,并结合常识进行推理。
|
||||
- **状态**:通常会同时微调 **Projector** 和 **LLM**。
|
||||
- **给它看 (训练输入)**:精心设计的高质量问答训练对。比如提供一张复杂的城市交通全景图。
|
||||
- **要求它答 (目标输出)**:User 提问:“`<图片>` 左下角那个骑白色自行车的男人有没有戴头盔?” Assistant 回答:“没有,他头上什么都没戴,这在城市里是很危险的行为。”
|
||||
- **优化目标**:让大模型不仅能接收视觉线索,还能结合从前的文明常识积淀,将文本逻辑与多模态表征彻底融汇贯通并做出推理。
|
||||
- **参数控制状态 (Freeze Strategy)**:此时视神经已经基本调通。在这个精调阶段,一般会继续冻结一部分视觉编码器底层权重,同时**彻底解冻开启 LLM 和 Projector**(或采用 LoRA 配置),进行全局大规模的联合反向传播调校。
|
||||
|
||||
<VLMInferenceDemo />
|
||||
|
||||
@@ -148,29 +156,28 @@ Projector 的工作就是把**视觉特征向量**(ViT 的输出)转换成**
|
||||
|
||||
## 5. 进阶:看得更清 (Advanced Tricks)
|
||||
|
||||
基础的 VLM 有个大问题:**视力不好**。
|
||||
传统的 ViT 只能看 $224 \times 224$ 或 $336 \times 336$ 分辨率的图。这就像透过一个低清摄像头看世界,小字根本看不清。
|
||||
虽然以上架构支撑起了最初的多模态范式,但第一代 VLM 模型存在一个非常令人头疼的基础硬伤——**近视眼(视力先天不足)**。
|
||||
|
||||
现在的模型(如 Qwen-VL, LLaVA-NeXT)用了一些聪明的方法来解决这个问题:
|
||||
早期的视觉编码器 ViT 因为历史设计原因,天生只能处理例如 $224 \times 224$ 或 $336 \times 336$ 这种极其低分辨率的方寸小图。这就像是强行通过一个模糊、低质的几十万像素复古摄像头去观察世界,图里面稍微小一点的文字牌匾等细节完全会糊成一团像素点,大脑就算再聪明也是“巧妇难为无米之炊”。
|
||||
|
||||
### 5.1 动态分辨率 (Dynamic Resolution)
|
||||
为了攻克低清病症,前沿的模型厂商(如 Qwen-VL 团队,LLaVA-NeXT 等)用了一些非常精妙的工程手段:
|
||||
|
||||
简单说,就是**“拼图法”**。
|
||||
### 5.1 动态高分辨率切分布局 (Dynamic High-Resolution Mapping)
|
||||
|
||||
如果图片很大(比如 $1000 \times 1000$),模型不会强行把它缩小,而是:
|
||||
如果直接输入大图会导致显存爆满,而粗暴缩小又会丢光所有细节,该如何破局?目前的解法是:**“局部特写 + 全局鸟瞰”的双视角策略**。
|
||||
|
||||
1. 把它切成好几张 $336 \times 336$ 的小图。
|
||||
2. 分别看这些小图(看细节)。
|
||||
3. 再把全图缩小看一遍(看全貌)。
|
||||
4. 最后把所有信息拼起来。
|
||||
1. **整体概览**:首先把巨大的原版高清图直接缩小压到 $336 \times 336$,送给眼睛看一眼。这让模型掌握画面的**总体宏观布局结构**(天空在哪?地面在哪?)。
|
||||
2. **切片放大看**:把高清原图切成好几十个独立、$336 \times 336$ 的无损局部特写切分块(Slice)。
|
||||
3. **逐一审视与空间回拼**:让视觉引擎挨个用放大镜去扫描这几十个无损切面收集高清细节。随后,Projector 会像拼图一样把这些细节块的语义与初始的总览语境相互缝合。
|
||||
|
||||
这就好比你用手机拍全景照片,分段扫描,最后合成一张高清大图。
|
||||
这种做法,就好比是你拿手机给一份报纸全景拍了一张照(看全貌版面布局),接着又端着手机贴近报纸连续拍下了几十张段落特写的组合过程。
|
||||
|
||||
### 5.2 换个大眼睛
|
||||
### 5.2 换个天生的大眼睛 (Scaling the Vision Encoder)
|
||||
|
||||
还有一种暴力美学:直接换一个更强的视觉模型。
|
||||
比如 **InternVL**,直接用了一个 60 亿参数的超大视觉模型(InternViT-6B)。
|
||||
这相当于从“手机摄像头”升级到了“哈勃望远镜”,不用切图也能看得一清二楚。
|
||||
另一种纯粹展现暴力美学的做法就是:既然原始的眼睛天生基因有缺陷,那我就重头炼制一颗最惊世骇俗的超级眼睛。
|
||||
|
||||
以国内优秀的开源模型 **InternVL** 为经典代表,它摒弃了常用的小规格视觉模型,从底向上直接耗费海量资源单独训练了一个参数量高达几十亿(如 60 亿参数的 InternViT-6B)的罕见超巨型视觉编码器前置基座。
|
||||
凭借极强的数据吸收能力,它生来就是原生支持高分辨率无缝输入的“哈勃空间望远镜”。这种设计大大降低了系统为了切图拼图而引入的复杂工程开销和特征错位风险,直接实现“一览无遗”的高清视觉感知。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,288 +1,120 @@
|
||||
# 语音合成与识别
|
||||
> 💡 **学习指南**:声音是空气的振动,也是情感的载体。本章节将带你了解 AI 如何"听懂"声音,又是如何像人一样"开口说话"甚至"作曲"的。从语音识别到音乐生成,探索音频 AI 的完整技术栈。
|
||||
|
||||
## 0. 快速上手:如何让 AI 说话?
|
||||
|
||||
### 0.1 常见的 AI 音频工具
|
||||
|
||||
**☁️ 在线服务 (简单易用)**
|
||||
|
||||
1. **ElevenLabs**: 目前最顶尖的语音合成,支持克隆任何声音。
|
||||
2. **Sunno AI**: 文本生成音乐,几秒钟内创作完整歌曲。
|
||||
|
||||
**💻 本地部署 (硬核玩家)**
|
||||
|
||||
1. **Coqui TTS**: 开源语音合成工具包。
|
||||
2. **Bark**: Meta 开源的零样本 TTS。
|
||||
3. **RVC (Retrieval-based Voice Conversion)**: 基于检索的语音变声。
|
||||
|
||||
### 0.2 为什么要学习 AI 音频?(Why Audio AI?)
|
||||
|
||||
你可能会问:_"文字交流已经很方便了,为什么还需要语音?"_ 或者 _"我是程序员,为什么要懂音频处理?"_
|
||||
|
||||
这并非为了替代文字交互,而是因为 **语音是最高效的信息传递方式之一**:
|
||||
|
||||
#### 1. 传递效率:秒级理解
|
||||
|
||||
- **文字**:阅读一段话需要数秒到数分钟。
|
||||
- **语音**:人类说话速度约 150-200 词/分钟,且可以同时传递情感。
|
||||
|
||||
#### 2. 情感载体:超越文字
|
||||
|
||||
- **文字**:只能通过标点符号和表情符号表达有限的情感。
|
||||
- **语音**:语调、停顿、语速、笑声都能传递丰富的情感信息。
|
||||
|
||||
#### 3. 解放双手:自然交互
|
||||
|
||||
- **场景**:开车、做饭、运动时,打字不方便,但说话很容易。
|
||||
- **未来**:AI 助手将通过语音成为我们的自然伙伴。
|
||||
# 语音合成与识别原理
|
||||
> 💡 **学习指南**:本章节将带你深入了解 AI 音频底层原理。我们不仅会探讨“生涩”的声学专业术语(如 STFT、流匹配、音色嵌入),还会通过通俗的比喻和直观的交互演示,让你彻底明白 AI 是如何“听懂人话”并“开口说话”的。即使你是零基础读者,也能轻松掌握!
|
||||
|
||||
<AudioQuickStartDemo />
|
||||
|
||||
## 1. 概念界定:音频的数字化 (Definition)
|
||||
## 0. 引言:物理声波的“数字化翻译”
|
||||
|
||||
_很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字化的音频信号**。_
|
||||
人类的语音和世界上的各种声音,本质上是空气振动产生的**连续物理声波**。但计算机的脑子里只有 `0` 和 `1`,它听不见声音。因此,让 AI 处理声音的第一步,就是跨越“物理世界”与“数字世界”的鸿沟。
|
||||
|
||||
在物理世界,声音是连续的波(Wave)。在数字世界,我们通常用**采样率**(比如 44.1kHz)把波形记录下来。
|
||||
这个过程叫做**声数转换 (A/D 转换)**,其核心输出就是 **脉冲编码调制 (PCM)** 波形,也就是我们常见的音频数据。它由两个核心指标决定:
|
||||
1. **采样率 (Sample Rate)**:一秒钟内给声波拍多少次“照片”。比如 16kHz 就是一秒钟记录 16,000 个振幅数字。
|
||||
2. **位深度 (Bit Depth)**:每次拍照的“标尺”有多精细。16-bit 意味着振幅有 65,536 个层级的区分度。
|
||||
|
||||
但对于 AI 来说,直接处理每秒 44100 个数字太累了,而且这些数字本身没有明显的语义含义。
|
||||
但这带来了一个问题:一秒钟 16,000 个数字,一句话几十万个数字,信息量大且冗杂。如果直接把这长长的一维波形丢给神经网络去处理,这就好比**让一个人通过凑近看毛衣上的一根根毛线结构,去判断这件毛衣的图案好不好看**——这显然是极其困难的计算挑战。
|
||||
|
||||
- **传统信号处理**:处理原始波形(WAV 文件)。
|
||||
- **AI 音频模型**:处理更有意义的"中间表示"。
|
||||
---
|
||||
|
||||
本质上,音频 AI 是一个**从物理信号到语义表示**的转换过程:
|
||||
## 1. 特征工程:给 AI 戴上“人类的耳朵”
|
||||
|
||||
- **物理层**:声波振动(模拟信号)
|
||||
- **数字层**:采样点序列(PCM 数据)
|
||||
- **表示层**:频谱图、Token、Embeddings(AI 能理解的形式)
|
||||
既然直接看“一维波形 (Time-Domain)”行不通,科学家们便想到了一个降维打击的办法:**把一维的声音,变成二维的频率图谱 (Frequency-Domain)。**
|
||||
|
||||
## 2. 核心架构:两种主流范式 (The Big Picture)
|
||||
### 1.1 从一条线到一张图:短时傅里叶变换 (STFT)
|
||||
想象一下,听一首交响乐时,我们很少去在意某个瞬间空气振动的位移总量,我们更在意的是这段时间里**有哪些乐器(不同频率)、声音有多大(能量)**。
|
||||
|
||||
要让 AI 处理音频,科学家们设计了两种完全不同的范式。理解它们的差异是掌握音频 AI 的关键。
|
||||
通过**短时傅里叶变换 (STFT)** 这个数学魔法,我们可以把平铺直叙的声波,拆解成一张包含“时间、频率、能量(颜色深浅)”的二维矩阵图片,这被称为 **频谱图 (Spectrogram)**。至此,处理声音的问题,被巧妙地转化为了 AI 更擅长处理的“看图”问题。
|
||||
|
||||
### 2.1 范式一:离散化 (Tokenization) — 把声音当文字
|
||||
### 1.2 迎合听觉习惯:梅尔刻度 (Mel Scale)
|
||||
物理学上的频率分布是线性的(0-100Hz 的跨度和 10000-10100Hz 一样长)。但**人类的耳朵是非常“双标”的**:我们对低沉的声音(低频)变化极其敏感,却对尖锐的高保真声音(高频)的细微差别迟钝不已。
|
||||
|
||||
如果把声音也变成 Token(就像 GPT 处理文本那样),是不是就能用语言模型来生成声音了?
|
||||
为了让 AI 能像人类一样,“把有限的注意力放在更重要的地方”,研究者引入了非线性的 **梅尔滤波器组 (Mel Filterbanks)**。它在低频区域划分极细,高频区域则粗略包裹。
|
||||
经过对数转换后,我们得到了当代音频 AI 的灵魂基石——**梅尔频谱 (Mel-Spectrogram)**。
|
||||
|
||||
**核心思想**:
|
||||
👇 **动手点点看**:在下方观察一维的机器波形如何被转化为符合人类感知的二维色彩图谱。
|
||||
<MelSpectrogramDemo />
|
||||
|
||||
1. **切碎**:把连续的音频波形切成小段(每段 20-40ms)。
|
||||
2. **量化**:在预训练的"声音字典"里找到最像的那段声音的代号(Code)。
|
||||
3. **序列化**:一段音频变成了一串数字序列:`[1024, 2048, 55, ...]`
|
||||
4. **语言建模**:用 GPT 生成下一个 Token,就像预测下一个词。
|
||||
---
|
||||
|
||||
## 2. 让大模型学会“外语”:两种主流生成范式
|
||||
|
||||
当提取完特征后,我们该如何教 AI 生成声音?目前学术界和工业界有两大并行的“魔法阵”。
|
||||
|
||||
### 2.1 范式一:把声音当文字 (Audio Tokenization)
|
||||
伴随 ChatGPT 的火爆,科学家们思考:如果把声音也变成一个接一个的“汉字(Token)”,大语言模型(LLM)是不是就能直接唱歌说话了?
|
||||
- **压缩与量化**:依靠强大的 **神经编解码器 (Neural Codec,如 EnCodec)** 和 VQ-VAE 架构,一段几兆大小的音频会被极限压缩,最终变成一本字典里的一个个离散代号(比如序列:`[82, 105, 33...]`)。
|
||||
- **生成接龙**:AI 模型只需像做文字接龙一样,预测下一个声音 Token 是什么。这极大地统一了多模态学习的底层架构!
|
||||
|
||||
<AudioTokenizationDemo />
|
||||
|
||||
**代表模型**:AudioLM, VALL-E, MusicLM
|
||||
### 2.2 范式二:把声音当画作 (Spectrogram Generation)
|
||||
这是目前大量成熟语音软件的基石方案,可控性极佳。
|
||||
- **谱图生成**:AI 模型并不输出最终的音频波形,而是直接学习“文本”到“二维梅尔频谱图”的映射,像画家一样画出一张声学特征图。
|
||||
- **还原波形 (Vocoder)**:由于频谱图丢失了相位等细节信息无法直接播放,我们需要一个**声码器 (Vocoder,如 HiFi-GAN)** 充当翻译官,将这张图完好无损地等效还原回能推动喇叭振动的一维波形。
|
||||
|
||||
**优点**:
|
||||
---
|
||||
|
||||
- 能学到非常自然的韵律和情感
|
||||
- 可以用同一个模型做语音合成、音乐生成、音效生成
|
||||
## 3. 双端互逆:ASR 与 TTS 的协同翻译
|
||||
|
||||
**缺点**:
|
||||
让机器拥有“耳朵”和“嘴巴”,其实是在做两场南辕北辙的翻译:
|
||||
|
||||
- 容易"胡言乱语"(重复、漏词)
|
||||
- 生成速度慢(必须逐个 Token 生成)
|
||||
|
||||
### 2.2 范式二:频谱生成 (Spectrogram-based) — 把声音当图像
|
||||
|
||||
声音本质上是波,而波的频谱(频率成分随时间变化)看起来像一张图像。
|
||||
|
||||
**核心思想**:
|
||||
|
||||
1. **变换**:通过傅里叶变换(FFT)将波形转换为**梅尔频谱图 (Mel-Spectrogram)**。
|
||||
2. **生成**:用图像生成模型(如 CNN、Diffusion)生成频谱图。
|
||||
3. **还原**:通过**声码器 (Vocoder)** 将频谱图还原为音频波形。
|
||||
|
||||
<MelSpectrogramDemo />
|
||||
|
||||
**代表模型**:Tacotron 2, FastSpeech, F5-TTS
|
||||
|
||||
**优点**:
|
||||
|
||||
- 生成速度快(可以并行生成整段频谱)
|
||||
- 鲁棒性强(不容易漏词)
|
||||
|
||||
**缺点**:
|
||||
|
||||
- 频谱图丢弃了相位信息,需要声码器重建
|
||||
- 情感和韵律的表达不如 Tokenization 自然
|
||||
|
||||
## 3. 梅尔频谱详解 (Mel-Spectrogram Deep Dive)
|
||||
|
||||
梅尔频谱是音频 AI 中最核心的表示之一。理解它需要一点点物理和信号处理知识。
|
||||
|
||||
### 3.1 什么是频谱图?
|
||||
|
||||
想象你听到一段音乐,有高音(小提琴)、低音(大提琴)、鼓点。**频谱图**就是把这些成分可视化:
|
||||
|
||||
- **横轴**:时间
|
||||
- **纵轴**:频率(音高)
|
||||
- **颜色深浅**:响度(音量)
|
||||
|
||||
### 3.2 为什么是"梅尔"频谱?
|
||||
|
||||
人耳对频率的感知不是线性的。我们能区分 100Hz 和 200Hz,但很难区分 10000Hz 和 10100Hz。
|
||||
|
||||
**梅尔刻度 (Mel Scale)** 模拟了人耳的感知特性:
|
||||
|
||||
- 低频区域:分辨率高(区分细微音高变化)
|
||||
- 高频区域:分辨率低(人耳听不出来)
|
||||
|
||||
这让 AI 更关注人耳敏感的部分,忽略不重要的细节。
|
||||
|
||||
## 4. TTS 流程全景 (TTS Pipeline)
|
||||
|
||||
文本转语音(TTS)是音频 AI 最核心的应用之一。让我们深入了解其完整流程。
|
||||
|
||||
<TTSPipelineDemo />
|
||||
|
||||
### 4.1 自回归 vs 非自回归
|
||||
|
||||
| 特性 | 自回归 (AR) | 非自回归 (NAR) | 流匹配 (Flow) |
|
||||
| -------- | ----------- | -------------- | ------------- |
|
||||
| 生成方式 | 逐个时间步 | 一次性生成 | 流匹配路径 |
|
||||
| 速度 | 慢 | 快 | 很快 |
|
||||
| 音质 | 高 | 中高 | 高 |
|
||||
| 代表模型 | Tacotron 2 | FastSpeech 2 | F5-TTS |
|
||||
|
||||
### 4.2 关键组件
|
||||
|
||||
1. **文本前端 (Text Frontend)**:将文本转换为音素序列,处理多音字、数字、缩写等。
|
||||
2. **声学模型 (Acoustic Model)**:将音素转换为声学特征(梅尔频谱)。
|
||||
3. **声码器 (Vocoder)**:将声学特征还原为音频波形。
|
||||
|
||||
## 5. ASR 与 TTS:语音的双向转换 (ASR vs TTS)
|
||||
|
||||
语音识别(ASR)和语音合成(TTS)是音频 AI 的两个核心方向,它们互为逆过程。
|
||||
- **自动语音识别 (ASR)**:将声音翻译为文字。这是一道**多对一的收敛选择题**。模型(如 Whisper)必须在充满嘈杂环境噪音、口音变化、同音字干扰(“期中”与“期终”)的海量音频中,提炼锁定出唯一正确的语义文字。
|
||||
- **文本转语音 (TTS)**:将文字翻译为声音。这是一道**一对多的发散创作题**。同样一句干瘪的“你好”,它可以带着一万种不同的语速、情绪、停顿和嗓音。模型必须有能力脑补出这些缺失的参数。
|
||||
|
||||
<ASRvsTTSDemo />
|
||||
|
||||
### 5.1 ASR:音频 → 文本
|
||||
---
|
||||
|
||||
- **输入**:音频波形
|
||||
- **输出**:文本/Token
|
||||
- **核心任务**:模式识别、分类
|
||||
- **代表模型**:Whisper, Conformer
|
||||
## 4. 从“挤牙膏”到“直通车”:TTS 核心架构换代
|
||||
|
||||
### 5.2 TTS:文本 → 音频
|
||||
在了解了基础流程后,我们看看 TTS 引擎是如何追求极致速度和连贯性的。
|
||||
|
||||
- **输入**:文本序列
|
||||
- **输出**:音频波形
|
||||
- **核心任务**:序列生成、回归
|
||||
- **代表模型**:F5-TTS, CosyVoice
|
||||
- **串行笨方法 (自回归 AR)**:老一代模型必须遵循时间先后,生成完上一毫秒,才能以此为基准预测下一毫秒。这种方法虽然稳妥,但**极易卡壳且速度缓慢**。
|
||||
- **神级预判 (非自回归 NAR)**:后续的模型引入了**时长预测器 (Duration Predictor)**,不再排队生成,而是一次性为每个声素“算命”出它该有的时长,接着兵分多路**瞬间并行输出整句音频**。
|
||||
- **常微分快车道 (流匹配 Flow Matching)**:这是当下的**终极前沿方案**(如 F5-TTS)。它运用连续正规化流和常微分方程 (ODE) 等复杂数学原理,摒弃了传统的生硬搭建。模型学习的是一条从“纯白噪声”到“完美频谱”的最优直达运动轨迹(概率流)。不仅计算效率呈指数级上升,其声音的平滑与自然度也达到了巅峰。
|
||||
|
||||
### 5.3 联合应用
|
||||
<TTSPipelineDemo />
|
||||
|
||||
- **语音助手**:ASR → LLM → TTS
|
||||
- **实时翻译**:ASR → 翻译 → TTS
|
||||
- **字幕生成**:视频 → ASR → 字幕
|
||||
---
|
||||
|
||||
## 6. 声音克隆:零样本能力的魔法 (Zero-Shot Voice Cloning)
|
||||
## 5. 零样本声音克隆 (Zero-Shot Voice Cloning)
|
||||
|
||||
早期的 TTS 需要几十小时的数据来训练一个声音。现在,我们只需要几秒钟。
|
||||
仅仅在几年前,要想用 AI 模仿某人的声音,还得让他在极其安静的录音棚录上几万句话并花费数天训练模型。而今天,仅需 **3 秒钟的语音条**,AI 就能以假乱真。
|
||||
|
||||
这背后依赖一项核心技术:**说话人特征编码器 (Speaker Encoder)** 和度量学习。
|
||||
- 这不仅是一个监听器,更是一个**“基因提取仪”**。它的任务是剥离掉音频里的背景噪音和具体说了什么话(Text),强行且唯一地抓取出关于你的生理恒定特征:声带有多宽?共鸣音腔有多大?咬字有什么习惯?
|
||||
- 这些特征最终会被压扁成一个几百维的**说话人嵌入向量 (Speaker Embeddings, 如 x-vector)**。这串如同条形码般的数字完全表征了你的声音身份。随后的 TTS 模型只要“带上这串向量”进行条件生成,吐出的任何语言都会带上你的嗓音特色。
|
||||
|
||||
<VoiceCloningDemo />
|
||||
|
||||
### 6.1 声音编码器 (Speaker Encoder)
|
||||
---
|
||||
|
||||
声音编码器是一个神经网络,它的任务是:**把一段音频压缩成一个固定长度的向量(Embedding)**。
|
||||
## 6. 赋予灵魂:情感节奏与细粒度风格控制
|
||||
|
||||
这个向量捕捉了声音的"身份":
|
||||
一句“真的吗”,既可以是惊喜,也可以是愤怒质疑。商业级的高阶 AI 不仅要“读对字”,更要“带有感情”。
|
||||
|
||||
- 音色(低沉 vs 清脆)
|
||||
- 声道特征(男声 vs 女声)
|
||||
- 说话风格(语速、停顿习惯)
|
||||
|
||||
### 6.2 零样本合成流程
|
||||
|
||||
有了声音编码器,我们就能实现"一句话克隆":
|
||||
|
||||
1. **提取声音特征**:参考音频 → 声音编码器 → 声音向量(如 256 维)
|
||||
2. **条件生成**:文本 + 声音向量 → TTS 模型 → 音频
|
||||
|
||||
这就是 ElevenLabs、CosyVoice 等工具的核心技术。
|
||||
|
||||
## 7. 情感与风格控制 (Emotion & Style Control)
|
||||
|
||||
现代 TTS 系统不仅能合成自然的语音,还能精确控制情感、语速、语调等风格特征。
|
||||
学术界提出了 **全局风格 Token (GST)** 以及特征瓶颈机制。大模型可以从海量的人类演绎录音中聚类提取出对应的“伤心”、“激动”、“慵懒”等抽象的软向量。
|
||||
在工程落地时,我们还引入了基频 (F0,掌控音调升降)、能量 (Energy,掌控音量爆破音) 等直观的适配器调节参数,赋予了创作者像捏游戏人物脸型一样,精细捏合“语音情绪”的能力。
|
||||
|
||||
<EmotionControlDemo />
|
||||
|
||||
### 7.1 全局风格 Token (GST)
|
||||
---
|
||||
|
||||
GST (Global Style Token) 是一种从参考音频中提取风格特征的方法。模型学习将情感、语速、语调等风格信息编码成一组 Token,在推理时可以通过选择或插值这些 Token 来控制合成风格。
|
||||
## 7. 结语
|
||||
|
||||
### 7.2 细粒度控制
|
||||
从基础的数字信号转换(PCM),到降维提纯(Mel-Spectrogram),直至时下大火的基于“流匹配算法(Flow Matching)”和“神经编解码(Neural Codec)”的多模态大基座,音频 AI 正在上演一场从机械仿真向原生理解的跃升。
|
||||
|
||||
现代 TTS 模型支持细粒度的风格控制:
|
||||
未来的人工智能代理(AI Agent),将彻底打通人类视、听、说的高维链路,像拥有真人直觉一般应对每一次交流!
|
||||
|
||||
- **速度控制**:调整音频播放速度而不改变音调
|
||||
- **音调控制**:改变基频 (F0) 曲线
|
||||
- **能量控制**:调整音量包络
|
||||
- **停顿控制**:调整句间和短语间的停顿长度
|
||||
---
|
||||
|
||||
## 8. 生成机制演进 (Generation Evolution)
|
||||
## 8. 核心术语速查表 (Glossary)
|
||||
|
||||
音频生成模型经历了从模仿人类到直接建模的演进。
|
||||
|
||||
### 8.1 Audio Language Model (如 VALL-E, AudioLM)
|
||||
|
||||
这一派的思想是:**把声音当语言学**。
|
||||
|
||||
- **原理**:使用 GPT 架构(Decoder-only Transformer)。
|
||||
- **输入**:文本 Token + 音频 Token
|
||||
- **预测**:像成语接龙一样,根据前面的声音,预测下一个声音 Token。
|
||||
|
||||
**优点**:
|
||||
|
||||
- 能学到非常自然的韵律、停顿和情感
|
||||
- 可以通过"上下文学习"快速适应新声音
|
||||
|
||||
**缺点**:
|
||||
|
||||
- 容易"胡言乱语"(重复、漏词)
|
||||
- 生成速度慢(必须逐个 Token 生成)
|
||||
|
||||
### 8.2 Flow Matching TTS (如 F5-TTS, CosyVoice, Matcha-TTS)
|
||||
|
||||
这是目前最前沿的流派,结合了生成模型的最新进展。
|
||||
|
||||
- **原理**:不预测 Token,而是直接在**频谱层面**进行流匹配(Flow Matching)。
|
||||
- **过程**:
|
||||
1. 输入:文本 + 带有噪声的频谱
|
||||
2. 模型:预测一个"向量场",指导噪声如何一步步"流"动变成清晰的语音频谱
|
||||
3. 声码器:把生成的频谱还原成波形
|
||||
|
||||
**优点**:
|
||||
|
||||
- **速度快**:不需要像 GPT 那样逐个 Token 蹦,可以并行生成
|
||||
- **鲁棒性强**:不容易丢字漏字
|
||||
- **零样本克隆**:给一段几秒钟的参考音频,立马就能模仿它的音色和语调
|
||||
|
||||
## 9. 总结 (Summary)
|
||||
|
||||
音频 AI 的进化,正在从"信号处理"走向"语义理解"。
|
||||
|
||||
- **Tokenization** 把声音变成了语言,让 GPT 能"开口说话"。
|
||||
- **Flow Matching** 把生成速度提升了数十倍,让实时语音合成成为可能。
|
||||
- **Speaker Encoder** 让声音克隆像换皮肤一样简单。
|
||||
- **Emotion Control** 让 AI 语音充满情感,适应各种场景。
|
||||
|
||||
未来的 AI(如 GPT-4o),将不再需要把声音转成文字再转回去,而是**直接在统一的多模态空间里理解声音的笑声、语气和情绪**。
|
||||
|
||||
## 附录:常用术语表 (Vocabulary)
|
||||
|
||||
| 术语 | 英文 | 解释 |
|
||||
| :--------------- | :--------------------------- | :------------------------------------------- |
|
||||
| **采样率** | Sample Rate | 每秒采集的音频样本数(如 44.1kHz)。 |
|
||||
| **梅尔频谱** | Mel-Spectrogram | 模拟人耳感知的频谱表示,音频 AI 的核心输入。 |
|
||||
| **声码器** | Vocoder | 将频谱图还原为音频波形的模型。 |
|
||||
| **TTS** | Text-to-Speech | 文本转语音,让 AI 说话的技术。 |
|
||||
| **ASR** | Automatic Speech Recognition | 自动语音识别,让 AI 听懂的技术。 |
|
||||
| **零样本克隆** | Zero-Shot Cloning | 只需几秒参考音频就能模仿任何声音。 |
|
||||
| **流匹配** | Flow Matching | 一种高效的生成方法,用于最新的 TTS 模型。 |
|
||||
| **声音编码器** | Speaker Encoder | 提取声音身份特征的神经网络。 |
|
||||
| **GST** | Global Style Token | 全局风格 Token,用于情感控制。 |
|
||||
| **神经编解码器** | Neural Codec | 将音频压缩为离散 Token 的模型。 |
|
||||
| 术语 | 英文全称 | 释义 |
|
||||
| :--- | :--- | :--- |
|
||||
| **PCM** | Pulse-Code Modulation | 脉冲编码调制,最原始、最庞大的一维音频波形记录方式。 |
|
||||
| **STFT** | Short-Time Fourier Transform | 短时傅里叶变换,将声音从随时间变化的单一振幅,变为兼具频率与能量的数学分析方法。 |
|
||||
| **梅尔频谱** | Mel-Spectrogram | 大模型处理声音的基础特征:一种经过对数与人类非线性听觉偏好调整后的高价值二维音频图谱。 |
|
||||
| **神经编解码器** | Neural Codec | 依靠极其硬核的变分自编码残差技术,将超大尺寸连续声波高度压缩转化成离散标号(Token)的 AI 组件。 |
|
||||
| **Vocoder** | 声码器 | “逆向翻译官”:负责将二维的梅尔频谱图重新物理渲染回能驱动音响发声的一维音频波形。 |
|
||||
| **Speaking Embeddings** | 说话人特征向量 | 将特定人员的专属嗓音音色固定下来的极高维度且不可变的数学 ID(如 x-vector)。 |
|
||||
| **Flow Matching** | 流匹配 | 将正态分布转化为经验数据分布的一种无需昂贵微分随机计算,而是沿常微分方程建立一条常态直线平滑生成路径的前沿 AI 推断过程。 |
|
||||
|
||||
@@ -1,3 +1,237 @@
|
||||
# 代码质量与重构
|
||||
|
||||
> 待实现
|
||||
::: tip 前言
|
||||
**代码写出来能跑就行了吗?** 你可能写过这样的代码:功能是实现了,但过了两周自己都看不懂了。或者团队里有人离职,留下一堆"只有上帝和他才能看懂"的代码。
|
||||
|
||||
本章带你理解什么是好代码,如何识别坏代码,以及如何安全地改进它。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | 代码坏味道 | 识别常见问题 |
|
||||
| **第 2 章** | 重构手法 | 安全地改进代码 |
|
||||
| **第 3 章** | 代码审查 | 团队协作中的质量保障 |
|
||||
| **第 4 章** | 质量度量 | 用数据衡量代码健康度 |
|
||||
|
||||
学完本章,你将掌握识别代码问题、安全重构、以及通过团队协作持续提升代码质量的方法。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:代码的生命周期
|
||||
|
||||
在软件开发中,有一个常被忽视的事实:**代码被阅读的次数远远多于被编写的次数**。
|
||||
|
||||
一段代码从诞生到退役,大致会经历这样的旅程:
|
||||
|
||||
::: tip 代码的一生
|
||||
- **编写阶段**:开发者写下第一版实现,功能跑通了,测试通过了。
|
||||
- **审查阶段**:团队成员阅读代码,提出改进建议。
|
||||
- **维护阶段**:修 Bug、加功能、适配新需求——这个阶段占据了代码生命周期的 80% 以上。
|
||||
- **重构阶段**:当代码变得难以维护时,需要在不改变外部行为的前提下改善内部结构。
|
||||
- **退役阶段**:技术迭代,旧代码被新方案替代。
|
||||
:::
|
||||
|
||||
Martin Fowler 在《重构》一书中说过:**"任何一个傻瓜都能写出计算机能理解的代码,唯有好的程序员才能写出人类能理解的代码。"**
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码坏味道:识别常见问题
|
||||
|
||||
### 1.1 什么是代码坏味道?
|
||||
|
||||
"代码坏味道"(Code Smell)这个概念由 Kent Beck 提出,指的是代码中那些**虽然不是 Bug,但暗示着更深层设计问题**的特征。就像房间里有股怪味——不会立刻让你生病,但说明某个地方需要清理了。
|
||||
|
||||
通过下面的交互组件,识别几种最常见的代码坏味道:
|
||||
|
||||
<CodeSmellDemo />
|
||||
|
||||
### 1.2 常见坏味道清单
|
||||
|
||||
| 坏味道 | 症状 | 危害 |
|
||||
|-------|------|------|
|
||||
| **过长函数** | 函数超过 50 行 | 难以理解、测试和复用 |
|
||||
| **魔法数字** | 代码中直接写 `86400000` | 含义不明,修改时容易遗漏 |
|
||||
| **重复代码** | 相似逻辑出现在多处 | 修改时必须同步多处,容易遗漏 |
|
||||
| **过深嵌套** | 超过 3 层的 if/for | 逻辑像迷宫,难以追踪 |
|
||||
| **过长参数列表** | 函数参数超过 4 个 | 调用困难,容易传错顺序 |
|
||||
| **上帝类** | 一个类/模块做了太多事 | 职责不清,牵一发动全身 |
|
||||
|
||||
::: tip 核心洞察
|
||||
坏味道不是"错误",而是"信号"。它告诉你:这里的设计可能需要改进。不是所有坏味道都需要立刻修复,但你需要有能力识别它们。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 2. 重构手法:安全地改进代码
|
||||
|
||||
### 2.1 什么是重构?
|
||||
|
||||
重构(Refactoring)的定义非常精确:**在不改变代码外部行为的前提下,改善其内部结构。**
|
||||
|
||||
关键词是"不改变外部行为"。重构不是重写,不是加功能,不是修 Bug。它是对代码内部的"整理收纳"。
|
||||
|
||||
通过下面的组件,对比几种常见重构手法的前后变化:
|
||||
|
||||
<RefactoringDemo />
|
||||
|
||||
### 2.2 常用重构手法
|
||||
|
||||
**提炼函数(Extract Function)**
|
||||
|
||||
这是最常用的重构手法。当一段代码可以用一个有意义的名字来概括时,就应该把它提炼成函数。
|
||||
|
||||
```javascript
|
||||
// 重构前
|
||||
function printReport(data) {
|
||||
// 计算总价
|
||||
let total = 0
|
||||
for (const item of data.items) {
|
||||
total += item.price * item.qty
|
||||
}
|
||||
// 打印...
|
||||
}
|
||||
|
||||
// 重构后
|
||||
function calculateTotal(items) {
|
||||
return items.reduce((sum, item) => sum + item.price * item.qty, 0)
|
||||
}
|
||||
|
||||
function printReport(data) {
|
||||
const total = calculateTotal(data.items)
|
||||
// 打印...
|
||||
}
|
||||
```
|
||||
|
||||
**重命名(Rename)**
|
||||
|
||||
好的命名是最廉价也最有效的文档。当你需要写注释来解释一个变量/函数的含义时,说明它的名字不够好。
|
||||
|
||||
```javascript
|
||||
// 重构前
|
||||
const d = new Date() - startTime // 经过的时间
|
||||
const arr = users.filter(u => u.a) // 活跃用户
|
||||
|
||||
// 重构后
|
||||
const elapsedMs = new Date() - startTime
|
||||
const activeUsers = users.filter(user => user.isActive)
|
||||
```
|
||||
|
||||
**用卫语句替代嵌套(Replace Nested Conditional with Guard Clauses)**
|
||||
|
||||
```javascript
|
||||
// 重构前
|
||||
function getPayAmount(employee) {
|
||||
if (employee.isSeparated) {
|
||||
return { amount: 0 }
|
||||
} else {
|
||||
if (employee.isRetired) {
|
||||
return { amount: employee.pension }
|
||||
} else {
|
||||
return { amount: employee.salary }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重构后
|
||||
function getPayAmount(employee) {
|
||||
if (employee.isSeparated) return { amount: 0 }
|
||||
if (employee.isRetired) return { amount: employee.pension }
|
||||
return { amount: employee.salary }
|
||||
}
|
||||
```
|
||||
|
||||
::: tip 重构的安全网
|
||||
重构最大的风险是"改着改着就改出 Bug 了"。所以重构的前提是**有测试覆盖**。每次小步重构后运行测试,确保行为没变。没有测试的代码,先补测试再重构。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码审查:团队协作中的质量保障
|
||||
|
||||
### 3.1 为什么需要代码审查?
|
||||
|
||||
代码审查(Code Review)是团队中最有效的质量保障手段之一。它的价值不仅在于发现 Bug,更在于:
|
||||
|
||||
- **知识共享**:团队成员了解彼此的代码,降低"巴士因子"(如果某人被巴士撞了,项目还能继续吗?)
|
||||
- **统一风格**:通过审查逐步形成团队的编码规范
|
||||
- **提前发现设计问题**:比 Bug 更难修的是糟糕的架构决策
|
||||
- **互相学习**:看别人的代码是提升编程能力的捷径
|
||||
|
||||
### 3.2 审查什么?
|
||||
|
||||
| 维度 | 关注点 |
|
||||
|------|--------|
|
||||
| **正确性** | 逻辑是否正确?边界条件是否处理? |
|
||||
| **可读性** | 命名是否清晰?结构是否易懂? |
|
||||
| **安全性** | 是否有注入风险?敏感数据是否暴露? |
|
||||
| **性能** | 是否有明显的性能问题?N+1 查询? |
|
||||
| **测试** | 是否有对应的测试?覆盖了关键路径吗? |
|
||||
|
||||
### 3.3 审查的礼仪
|
||||
|
||||
好的代码审查是**对代码的讨论,而不是对人的批评**:
|
||||
|
||||
- 用"我们"而不是"你":~~"你这里写错了"~~ → "这里我们可以考虑用 guard clause"
|
||||
- 提问而不是命令:~~"改成 const"~~ → "这个变量后面会被重新赋值吗?如果不会,用 const 更安全"
|
||||
- 给出理由:不只说"不好",要说"为什么不好"以及"怎样更好"
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码质量度量
|
||||
|
||||
### 4.1 圈复杂度
|
||||
|
||||
圈复杂度(Cyclomatic Complexity)衡量代码中独立路径的数量。每个 `if`、`for`、`case`、`&&`、`||` 都会增加复杂度。
|
||||
|
||||
| 复杂度 | 评价 | 建议 |
|
||||
|--------|------|------|
|
||||
| 1-10 | 简单 | 容易理解和测试 |
|
||||
| 11-20 | 中等 | 考虑拆分 |
|
||||
| 21-50 | 复杂 | 必须重构 |
|
||||
| 50+ | 不可维护 | 紧急重构 |
|
||||
|
||||
### 4.2 代码覆盖率
|
||||
|
||||
代码覆盖率衡量测试执行了多少比例的代码。常见指标:
|
||||
|
||||
- **行覆盖率**:被执行的代码行占总行数的比例
|
||||
- **分支覆盖率**:被执行的条件分支占总分支的比例
|
||||
|
||||
::: tip 覆盖率的陷阱
|
||||
80% 的覆盖率不代表代码质量好。覆盖率只能告诉你"哪些代码没被测试到",不能告诉你"测试是否有意义"。一个只断言 `expect(true).toBe(true)` 的测试可以提高覆盖率,但毫无价值。
|
||||
:::
|
||||
|
||||
### 4.3 实用工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| **ESLint** | JavaScript/TypeScript 静态分析 |
|
||||
| **Prettier** | 代码格式化,统一风格 |
|
||||
| **SonarQube** | 综合代码质量平台 |
|
||||
| **Husky** | Git hooks,提交前自动检查 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 总结
|
||||
|
||||
回顾这一路,我们从识别问题到解决问题,建立了一套完整的代码质量改进体系:
|
||||
|
||||
1. **识别**:学会闻到代码坏味道,知道哪里需要改进
|
||||
2. **重构**:掌握安全的重构手法,在测试保护下小步改进
|
||||
3. **协作**:通过代码审查,让团队共同守护代码质量
|
||||
4. **度量**:用客观指标追踪代码健康度
|
||||
|
||||
::: tip 终极思考
|
||||
代码质量不是一次性的工作,而是持续的习惯。就像保持房间整洁一样——不是等到乱得不行了才大扫除,而是每天随手整理。**童子军法则**说得好:离开时让代码比你来时更干净一点。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- **经典书籍**:Martin Fowler《重构:改善既有代码的设计》是这个领域的圣经。
|
||||
- **代码整洁之道**:Robert C. Martin《Clean Code》提供了大量实用的编码原则。
|
||||
- **实践工具**:尝试在项目中配置 ESLint + Prettier + Husky,体验自动化代码质量保障。
|
||||
- **代码审查**:Google 的 Code Review 指南是业界标杆,值得学习。
|
||||
|
||||
@@ -1,3 +1,228 @@
|
||||
# 设计模式
|
||||
|
||||
> 待实现
|
||||
::: tip 前言
|
||||
**为什么你的代码总是"能跑但很乱"?** 你可能遇到过这样的情况:需求一变,代码就要大改;想复用一段逻辑,却发现它和其他代码纠缠在一起。设计模式就是前人总结的"代码组织套路",帮你写出灵活、可维护的代码。
|
||||
|
||||
本章带你理解最实用的设计模式,不是死记硬背,而是理解"什么场景用什么套路"。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | 设计模式是什么 | 模式的本质与分类 |
|
||||
| **第 2 章** | 创建型模式 | 如何优雅地创建对象 |
|
||||
| **第 3 章** | 结构型模式 | 如何组织代码结构 |
|
||||
| **第 4 章** | 行为型模式 | 如何管理对象间的交互 |
|
||||
|
||||
学完本章,你将掌握最常用的设计模式,能在实际项目中识别适用场景并灵活运用。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:设计模式的本质
|
||||
|
||||
想象你在学做菜。你可以每次都从零开始摸索,也可以学习经典菜谱——菜谱不会限制你的创造力,反而让你站在前人的肩膀上。设计模式就是编程世界的"经典菜谱"。
|
||||
|
||||
::: tip 设计模式的价值
|
||||
- **共同语言**:说"这里用观察者模式",团队立刻理解你的设计意图
|
||||
- **经验复用**:不用重新踩前人踩过的坑
|
||||
- **灵活扩展**:好的模式让代码面对变化时只需小改,而不是大改
|
||||
:::
|
||||
|
||||
通过下面的交互组件,浏览常见设计模式的分类和用途:
|
||||
|
||||
<DesignPatternCatalogDemo />
|
||||
|
||||
---
|
||||
|
||||
## 1. 创建型模式:如何优雅地创建对象
|
||||
|
||||
### 1.1 单例模式(Singleton)
|
||||
|
||||
**场景**:全局只需要一个实例,比如配置管理器、日志记录器、数据库连接池。
|
||||
|
||||
```javascript
|
||||
class ConfigManager {
|
||||
static instance = null
|
||||
|
||||
static getInstance() {
|
||||
if (!ConfigManager.instance) {
|
||||
ConfigManager.instance = new ConfigManager()
|
||||
}
|
||||
return ConfigManager.instance
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.config = {}
|
||||
}
|
||||
}
|
||||
|
||||
// 无论调用多少次,都是同一个实例
|
||||
const a = ConfigManager.getInstance()
|
||||
const b = ConfigManager.getInstance()
|
||||
console.log(a === b) // true
|
||||
```
|
||||
|
||||
### 1.2 工厂模式(Factory)
|
||||
|
||||
**场景**:根据不同条件创建不同类型的对象,调用方不需要知道具体的创建细节。
|
||||
|
||||
```javascript
|
||||
function createNotification(type, message) {
|
||||
switch (type) {
|
||||
case 'email':
|
||||
return { send: () => console.log(`发送邮件: ${message}`) }
|
||||
case 'sms':
|
||||
return { send: () => console.log(`发送短信: ${message}`) }
|
||||
case 'push':
|
||||
return { send: () => console.log(`推送通知: ${message}`) }
|
||||
default:
|
||||
throw new Error(`未知通知类型: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用方不关心具体实现
|
||||
const notification = createNotification('email', '你好')
|
||||
notification.send()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 结构型模式:如何组织代码结构
|
||||
|
||||
### 2.1 适配器模式(Adapter)
|
||||
|
||||
**场景**:两个接口不兼容,需要一个"转换插头"。比如旧 API 返回的数据格式和新组件期望的格式不一致。
|
||||
|
||||
```javascript
|
||||
// 旧 API 返回的格式
|
||||
const oldApi = {
|
||||
getUserInfo: () => ({ user_name: '张三', user_age: 25 })
|
||||
}
|
||||
|
||||
// 适配器:转换为新格式
|
||||
function adaptUser(oldUser) {
|
||||
return { name: oldUser.user_name, age: oldUser.user_age }
|
||||
}
|
||||
|
||||
const user = adaptUser(oldApi.getUserInfo())
|
||||
// { name: '张三', age: 25 }
|
||||
```
|
||||
|
||||
### 2.2 装饰器模式(Decorator)
|
||||
|
||||
**场景**:在不修改原有代码的前提下,给对象添加新功能。像给手机套壳——手机功能不变,但多了保护。
|
||||
|
||||
```javascript
|
||||
// 基础日志函数
|
||||
function log(message) {
|
||||
console.log(message)
|
||||
}
|
||||
|
||||
// 装饰:添加时间戳
|
||||
function withTimestamp(fn) {
|
||||
return (message) => fn(`[${new Date().toISOString()}] ${message}`)
|
||||
}
|
||||
|
||||
// 装饰:添加日志级别
|
||||
function withLevel(fn, level) {
|
||||
return (message) => fn(`[${level}] ${message}`)
|
||||
}
|
||||
|
||||
const enhancedLog = withTimestamp(withLevel(log, 'INFO'))
|
||||
enhancedLog('服务启动成功')
|
||||
// [2025-01-15T10:30:00.000Z] [INFO] 服务启动成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 行为型模式:如何管理对象间的交互
|
||||
|
||||
### 3.1 观察者模式(Observer)
|
||||
|
||||
**场景**:一个对象状态变化时,需要自动通知其他对象。比如用户下单后,需要同时发邮件、扣库存、记日志。
|
||||
|
||||
```javascript
|
||||
class EventEmitter {
|
||||
constructor() {
|
||||
this.listeners = {}
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners[event]) this.listeners[event] = []
|
||||
this.listeners[event].push(callback)
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
(this.listeners[event] || []).forEach(cb => cb(data))
|
||||
}
|
||||
}
|
||||
|
||||
const bus = new EventEmitter()
|
||||
bus.on('order:created', (order) => console.log('发送确认邮件', order.id))
|
||||
bus.on('order:created', (order) => console.log('扣减库存', order.id))
|
||||
bus.emit('order:created', { id: 'ORD-001' })
|
||||
```
|
||||
|
||||
### 3.2 策略模式(Strategy)
|
||||
|
||||
**场景**:同一个操作有多种算法/策略,需要在运行时切换。比如不同的排序方式、不同的价格计算规则。
|
||||
|
||||
```javascript
|
||||
const pricingStrategies = {
|
||||
normal: (price) => price,
|
||||
vip: (price) => price * 0.8,
|
||||
svip: (price) => price * 0.6
|
||||
}
|
||||
|
||||
function calculatePrice(price, memberLevel) {
|
||||
const strategy = pricingStrategies[memberLevel] || pricingStrategies.normal
|
||||
return strategy(price)
|
||||
}
|
||||
|
||||
calculatePrice(100, 'vip') // 80
|
||||
calculatePrice(100, 'svip') // 60
|
||||
```
|
||||
|
||||
通过下面的交互组件,动手体验不同设计模式的运行效果:
|
||||
|
||||
<PatternPlaygroundDemo />
|
||||
|
||||
---
|
||||
|
||||
## 4. 如何选择设计模式?
|
||||
|
||||
| 你遇到的问题 | 推荐模式 | 核心思路 |
|
||||
|-------------|---------|---------|
|
||||
| 全局只需一个实例 | 单例 | 控制实例数量 |
|
||||
| 根据条件创建不同对象 | 工厂 | 封装创建逻辑 |
|
||||
| 接口不兼容需要转换 | 适配器 | 包装一层转换 |
|
||||
| 动态添加功能 | 装饰器 | 层层包装增强 |
|
||||
| 状态变化需通知多方 | 观察者 | 发布-订阅解耦 |
|
||||
| 多种算法需运行时切换 | 策略 | 将算法封装为对象 |
|
||||
|
||||
::: tip 核心原则
|
||||
设计模式不是越多越好。**过度设计**和**没有设计**一样糟糕。只在真正需要灵活性的地方使用模式,简单问题用简单方案。记住 KISS 原则:Keep It Simple, Stupid。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 5. 总结
|
||||
|
||||
1. **创建型模式**:解决"如何创建对象"的问题,让创建过程更灵活
|
||||
2. **结构型模式**:解决"如何组织代码"的问题,让结构更清晰
|
||||
3. **行为型模式**:解决"对象间如何交互"的问题,让协作更松耦合
|
||||
4. **灵活运用**:根据实际场景选择,不要为了用模式而用模式
|
||||
|
||||
::: tip 终极思考
|
||||
设计模式的本质是**管理变化**。好的设计让变化的部分容易修改,不变的部分保持稳定。当你写代码时问自己:"如果需求变了,我需要改多少地方?"——如果答案是"很多地方",那可能需要一个设计模式来帮忙了。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- **经典书籍**:GoF《设计模式:可复用面向对象软件的基础》是设计模式的开山之作。
|
||||
- **现代视角**:JavaScript 中很多模式因为语言特性(闭包、高阶函数)变得更简洁。
|
||||
- **实践建议**:先理解问题,再考虑模式。不要拿着锤子找钉子。
|
||||
- **进阶学习**:了解 SOLID 原则,它是设计模式背后的指导思想。
|
||||
|
||||
@@ -1,3 +1,163 @@
|
||||
# 开源协作
|
||||
|
||||
> 待实现
|
||||
::: tip 前言
|
||||
**想参与开源项目但不知道从哪开始?** 开源不只是"免费用别人的代码",更是一种协作方式和职业加速器。一次高质量的开源贡献,可能比简历上写十个个人项目更有说服力。
|
||||
|
||||
本章带你理解开源协作的完整流程,从找项目到提交 PR,迈出开源贡献的第一步。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | 开源贡献流程 | Fork → PR 的完整链路 |
|
||||
| **第 2 章** | 开源许可证 | 不同许可证的区别 |
|
||||
| **第 3 章** | 协作礼仪 | 如何做一个受欢迎的贡献者 |
|
||||
| **第 4 章** | 从零开始贡献 | 找到适合新手的项目 |
|
||||
|
||||
学完本章,你将掌握开源协作的完整流程和礼仪,有信心向任何开源项目提交贡献。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:开源的价值
|
||||
|
||||
开源不只是代码共享,更是一种**全球化的协作模式**。Linux、React、Vue、Node.js——这些改变世界的项目都是开源的。
|
||||
|
||||
::: tip 参与开源的好处
|
||||
- **技术成长**:阅读优秀代码,接受高手 Review
|
||||
- **职业发展**:开源贡献是最好的技术名片
|
||||
- **社区归属**:成为全球开发者社区的一员
|
||||
- **回馈生态**:你每天用的工具,也需要有人维护
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 1. 开源贡献流程
|
||||
|
||||
通过下面的交互组件,逐步了解从 Fork 到 Merge 的完整流程:
|
||||
|
||||
<OpenSourceWorkflowDemo />
|
||||
|
||||
### 1.1 流程概览
|
||||
|
||||
```
|
||||
Fork → Clone → Branch → Commit → Push → PR → Review → Merge
|
||||
```
|
||||
|
||||
### 1.2 关键步骤详解
|
||||
|
||||
**创建功能分支**:不要直接在 main 上开发。
|
||||
|
||||
```bash
|
||||
git checkout -b fix/typo-in-readme
|
||||
```
|
||||
|
||||
**写清晰的 Commit Message**:遵循项目的提交规范。
|
||||
|
||||
```bash
|
||||
git commit -m "fix: 修复 README 中的安装命令拼写错误"
|
||||
```
|
||||
|
||||
**创建 Pull Request**:PR 描述应包含:
|
||||
- 改了什么、为什么改
|
||||
- 关联的 Issue 编号(如 `Fixes #123`)
|
||||
- 如何测试你的改动
|
||||
|
||||
---
|
||||
|
||||
## 2. 开源许可证
|
||||
|
||||
通过下面的交互组件,对比常见开源许可证的区别:
|
||||
|
||||
<LicenseComparisonDemo />
|
||||
|
||||
### 2.1 常见许可证
|
||||
|
||||
| 许可证 | 特点 | 典型项目 |
|
||||
|-------|------|---------|
|
||||
| **MIT** | 最宽松,几乎无限制 | React, Vue, jQuery |
|
||||
| **Apache 2.0** | 需保留版权声明,有专利授权 | Android, Kubernetes |
|
||||
| **GPL** | 衍生作品必须也开源 | Linux, WordPress |
|
||||
| **BSD** | 类似 MIT,略有不同 | FreeBSD, Flask |
|
||||
|
||||
### 2.2 如何选择?
|
||||
|
||||
- **想让更多人用**:选 MIT
|
||||
- **想保护专利**:选 Apache 2.0
|
||||
- **想确保衍生品也开源**:选 GPL
|
||||
|
||||
---
|
||||
|
||||
## 3. 协作礼仪
|
||||
|
||||
### 3.1 提 Issue 的礼仪
|
||||
|
||||
```markdown
|
||||
<!-- 差 -->
|
||||
标题:不能用了
|
||||
内容:你们的东西有 bug
|
||||
|
||||
<!-- 好 -->
|
||||
标题:v2.1.0 在 Safari 17 下登录页白屏
|
||||
内容:
|
||||
- 环境:macOS 14.2, Safari 17.2
|
||||
- 复现步骤:1. 打开登录页 2. 输入账号密码 3. 点击登录
|
||||
- 期望行为:跳转到首页
|
||||
- 实际行为:页面白屏,控制台报错 TypeError: xxx
|
||||
- 截图:[附图]
|
||||
```
|
||||
|
||||
### 3.2 提 PR 的礼仪
|
||||
|
||||
- 先看 `CONTRIBUTING.md`,了解项目的贡献规范
|
||||
- 一个 PR 只做一件事,不要混合多个改动
|
||||
- 保持 PR 小而聚焦,方便 Review
|
||||
- 耐心等待 Review,礼貌回应反馈
|
||||
|
||||
### 3.3 Review 他人代码
|
||||
|
||||
- 先肯定做得好的地方,再提改进建议
|
||||
- 提问而不是命令:"这里是否考虑过用 X 方案?"
|
||||
- 给出理由和替代方案,而不只是说"不好"
|
||||
|
||||
---
|
||||
|
||||
## 4. 从零开始贡献
|
||||
|
||||
### 4.1 适合新手的贡献类型
|
||||
|
||||
| 类型 | 难度 | 说明 |
|
||||
|------|------|------|
|
||||
| 修复文档错误 | 低 | 错别字、过时链接、不清晰的说明 |
|
||||
| 翻译 | 低 | 将文档翻译为其他语言 |
|
||||
| 补充测试 | 中 | 为未覆盖的代码添加测试 |
|
||||
| 修复标记为 `good first issue` 的 Bug | 中 | 项目维护者标记的新手友好问题 |
|
||||
| 新功能 | 高 | 先在 Issue 中讨论方案,获得认可后再动手 |
|
||||
|
||||
### 4.2 找到合适的项目
|
||||
|
||||
- 从你日常使用的工具开始
|
||||
- GitHub 搜索 `good first issue` 标签
|
||||
- 关注项目的活跃度(最近是否有人维护)
|
||||
|
||||
---
|
||||
|
||||
## 5. 总结
|
||||
|
||||
1. **流程**:Fork → Branch → Commit → PR → Review → Merge
|
||||
2. **许可证**:MIT 最宽松,GPL 最严格,根据需求选择
|
||||
3. **礼仪**:清晰的 Issue、聚焦的 PR、礼貌的沟通
|
||||
4. **起步**:从文档修复和 `good first issue` 开始
|
||||
|
||||
::: tip 终极思考
|
||||
开源的本质是**协作**。技术能力固然重要,但沟通能力、协作意识同样关键。一个态度友好、描述清晰的 PR,比一个代码完美但沟通粗暴的 PR 更受欢迎。**你的第一个 PR 不需要完美,只需要迈出第一步。**
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- **入门指南**:GitHub 的 Open Source Guide 是最好的开源入门资源。
|
||||
- **实践建议**:找一个你喜欢的项目,先 Star,再读代码,最后找机会贡献。
|
||||
- **社区参与**:参加 Hacktoberfest 等开源活动,获得社区支持。
|
||||
- **维护者视角**:了解维护者的工作量和压力,做一个体贴的贡献者。
|
||||
|
||||
@@ -1,3 +1,169 @@
|
||||
# 安全思维与攻防基础
|
||||
|
||||
> 待实现
|
||||
::: tip 前言
|
||||
**你的网站安全吗?** 很多开发者觉得"安全是安全团队的事",直到自己的项目被攻击、用户数据泄露。安全不是可选项,而是每个开发者的基本功。
|
||||
|
||||
本章带你建立安全思维,理解最常见的 Web 安全威胁和防御方法。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | 安全思维模型 | 像攻击者一样思考 |
|
||||
| **第 2 章** | 常见 Web 攻击 | XSS、SQL 注入、CSRF |
|
||||
| **第 3 章** | 防御策略 | 输入验证、输出编码、权限控制 |
|
||||
| **第 4 章** | 安全检查清单 | 项目上线前的安全自查 |
|
||||
|
||||
学完本章,你将具备基本的安全意识,能识别和防御最常见的 Web 安全威胁。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:为什么开发者需要懂安全?
|
||||
|
||||
想象你建了一栋房子,功能齐全、装修漂亮,但忘了装锁。安全漏洞就是代码世界里"忘了装的锁"。
|
||||
|
||||
::: tip 安全的核心原则
|
||||
- **最小权限**:只给必要的权限,不多给一分
|
||||
- **纵深防御**:不依赖单一防线,层层设防
|
||||
- **永不信任输入**:所有来自外部的数据都可能是恶意的
|
||||
- **安全默认**:默认配置应该是安全的,而不是方便的
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 1. 常见 Web 攻击
|
||||
|
||||
通过下面的交互组件,了解三种最常见的 Web 攻击原理(仅用于教育目的):
|
||||
|
||||
<WebSecurityDemo />
|
||||
|
||||
### 1.1 XSS(跨站脚本攻击)
|
||||
|
||||
攻击者将恶意脚本注入到网页中,当其他用户访问时,脚本在他们的浏览器中执行。
|
||||
|
||||
```javascript
|
||||
// 危险:直接将用户输入插入 HTML
|
||||
element.innerHTML = userInput
|
||||
// 如果 userInput 是 <script>恶意代码</script>,就会执行
|
||||
|
||||
// 安全:使用 textContent 或转义
|
||||
element.textContent = userInput
|
||||
// 或使用框架的自动转义(Vue 的 {{ }}、React 的 JSX)
|
||||
```
|
||||
|
||||
**防御要点**:
|
||||
- 输出时转义 HTML 特殊字符(`<`, `>`, `&`, `"`, `'`)
|
||||
- 使用现代框架的自动转义机制
|
||||
- 设置 `Content-Security-Policy` HTTP 头
|
||||
|
||||
### 1.2 SQL 注入
|
||||
|
||||
攻击者通过构造特殊输入,篡改 SQL 查询的逻辑。
|
||||
|
||||
```javascript
|
||||
// 危险:字符串拼接 SQL
|
||||
const query = `SELECT * FROM users WHERE name = '${userInput}'`
|
||||
// 如果 userInput 是 ' OR '1'='1,就会返回所有用户
|
||||
|
||||
// 安全:使用参数化查询
|
||||
const query = 'SELECT * FROM users WHERE name = ?'
|
||||
db.execute(query, [userInput])
|
||||
```
|
||||
|
||||
**防御要点**:
|
||||
- 永远使用参数化查询 / 预编译语句
|
||||
- 使用 ORM 框架(如 Prisma、Sequelize)
|
||||
- 限制数据库账号权限
|
||||
|
||||
### 1.3 CSRF(跨站请求伪造)
|
||||
|
||||
攻击者诱导已登录用户访问恶意页面,利用用户的登录状态发起请求。
|
||||
|
||||
**防御要点**:
|
||||
- 使用 CSRF Token
|
||||
- 检查 `Referer` / `Origin` 头
|
||||
- 关键操作使用 POST 而非 GET
|
||||
- Cookie 设置 `SameSite` 属性
|
||||
|
||||
---
|
||||
|
||||
## 2. 防御策略
|
||||
|
||||
### 2.1 输入验证
|
||||
|
||||
```javascript
|
||||
// 白名单验证:只允许预期的格式
|
||||
function isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
// 长度限制
|
||||
function isValidUsername(name) {
|
||||
return name.length >= 2 && name.length <= 50
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 敏感数据保护
|
||||
|
||||
| 数据类型 | 保护措施 |
|
||||
|---------|---------|
|
||||
| 密码 | bcrypt/argon2 哈希,永不明文存储 |
|
||||
| API 密钥 | 环境变量,不提交到代码仓库 |
|
||||
| 用户数据 | HTTPS 传输,加密存储 |
|
||||
| 会话令牌 | HttpOnly + Secure + SameSite Cookie |
|
||||
|
||||
### 2.3 HTTP 安全头
|
||||
|
||||
```
|
||||
Content-Security-Policy: default-src 'self'
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Strict-Transport-Security: max-age=31536000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 安全检查清单
|
||||
|
||||
上线前,用下面的交互组件检查你的项目安全状况:
|
||||
|
||||
<SecurityChecklistDemo />
|
||||
|
||||
### 3.1 开发阶段
|
||||
|
||||
- [ ] 所有用户输入都经过验证和转义
|
||||
- [ ] 使用参数化查询,无 SQL 拼接
|
||||
- [ ] 密码使用 bcrypt 等算法哈希存储
|
||||
- [ ] 敏感配置通过环境变量管理
|
||||
- [ ] `.env` 文件已加入 `.gitignore`
|
||||
|
||||
### 3.2 部署阶段
|
||||
|
||||
- [ ] 启用 HTTPS
|
||||
- [ ] 配置安全 HTTP 头
|
||||
- [ ] 关闭调试模式和详细错误信息
|
||||
- [ ] 数据库使用最小权限账号
|
||||
- [ ] 定期更新依赖(`npm audit`)
|
||||
|
||||
---
|
||||
|
||||
## 4. 总结
|
||||
|
||||
1. **安全思维**:永不信任外部输入,最小权限,纵深防御
|
||||
2. **常见攻击**:XSS、SQL 注入、CSRF 是最高频的 Web 安全威胁
|
||||
3. **防御策略**:输入验证、输出编码、参数化查询、安全 HTTP 头
|
||||
4. **安全习惯**:上线前过安全检查清单,定期审计依赖
|
||||
|
||||
::: tip 终极思考
|
||||
安全不是一次性的工作,而是贯穿开发全过程的习惯。就像开车系安全带——不是因为你预期会出事故,而是因为这是基本的安全意识。**写每一行代码时都问自己:如果这个输入是恶意的,会发生什么?**
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- **OWASP Top 10**:Web 应用安全十大风险清单,每个开发者都应该了解。
|
||||
- **实践工具**:使用 `npm audit` 检查依赖漏洞,使用 ESLint 安全插件检查代码。
|
||||
- **深入学习**:了解 HTTPS 原理、JWT 安全实践、OAuth 2.0 安全考量。
|
||||
- **安全社区**:关注安全公告,及时修补已知漏洞。
|
||||
|
||||
@@ -1,3 +1,172 @@
|
||||
# 技术文档写作
|
||||
|
||||
> 待实现
|
||||
::: tip 前言
|
||||
**你写的文档有人看吗?** 很多开发者觉得"代码能跑就行,文档以后再说"。结果就是:新人入职看不懂项目、API 对接全靠口头沟通、半年后自己都忘了当初为什么这么设计。
|
||||
|
||||
本章带你掌握技术文档写作的核心方法,让你的文档真正有人看、看得懂、用得上。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | 文档的类型与结构 | 不同文档的写法 |
|
||||
| **第 2 章** | 写作原则 | 清晰、准确、简洁 |
|
||||
| **第 3 章** | 实战对比 | 好文档 vs 差文档 |
|
||||
| **第 4 章** | 文档维护 | 让文档保持更新 |
|
||||
|
||||
学完本章,你将能写出结构清晰、内容准确、易于维护的技术文档。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:为什么技术文档重要?
|
||||
|
||||
代码告诉计算机"怎么做",文档告诉人类"为什么这么做"。没有文档的项目就像没有说明书的电器——能用,但用起来全靠猜。
|
||||
|
||||
::: tip 好文档的价值
|
||||
- **降低沟通成本**:新人自助上手,减少重复解答
|
||||
- **保存决策上下文**:记录"为什么",而不只是"是什么"
|
||||
- **提升项目可信度**:好文档是开源项目的门面
|
||||
- **加速协作**:API 文档让前后端并行开发
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档的类型与结构
|
||||
|
||||
通过下面的交互组件,了解不同类型文档的标准结构:
|
||||
|
||||
<DocStructureDemo />
|
||||
|
||||
### 1.1 常见文档类型
|
||||
|
||||
| 文档类型 | 目标读者 | 核心内容 |
|
||||
|---------|---------|---------|
|
||||
| **README** | 所有人 | 项目是什么、怎么用、怎么贡献 |
|
||||
| **API 文档** | 接口调用方 | 端点、参数、响应、错误码 |
|
||||
| **架构文档** | 开发团队 | 系统设计、技术选型、数据流 |
|
||||
| **变更日志** | 用户/开发者 | 版本变化、新增/修复/破坏性变更 |
|
||||
| **贡献指南** | 贡献者 | 开发环境、代码规范、PR 流程 |
|
||||
|
||||
### 1.2 README 的黄金结构
|
||||
|
||||
一个好的 README 应该包含:
|
||||
|
||||
1. **项目名称 + 一句话描述**:让人 3 秒内知道这是什么
|
||||
2. **快速开始**:最少步骤跑起来
|
||||
3. **功能特性**:核心卖点
|
||||
4. **安装方式**:详细的环境要求和安装步骤
|
||||
5. **使用示例**:可复制粘贴的代码
|
||||
6. **贡献指南**:如何参与
|
||||
7. **许可证**:法律信息
|
||||
|
||||
---
|
||||
|
||||
## 2. 写作原则
|
||||
|
||||
### 2.1 清晰优先
|
||||
|
||||
```markdown
|
||||
<!-- 差:模糊不清 -->
|
||||
这个函数处理数据。
|
||||
|
||||
<!-- 好:具体明确 -->
|
||||
将原始订单数据转换为发票格式,包含税费计算和币种转换。
|
||||
```
|
||||
|
||||
### 2.2 面向读者
|
||||
|
||||
写文档前先问:**谁会读这个文档?他们需要什么信息?**
|
||||
|
||||
- 给新手写:解释术语,提供完整示例
|
||||
- 给有经验的开发者写:直奔主题,提供 API 参考
|
||||
- 给非技术人员写:用类比,避免术语
|
||||
|
||||
### 2.3 代码示例是最好的文档
|
||||
|
||||
```markdown
|
||||
<!-- 差:只有文字描述 -->
|
||||
调用 createUser 函数,传入用户名和邮箱参数。
|
||||
|
||||
<!-- 好:给出可运行的示例 -->
|
||||
const user = await createUser({
|
||||
name: '张三',
|
||||
email: 'zhangsan@example.com'
|
||||
})
|
||||
// 返回: { id: 'u_123', name: '张三', createdAt: '2025-01-15' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 实战对比
|
||||
|
||||
通过下面的交互组件,对比好的技术写作和差的技术写作:
|
||||
|
||||
<TechWritingPracticeDemo />
|
||||
|
||||
### 3.1 Commit Message 规范
|
||||
|
||||
```
|
||||
# 差
|
||||
fix bug
|
||||
update code
|
||||
|
||||
# 好(Conventional Commits)
|
||||
fix: 修复登录页在 Safari 下白屏的问题
|
||||
feat: 支持批量导出 PDF 格式报表
|
||||
docs: 更新 API 认证章节的示例代码
|
||||
```
|
||||
|
||||
### 3.2 注释的艺术
|
||||
|
||||
```javascript
|
||||
// 差:描述"是什么"(代码已经说了)
|
||||
// 遍历数组
|
||||
for (const item of items) { ... }
|
||||
|
||||
// 好:解释"为什么"
|
||||
// 倒序遍历,因为删除元素时正序会跳过下一个
|
||||
for (let i = items.length - 1; i >= 0; i--) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 文档维护
|
||||
|
||||
### 4.1 文档即代码
|
||||
|
||||
把文档和代码放在同一个仓库,用同样的工作流管理:
|
||||
|
||||
- 文档变更随代码一起提交 PR
|
||||
- CI 检查文档格式和链接有效性
|
||||
- 版本发布时同步更新文档
|
||||
|
||||
### 4.2 避免文档腐烂
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
|------|---------|
|
||||
| 文档过时 | 代码变更时强制更新文档(PR 检查) |
|
||||
| 无人维护 | 指定文档负责人 |
|
||||
| 内容重复 | 单一信息源,其他地方引用链接 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 总结
|
||||
|
||||
1. **类型匹配**:不同文档有不同的结构和写法
|
||||
2. **清晰优先**:具体、准确、面向读者
|
||||
3. **示例驱动**:好的代码示例胜过千言万语
|
||||
4. **持续维护**:文档即代码,随项目一起演进
|
||||
|
||||
::: tip 终极思考
|
||||
写文档不是浪费时间,而是**节省未来的时间**。你今天花 30 分钟写的文档,可能帮 10 个人各节省 1 小时。好的文档是对团队最好的投资。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- **写作指南**:Google 的技术写作课程(Technical Writing)免费且实用。
|
||||
- **文档工具**:VitePress、Docusaurus、GitBook 等现代文档框架。
|
||||
- **API 文档**:OpenAPI/Swagger 规范是 API 文档的行业标准。
|
||||
- **实践建议**:从给自己的项目写一个好的 README 开始。
|
||||
|
||||
@@ -1,3 +1,140 @@
|
||||
# 技术选型方法论
|
||||
|
||||
> 待实现
|
||||
::: tip 前言
|
||||
**React 还是 Vue?MySQL 还是 PostgreSQL?** 技术选型是每个项目开始时最重要的决策之一。选错了,可能要花几个月重写;选对了,团队效率翻倍。
|
||||
|
||||
本章带你建立系统化的技术选型思维,不再凭感觉选技术。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | 技术雷达 | 了解技术的成熟度 |
|
||||
| **第 2 章** | 选型维度 | 从哪些角度评估技术 |
|
||||
| **第 3 章** | 决策矩阵 | 量化对比做决策 |
|
||||
| **第 4 章** | 常见陷阱 | 避免选型中的坑 |
|
||||
|
||||
学完本章,你将掌握一套系统化的技术选型方法,能为项目做出理性的技术决策。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:技术选型的本质
|
||||
|
||||
技术选型不是"哪个技术最好"的问题,而是"哪个技术最适合当前场景"的问题。就像选交通工具——飞机最快,但去隔壁小区不需要坐飞机。
|
||||
|
||||
::: tip 选型的核心原则
|
||||
- **没有银弹**:没有一种技术适合所有场景
|
||||
- **场景驱动**:先明确需求,再选技术
|
||||
- **团队优先**:团队熟悉的技术往往是最好的选择
|
||||
- **可逆性**:优先选择容易替换的方案
|
||||
:::
|
||||
|
||||
通过下面的交互组件,了解当前技术生态的全景:
|
||||
|
||||
<TechRadarDemo />
|
||||
|
||||
---
|
||||
|
||||
## 1. 选型维度
|
||||
|
||||
### 1.1 核心评估维度
|
||||
|
||||
| 维度 | 关注点 | 权重建议 |
|
||||
|------|--------|---------|
|
||||
| **团队能力** | 团队是否熟悉?学习成本多高? | 高 |
|
||||
| **社区生态** | 文档质量、第三方库、Stack Overflow 答案数 | 高 |
|
||||
| **性能需求** | 是否满足性能要求? | 中-高 |
|
||||
| **维护状态** | 是否活跃维护?最近一次发布是什么时候? | 中 |
|
||||
| **许可证** | 是否与项目的商业模式兼容? | 中 |
|
||||
| **招聘市场** | 能否招到熟悉这个技术的人? | 中 |
|
||||
|
||||
### 1.2 实际案例:前端框架选型
|
||||
|
||||
```
|
||||
项目:企业内部管理系统
|
||||
团队:5 人,3 人熟悉 Vue,1 人熟悉 React,1 人新手
|
||||
需求:表单密集、权限复杂、不需要 SEO
|
||||
|
||||
分析:
|
||||
- 团队 60% 熟悉 Vue → Vue 优先
|
||||
- 表单密集 → Element Plus 生态成熟
|
||||
- 不需要 SSR → 不需要 Next.js/Nuxt
|
||||
- 结论:Vue 3 + Element Plus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 决策矩阵
|
||||
|
||||
当多个选项难以直觉判断时,用决策矩阵量化对比。
|
||||
|
||||
通过下面的交互组件,体验决策矩阵的使用方法:
|
||||
|
||||
<DecisionMatrixDemo />
|
||||
|
||||
### 2.1 如何使用决策矩阵
|
||||
|
||||
1. **列出候选方案**:比如 React vs Vue vs Svelte
|
||||
2. **确定评估维度**:团队能力、生态、性能、学习曲线
|
||||
3. **分配权重**:根据项目需求,给每个维度打权重(总和 100%)
|
||||
4. **逐项打分**:每个方案在每个维度上打 1-5 分
|
||||
5. **加权求和**:得出最终得分
|
||||
|
||||
### 2.2 示例
|
||||
|
||||
| 维度 | 权重 | React | Vue | Svelte |
|
||||
|------|------|-------|-----|--------|
|
||||
| 团队能力 | 30% | 3 | 5 | 1 |
|
||||
| 社区生态 | 25% | 5 | 4 | 2 |
|
||||
| 学习曲线 | 20% | 3 | 4 | 5 |
|
||||
| 性能 | 15% | 4 | 4 | 5 |
|
||||
| 招聘市场 | 10% | 5 | 4 | 2 |
|
||||
| **加权总分** | | **3.75** | **4.35** | **2.75** |
|
||||
|
||||
---
|
||||
|
||||
## 3. 常见陷阱
|
||||
|
||||
### 3.1 简历驱动开发
|
||||
|
||||
> "用这个新技术,我简历上又能多写一条"
|
||||
|
||||
选技术应该基于项目需求,而不是个人简历。新技术意味着更多的未知风险和更少的社区支持。
|
||||
|
||||
### 3.2 盲目追新
|
||||
|
||||
| 心态 | 现实 |
|
||||
|------|------|
|
||||
| "新的一定比旧的好" | 新技术可能有未发现的 Bug |
|
||||
| "大厂在用,我们也该用" | 大厂的场景和你的可能完全不同 |
|
||||
| "这个技术 Star 数最多" | Star 数不等于适合你的项目 |
|
||||
|
||||
### 3.3 忽视迁移成本
|
||||
|
||||
选型时不仅要看"用起来怎么样",还要看"如果要换,代价多大"。优先选择:
|
||||
- 遵循标准协议的方案(如 SQL vs 私有查询语言)
|
||||
- 有清晰迁移路径的方案
|
||||
- 不会深度锁定的方案
|
||||
|
||||
---
|
||||
|
||||
## 4. 总结
|
||||
|
||||
1. **技术雷达**:了解技术的成熟度,区分采纳/试验/评估/暂缓
|
||||
2. **选型维度**:团队能力 > 社区生态 > 性能需求 > 维护状态
|
||||
3. **决策矩阵**:量化对比,减少主观偏见
|
||||
4. **避免陷阱**:不追新、不跟风、考虑迁移成本
|
||||
|
||||
::: tip 终极思考
|
||||
最好的技术选型往往是**最无聊的选型**。选择成熟、稳定、团队熟悉的技术,把创新的精力留给业务本身。记住:**技术是手段,不是目的。用户不关心你用了什么框架,他们只关心产品好不好用。**
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- **ThoughtWorks 技术雷达**:每半年发布一次,是了解技术趋势的权威参考。
|
||||
- **实践建议**:下次选型时,试着用决策矩阵做一次量化对比。
|
||||
- **架构决策记录(ADR)**:用文档记录每次技术选型的理由和权衡。
|
||||
- **反面教材**:了解一些因技术选型失误导致项目失败的案例。
|
||||
|
||||
@@ -1,3 +1,170 @@
|
||||
# 测试策略
|
||||
|
||||
> 待实现
|
||||
::: tip 前言
|
||||
**你的代码真的"没问题"吗?** 每次改完代码手动点一遍看看有没有坏——这种方式在项目小的时候还能凑合,但当代码量增长到几万行、团队扩展到十几人时,"手动点点看"就是一场灾难。
|
||||
|
||||
本章带你理解软件测试的核心策略,从测试金字塔到 TDD,建立系统化的质量保障思维。
|
||||
:::
|
||||
|
||||
**这篇文章会带你学什么?**
|
||||
|
||||
| 章节 | 内容 | 核心概念 |
|
||||
|-----|------|---------|
|
||||
| **第 1 章** | 测试金字塔 | 测试的层次与比例 |
|
||||
| **第 2 章** | 单元测试实战 | 如何写好一个测试 |
|
||||
| **第 3 章** | TDD 驱动开发 | 红绿重构循环 |
|
||||
| **第 4 章** | 测试策略选择 | 不同场景的方案 |
|
||||
|
||||
学完本章,你将理解如何为项目选择合适的测试策略,写出有价值的测试,并通过 TDD 提升代码设计质量。
|
||||
|
||||
---
|
||||
|
||||
## 0. 全景图:为什么需要自动化测试?
|
||||
|
||||
想象你是一个建筑工程师。每次修改图纸后,你不会亲自爬上每一层楼去检查结构是否安全——你会依赖一套**自动化的检测系统**。软件测试就是代码世界的"结构检测系统"。
|
||||
|
||||
::: tip 自动化测试的价值
|
||||
- **回归保护**:修改 A 功能时,自动检测 B、C、D 功能是否被影响
|
||||
- **重构信心**:有测试覆盖的代码,重构时心里有底
|
||||
- **活文档**:好的测试就是最好的使用说明书
|
||||
- **快速反馈**:几秒钟内知道代码是否正确,而不是等到部署后才发现问题
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试金字塔:测试的层次与比例
|
||||
|
||||
### 1.1 三层金字塔
|
||||
|
||||
Mike Cohn 提出的测试金字塔是测试策略的经典模型。它告诉我们:**不同类型的测试应该有不同的数量比例**。
|
||||
|
||||
通过下面的交互组件,点击金字塔的每一层,了解各层测试的特点:
|
||||
|
||||
<TestPyramidDemo />
|
||||
|
||||
### 1.2 为什么是金字塔形?
|
||||
|
||||
金字塔形状反映了一个核心权衡:**速度与真实度的取舍**。
|
||||
|
||||
- **底层(单元测试)**:速度极快、数量最多、成本最低,但只能验证单个零件
|
||||
- **中层(集成测试)**:速度适中、数量适中,验证零件之间的配合
|
||||
- **顶层(E2E 测试)**:最接近真实用户,但速度慢、维护成本高、容易因环境问题失败
|
||||
|
||||
> **反模式:冰淇淋甜筒** —— 如果你的项目 E2E 测试最多、单元测试最少,那就是倒过来的"冰淇淋甜筒"。这意味着测试套件运行缓慢、经常失败、维护成本极高。
|
||||
|
||||
---
|
||||
|
||||
## 2. 单元测试实战
|
||||
|
||||
### 2.1 什么是好的单元测试?
|
||||
|
||||
好的单元测试遵循 **FIRST** 原则:
|
||||
|
||||
| 原则 | 含义 | 说明 |
|
||||
|------|------|------|
|
||||
| **F**ast | 快速 | 毫秒级完成,开发者愿意频繁运行 |
|
||||
| **I**ndependent | 独立 | 测试之间互不依赖,可以单独运行 |
|
||||
| **R**epeatable | 可重复 | 任何环境下运行结果一致 |
|
||||
| **S**elf-validating | 自验证 | 结果是明确的通过/失败,不需要人工判断 |
|
||||
| **T**imely | 及时 | 在写代码的同时(或之前)写测试 |
|
||||
|
||||
### 2.2 测试的结构:AAA 模式
|
||||
|
||||
每个测试都应该有清晰的三段式结构:
|
||||
|
||||
```javascript
|
||||
test('应该正确计算含税价格', () => {
|
||||
// Arrange(准备)—— 设置测试数据
|
||||
const price = 100
|
||||
const taxRate = 0.13
|
||||
|
||||
// Act(执行)—— 调用被测函数
|
||||
const result = calculateTotalWithTax(price, taxRate)
|
||||
|
||||
// Assert(断言)—— 验证结果
|
||||
expect(result).toBe(113)
|
||||
})
|
||||
```
|
||||
|
||||
### 2.3 测试什么?不测什么?
|
||||
|
||||
**应该测试的:**
|
||||
- 核心业务逻辑(价格计算、权限判断、数据转换)
|
||||
- 边界条件(空值、零、负数、超大数)
|
||||
- 错误处理路径
|
||||
|
||||
**不需要测试的:**
|
||||
- 第三方库的内部实现
|
||||
- 简单的 getter/setter
|
||||
- 框架自身的功能(如 Vue 的响应式系统)
|
||||
|
||||
---
|
||||
|
||||
## 3. TDD:测试驱动开发
|
||||
|
||||
### 3.1 红绿重构循环
|
||||
|
||||
TDD(Test-Driven Development)的核心是一个简单的循环:**先写测试,再写实现,最后重构**。
|
||||
|
||||
通过下面的交互组件,亲自体验 TDD 的完整循环:
|
||||
|
||||
<TDDCycleDemo />
|
||||
|
||||
### 3.2 TDD 的三条规则
|
||||
|
||||
1. **不写任何产品代码,除非是为了让一个失败的测试通过**
|
||||
2. **只写刚好让测试失败的测试代码**(编译不过也算失败)
|
||||
3. **只写刚好让测试通过的产品代码**
|
||||
|
||||
### 3.3 TDD 的真正价值
|
||||
|
||||
TDD 的价值不仅在于"先写测试",更在于它**迫使你思考接口设计**。当你先写测试时,你是站在"使用者"的角度思考:这个函数应该接收什么参数?返回什么结果?这自然会导向更好的 API 设计。
|
||||
|
||||
::: tip TDD 不是银弹
|
||||
TDD 适合逻辑密集的代码(算法、业务规则、数据转换),但对于 UI 布局、探索性原型等场景,强制 TDD 反而会拖慢速度。关键是理解它的思想,灵活运用。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试策略选择
|
||||
|
||||
### 4.1 不同项目的测试重点
|
||||
|
||||
| 项目类型 | 测试重点 | 推荐比例 |
|
||||
|----------|----------|----------|
|
||||
| **工具库/SDK** | 单元测试为主 | 90% 单元 + 10% 集成 |
|
||||
| **API 服务** | 集成测试为主 | 30% 单元 + 60% 集成 + 10% E2E |
|
||||
| **Web 应用** | 均衡分布 | 50% 单元 + 30% 集成 + 20% E2E |
|
||||
| **MVP/原型** | 关键路径 E2E | 少量核心测试即可 |
|
||||
|
||||
### 4.2 常用测试工具
|
||||
|
||||
| 工具 | 类型 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **Vitest** | 单元/集成 | Vite 项目首选,兼容 Jest API |
|
||||
| **Jest** | 单元/集成 | Node.js 生态最流行 |
|
||||
| **Playwright** | E2E | 跨浏览器,微软出品 |
|
||||
| **Cypress** | E2E | 开发体验好,调试方便 |
|
||||
| **Testing Library** | 组件测试 | 以用户视角测试 UI 组件 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 总结
|
||||
|
||||
1. **测试金字塔**:底层多、顶层少,平衡速度与真实度
|
||||
2. **单元测试**:遵循 FIRST 原则和 AAA 模式,测试核心逻辑
|
||||
3. **TDD**:红绿重构循环,用测试驱动设计
|
||||
4. **策略选择**:根据项目类型和阶段,选择合适的测试比例
|
||||
|
||||
::: tip 终极思考
|
||||
测试不是负担,而是**加速器**。短期看,写测试确实多花了时间;长期看,它节省了无数次手动验证、排查回归 Bug、以及深夜紧急修复的时间。好的测试让你有信心说出那句话:**"放心改,测试会告诉我们有没有问题。"**
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- **经典书籍**:Kent Beck《测试驱动开发》是 TDD 的开山之作。
|
||||
- **实用指南**:尝试用 Vitest 为一个小项目写测试,体验从零开始的测试流程。
|
||||
- **测试模式**:了解 Mock、Stub、Spy 的区别和使用场景。
|
||||
- **持续集成**:将测试集成到 CI/CD 流水线中,每次提交自动运行。
|
||||
|
||||
Reference in New Issue
Block a user