docs: update URL-to-browser explanation with online shopping metaphor

- Change primary analogy from "delivery service" to "online shopping" to make concepts more relatable
- Update all documentation sections to align with the new metaphor
- Refactor interactive demo components to use compact layouts and improve visual clarity
- Add developer insights section explaining HTTP-API relationship
- Enhance browser rendering explanation with assembly metaphor
- Improve visual components with better responsive design and user interactions
This commit is contained in:
sanbuphy
2026-02-04 16:16:34 +08:00
parent 084ebed417
commit 3c4a5c0e0b
7 changed files with 3398 additions and 3128 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,135 +1,126 @@
<!--
HttpExchangeDemo.vue
HTTP请求响应演示 - 快递员送达对话类比
HTTP请求响应演示 - 紧凑交互版
用途
"快递员和收件人对话"的生活化比喻让用户理解HTTP请求响应的过程
把枯燥的HTTP协议变成直观的对话场景
设计理念
1. 循循善诱"快递员投递"类比 HTTP 请求响应
2. 紧凑布局横向舞台固定底部详情板
-->
<template>
<div class="delivery-dialog-demo">
<!-- 标题 -->
<div class="dialog-header">
<span class="dialog-icon">[送达]</span>
<span class="dialog-title">送达对话请求与响应</span>
<div class="http-compact">
<!-- 顶部标题与场景选择 -->
<div class="top-bar">
<div class="title-section">
<span class="app-icon">📦</span>
<span class="app-title">HTTP 请求/响应</span>
</div>
<!-- 场景选择 -->
<div class="scenario-selector">
<div class="selector-label">选择送达场景</div>
<div class="scenario-buttons">
<div class="scenario-tabs">
<button
v-for="scene in scenarios"
:key="scene.id"
@click="selectScenario(scene)"
class="scenario-btn"
:class="{ active: currentScenario?.id === scene.id }"
:disabled="isDelivering"
v-for="s in scenarios"
:key="s.id"
@click="selectScenario(s)"
class="tab-btn"
:class="{ active: currentScenario.id === s.id }"
:disabled="isAnimating"
>
<span class="btn-text">{{ scene.name }}</span>
{{ s.name }}
</button>
</div>
</div>
<!-- 对话场景 -->
<div class="dialog-scene" v-if="currentScenario">
<div class="scene-background">
<!-- 快递员请求方 -->
<div class="character courier">
<div class="char-avatar"></div>
<div class="char-name">快递员浏览器</div>
</div>
<!-- 对话区域 -->
<div class="conversation-area">
<!-- 请求消息 -->
<div class="message request" :class="{ sent: step >= 1 }">
<div class="message-bubble">
<div class="bubble-header">
<span class="method-badge" :class="currentScenario.method.toLowerCase()">
{{ currentScenario.method }}
</span>
<span class="path-text">{{ currentScenario.path }}</span>
</div>
<div class="bubble-body">{{ currentScenario.requestText }}</div>
</div>
<div class="message-meta">请求</div>
</div>
<!-- 传输动画 -->
<div class="transit-animation" v-if="step === 2">
<div class="transit-line"></div>
<div class="transit-package"></div>
</div>
<!-- 响应消息 -->
<div class="message response" :class="{ sent: step >= 3 }">
<div class="message-meta">响应</div>
<div class="message-bubble" :class="currentScenario.statusType">
<div class="bubble-header">
<span class="status-badge" :class="currentScenario.statusType">
{{ currentScenario.status }}
</span>
<span class="status-text">{{ currentScenario.statusText }}</span>
</div>
<div class="bubble-body">{{ currentScenario.responseText }}</div>
</div>
</div>
</div>
<!-- 收件人响应方 -->
<div class="character recipient">
<div class="char-avatar"></div>
<div class="char-name">收件人服务器</div>
</div>
</div>
</div>
<!-- 控制按钮 -->
<div class="dialog-controls">
<div class="actions">
<button
class="control-btn primary"
@click="nextStep"
:disabled="isDelivering || step >= 3"
class="action-btn primary"
@click="toggleAutoPlay"
>
{{ step === 0 ? '[开始]' : step === 3 ? '对话完成' : '[下一步]' }}
{{ isAutoPlaying ? '' : '▶ 演示' }}
</button>
<button class="control-btn" @click="reset" v-if="step > 0">
[重置]
<button
class="action-btn outline"
@click="reset"
>
</button>
</div>
</div>
<!-- 状态码说明 -->
<div class="status-legend">
<div class="legend-title">[对照] HTTP状态码速查</div>
<div class="legend-grid">
<div class="legend-item success">
<span class="status-dot"></span>
<span class="status-code">2xx</span>
<span class="status-meaning">成功送达</span>
</div>
<div class="legend-item redirect">
<span class="status-dot"></span>
<span class="status-code">3xx</span>
<span class="status-meaning">地址变更</span>
</div>
<div class="legend-item client-error">
<span class="status-dot"></span>
<span class="status-code">4xx</span>
<span class="status-meaning">请求有误</span>
</div>
<div class="legend-item server-error">
<span class="status-dot"></span>
<span class="status-code">5xx</span>
<span class="status-meaning">服务器问题</span>
<!-- 核心可视化舞台 -->
<div class="stage-area">
<!-- 客户端 -->
<div class="actor client">
<div class="avatar-box">
<span class="avatar-icon">🧑💻</span>
<span class="avatar-label">浏览器</span>
</div>
</div>
<!-- 传输通道 -->
<div class="channel">
<div class="channel-bg"></div>
<!-- 请求包 -->
<div class="packet request" :class="{ moving: step === 1, done: step > 1 }">
<span class="packet-icon">📤</span>
<span class="packet-label">GET</span>
</div>
<!-- 响应包 -->
<div class="packet response" :class="{ moving: step === 2, done: step > 2 }" v-if="step >= 2">
<span class="packet-icon">📦</span>
<span class="packet-label">{{ currentScenario.status }}</span>
</div>
</div>
<!-- 服务器 -->
<div class="actor server">
<div class="avatar-box">
<span class="avatar-icon">🏢</span>
<span class="avatar-label">服务器</span>
</div>
</div>
</div>
<!-- 底部详情面板 (固定高度) -->
<div class="detail-panel">
<transition name="fade" mode="out-in">
<div v-if="step > 0" class="detail-content" :key="step">
<!-- 左侧状态徽章 -->
<div class="detail-left" :style="{ borderColor: getStatusColor() }">
<div class="status-badge" :class="currentScenario.statusType">
{{ step === 1 ? '请求中' : currentScenario.status + ' ' + currentScenario.statusText }}
</div>
</div>
<div class="detail-divider"></div>
<!-- 右侧详情 -->
<div class="detail-right">
<div class="info-row">
<span class="tag life">快递员说</span>
<span class="text highlight">
{{ step === 1 ? currentScenario.requestText : currentScenario.responseText }}
</span>
</div>
<div class="info-row">
<span class="tag tech">技术报文</span>
<span class="text code">
{{ step === 1 ? `${currentScenario.method} ${currentScenario.path} HTTP/1.1` : `HTTP/1.1 ${currentScenario.status} ${currentScenario.statusText}` }}
</span>
</div>
</div>
</div>
<div v-else class="detail-placeholder">
<span class="guide-bounce">📦</span>
<span>选择一个场景点击"演示"看看发生了什么</span>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onUnmounted } from 'vue'
const scenarios = [
{
@@ -137,397 +128,381 @@ const scenarios = [
name: '正常送达',
method: 'GET',
path: '/index.html',
requestText: '您好,这是您的包裹,请签收',
requestText: '您好,请给我 index.html 的包裹',
status: '200',
statusText: 'OK',
statusType: 'success',
responseText: '好的,收到了,谢谢!'
responseText: '好的,这是您的 index.html,请签收!',
qa: {
title: '🤔 200 OK 是什么意思?',
content: [
{
q: '200 这个数字代表什么?',
a: '就像快递单上的"已妥投"。代表一切顺利,服务器成功找到了你要的东西并给了你。'
},
{
q: 'GET 是什么?',
a: '就像你对服务员说"给我来一份菜单"。是向服务器"索要"东西的意思。绝大多数网页访问都是 GET 请求。'
}
]
}
},
{
id: 'notfound',
name: '地址错误',
name: '查无此人',
method: 'GET',
path: '/nopage',
requestText: '您好,送包裹到这个地方。',
requestText: '您好,我要找 nopage 这个人。',
status: '404',
statusText: 'Not Found',
statusType: 'client-error',
responseText: '这里没有这个人,您送错地方了。'
statusType: 'error',
responseText: '抱歉,这里查无此人 (404)。',
qa: {
title: '🤔 为什么叫 404',
content: [
{
q: '是谁的错?',
a: '通常是"你"(客户端)的错。4开头的代码都代表客户端问题,比如你地址输错了,或者这个网页已经被删除了。'
}
]
}
},
{
id: 'redirect',
name: '地址变更',
name: '搬家了',
method: 'GET',
path: '/old-address',
requestText: '您好,送包裹到这个地址。',
path: '/old-path',
requestText: '您好,送到 old-path 这里。',
status: '301',
statusText: 'Moved',
statusType: 'redirect',
responseText: '这里搬了,请送到新地址。'
statusType: 'warn',
responseText: '这里搬了,请新地址 (301)。',
qa: {
title: '🤔 301 重定向是什么?',
content: [
{
q: '浏览器会怎么做?',
a: '浏览器收到 301 后,会自动去访问新的地址。这个过程很快,你可能都感觉不到。'
},
{
id: 'error',
name: '家中故障',
method: 'POST',
path: '/api/order',
requestText: '您好,我来送您订购的商品。',
q: '为什么要重定向?',
a: '就像店铺搬迁要在门口贴个告示。保证收藏了旧网址的老顾客也能找到新店。'
}
]
}
},
{
id: 'servererror',
name: '系统崩溃',
method: 'GET',
path: '/api/data',
requestText: '您好,我要取数据。',
status: '500',
statusText: 'Error',
statusType: 'server-error',
responseText: '抱歉,我们家系统出问题了,暂时无法接收。'
statusType: 'error',
responseText: '抱歉,仓库塌了,暂时无法取货 (500)。',
qa: {
title: '🤔 500 是谁的错?',
content: [
{
q: '我能修好它吗?',
a: '不能。5开头的代码代表"服务器"出问题了(比如代码崩了、数据库挂了)。跟你没关系,只能等待网站管理员修复。'
}
]
}
}
]
const currentScenario = ref(scenarios[0])
const step = ref(0)
const isDelivering = ref(false)
const step = ref(0) // 0: Idle, 1: Requesting, 2: Responding, 3: Done
const isAnimating = ref(false)
const isAutoPlaying = ref(false)
let timer = null
const selectScenario = (scenario) => {
currentScenario.value = scenario
const selectScenario = (s) => {
if (isAnimating.value) return
currentScenario.value = s
reset()
}
const nextStep = () => {
if (step.value < 3) {
isDelivering.value = true
step.value++
if (step.value === 2) {
setTimeout(() => {
step.value++
isDelivering.value = false
}, 1000)
const toggleAutoPlay = () => {
if (isAutoPlaying.value) {
reset()
} else {
isDelivering.value = false
}
startAnimation()
}
}
const startAnimation = () => {
if (isAnimating.value) return
isAnimating.value = true
isAutoPlaying.value = true
step.value = 1
// Step 1: Request (Client -> Server)
timer = setTimeout(() => {
step.value = 2
// Step 2: Response (Server -> Client)
timer = setTimeout(() => {
step.value = 3
isAnimating.value = false
isAutoPlaying.value = false
}, 1500)
}, 1500)
}
const reset = () => {
clearTimeout(timer)
step.value = 0
isDelivering.value = false
isAnimating.value = false
isAutoPlaying.value = false
}
const getStatusColor = () => {
if (step.value === 1) return '#3b82f6' // Blue for request
const type = currentScenario.value.statusType
if (type === 'success') return '#10b981'
if (type === 'warn') return '#f59e0b'
if (type === 'error') return '#ef4444'
return '#909399'
}
onUnmounted(() => {
clearTimeout(timer)
})
</script>
<style scoped>
.delivery-dialog-demo {
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
border: 2px solid var(--vp-c-divider);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
.http-compact {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg);
padding: 16px;
margin: 16px 0;
font-size: 14px;
}
/* 头部 */
.dialog-header {
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title-section {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--vp-c-divider);
}
.dialog-icon {
font-size: 14px;
color: var(--vp-c-brand);
gap: 8px;
font-weight: 600;
}
.dialog-title {
font-size: 17px;
font-weight: 700;
color: var(--vp-c-text-1);
}
/* 场景选择 */
.scenario-selector {
margin-bottom: 20px;
}
.selector-label {
font-size: 13px;
color: var(--vp-c-text-3);
margin-bottom: 10px;
}
.scenario-buttons {
.scenario-tabs {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.scenario-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
}
.scenario-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
transform: translateY(-2px);
}
.scenario-btn.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.scenario-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-text { font-size: 13px; }
/* 对话场景 */
.dialog-scene {
background: var(--vp-c-bg);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.scene-background {
display: flex;
align-items: center;
gap: 20px;
background: var(--vp-c-bg-alt);
padding: 2px;
border-radius: 6px;
gap: 2px;
}
/* 角色 */
.character {
text-align: center;
flex-shrink: 0;
}
.char-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 600;
color: white;
margin-bottom: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.courier .char-avatar {
background: linear-gradient(135deg, #409eff, #67c23a);
}
.recipient .char-avatar {
background: linear-gradient(135deg, #e6a23c, #f56c6c);
}
.char-name {
.tab-btn {
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.tab-btn.active {
background: var(--vp-c-brand);
color: white;
font-weight: 500;
}
/* 对话区域 */
.conversation-area {
flex: 1;
.actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn.primary {
background: var(--vp-c-brand);
color: white;
border: 1px solid var(--vp-c-brand);
}
.action-btn.outline {
background: transparent;
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
}
/* 舞台区 */
.stage-area {
display: flex;
justify-content: space-between;
align-items: center;
height: 100px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0 30px;
position: relative;
margin-bottom: 20px;
}
.actor {
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
align-items: center;
gap: 12px;
opacity: 0.3;
transform: translateY(10px);
transition: all 0.4s;
}
.message.sent {
opacity: 1;
transform: translateY(0);
}
.message.request {
justify-content: flex-end;
}
.message.response {
justify-content: flex-start;
width: 60px;
z-index: 2;
}
.message-bubble {
max-width: 280px;
padding: 14px;
border-radius: 12px;
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
}
.message.request .message-bubble {
background: #409eff;
border-color: #409eff;
color: white;
}
.message.response .message-bubble.success {
background: #67c23a;
border-color: #67c23a;
color: white;
}
.message.response .message-bubble.redirect {
background: #e6a23c;
border-color: #e6a23c;
color: white;
}
.message.response .message-bubble.client-error {
background: #f56c6c;
border-color: #f56c6c;
color: white;
}
.message.response .message-bubble.server-error {
background: #909399;
border-color: #909399;
color: white;
}
.avatar-icon { font-size: 28px; }
.avatar-label { font-size: 12px; color: var(--vp-c-text-2); margin-top: 4px; }
.bubble-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.2);
}
.method-badge, .status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
font-family: monospace;
background: rgba(255,255,255,0.2);
}
.path-text, .status-text {
font-size: 12px;
opacity: 0.9;
}
.bubble-body {
font-size: 13px;
line-height: 1.5;
}
.message-meta {
font-size: 11px;
color: var(--vp-c-text-3);
font-weight: 500;
}
/* 传输动画 */
.transit-animation {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.channel {
flex: 1;
height: 40px;
margin: 0 20px;
position: relative;
display: flex;
align-items: center;
}
.transit-line {
.channel-bg {
position: absolute;
width: 100%;
height: 2px;
background: var(--vp-c-divider);
top: 50%;
}
.transit-package {
.packet {
position: absolute;
font-size: 20px;
font-weight: 600;
color: var(--vp-c-brand);
animation: deliver 1s ease-in-out;
}
@keyframes deliver {
0% { transform: translateX(-100px); }
50% { transform: translateX(0) scale(1.2); }
100% { transform: translateX(100px); }
}
/* 控制按钮 */
.dialog-controls {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 20px;
}
.control-btn {
padding: 12px 24px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 24px;
cursor: pointer;
font-size: 14px;
color: var(--vp-c-text-2);
transition: all 0.3s;
}
.control-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-btn.primary {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.control-btn.primary:hover:not(:disabled) {
background: #66b1ff;
flex-direction: column;
align-items: center;
transition: all 1.5s ease-in-out;
opacity: 0;
top: 0;
}
/* 状态码说明 */
.status-legend {
padding: 16px;
background: var(--vp-c-bg);
border-radius: 12px;
.packet-icon { font-size: 20px; }
.packet-label { font-size: 10px; color: var(--vp-c-text-2); background: var(--vp-c-bg); padding: 0 2px; }
.packet.request { left: 0; opacity: 1; }
.packet.request.moving { left: 100%; transform: translateX(-100%); opacity: 0; }
/* Request moves from 0 to 100% then disappears */
.packet.response { left: 100%; transform: translateX(-100%); opacity: 0; }
.packet.response.moving { left: 0; transform: translateX(0); opacity: 1; }
/* Response starts at 100%, moves to 0 */
/* 动画调整 */
.packet.request.moving {
animation: sendRequest 1.5s forwards;
}
.legend-title {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 12px;
.packet.response.moving {
animation: sendResponse 1.5s forwards;
}
.legend-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
@keyframes sendRequest {
0% { left: 0; opacity: 1; transform: translateX(0); }
90% { left: 100%; opacity: 1; transform: translateX(-100%); }
100% { left: 100%; opacity: 0; transform: translateX(-100%); }
}
.legend-item {
@keyframes sendResponse {
0% { left: 100%; opacity: 1; transform: translateX(-100%); }
100% { left: 0; opacity: 1; transform: translateX(0); }
}
/* 详情面板 */
.detail-panel {
height: 80px;
background: var(--vp-c-bg-alt);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
padding: 0 16px;
display: flex;
align-items: center;
overflow: hidden;
}
.detail-content {
display: flex;
width: 100%;
align-items: center;
}
.detail-left {
padding-right: 16px;
border-right: 2px solid transparent;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
color: white;
font-size: 12px;
font-weight: bold;
}
.status-badge.success { background: #10b981; }
.status-badge.warn { background: #f59e0b; }
.status-badge.error { background: #ef4444; }
.detail-divider {
width: 1px;
height: 40px;
background: var(--vp-c-divider);
margin: 0 16px;
}
.detail-right {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-item.success .status-dot { background: #67c23a; }
.legend-item.redirect .status-dot { background: #e6a23c; }
.legend-item.client-error .status-dot { background: #f56c6c; }
.legend-item.server-error .status-dot { background: #909399; }
.status-code {
font-family: monospace;
font-size: 12px;
font-weight: 700;
color: var(--vp-c-text-1);
}
.status-meaning {
font-size: 12px;
color: var(--vp-c-text-2);
}
@media (max-width: 640px) {
.scene-background {
flex-direction: column;
}
.character {
order: -1;
}
.legend-grid {
grid-template-columns: 1fr;
}
.tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
}
.tag.life { background: #e6f7ff; color: #1890ff; }
.tag.tech { background: #f6ffed; color: #52c41a; }
.text { font-size: 13px; color: var(--vp-c-text-1); }
.text.highlight { font-weight: 500; color: var(--vp-c-brand); }
.text.code { font-family: monospace; }
.detail-placeholder {
display: flex;
align-items: center;
gap: 8px;
color: var(--vp-c-text-3);
width: 100%;
justify-content: center;
}
.guide-bounce { animation: bounce 1.5s infinite; }
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
</style>
File diff suppressed because it is too large Load Diff
@@ -1,197 +1,253 @@
<!--
UrlParserDemo.vue
URL解析演示 - 交互式可视化组件
URL解析演示 - 网购订单隐喻版
用途
URL 解析过程可视化通过颜色编码和分块展示
直观地展示 URL 的各个组成部分及其对应的技术含义和生活比喻
设计理念
1. 隐喻对齐严格对应 url-to-browser.md 中的"网购订单"比喻
2. 视觉映射将枯燥的 URL 字符串映射为一张清晰的"购物清单"
3. 实时交互输入即解析所见即所得
-->
<template>
<div class="url-parser-demo">
<!-- 头部控制 -->
<div class="control-panel">
<div class="header-section">
<span class="icon">🔍</span>
<span class="title">URL 解析器</span>
<div class="url-parser-order">
<!-- 顶部输入 -->
<div class="input-section">
<div class="url-input-box" :class="{ 'has-error': error }">
<span class="input-label">URL</span>
<input
v-model="urlInput"
type="text"
placeholder="https://www.example.com/path?query=1"
class="real-input"
@input="parseUrl"
/>
<button v-if="urlInput" class="clear-btn" @click="clear"></button>
</div>
<div class="examples-section">
<span class="label">试一试</span>
<div class="button-group">
<div class="quick-actions">
<span class="action-label">试一试</span>
<button
v-for="ex in examples"
:key="ex.name"
@click="useExample(ex)"
class="action-btn outline small"
class="action-chip"
:class="{ active: currentExample === ex.name }"
>
{{ ex.name }}
</button>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-section">
<div class="input-wrapper">
<input
v-model="urlInput"
type="text"
placeholder="输入或粘贴一个网址..."
@input="parseUrl"
class="url-input"
/>
<button class="clear-btn" @click="clear" v-if="urlInput"></button>
<!-- 核心区域左右对照布局 -->
<template v-if="parsed.protocol">
<div class="core-stage">
<!-- 左侧解析结果 (技术视角) -->
<div class="tech-view">
<div class="view-header">
<span class="icon">💻</span>
<span class="title">技术解析</span>
</div>
</div>
<!-- 可视化展示区 -->
<div class="visualization-area" v-if="parsed.protocol">
<div class="url-blocks">
<div class="code-blocks">
<div
v-for="(field, key) in formFields"
:key="key"
v-show="shouldShowField(key)"
class="url-block"
class="code-block"
:class="[key, { active: hovered === key }]"
:style="{ '--block-color': field.color, '--block-bg': hexToRgba(field.color, 0.15) }"
:style="{ '--color': field.color }"
@mouseenter="hovered = key"
@mouseleave="hovered = null"
>
<span class="block-value">{{ getDisplayValue(key) }}</span>
<span class="block-label">{{ field.techName }}</span>
<span class="field-name">{{ key }}</span>
<span class="field-value">{{ getDisplayValue(key) }}</span>
</div>
</div>
</div>
<!-- 详情说明卡片 -->
<div class="info-card" v-if="hovered && formFields[hovered]">
<div class="info-header" :style="{ borderLeftColor: formFields[hovered].color }">
<span class="info-title">{{ formFields[hovered].techLabel }} ({{ formFields[hovered].techName }})</span>
<span class="info-badge" :style="{ backgroundColor: formFields[hovered].color }">
{{ formFields[hovered].icon }}
</span>
<!-- 中间转换箭头 -->
<div class="transform-arrow">
<span class="arrow-icon"></span>
<span class="arrow-text">映射为</span>
</div>
<div class="info-content">
<div class="info-row">
<div class="info-label">技术含义</div>
<div class="info-value">
<strong>{{ formFields[hovered].techDesc }}</strong>
<div class="info-detail">{{ formFields[hovered].techDetail }}</div>
<!-- 右侧购物单 (生活视角) -->
<div class="life-view">
<div class="view-header">
<span class="icon">🧾</span>
<span class="title">购物订单</span>
</div>
<div class="order-ticket">
<div class="ticket-hole"></div>
<div
v-for="(field, key) in formFields"
:key="key"
v-show="shouldShowField(key)"
class="ticket-row"
:class="{ active: hovered === key }"
:style="{ '--color': field.color }"
@mouseenter="hovered = key"
@mouseleave="hovered = null"
>
<div class="ticket-icon" :style="{ backgroundColor: field.color }">{{ field.icon }}</div>
<div class="ticket-content">
<span class="ticket-label">{{ field.analogyLabel }}</span>
<span class="ticket-desc">{{ field.analogyDesc }}</span>
</div>
<div class="info-divider"></div>
<div class="info-row">
<div class="info-label">生活比喻</div>
<div class="info-value">
<strong>{{ formFields[hovered].analogyLabel }}</strong>
<div class="info-detail">{{ formFields[hovered].analogyDesc }}</div>
</div>
</div>
</div>
</div>
<!-- 空状态提示 -->
<div class="empty-state" v-else-if="!urlInput">
<p>👆 在上方输入网址查看它是由哪些部分组成的</p>
<!-- 技术答疑面板 -->
<transition name="fade">
<div class="qa-panel" v-if="activeQa">
<div class="qa-header">{{ activeQa.title }}</div>
<div class="qa-content">
<div v-for="(item, idx) in activeQa.content" :key="idx" class="qa-item">
<div class="qa-q">Q: {{ item.q }}</div>
<div class="qa-a">A: {{ item.a }}</div>
</div>
</div>
</div>
<div class="qa-placeholder" v-else>
👆 鼠标悬停在上方色块查看详细技术解释
</div>
</transition>
</template>
<div class="default-info" v-else>
<p>👆 鼠标悬停在上方色块上查看详细解释</p>
<!-- 空状态引导 -->
<div class="empty-state" v-else>
<div class="empty-icon">🛒</div>
<div class="empty-text">
<p>输入网址生成你的"数字购物单"</p>
<span class="sub-text">看看浏览器如何理解这一长串字符</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
const urlInput = ref('')
const parsed = ref({})
const hovered = ref(null)
const currentExample = ref('')
const error = ref(false)
const examples = [
{ name: '百度搜索', url: 'https://www.baidu.com/s?wd=hello' },
{ name: 'GitHub项目', url: 'https://github.com/vuejs/core' },
{ name: '带端口', url: 'http://localhost:8080/api/users' },
{ name: '带锚点', url: 'https://vuejs.org/guide/introduction.html#what-is-vue' }
{ name: '标准网购', url: 'https://www.nike.com/shoes/running?size=42&color=red' },
{ name: '带端口', url: 'http://localhost:8080/api/status' },
{ name: '带锚点', url: 'https://vuejs.org/guide.html#setup' }
]
// 定义字段映射(严格对齐 url-to-browser.md
const formFields = {
protocol: {
techName: 'Protocol',
techLabel: '协议',
color: '#f43f5e', // Red
icon: '',
analogyLabel: '快递公司',
techDesc: '通信规则',
analogyDesc: '决定是用"顺丰"(HTTPS)还是"平邮"(HTTP)传输',
techDetail: 'https (加密) 或 http (明文)'
icon: '🚚',
analogyLabel: '物流方式',
analogyDesc: '决定怎么送货(HTTP普通/HTTPS加密保密)。',
qa: {
title: '🤔 为什么要写 http/https',
content: [
{
q: '这两者有什么区别?',
a: 'HTTP 就像寄明信片,邮递员(黑客)能看到内容。HTTPS 就像寄密封的信封,只有收件人能拆开看。'
},
{
q: '为什么现在都是 https',
a: '为了安全!现在的浏览器如果发现不是 HTTPS,会提示"不安全",就像快递公司拒收没封口的信件一样。'
}
]
}
},
hostname: {
techName: 'Hostname',
techLabel: '域名',
color: '#3b82f6', // Blue
icon: '',
analogyLabel: '收件人',
techDesc: '服务器地址',
analogyDesc: '如 "google.com",方便人类记忆的名字',
techDetail: '最终需要通过 DNS 解析为 IP 地址'
icon: '🏠',
analogyLabel: '店铺名称',
analogyDesc: '告诉浏览器去哪家店(服务器)买东西。',
qa: {
title: '🤔 域名还是 IP',
content: [
{
q: '浏览器认识域名吗?',
a: '其实不认识。浏览器只认识 IP 地址(一串数字)。域名是为了方便人记的。下一步(DNS 查询)就是把这个名字翻译成数字。'
}
]
}
},
port: {
techName: 'Port',
techLabel: '端口',
color: '#f59e0b', // Amber
icon: '',
analogyLabel: '门牌号',
techDesc: '服务入口',
analogyDesc: '如 ":8080",大楼里的具体房间号',
techDetail: '默认端口(80/443)通常会被浏览器省略'
icon: '🔢',
analogyLabel: '柜台编号',
analogyDesc: '店铺很大,指定去几号柜台办理业务。',
qa: {
title: '🤔 这里的数字是什么意思?',
content: [
{
q: '为什么平时上网看不到它?',
a: '因为有默认值!就像去银行默认去"综合柜台"一样。HTTP 默认是 80HTTPS 默认是 443。只有特殊的才需要写出来。'
}
]
}
},
pathname: {
techName: 'Path',
techLabel: '路径',
color: '#10b981', // Emerald
icon: '',
analogyLabel: '具体位置',
techDesc: '资源路径',
analogyDesc: '如 "/files/doc.txt",文件柜的位置',
techDetail: '指向服务器上的具体资源'
icon: '📦',
analogyLabel: '货架位置',
analogyDesc: '商品在仓库里的具体存放位置。',
qa: {
title: '🤔 这一长串是干嘛的?',
content: [
{
q: '它对应服务器上的文件吗?',
a: '通常是的。/shoes/running 就像告诉仓库管理员:去"鞋子区"的"跑步架"上拿货。'
}
]
}
},
search: {
techName: 'Query',
techLabel: '参数',
color: '#8b5cf6', // Violet
icon: '',
analogyLabel: '备注',
techDesc: '查询参数',
analogyDesc: '如 "?q=hello",告诉对方具体要求',
techDetail: '键值对形式的附加数据'
icon: '📝',
analogyLabel: '订单备注',
analogyDesc: '给商家的额外要求(如:红色、42码)。',
qa: {
title: '🤔 问号后面的内容?',
content: [
{
q: '这对网页有什么影响?',
a: '就像你点外卖备注"不要香菜"。网页内容会根据这些参数变化,比如只显示红色的鞋子。'
}
]
}
},
hash: {
techName: 'Hash',
techLabel: '锚点',
color: '#ec4899', // Pink
icon: '',
analogyLabel: '页码',
techDesc: '页内定位',
analogyDesc: '如 "#section1",书的某一页',
techDetail: '浏览器滚动到指定位置,不会发送给服务器'
icon: '🔖',
analogyLabel: '说明书页码',
analogyDesc: '拿到商品后,直接翻到说明书的第几页。',
qa: {
title: '🤔 为什么要用 # 号?',
content: [
{
q: '这部分会发给服务器吗?',
a: '不会。这只是给你自己(浏览器)看的。就像你买书回家后翻到第10页,书店老板并不需要知道你看哪一页。'
}
]
}
}
}
const hexToRgba = (hex, alpha) => {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const activeField = computed(() => hovered.value || null)
const activeQa = computed(() => {
if (!activeField.value) return null
return formFields[activeField.value].qa
})
const shouldShowField = (key) => {
const val = parsed.value[key]
if (!val) return false
if (val === '无') return false
if (key === 'search' && (val === '' || val === '?')) return false
if (key === 'hash' && (val === '' || val === '#')) return false
return true
@@ -199,10 +255,8 @@ const shouldShowField = (key) => {
const getDisplayValue = (key) => {
let val = parsed.value[key]
if (key === 'protocol') return val + '://'
if (key === 'port') return ':' + val.replace('(默认)', '')
// simple formatting
if (key === 'port') return ':' + val
return val
}
@@ -216,6 +270,8 @@ const clear = () => {
urlInput.value = ''
parsed.value = {}
currentExample.value = ''
hovered.value = null
error.value = false
}
const parseUrl = () => {
@@ -223,295 +279,341 @@ const parseUrl = () => {
parsed.value = {}
return
}
try {
let urlStr = urlInput.value.trim()
// Auto-add protocol if missing for better UX
// 自动补全协议以支持 new URL() 解析
if (!urlStr.match(/^https?:\/\//)) {
if (urlStr.startsWith('localhost')) {
urlStr = 'http://' + urlStr
} else {
urlStr = 'https://' + urlStr
urlStr = (urlStr.startsWith('localhost') ? 'http://' : 'https://') + urlStr
}
}
const u = new URL(urlStr)
// Determine if port is explicit or default
let portDisplay = u.port
if (!portDisplay) {
// Just for display logic in the parser, we might not show it if it's default/hidden
// But let's show it if we want to be educational
// Actually, let's only show if it's in the string or we want to be explicit
// For visualizer, maybe better to show what's THERE.
// But to be educational, maybe show implied?
// Let's stick to what's in the URL object but handle defaults
// If the user typed it, u.port is set. If implied, it's empty string.
}
parsed.value = {
protocol: u.protocol.replace(':', ''),
hostname: u.hostname,
port: u.port, // Only show if explicit
pathname: u.pathname === '/' ? '/' : u.pathname,
port: u.port || (u.protocol === 'https:' ? '443 (默认)' : '80 (默认)'),
pathname: u.pathname,
search: u.search,
hash: u.hash
}
error.value = false
} catch (e) {
// simplistic fallback or error state could go here
// parsed.value = {}
// 解析失败时不更新 parsed,或者显示错误状态
// 这里选择静默失败,等待用户输入完整
if (urlInput.value.length > 10) {
// error.value = true
}
}
}
</script>
<style scoped>
.url-parser-demo {
.url-parser-order {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background-color: var(--vp-c-bg);
padding: 20px;
margin: 16px 0;
font-family: var(--vp-font-family-base);
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
/* 输入区 */
.input-section {
margin-bottom: 24px;
}
.url-input-box {
display: flex;
align-items: center;
background: var(--vp-c-bg-alt);
border: 2px solid transparent;
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
.control-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
background: var(--vp-c-bg);
padding: 0.75rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
.header-section {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: bold;
color: var(--vp-c-text-1);
}
.examples-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.action-btn {
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
background: transparent;
color: var(--vp-c-text-2);
padding: 8px 12px;
transition: all 0.2s;
}
.action-btn:hover {
.url-input-box:focus-within {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.action-btn.active {
background-color: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
border-color: var(--vp-c-brand);
}
.input-section {
margin-bottom: 1.5rem;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.url-input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
font-family: inherit;
font-size: 1rem;
transition: border-color 0.2s;
}
.url-input:focus {
outline: none;
border-color: var(--vp-c-brand);
.input-label {
font-weight: bold;
font-size: 13px;
color: var(--vp-c-text-2);
margin-right: 12px;
user-select: none;
}
.clear-btn {
position: absolute;
right: 0.75rem;
background: none;
.real-input {
flex: 1;
background: transparent;
border: none;
font-family: var(--vp-font-family-mono);
font-size: 14px;
color: var(--vp-c-text-1);
outline: none;
min-width: 0;
}
.clear-btn {
color: var(--vp-c-text-3);
cursor: pointer;
font-size: 1rem;
padding: 4px;
}
.clear-btn:hover { color: var(--vp-c-text-1); }
.clear-btn:hover {
color: var(--vp-c-text-1);
}
.visualization-area {
margin-bottom: 1rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.url-blocks {
.quick-actions {
display: flex;
gap: 4px;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.action-label {
font-size: 12px;
color: var(--vp-c-text-2);
padding-top: 4px;
}
.action-chip {
padding: 4px 12px;
border-radius: 100px;
background: var(--vp-c-bg-soft);
font-size: 12px;
color: var(--vp-c-text-1);
transition: all 0.2s;
border: 1px solid transparent;
}
.action-chip:hover {
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
}
.action-chip.active {
background: var(--vp-c-brand);
color: white;
}
.url-block {
/* 核心展示区 */
.core-stage {
display: flex;
align-items: stretch;
gap: 16px;
min-height: 200px;
}
/* 通用标题 */
.view-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--vp-c-divider);
}
.view-header .icon { font-size: 16px; }
.view-header .title { font-weight: bold; font-size: 14px; color: var(--vp-c-text-1); }
/* 左侧:技术视图 */
.tech-view {
flex: 1;
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 6px;
background-color: var(--block-bg);
border: 1px solid var(--block-color);
cursor: help;
transition: all 0.2s;
min-width: fit-content;
}
.url-block:hover, .url-block.active {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
filter: brightness(1.05);
}
.block-value {
font-weight: bold;
color: var(--block-color);
font-size: 1rem;
white-space: nowrap;
}
.block-label {
font-size: 0.7rem;
text-transform: uppercase;
margin-top: 0.25rem;
opacity: 0.7;
color: var(--block-color);
}
.info-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
animation: slide-up 0.3s ease;
}
.info-header {
padding: 0.75rem 1rem;
background: var(--vp-c-bg-alt);
.code-blocks {
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.code-block {
display: flex;
flex-direction: column;
padding: 8px;
border-radius: 6px;
background: var(--vp-c-bg);
border-left: 3px solid var(--color);
transition: all 0.2s;
cursor: default;
}
.code-block:hover, .code-block.active {
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.field-name {
font-size: 11px;
color: var(--vp-c-text-2);
text-transform: uppercase;
margin-bottom: 2px;
}
.field-value {
font-family: var(--vp-font-family-mono);
font-size: 13px;
color: var(--vp-c-text-1);
word-break: break-all;
}
/* 中间:箭头 */
.transform-arrow {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-left: 4px solid transparent;
color: var(--vp-c-text-3);
font-size: 12px;
width: 40px;
}
.arrow-icon { font-size: 20px; }
/* 右侧:生活视图 (票据样式) */
.life-view {
flex: 1;
background: #fff;
border-radius: 8px;
padding: 12px;
position: relative;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
border: 1px solid var(--vp-c-divider);
}
/* 暗黑模式适配票据背景 */
:root.dark .life-view {
background: #1e1e20;
}
.info-title {
font-weight: bold;
font-size: 0.95rem;
.order-ticket {
display: flex;
flex-direction: column;
gap: 12px;
background: var(--vp-c-bg-alt);
border: 1px dashed var(--vp-c-divider);
padding: 16px;
border-radius: 8px;
position: relative;
}
.info-badge {
width: 24px;
height: 24px;
.ticket-hole {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 16px;
background: var(--vp-c-bg);
border-radius: 50%;
border: 1px solid var(--vp-c-divider);
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
}
.ticket-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px;
border-radius: 6px;
transition: all 0.2s;
opacity: 0.8;
}
.ticket-row:hover, .ticket-row.active {
background: var(--vp-c-bg-soft);
opacity: 1;
transform: scale(1.02);
}
.ticket-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: white;
font-size: 0.75rem;
font-weight: bold;
flex-shrink: 0;
}
.info-content {
padding: 1rem;
display: grid;
grid-template-columns: 1fr 1px 1fr;
gap: 1rem;
}
.info-divider {
background: var(--vp-c-divider);
height: 100%;
}
.info-row {
.ticket-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-label {
font-size: 0.75rem;
.ticket-label {
font-weight: bold;
font-size: 13px;
color: var(--vp-c-text-1);
}
.ticket-desc {
font-size: 12px;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.4;
}
.info-value {
font-size: 0.9rem;
}
.info-detail {
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-top: 0.25rem;
}
.empty-state, .default-info {
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: var(--vp-c-text-3);
text-align: center;
padding: 2rem;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
.empty-text p { font-size: 16px; font-weight: bold; margin: 0 0 8px 0; color: var(--vp-c-text-2); }
.sub-text { font-size: 13px; }
/* 响应式 */
@media (max-width: 640px) {
.core-stage {
flex-direction: column;
}
.transform-arrow {
flex-direction: row;
width: 100%;
height: 40px;
gap: 8px;
}
.arrow-icon { transform: rotate(90deg); }
}
/* QA Panel */
.qa-panel {
margin-top: 16px;
background: var(--vp-c-bg-alt);
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 12px;
font-size: 13px;
}
.qa-header {
font-weight: 600;
color: var(--vp-c-brand);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--vp-c-divider);
}
.qa-item {
margin-bottom: 10px;
}
.qa-item:last-child {
margin-bottom: 0;
}
.qa-q {
color: var(--vp-c-text-1);
font-weight: 500;
margin-bottom: 4px;
}
.qa-a {
color: var(--vp-c-text-2);
line-height: 1.5;
}
.qa-placeholder {
margin-top: 16px;
text-align: center;
color: var(--vp-c-text-3);
font-size: 13px;
padding: 12px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
border: 1px dashed var(--vp-c-divider);
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
/* Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
@media (max-width: 640px) {
.info-content {
grid-template-columns: 1fr;
}
.info-divider {
height: 1px;
width: 100%;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
File diff suppressed because it is too large Load Diff
+120 -135
View File
@@ -9,66 +9,66 @@ import HttpExchangeDemo from '../../.vitepress/theme/components/appendix/url-to-
import BrowserRenderingDemo from '../../.vitepress/theme/components/appendix/url-to-browser/BrowserRenderingDemo.vue'
</script>
> **学习指南**:本章节无需编程基础。我们将用**"寄快递"**的生活化比喻,配合**真实的技术过程**,带你一步步理解浏览器如何将一行网址变成丰富多彩的页面。
> **学习指南**:本章节无需编程基础。我们将用**"网购"**的生活化比喻,配合**真实的技术过程**,带你一步步理解浏览器如何将一行网址变成丰富多彩的页面。
---
## 0. 引言:当你按下回车键的那一刻
想象你要给远方的朋友寄一份礼物。你需要:
1. **填写快递**写明地址、收件人
2. **快递公司查地址**(把"XX市XX区"转换成具体的门牌号
3. **打电话确认**(确保对方在家能收件
4. **快递员送达**把包裹交给对方
5. **朋友拆开包裹**(看到礼物
想象你正在进行一次**网购**。你需要:
1. **填写**选好商品,确认收货地址
2. **系统查找仓库**(根据店铺名找到具体的发货仓库
3. **建立物流通道**(确保仓库正常营业且能发货
4. **仓库发货**快递员把包裹送上门
5. **拆箱体验**(打开包裹,看到心仪的商品
**访问网页的过程和寄快递惊人地相似!**
**访问网页的过程和网购惊人地相似!**
当你在浏览器输入 `google.com` 并按下回车,浏览器就是那个"快递员",它要完成一次从"你的电脑"到"远方服务器"再到"屏幕显示"的完整旅程
当你在浏览器输入 `google.com` 并按下回车,就是那个"买家",浏览器通过一系列操作,最终把远方服务器上的"商品"(网页内容)送到你的屏幕上
<UrlToBrowserQuickStart />
---
## 1. 第一步:填写"快递单" —— URL 解析
## 1. 第一步:填写"单" —— URL 解析
### 生活比喻:填写快递
### 生活比喻:填写购物
假设你只在快递单上写"寄给张三",快递员肯定找不到人。你需要写清楚:
- **用什么快递**顺丰/中通
- **哪个城市**北京市
- **具体地址**朝阳区XX街道XX号
- **哪栋楼哪间房**3号楼201
- **备注信息**放快递柜/打电话
假设你只在单上写"买鞋子",仓库肯定不知道发哪双。你需要写清楚:
- **店铺类型**官方旗舰店/普通店
- **店铺名称**Nike 官方店
- **商品位置**男鞋区/跑鞋系列
- **具体型号**Air Max 90
- **备注信息**我要红色的
### 真实过程:浏览器解析 URL
**URLUniform Resource Locator,统一资源定位符)**就是浏览器世界的"快递单格式"。当你在地址栏输入 `https://www.example.com:8080/path/page.html?id=123#section`,浏览器会立即拆解它:
**URLUniform Resource Locator,统一资源定位符)**就是浏览器世界的"商品定位码"。当你在地址栏输入 `https://www.example.com:8080/path/page.html?id=123#section`,浏览器会立即拆解它:
| URL 部分 | 示例值 | 快递单类比 | 技术作用 |
| URL 部分 | 示例值 | 网购类比 | 技术作用 |
|----------|--------|-----------|----------|
| **协议** `https://` | 安全超文本传输协议 | **快递公司**顺丰(安全)vs 中通(普通 | 决定使用什么规则通信。`http` 是普通传输,`https` 是加密传输 |
| **域名** `www.example.com` | 服务器的人类可读名字 | **收件人姓名**张三 | 告诉浏览器要找哪台服务器。域名是为了让人记住,最终要转换成 IP 地址 |
| **端口** `:8080` | 服务器的具体"门牌号" | **详细门牌**3号楼201(默认不写) | 服务器上可能有多个服务,端口指定访问哪一个。HTTP 默认 80,HTTPS 默认 443 |
| **路径** `/path/page.html` | 服务器上的文件位置 | **房间里的抽屉**:衣柜第二层 | 指定服务器上的具体资源位置 |
| **查询参数** `?id=123` | 附加信息 | **备注**请轻拿轻放 | 传递给服务器的额外数据,如搜索关键词、页码等 |
| **锚点** `#section` | 页面内的位置 | **书里的页码**:翻到第5页 | 页面加载后自动滚动到指定位置,不发送给服务器 |
| **协议** `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 的存在是为了让**人类**能记住和输入。计算机最终需要的是 **IP 地址**(就像快递员最终需要的是门牌号,而不是"张三的家")。
> **关键理解**:URL 的存在是为了让**人类**能记住和输入。计算机最终需要的是 **IP 地址**(就像快递员最终需要的是具体的仓库地址,而不是"Nike 官方店"这个名字)。
---
## 2. 第二步:查"地址簿" —— DNS 查询
### 生活比喻:查地址簿
### 生活比喻:查仓库地址
告诉快递员"送到张三那里",但快递员怎么知道张三住哪?他需要查地址簿:
1. 先**通讯录**(最近联系过的人)→ 浏览器缓存
2. 没有的话问**社区服务中心**(他们知道各个小区归谁管)→ 本地 DNS 服务器
3. 社区问**总管理处**(他们知道XX街道归哪个片区管)→ 根域名服务器
4. 片区查**住户登记**(最终找到张三的门牌号)→ 权威域名服务器
下单写的是"Nike 官方店",但物流系统不知道仓库在哪。它需要查地址簿:
1. 先**常用地址**(最近买过这家吗)→ 浏览器缓存
2. 没有的话问**小区快递点**(他们知道大区域的分配)→ 本地 DNS 服务器
3. 问**总部调度中心**(知道.com类店铺归谁管)→ 根域名服务器
4. 问**品牌管理处**(最终找到 Nike 店铺的真实发货仓库)→ 权威域名服务器
### 真实过程:DNS 分层查询
@@ -101,29 +101,29 @@ import BrowserRenderingDemo from '../../.vitepress/theme/components/appendix/url
## 3. 第三步:打电话确认 —— TCP 三次握手
### 生活比喻:打电话确认
### 生活比喻:建立物流通道
假设快递员直接冲到张三家门口,结果:
- 张三不在家 → 白跑一趟
- 电话打不通 → 不知道送哪
- 地址错了 → 送错地方
假设物流车直接开到仓库,结果:
- 仓库关门了 → 白跑一趟
- 仓库爆仓不接单 → 无法发货
- 找不到卸货口 → 无法对接
**所以在真正送包裹之前,必须先确认"对方能收到"**。
**所以在真正发货之前,必须先建立可靠的运输通道**。
### 真实过程:TCP 三次握手
**TCPTransmission Control Protocol,传输控制协议)**是确保数据可靠传输的规则。在发送任何数据前,客户端和服务器必须通过"三次握手"建立可靠连接:
**TCPTransmission Control Protocol,传输控制协议)**是确保数据可靠传输的规则。在传输商品(数据前,必须通过"三次握手"建立连接:
```
客户端(你的浏览器 服务器(网站
客户端(你的电脑 服务器(商家仓库
| |
|--- SYN=1, seq=x ------------->| 第1次:我想连接你,我的初始序号是 x
|--- SYN=1 --------------------->| 第1次:你好,我在家,准备收货!(SYN)
| |
|<-- SYN=1, ACK=1, seq=y, ack=x+1 | 第2次:我也想连接你,我的初始序号是 y,期待收到 x+1
|<-- SYN=1, ACK=1 ---------------| 第2次:收到!我也准备好发货了,你在家吗?(SYN-ACK)
| |
|--- ACK=1, ack=y+1 ----------->| 第3次:确认,期待收到 y+1
|--- ACK=1 --------------------->| 第3次:在的!请发货吧。(ACK)
| |
===== 连接建立,开始传输数据 =====
===== 通道建立,开始发货 =====
```
**为什么是三次,不是两次?**
@@ -146,16 +146,16 @@ import BrowserRenderingDemo from '../../.vitepress/theme/components/appendix/url
---
## 4. 第四步:"快递员"和"收件人"的对话 —— HTTP 请求与响应
## 4. 第四步:"买家"和"商家"的对话 —— HTTP 请求与响应
### 生活比喻:快递员送达
### 生活比喻:仓库发货
快递员敲门:"张三在吗?您的快递!"
张三开门:"好的,给我吧。" 或者 "我没买东西啊,退回去吧。"
物流车到达仓库:"这是订单(HTTP请求),**我要取回商品(网页 HTML 源代码)!**"
仓库管理员核对:"订单有效,这是你要的包裹(**HTML 文件**),请拿好。"
### 真实过程:HTTP 协议通信
**HTTPHyperText Transfer Protocol,超文本传输协议)**是浏览器和服务器之间的"对话规则"。TCP 连接建立后,浏览器发送 HTTP 请求
**HTTPHyperText Transfer Protocol,超文本传输协议)**是浏览器和服务器之间的"对话规则"。通道建立后,浏览器发送**取货请求**,**核心目标是拿回网页的源代码(HTML 文件)**
**HTTP 请求示例:**
```http
@@ -169,6 +169,20 @@ 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`:提交数据(创建资源,如注册、登录)
@@ -193,16 +207,16 @@ Set-Cookie: user_id=xyz789 ← 设置 Cookie
| 状态码 | 类别 | 含义 | 生活类比 |
|--------|------|------|----------|
| **200** | 成功 | 请求成功处理 | "给,这是你要的" |
| **301/302** | 重定向 | 资源已移动 | "搬家了,去新地址取" |
| **304** | 未修改 | 缓存仍有效 | "上次一样,不用重新" |
| **400** | 客户端错误 | 请求格式错误 | "你说的话我听不懂" |
| **401** | 未授权 | 需要身份验证 | "请出示证件" |
| **403** | 禁止访问 | 权限不足 | "你不准进" |
| **404** | 未找到 | 资源不存在 | "没这个人/没这个东西" |
| **500** | 服务器错误 | 服务器内部错误 | "我们这系统出故障了" |
| **502** | 网关错误 | 上游服务器无响应 | "我们上级部门没回应" |
| **503** | 服务不可用 | 服务器过载或维护 | "今天休息,不营业" |
| **200** | 成功 | 请求成功处理 | "订单确认,马上发货" |
| **301/302** | 重定向 | 资源已移动 | "本店搬家了,去新店下单" |
| **304** | 未修改 | 缓存仍有效 | "上次买的还能用,不用重新发货" |
| **400** | 客户端错误 | 请求格式错误 | "订单填写模糊,看不懂" |
| **401** | 未授权 | 需要身份验证 | "请出示会员卡" |
| **403** | 禁止访问 | 权限不足 | "非内部人员禁止入内" |
| **404** | 未找到 | 资源不存在 | "仓库里没这款商品" |
| **500** | 服务器错误 | 服务器内部错误 | "仓库起火了,暂时发不了货" |
| **502** | 网关错误 | 上游服务器无响应 | "总仓没货了,分仓也调不到" |
| **503** | 服务不可用 | 服务器过载或维护 | "爆单了,暂停接单" |
<HttpExchangeDemo />
@@ -210,118 +224,89 @@ Set-Cookie: user_id=xyz789 ← 设置 Cookie
## 5. 第五步:拆开"包裹" —— 浏览器渲染
### 生活比喻:拆开包裹看到礼物
### 生活比喻:拆箱与组装
快递员把包裹交给张三,张三看到的是**包装盒**。他需要
1. **拆开包装**(去掉快递袋)→ 解析 HTML
2. **查看说明书**(了解怎么用)→ 解析 CSS
3. **组装零件**(按说明书拼装)→ 构建渲染树
4. **摆放位置**(确定放哪里)→ 布局计算
5. **最终呈现**(展示成品)→ 绘制到屏幕
你终于收到了快递包裹(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 树
#### 步骤1:解析 HTML → 构建 DOM 树 (零件清单)
浏览器读取 HTML 字节流,按编码(通常是 UTF-8)转换成字符,通过词法分析生成 Token,再解析成 DOM 节点,最终构建成**DOMDocument Object Model,文档对象模型)树**:
浏览器读取 HTML 字节流,将其解析为**DOMDocument Object Model,文档对象模型)树**。这就像把一堆散乱的零件整理成一个有层级关系的清单
```html
<!-- 原始 HTML -->
<html>
<body>
<div class="header">标题</div>
<div class="content">内容</div>
</body>
</html>
<div class="header">标题</div>
<div class="content">内容</div>
```
```
变成树形结构:
Document
html
body
/ \
div div
.header .content
│ │
"标题" "内容"
```text
DOM 树结构:
Document
└─ html
└─ body
├─ div.header ("标题")
└─ div.content ("内容")
```
**关键特性:**
- **流式解析**:浏览器边下载边解析,不需要等整个 HTML 下载完
- **遇到 script 标签**:会暂停解析,先下载并执行 JavaScript(除非加 `async``defer`
- **遇到 css 链接**:不会阻塞解析,但会阻塞渲染(需要等 CSS 下载完)
#### 步骤2:解析 CSS → 构建 CSSOM 树 (说明书)
#### 步骤2:解析 CSS → 构建 CSSOM 树
浏览器同时解析 CSS(内联样式、`<style>` 标签、外部 `.css` 文件),构建**CSSOMCSS Object ModelCSS 对象模型)树**
浏览器解析所有的 CSS(内联、外部文件),构建**CSSOMCSS Object Model)树**。这就像理解说明书上的样式规则:
```css
.header { color: blue; font-size: 24px; }
.content { margin: 20px; background: #f0f0f0; }
.header { color: blue; font-size: 24px; } /* 标题要是蓝色的 */
.content { display: none; } /* 内容暂时隐藏 */
```
CSSOM 树与 DOM 树结构类似,但存储的是样式规则。
#### 步骤3:合并 → 渲染树 (准备组装)
**CSS 解析特点:**
- **阻塞渲染**:CSS 会阻塞渲染,因为浏览器不知道元素长什么样就无法绘制
- **层叠和继承**CSS 的"C"就是 Cascading(层叠),遵循特定的优先级规则
DOM 树 + CSSOM 树 = **渲染树 (Render Tree)**
关键点:**只有"可见"的元素才会在渲染树中**。
* `.header`:在渲染树中(可见)。
* `.content`**不在**渲染树中(因为 `display: none`,就像被扔掉的包装纸,不需要组装)。
#### 步骤3:合并 → 渲染树(Render Tree
#### 步骤4:布局 (Layout / Reflow) —— 测量尺寸
DOM 树 + CSSOM 树 = **渲染树**。渲染树只包含可见元素(`display: none` 的元素不会进入渲染树)
浏览器计算渲染树中每个节点在屏幕上的**精确坐标和大小**
* "这个标题框宽 100px,高 50px,放在屏幕左上角 (0,0) 位置。"
* 这个过程叫**重排 (Reflow)**。如果窗口大小变了(比如手机横屏),所有元素的位置都要重新计算,非常消耗性能。
```
渲染树节点示例:
- 节点类型:div
- 样式:color: blue, font-size: 24px
- 内容:"标题"
```
#### 步骤5:绘制 (Paint) —— 上色
#### 步骤4:布局(Layout / Reflow
知道位置后,浏览器开始填充像素:画背景色、文字颜色、边框、阴影等。
浏览器计算渲染树中每个节点的**精确位置和大小**:
- 视口(viewport)多大?
- 每个元素占多少空间?
- 元素之间如何排列?
#### 步骤6:合成 (Composite) —— 最终展示
这个过程也叫**重排(Reflow)**,是性能开销较大的操作
**影响布局的因素:**
- 视口大小变化(窗口缩放)
- 元素尺寸变化(内容增减)
- 字体大小变化
- CSS 布局属性变化(`width``height``margin` 等)
#### 步骤5:绘制(Paint)→ 合成(Composite
**绘制阶段**:把渲染树的每个节点绘制成像素,填充到**图层(Layer)**中。
**合成阶段**:如果页面有多个图层(如 `position: fixed`、CSS 动画、3D 变换等),浏览器会把这些图层按正确顺序叠加,最终显示到屏幕上。
**GPU 加速**:现代浏览器会把某些图层交给 GPU 处理,实现流畅的动画效果。
现代浏览器会将页面分成多个**图层 (Layers)** 分别绘制(比如 3D 变换、滚动条独立图层),最后由 GPU 将它们像 Photoshop 图层一样叠加在一起,呈现在屏幕上
<BrowserRenderingDemo />
> **关键洞察**:渲染是"渐进式"的。浏览器不需要等所有内容都下载完才开始显示,而是收到一部分就渲染一部分。这就是为什么大网页会"慢慢加载出来"。
> **💡 你知道吗?**
>
> **布局和绘制**是浏览器最忙碌的时候。网页里的元素越多、结构越复杂,浏览器就需要花更多时间来计算位置和上色。这就是为什么有的复杂网页打开会卡顿的原因。
---
## 6. 总结:一次完整的"网络快递"之旅
## 6. 总结:一次完整的"网"之旅
让我们回顾整个旅程:
| 阶段 | 技术术语 | 快递类比 | 核心任务 | 关键技术 |
| 阶段 | 技术术语 | 网购类比 | 核心任务 | 关键技术 |
|------|----------|----------|----------|----------|
| **1. 解析** | URL 解析 | 填写快递单 | 理解用户想访问哪里 | 协议、域名、端口、路径、参数 |
| **2. 查询** | DNS 查询 | 查地址簿 | 把域名转换成 IP 地址 | 递归/迭代查询、缓存机制 |
| **3. 连接** | TCP 握手 | 打电话确认 | 确保双方能可靠通信 | 三次握手、序列号、流量控制 |
| **4. 对话** | HTTP 交换 | 快递员送达 | 请求和传输数据 | 请求方法、状态码、头部字段 |
| **5. 展示** | 浏览器渲染 | 拆开包裹 | 把代码变成画面 | DOM、CSSOM、渲染树、布局、绘制 |
| **1. 解析** | URL 解析 | 填写单 | 理解买家想买什么 | 协议、域名、端口、路径、参数 |
| **2. 查询** | DNS 查询 | 查仓库址 | 找到店铺的发货仓库 | 递归/迭代查询、缓存机制 |
| **3. 连接** | TCP 握手 | 建立通道 | 确保物流通畅 | 三次握手、序列号、流量控制 |
| **4. 对话** | HTTP 交换 | 仓库发货 | 提交订单并收货 | 请求方法、状态码、头部字段 |
| **5. 展示** | 浏览器渲染 | 拆箱组装 | 把商品展示出来 | DOM、CSSOM、渲染树、布局、绘制 |
**整个过程通常在几百毫秒内完成** —— 想想这有多么不可思议!