LOOK / FRONTEND HANDBOOK / 从概念到实践

前端开发,从 0 到 1。HTML · CSS · JavaScript · Vue 3 学习手册

理解 HTML 的文档结构、CSS 的布局规则和 JavaScript 的交互逻辑,逐步掌握 Vue 3 的组件与响应式状态。通过可编辑代码、实际效果和跨平台对照,把概念转化为可验证的实践。

HTMLCSSJavaScriptVue 3TypeScript工程实践
直接动手改代码
LOOK · 更新于 2026-10-1322 章 / 5 个实验 / 61 段示例Windows · Android · iOS 概念对照

CHAPTER 01学习目标与阅读路径

从基础概念,到独立完成一个页面沿着 HTML 结构、CSS 布局、JavaScript 交互和 Vue 3 工程逐步学习。每一阶段都可以通过可编辑实验验证理解;已经熟悉某部分时,也可以用目录直接查阅。选择「逐章阅读」可聚焦当前章节,随时返回全文。

五个实验均可在本页直接运行,无需先安装开发环境。跨平台对照用于理解共同概念,Web 的布局、事件和运行机制仍需单独掌握。

这份手册从 HTML、CSS 和 JavaScript 基础开始,逐步介绍 Vue 3 组件、响应式数据与工程实践。初学者可按顺序阅读,已有开发经验的读者可按知识点选择章节;macOS 和 Windows 都能完成基础练习。

  1. 用自己的话解释 HTML、CSS、JavaScript、TypeScript、Vue 3、Node.js 和 Vite 各自做什么。
  2. 写一个 HTML 页面,调整布局,让按钮真正响应点击。
  3. 启动 Vue 3 项目,读懂一个 .vue 文件,让数据变化自动反映到页面。
  4. 做出一个商品选择练习:选择商品、修改数量、计算合计、处理空状态。
  5. 用浏览器开发者工具定位问题,知道如何接接口、构建和验收。
学习顺序先理解「结构 → 样式 → 行为」,再学「组件 → 状态 → 数据流」。每完成一段,亲手改一处代码并验证结果,比背完所有 API 更有用。

CHAPTER 02HTML、CSS、JS 各负责什么

HTML有什么

这是一张商品卡片,里面有名称、价格和按钮。

CSS长什么样

卡片并排、价格醒目,窄屏时改为单列。

JavaScript发生什么

点击加入,数量加一,然后更新合计。

浏览器如何把代码变成页面

读取 HTML→建立 DOM+解析 CSS→布局与绘制

DOM(文档对象模型)是浏览器把 HTML 组织成的节点树。JavaScript 可以监听事件、修改节点和样式;相关变化会使浏览器重新计算或绘制。Vue 帮你根据状态更新需要变化的界面部分。

用户点击→事件处理函数→修改状态→Vue 更新界面

最容易混淆的术语

术语一句话解释例子
元素 / 标签 / 属性标签书写元素,属性描述它的配置<button type="button">加入</button>
组件封装了一小块界面和逻辑的可复用单元商品卡片、搜索栏、确认弹窗
状态(state)当前界面依赖的数据数量、是否加载、选中的商品
渲染(render)把数据和界面描述转换为用户看得到的内容数量从 1 变 2 后文字更新
API / 接口程序之间约定的调用方式;业务中常指 HTTP 接口GET /api/products
JSON一种文本数据格式,常用来交换数据{"name":"咖啡","priceCent":1800}
依赖 / npm 包项目复用的第三方代码,通过包管理器安装vue、vite
开发 / 构建 / 部署编写调试 → 生成产物 → 把产物交给服务器提供访问dev → build → 发布 dist
SPA / 路由单页应用通过前端路由切换视图,通常不整页重新加载从商品列表切换到订单页
响应式数据 / 响应式布局前者描述数据变化驱动界面;后者描述界面随屏幕尺寸调整ref() 与 CSS 媒体查询是两回事

与其他客户端的概念对照

共同问题Web / Vue 3Windows / Android / iOS 中相近的思路
页面内容HTML 元素、Vue 组件控件、View、可组合 UI 单元
界面布局CSS Flex / Grid布局容器和约束
用户操作click、input、submit点击、输入、提交事件
数据变化ref、computed 与模板绑定可观察状态与界面绑定
异步工作Promise / async / await异步任务,避免长时间阻塞界面

这里只类比概念,不代表 API 一一对应。浏览器中长时间同步计算也会卡住交互;写了 async 并不会自动把计算搬到另一个线程。

CHAPTER 03跨平台界面概念对照

不同平台都会遇到类似的界面问题:显示文字、排列按钮、接受输入、更新状态。学习 Web 前端时,可以沿着四个问题建立认识:界面由谁运行?内容怎样描述?元素怎么排列?操作后由谁更新画面?本节用 Windows、Android 和 iOS 的常见技术作参照,帮助理解 HTML、CSS、JavaScript 和 Vue 3;没有原生开发经验,也可以只读 Web 一列并完成示例。

本节对照的使用方法表中的组件解决的是相近的界面问题,不表示 API、生命周期、布局算法或性能一一等价。Windows 示例使用 WPF 和 WinForms;Android 使用传统 Views 与 Jetpack Compose;iOS 使用 UIKit 与 SwiftUI。这些只是概念参照,不要求同时学习所有平台。

先认运行环境:浏览器、WebView 和原生界面

技术路线你写什么由谁组织和显示界面
Web 页面HTML + CSS + JavaScript;Vue 项目还会有 .vue 文件浏览器引擎解析文档、计算样式与布局、绘制页面;JavaScript 处理交互
Windows WPF常用 XAML 描述对象和界面,C# 写逻辑,也可在代码中创建 UIWPF 创建对象,并通过逻辑树、可视化树等机制组织与呈现界面
Windows WinForms设计器生成的 C# 控件初始化代码,配合 C# 事件处理WinForms 管理 Form 与控件;通常不使用 XAML
Android Views常用布局 XML + Kotlin / Java,也可在代码中创建 View布局资源经加载形成 View / ViewGroup 层级,Android 完成测量、布局和绘制
Android ComposeKotlin 可组合函数与状态Compose 组合界面,状态变化触发相关重组;不是把 XML 逐行换成 Kotlin 标签
iOS UIKitSwift / Objective-C 创建视图,也可用 Storyboard / XIB 配置界面UIKit 管理 UIView 层级、布局和交互;视图控制器等对象负责组织页面行为
iOS SwiftUISwift 的 View 描述、布局容器与状态SwiftUI 根据声明描述界面,状态变化后更新受影响的部分;View 描述不等于逐个创建 UIKit 对象

看到尖括号,不代表是同一种文件。HTML、XAML、Android 布局 XML 看起来都可能有标签,但解析器、能使用的元素、属性和运行环境不同。把 <TextView> 放到 HTML 里,不会得到 Android 原生 TextView;把 <div> 放到 WPF XAML 里,也不会自动出现网页容器。Compose 与 SwiftUI 分别是 Kotlin 和 Swift 代码,不能当作 HTML 或 XML 加载。

WebView 是原生应用里承载网页的区域。Windows 可嵌入 WebView2,Android 可放入 WebView,iOS 可使用 WebKit 的 WKWebView。外层仍是原生应用,内部网页仍按 Web 的规则运行;网页里的 button 不会因此变成 Android Button 或 UIKit UIButton。WKWebView 本身可以加入 UIKit 视图层级,但其中的 HTML 元素由 WebKit 处理,不能用 UIKit 的子视图查找方式直接取得 DOM 元素。

WebView 通常不自带完整浏览器的地址栏和导航界面。宿主决定加载地址、导航处理、脚本通信与数据存储等配置;因此不能假定嵌入页与独立浏览器有相同的登录态和行为。WKWebView、Android WebView、WebView2 也不是同一套原生 API。

前端页面能否调用原生能力,取决于宿主提供了什么打开网页不等于它能直接调用 C# / Kotlin / Swift 方法。读设备信息、扫码或打印,通常需要可用的 Web API 或宿主提供的通信接口,并约定参数、结果和权限。Android WebView 的 JavaScript 默认关闭,承载 Vue 等交互页面时还需由宿主正确启用;各平台的脚本配置不能照搬。

官方依据:WPF 中的树、Android Views 布局、WebView2 简介、Android WebView、Apple · WKWebView、Apple · UIKit 视图与控件。

看懂一份 HTML:head 放信息,body 放内容

把 HTML 想成一份“有层级的页面内容说明”。下面是一份完整文件,可保存为 structure.html 后用浏览器双击打开。先只看结构;按钮的 type="button" 表示普通操作按钮,这份示例还没有给它编写点击行为。

structure.html · 完整文件
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>我的商品页</title>
  </head>
  <body>
    <header>
      <h1>下午茶小店</h1>
      <p>欢迎选择今天的饮品。</p>
    </header>
    <main>
      <section>
        <h2>商品信息</h2>
        <p>拿铁:<span class="price">¥18.00</span></p>
        <button id="add" type="button">加入一件</button>
      </section>
    </main>
  </body>
</html>
结构作用容易看错的地方
<!doctype html>让浏览器按标准模式处理页面它是文档声明,不是页面里的一个可见控件
htmlHTML 文档的根元素lang="zh-CN" 声明内容语言,不是设置操作系统语言
head放文档元信息和资源声明,例如 title、meta、样式表链接不是页面顶部;不要把商品列表放进 head
title浏览器标签页、书签等处使用的文档标题改 title 不会自动改页面中的 h1
meta声明编码、移动端视口等信息UTF-8 声明要与文件保存编码一致;viewport 也不等于已经完成响应式布局
body承载页面主体内容不是桌面窗口对象;一个页面文档通常有一个 body
header / main / section介绍性区域 / 主要内容 / 有主题的内容分组它们表达语义,不会自动生成系统工具栏、页面导航或特定配色

这份源码经浏览器解析后,会形成 DOM(Document Object Model,文档对象模型)。DOM 是运行中的对象树,JavaScript 可查找和修改它;它不只是编辑器中的文本。下面只展示元素层级,省略了文字等其他节点:

元素树 · 缩进表示父子关系
html
├── head
│   ├── meta
│   ├── meta
│   └── title
└── body
    ├── header
    │   ├── h1
    │   └── p
    └── main
        └── section
            ├── h2
            ├── p
            │   └── span.price
            └── button#add

可以借“父容器包含子控件”理解层级。区别在于:DOM 还包含文字、注释等节点,不等于屏幕上所有可见盒子的清单,也不等于 WPF 可视化树、Android View 树或 UIKit 视图层级。例如 head 在 DOM 中但不作为正文显示,CSS 可隐藏或改变元素的布局方式,浏览器也会按 HTML 解析规则修正某些不规范结构。

官方依据:MDN · HTML 元素、MDN · DOM。

标签、元素、属性:别把“写法”和“对象”混在一起

一行 HTML
<button id="add" class="primary" type="button">加入一件</button>
术语这一行里是什么怎样理解
标签 tag开始标签 <button ...> 与结束标签 </button>源码中描述结构的写法;不是运行中的完整对象
元素 element这整个 button 元素,包括内容可近似理解为页面里的一个结构单元;浏览器为它创建对应的 DOM 元素对象
属性 attributeid、class、typeHTML 源码上的配置;作用取决于元素及具体属性
id这个元素的标识,文档内应唯一查找目的类似 x:Name / android:id,但不具有相同的资源或代码生成机制
class一组可复用的分类名称主要用于样式和选择;这里的 class 不是 C# / Kotlin / Swift 的类声明
内容“加入一件”这段文字显示文字可类比 Button 的 Content / Text,但 HTML button 还可包含其他允许的内容

属性和运行时对象属性(property)有关联,但不总是同一份值。例如 <input value="1"> 给出初始内容;用户输入后,用 JavaScript 的 input.value 读取当前值,getAttribute('value') 不会自动变成当前输入。HTML 中 disabled 是布尔属性,disabled="false" 仍然表示禁用;Vue 的 :disabled="false" 才是在计算表达式后绑定 false。

常用元素对照:先按“任务”找,不必背全部标签

先确定“要展示文字、接受输入还是容纳子内容”,再选元素。下面的原生组件名称是理解概念的参照;同一行中的默认行为、可访问性、选中机制与外观可能不同。

要做什么HTML / WebWindows:WPF / WinFormsAndroid:Views / ComposeiOS:UIKit / SwiftUI
标题、段落h1~h6、p;表达内容层级和段落,不只决定字号WPF:TextBlock、Label 等;WinForms:Label。标题语义需另行考虑Views:TextView;Compose:Text。文本样式和可访问性标题语义需明确配置UIKit:UILabel;SwiftUI:Text。标题层级与辅助功能语义需明确配置,不只是调大字号
一段文字中的局部内容span;例如只把价格标红,默认行内显示WPF:TextBlock 内的 Run / Span;WinForms 可用富文本或组合控件,普通 Label 没有相同的 span 树Views:Spannable 等文本跨度;Compose:AnnotatedString / SpanStyle。与 HTML span 不同UIKit:NSAttributedString 配合 UILabel 等;SwiftUI:Text 可使用 AttributedString。不是 HTML span 元素树
包住一组内容div 通用容器;布局由 CSS 决定WPF:Grid / StackPanel 等 Panel;WinForms:Panel / FlowLayoutPanel 等Views:ViewGroup 的具体子类;Compose:Box / Row / Column 等布局UIKit:UIView / UIStackView;SwiftUI:HStack / VStack / ZStack 等布局容器
触发操作button;普通操作写 type="button"WPF / WinForms:ButtonViews:Button;Compose:Button + 内容UIKit:UIButton;SwiftUI:Button 的 action 闭包
输入一行文字input type="text";多行用 textareaWPF / WinForms:TextBox,按需要配置多行Views:EditText;Compose:TextField / OutlinedTextFieldUIKit:UITextField,多行用 UITextView;SwiftUI:TextField,多行编辑可用 TextEditor
勾选 / 单选input type="checkbox" / radio;同组 radio 常共用 nameWPF / WinForms:CheckBox / RadioButtonViews:CheckBox / RadioButton;Compose:Checkbox / RadioButton,选择状态由应用管理布尔选择常用 UISwitch / Toggle;互斥选项可用 Picker 等。外观与分组机制不等同于 checkbox / radio
从选项中选择select + option;显示形式可能受浏览器和系统影响WPF / WinForms:ComboBox,具体是否可编辑需配置Views:Spinner;Compose 可组合菜单或 Material 下拉选择组件UIKit:UIPickerView 等;SwiftUI:Picker,可按场景选择样式。不要假定一定显示为网页下拉框
显示图片img src="..." alt="...";alt 给出有意义的替代文本WPF:Image;WinForms:PictureBoxViews:ImageView;Compose:Image;用原生相应语义提供图片说明UIKit:UIImageView;SwiftUI:Image。使用各平台的辅助功能标签表达图片含义
展示一组项目ul / ol + li;Vue 的 v-for 负责按数据生成项目WPF:ItemsControl / ListBox / ListView;WinForms:ListBox / ListViewViews:RecyclerView 等;Compose:Column 或 LazyColumn,取决于内容规模UIKit:UITableView / UICollectionView;SwiftUI:List。滚动、复用与选择行为由相应组件和配置决定
展示二维业务数据table;thead / tbody 分组,tr 表示行,th / td 表示单元格WPF:DataGrid;WinForms:DataGridView,可提供比普通 HTML table 更丰富的行为可用表格布局或列表组合行;Compose 也可用布局组合。应按滚动、编辑等需求设计可用 UICollectionView 或自定义行列,SwiftUI 可用 Grid 组合;UITableView 主要是单列行列表,不等于 HTML table
收集并提交输入form;label 描述字段,input / select 等接受输入用控件与提交逻辑组合。WinForms 的 Form 是窗体,不能等同于 HTML form用 View 或可组合函数组织字段、校验与提交;没有逐项对应的 HTML form 对象UIKit 组合字段与按钮;SwiftUI Form 为内容分组并应用平台样式,不会自动执行 HTML form 的提交请求
去另一个页面或位置a href="...";链接有打开、复制地址等浏览器语义WPF:Hyperlink 等;WinForms:LinkLabel;具体导航需应用处理链接文本或导航动作;目的地与返回行为由应用负责UIKit 可用链接文本或导航动作;SwiftUI Link 打开 URL,NavigationLink 用于应用内导航。两者用途不同
三个特别有用的区别

① ul 不是自动带选中、分页和虚拟化的 ListBox;普通 table 也不自带 DataGrid 的排序、编辑、冻结列。② HTML 的 form 有浏览器原生提交行为;按钮没写 type 时可能提交表单并导航,写 type="button" 可明确普通按钮意图。③ 选择元素先看语义:去地址用 a,执行动作用 button,不要只给 div 加点击事件就当作完整按钮。

把下面这个 HTML 片段放到前面示例的 main 内,就能看到表单字段。通过阻止 submit 默认行为,它暂不提交数据;点击 label 应聚焦输入框,“确认”按钮等待添加逻辑。

HTML · 插入 main 内
<form onsubmit="event.preventDefault()">
  <label for="guest">顾客称呼</label>
  <input id="guest" name="guestName" type="text" placeholder="例如:小林">
  <label for="size">杯型</label>
  <select id="size" name="size">
    <option value="small">小杯</option>
    <option value="large">大杯</option>
  </select>
  <button type="button">确认(待接逻辑)</button>
</form>

控件与用法核对:WPF 控件分类、WinForms 控件选择、Android Spinner、Compose 菜单、HTML 元素参考、Apple · UIKit 视图与控件、SwiftUI 控件、SwiftUI 文本输入与输出、SwiftUI Form。上表的跨端对应关系是按用途整理的概念类比。

布局怎么迁移理解:HTML 是内容,CSS 决定排法

在 WPF / Android Views 中,选什么父容器通常会影响布局规则;UIKit 常组合视图与 Auto Layout 约束,SwiftUI 常用布局容器与修饰器。在 Web 中,同一个 div 可以通过 CSS 变成普通块、Flex 容器或 Grid 容器,通常不必为了换排列方向重写所有子元素。

布局需求Web相近的原生思路不能直接照搬的地方iOS 的相近做法
横排或竖排一组内容Flex:display: flex;flex-direction 指定主轴WPF StackPanel;WinForms FlowLayoutPanel;Views LinearLayout;Compose Row / ColumnFlex 还有伸缩、换行、主轴分配空间等规则;StackPanel 和 Row 并不自动复刻这些规则UIKit:UIStackView;SwiftUI:HStack / VStack。间距、内容尺寸和剩余空间分配须按各自布局规则配置
按行和列组织CSS Grid:定义列、行和 gapWPF Grid;WinForms TableLayoutPanel;Views GridLayout / TableLayout;Compose 网格或布局组合CSS Grid 有轨道、自动放置、fr 等规则;同名 Grid 也不是同一种 API。CSS Grid 用于布局,HTML table 用于表格数据UIKit:UICollectionView 布局或约束组合;SwiftUI:Grid / LazyVGrid 等。Grid 不是 CSS Grid 的原生版本
内容与边界留距离padding 内边距,margin 外边距,gap 子项间距原生容器/控件的内外边距;Compose 的 Modifier.padding、Arrangement 等Compose 的 Modifier 顺序会影响结果;CSS 有盒模型和部分 margin 折叠规则,不能只按名字换算UIKit:layoutMargins、约束与 UIStackView.spacing;SwiftUI:padding、容器 spacing。它们不是 CSS 盒模型属性的直接替代
内容重叠或角标定位定位容器配合 position: absolute 等WPF Grid / Canvas;Views FrameLayout;Compose Box定位参照、布局占位、绘制顺序与点击区域仍须按各自规则处理UIKit:叠放子视图并约束位置;SwiftUI:ZStack / overlay。与 absolute 的包含块和文档流规则不同
屏幕窄了就换一列媒体查询、弹性尺寸、Flex 换行 / Grid 列数调整原生自适应布局、窗口大小判断与约束调整CSS px、Android dp/sp、WPF 设备无关单位不是可以盲目一比一复制的设计尺寸UIKit 按可用空间和 traits 调整约束;SwiftUI 可使用自适应容器。iOS 的 point 也不能与 CSS px 盲目一比一复制

同样是“左侧文字、右侧按钮”,下面三种写法体现相似意图。这里只是布局片段:HTML 与 CSS 放在页面对应位置;XAML 放在已有 WPF Window 的内容区;Compose 放在已配置的可组合函数内,并导入对应组件。

Web · HTML + CSS
<div class="row">
  <span>拿铁</span>
  <button type="button">加入</button>
</div>

/* 将以下 CSS 放入 style,不要贴入正文 */
.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
Windows WPF · XAML 片段
<Grid>
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="*" />
    <ColumnDefinition Width="Auto" />
  </Grid.ColumnDefinitions>
  <TextBlock Text="拿铁" VerticalAlignment="Center" />
  <Button Grid.Column="1" Content="加入" Margin="12,0,0,0" />
</Grid>
Android Compose · Kotlin 片段
Row(
    modifier = Modifier.fillMaxWidth(),
    verticalAlignment = Alignment.CenterVertically,
    horizontalArrangement = Arrangement.SpaceBetween
) {
    Text("拿铁")
    Button(onClick = { /* 稍后加入逻辑 */ }) {
        Text("加入")
    }
}

可以进一步检查:“如果商品名特别长,按钮还会不会留在屏幕里?”三段代码表达基本排列意图,不保证长文案约束完全相同;需要分别设置收缩、换行、权重或宽度约束,并在窄屏验证。

官方依据:MDN · Flexbox、WPF 布局、Compose 布局基础、Apple · UIStackView、SwiftUI 布局基础。

点击和输入:各端都有事件,但默认行为要单独理解

场景WebWindowsAndroidiOS
用户点击按钮原生 JS:addEventListener('click', handler);Vue:@click="handler"WPF / WinForms 的 Click;WPF 也常通过 Command 组织行为Views:setOnClickListener;Compose:onClickUIKit:UIButton 通过 target-action / UIAction 处理事件;SwiftUI:Button 的 action 闭包
用户编辑文本监听 input 读取当前 value;Vue 常用 v-modelTextChanged 或数据绑定;更新时机取决于控件和配置Views:TextWatcher 等;Compose:TextField 的 onValueChangeUIKit:UITextField 的 editingChanged 等控件事件;SwiftUI:TextField 通常通过 Binding(如 text: $name)读写状态
数据变了,文字也要变原生 JS 明确更新 DOM;Vue 使用响应式状态驱动模板直接设置控件属性,或让可通知变化的数据参与绑定Views 更新 View 属性;Compose 读取可观察状态,变化后触发相应重组UIKit 可设置 UILabel.text 等属性;SwiftUI 让视图读取 State 等可观察数据并响应变化

下面这段可以完整替换前面 structure.html 的 body 内容。输入称呼时下方文字即时变化,点击加入时数量加一;浏览器无需安装框架。

原生 HTML + JS · 放到 body 内
<label for="guest-name">称呼</label>
<input id="guest-name" type="text">
<p id="greeting">你好,访客</p>
<button id="add-one" type="button">加入一件</button>
<p aria-live="polite">数量:<span id="quantity">0</span></p>

<script>
  const input = document.querySelector('#guest-name');
  const greeting = document.querySelector('#greeting');
  const button = document.querySelector('#add-one');
  const quantity = document.querySelector('#quantity');
  let count = 0;
  input.addEventListener('input', () => {
    greeting.textContent = `你好,${input.value.trim() || '访客'}`;
  });
  button.addEventListener('click', () => {
    count += 1;
    quantity.textContent = String(count);
  });
</script>

从控件操作理解 DOM:querySelector 像“先拿到控件引用”,addEventListener 像“订阅事件”,textContent 像“给显示文字赋新值”。JS 的 input 事件适合响应用户的文本编辑;代码直接给 input.value 赋值,并不会自动触发这个 input 监听。

三个事件不要混为一谈input 关注用户编辑过程,change 关注值被确认的变化(具体时机因控件而异),submit 属于表单提交。Web 的事件可能沿 DOM 冒泡;WPF 的路由事件、Android 的输入分发、UIKit 的响应者链与 target-action 各有规则,不能直接当作 DOM 冒泡。

官方参考:Android 输入事件、MDN · input 事件、Apple · UIControl 事件、SwiftUI TextField。

从“改控件”到“改状态”:怎样理解 Vue 3

用户点击→事件处理→状态从 0 变 1→显示新的数量

前面的原生 JS 示例同时做了两件事:修改 count,以及把它写进 DOM。Vue 3 让你声明“这里显示 count”,以后通常只改响应式状态。Compose 与 SwiftUI 也使用状态来描述界面;WPF 数据绑定则可以把可通知变化的数据连接到 UI。类比的是数据流,具体依赖跟踪、绑定与刷新机制仍不同。

技术状态放哪里界面如何获得新值关键条件
原生 Web / WinForms / Android Views / UIKit 的直接写法代码中的变量事件里设置 DOM 或控件属性单改普通变量,界面不会凭空知道该如何更新;也可以按项目需要另行组织绑定或观察机制
Vue 3ref / reactive 等响应式数据模板读取状态,Vue 跟踪依赖并安排 DOM 更新脚本中的 ref 使用 .value;DOM 更新会批量调度,不要假设每次赋值后已立即完成绘制
WPF 数据绑定 / MVVMViewModel 等数据源Binding 把属性连接到 UI普通 CLR 属性变化常需 INotifyPropertyChanged 等通知;仅有 Binding 写法不保证任意字段变化都可观察
Jetpack Compose例如 remember { mutableStateOf(...) }读取状态的相关可组合范围在状态变化后参与重组普通可变变量并不自动成为 Compose 状态;remember 也不是永久存储
SwiftUI例如视图内的 @State private var count = 0SwiftUI 管理状态存储,值变化后更新依赖它的界面普通变量不自动成为可观察状态;$count 可提供 Binding,但与 Vue 的 .value 不是同一种语法或机制

下面三个“状态驱动计数器”表达相同交互:点击后显示数量加一。Vue 示例可替换 src/App.vue 的完整内容;Compose 示例放入已启用 Compose 和 Material 3 的 Android 工程,并在界面中调用 Counter();SwiftUI 示例放入 Xcode 的 iOS SwiftUI 工程,保存为 CounterView.swift,再在现有 ContentView 的 body 中使用 CounterView()。它们各自运行,不应粘贴到对方的文件里。

Vue 3 · 完整 App.vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
function add() { count.value += 1 }
</script>

<template>
  <button type="button" @click="add">
    已加入 {{ count }} 件
  </button>
</template>
Android Compose · Kotlin 函数
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember

@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Button(onClick = { count.value += 1 }) {
        Text("已加入 ${count.value} 件")
    }
}
iOS SwiftUI · 完整 CounterView.swift
import SwiftUI

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        Button("已加入 \(count) 件") {
            count += 1
        }
        .padding()
    }
}

SwiftUI 示例中,@State 表示由 SwiftUI 管理的状态,Button 的闭包负责修改数量,body 描述显示内容。它不操作网页 DOM,也不要求手动取得 UILabel 后赋值。

别把界面更新当成持久化。Vue 组件被重新创建后,局部 ref 会重新初始化;Compose 的 remember 与 SwiftUI 的 State 也有作用域和生命周期。SwiftUI 重新计算 body 不等于每次清零 State,但视图身份改变或移出层级后重新建立时,局部状态可能重新初始化。刷新网页、重建界面或重启应用后是否保留数据,需要另外选择持久化或状态恢复方案。

本节自测
  1. 浏览器标签页标题改哪里,页面大标题改哪里?——title 与 h1。
  2. 同一个按钮要变成绿色、点击后加一,分别改哪层?——CSS 与 JS / Vue 逻辑。
  3. HTML form 和 WinForms Form、SwiftUI Form 是否等价?——不等价,分别涉及浏览器表单提交、窗体、平台样式的字段分组。
  4. Vue 的 count 变化后为什么能更新?——它是模板读取的响应式状态;并非“任意变量都会自动刷新页面”。

官方依据:WPF 数据绑定、Compose 状态、Apple · SwiftUI State、SwiftUI 视图身份与状态、Vue 响应式基础。继续阅读本手册的 HTML、CSS、JavaScript 和 Vue 3 章节完成动手练习。

CHAPTER 04HTML:页面的结构

<!doctype html><html>    </head>         </body></html>

head · 文档信息

放编码、浏览器标签页标题和资源声明。它不会成为屏幕顶部的一块区域。

客户端类比:更接近页面的元信息与资源配置,不是一个布局容器。点击左边其他标签继续查看。

HTML(HyperText Markup Language,超文本标记语言)描述内容及其结构。一个页面里哪些是标题、导航、表格、输入框,应先由 HTML 说清楚。CSS 再决定它们的外观,JavaScript 再决定用户操作之后发生什么。

“H5” 到底是什么?国内业务沟通中的“H5 页面”通常指在手机浏览器或 App WebView 中打开的网页,仍然主要由 HTML、CSS 和 JavaScript 构成。HTML5 是 HTML 发展中的历史版本名称;现代 HTML 按持续更新的标准演进。“H5”不是一门与 HTML 并列的新语言,也不意味着只能在手机上打开。

先认识页面的骨架

写法是什么你会在哪里看到它
<!doctype html>文档类型声明,让浏览器按标准模式处理页面文件第一行;不是页面里可见的元素
<html lang="zh-CN">整份 HTML 的根元素,lang 声明内容语言包住 head 和 body
<head>页面的元信息和资源声明编码、标题、样式等;不是页面顶部横幅
<title>网页标题浏览器标签页和书签中
<meta>描述编码、视口等信息charset="UTF-8" 配合 UTF-8 保存防止中文乱码;viewport 让移动端按设备宽度布局
<body>页面内容的容器用户实际看到和操作的内容
<style> / <script>分别放 CSS 样式和 JavaScript 脚本小练习可写在同一文件,项目中通常会拆分

例如 <button id="add" type="button">点我</button>:button 是标签名,id、type 是属性,“点我”是内容。大多数元素有开始与结束标签;img、input、meta 等空元素没有结束标签。

id="add" 用于标识这一个元素,同一文档中应唯一;class="card" 用于分类,多个元素可以使用相同 class,一个元素也可以写 class="card active"。CSS 里用 #add 选 id,用 .card 选 class。扩展阅读:MDN · HTML 基础语法。

常见标签:能看懂比背全更重要

标签用途入门记忆点
h1~h6、p标题层级、段落按内容层级选标题;字体大小由 CSS 调整
a跳转到链接或页内位置href 指定目标;“去哪里”用链接,“做什么”用按钮
img展示图片src 指向图片;alt 描述有意义的图片,纯装饰图可用空 alt=""
ul / ol + li无序 / 有序列表及列表项商品要点用 ul,操作步骤用 ol
div、span没有特定语义的通用容器默认 div 是块级、span 是行内;显示方式可由 CSS 修改
header、nav、main、section介绍性区域、主要导航、主体、带主题的内容分组语义帮助人和辅助技术理解页面;section 通常应有标题
table、thead、tbody、tr、th、td表格、表头分组、表体分组、行、表头单元格、数据单元格适合订单明细等二维数据;页面左右布局用 Flex 或 Grid
form、label、input、button表单、字段说明、输入框、按钮label 的 for 对应 input 的 id;表单内明确按钮 type
两个常见误会head 放元信息,header 是 body 里的内容区域。div 也不是“前端万能控件”:可点击操作优先使用原生 button,能直接获得键盘操作和正确语义。

下面是可插入到下一节 <main> 内部的 HTML 片段,认识结构即可。表单先做输入练习,暂不向服务端提交;图片示例的 product.png 需要你自行放到 HTML 同级目录。

代码示例
<section>
  <h2>商品信息</h2>
  <img src="product.png" alt="一杯热拿铁" width="120">
  <ul><li>热饮</li><li>可选无糖</li></ul>
  <table>
    <thead><tr><th scope="col">商品</th><th scope="col">数量</th></tr></thead>
    <tbody><tr><td>拿铁</td><td>2</td></tr></tbody>
  </table>
  <form id="guest-form">
    <label for="guest-name">顾客称呼</label>
    <input id="guest-name" name="guestName" type="text" placeholder="请输入称呼">
    <button type="button">确认(待接逻辑)</button>
  </form>
  <a href="https://developer.mozilla.org/zh-CN/">查看 MDN 文档</a>
</section>

这里的 placeholder 是提示,不应替代 label。name 用于表单数据中的字段名。表单中 type="submit" 会触发提交;这段示例用 type="button",所以点击暂时没有行为。

按 Enter 也可能触发表单提交。为让这个输入练习停留在当前页,在完整示例末尾的 script 中追加以下监听;preventDefault() 阻止浏览器默认提交:

JavaScript · 配合上方表单
document.querySelector('#guest-form').addEventListener('submit', (event) => {
  event.preventDefault();
});

完整 Hello World:保存后双击就能运行

  1. 在编辑器里新建文件,将下面完整代码保存为 index.html,确保不是 index.html.txt。
  2. 用浏览器打开这个文件,点击按钮,数字应从 0 变成 1、2、3。
  3. 将“点我 +1”改为“加入一件商品”,保存文件并刷新浏览器。

运行环境:现代浏览器;不需要 Node.js、联网或安装依赖。代码中的 <script> 放在内容后面,执行时才能找到前面已经解析的按钮和数字元素。

代码示例
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>我的第一个前端页面</title>
  <style>
    * { box-sizing: border-box; }
    body {
      margin: 0; padding: 24px; background: #f3f6fb;
      color: #1f2937; font: 16px/1.7 system-ui, sans-serif;
    }
    .card {
      max-width: 480px; margin: 24px auto; padding: 24px;
      border: 1px solid #dbe3ee; border-radius: 16px;
      background: white;
    }
    h1 { margin: 0 0 12px; font-size: 28px; }
    button {
      padding: 10px 18px; border: 0; border-radius: 8px;
      background: #2563eb; color: white; font: inherit;
      cursor: pointer;
    }
    button:hover { background: #1d4ed8; }
    button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 3px; }
  </style>
</head>
<body>
  <main class="card">
    <h1>Hello, 前端!</h1>
    <p>HTML 放内容,CSS 管样式,JavaScript 处理交互。</p>
    <p aria-live="polite">你已经点击了 <strong id="count">0</strong> 次。</p>
    <button id="add" type="button">点我 +1</button>
  </main>
  <script>
    let count = 0;
    const countElement = document.querySelector('#count');
    const addButton = document.querySelector('#add');
    addButton.addEventListener('click', () => {
      count += 1;
      countElement.textContent = String(count);
    });
  </script>
</body>
</html>
这一页已经包含前端的完整闭环HTML 创建按钮 → CSS 改变按钮外观 → JavaScript 监听点击 → 修改变量 → 更新页面文字。刷新后数字回到 0,因为它现在只保存在运行中的内存里。

自己改一处,才能算跑通

CHAPTER 05CSS:布局与样式

CSS(Cascading Style Sheets,层叠样式表)给 HTML 元素设置样式。.card { padding: 24px; } 中,.card 是选择器,padding 是属性,24px 是值。先学盒子和布局,再学动画。

选择器和层叠:为什么我写了却没变化?

写法选中什么示例
button指定标签页面里的所有 button
.primary指定 classclass="primary" 的元素
#add指定 idid="add" 的元素
.card p后代元素card 内任意层级的 p
button:hover特定状态鼠标悬停在按钮上时
button:focus-visible应显示焦点提示的状态例如用键盘 Tab 选到按钮时

多个规则同时命中时,浏览器按层叠规则决定哪个值生效。入门先记:在同一来源、同一层级和重要性下,通常 id 选择器比 class 优先,class 比标签优先;优先级相同再比较先后顺序,后面的生效。“永远最后一行赢”并不成立。文字颜色、字体等属性常会继承,宽度、边距通常不会。

样式不生效时,在开发者工具的 Elements(元素)面板选中元素,查看 Styles(样式)里哪些规则被划掉,再看 Computed(计算样式)的最终值。不要一上来堆 !important。详见 MDN · CSS 层叠。

盒模型:内容、内边距、边框、外边距

属性 / 区域直观含义常见用途
content装文字或子元素的内容区width、height 的含义受 box-sizing 影响
padding内容到边框的距离让卡片文字别贴边;让按钮点击区域更大
border盒子边界的线卡片描边、输入框边框
margin盒子外的间距与别的盒子保持距离;常规文档流中某些上下 margin 会折叠
代码示例
/* 添加到 index.html 的 style 内:这只是 CSS,不要放进 script */
* { box-sizing: border-box; }
.card {
  width: 320px;
  max-width: 100%;
  padding: 20px;
  border: 1px solid #dbe3ee;
  margin: 24px auto;
}

content-box 下,width: 320px 只算内容宽,左右各 20px 的 padding 和 1px 的 border 会让可见盒子达到 362px。border-box 下,这些都包含在 320px 内,内容宽是 278px;margin 都不包含在这两个宽度中。详见 MDN · 盒模型。

布局:Flex 管一条轴,Grid 管行与列

能力何时使用先会的属性
display: block / inline / inline-block理解普通文档流及元素如何占位block 通常另起一行;普通 inline 的宽高控制和 block 不一样
display: flex导航栏、按钮组、标签与输入框对齐gap、align-items、justify-content、flex-wrap
display: grid商品卡片、仪表盘等行列布局grid-template-columns、gap
display: none暂时隐藏元素元素不再占据布局空间

Flex 默认主轴横向:justify-content 控制主轴对齐,align-items 控制交叉轴对齐。设置 flex-direction: column 后主轴变成纵向,所以它们不是固定的“水平”和“垂直”开关。

把这段 HTML 加进 Hello World 的 <main>,CSS 加到同一文件的 <style> 末尾,然后拖窄浏览器观察两列变一列。

代码示例
<!-- HTML:放在 main 内 -->
<div class="toolbar">
  <strong>今日菜单</strong>
  <span>共 2 件</span>
</div>
<div class="products">
  <article class="product"><h2>拿铁</h2><p>¥18</p></article>
  <article class="product"><h2>红茶</h2><p>¥12</p></article>
</div>
代码示例
/* CSS:放在 style 内 */
.toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  margin-top: 24px;
}
.products {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  margin-top: 12px;
}
.product { padding: 16px; background: #eef4ff; border-radius: 8px; }
.product h2 { margin: 0; font-size: 1.125rem; }
@media (max-width: 480px) {
  .products { grid-template-columns: 1fr; }
}

1fr 表示网格可分配空间的一份,minmax(0, 1fr) 允许列收缩,避免长内容将列硬撑开。媒体查询 @media 根据视口等条件切换规则;响应式布局是让同一份页面适应宽度变化,不是把桌面页面整体缩小。

定位、溢出、尺寸单位

概念一句话典型坑
position: relative仍占据原来的空间,可相对原位置偏移,也常为绝对定位子元素提供参照偏移后,其他元素通常仍按原位置排布
position: absolute脱离普通文档流,常相对最近的已定位祖先定位不能用它替代整页布局;没有合适参照时容易“飞走”
position: fixed / sticky常用于固定工具条 / 滚动时吸附的区域sticky 需要 top 等阈值,祖先的滚动与 overflow 会影响它
overflow: auto / hidden内容超出时出现滚动条 / 裁剪hidden 可能把菜单或文字裁掉;先查为何超出
pxCSS 像素,适合边框与明确的尺寸不等于屏幕的一个物理像素
rem相对根元素 html 的字号根字号为 16px 时 1.5rem = 24px;不要假设根字号永远不变
%相对值,参照对象取决于具体属性例如普通块的 width 通常参照包含块内容宽度;height: 100% 需要明确条件
CSS 排错顺序先确认选择器命中 → 看是否被别的规则覆盖 → 看盒子实际尺寸 → 看父容器的布局与限制 → 再检查定位和溢出。移动端至少检查窄屏、长文案和按钮是否能点到。

动手:把两列商品改成三列;再让小于 640px 的屏幕变为一列。验收时同时检查“列数”和“没有横向溢出”,不是只看桌面截图。

CHAPTER 06JavaScript:事件与数据

JavaScript 是语言,ECMAScript 是其语言核心遵循的标准。浏览器提供 document、DOM 事件等 Web API;Node.js 提供在浏览器之外执行 JavaScript 的运行环境及文件系统等能力。语言语法相通,运行环境提供的能力不同:把依赖 document 的代码直接交给 Node.js 会报错。

先会这些值和语法

运行方式:打开 Hello World 页面的开发者工具,在 Console(控制台)执行下面整段代码;也可保存为 basics.js,装好 Node.js 后在终端执行 node basics.js。这里不依赖页面 DOM。重复在 Console 声明相同 const 若报错,可刷新页面后再执行。

代码示例
const shopName = '我的小店';       // string:文本
let quantity = 2;                // number:数字;let 允许重新赋值
const available = true;          // boolean:真 / 假
const note = null;               // 主动表示“没有值”
let selectedProduct;            // 尚未赋值,值为 undefined
const product = { id: 1, name: '拿铁', priceCent: 1800 }; // 对象
const products = [product, { id: 2, name: '红茶', priceCent: 1200 }]; // 数组

function subtotal(priceCent, count) {
  return priceCent * count;
}
const formatYuan = (cent) => `¥${(cent / 100).toFixed(2)}`;
quantity += 1;
if (available && quantity > 0) {
  console.log(`${shopName}:${formatYuan(subtotal(product.priceCent, quantity))}`);
}
console.log(1 === '1'); // false:严格相等,不把字符串自动转成数字

const 表示变量不能重新赋值,不代表对象内容不可改;上面的 product.name = '冰拿铁' 是允许的。优先使用 const,需要重新赋值时用 let。函数接收参数,用 return 给出结果;箭头函数是另一种常见写法,并且没有自己的 this。

数据类型会影响结果输入框的 value 通常是字符串:'2' + 1 得到 '21',Number('2') + 1 才是 3。业务金额建议使用约定的最小货币单位整数或团队规定的精度方案;JS 的普通小数运算存在二进制浮点误差。

数组、对象与常用简写

以下代码是独立练习,放在新的 list-demo.js 中用 node list-demo.js 执行,或者刷新页面后在 Console 执行。

代码示例
const items = [
  { id: 1, name: '拿铁', priceCent: 1800, quantity: 2 },
  { id: 2, name: '红茶', priceCent: 1200, quantity: 1 }
];
const names = items.map((item) => item.name);                 // ['拿铁', '红茶']
const cheaper = items.filter((item) => item.priceCent < 1500); // 只保留红茶
const match = items.find((item) => item.id === 1);            // 第一个匹配对象
const totalCent = items.reduce(
  (sum, item) => sum + item.priceCent * item.quantity, 0
);                                                          // 4800
const { name, quantity } = items[0];                         // 对象解构
const [first] = items;                                      // 数组解构
const missing = items.find((item) => item.id === 99);
const displayName = missing?.name ?? '未找到商品';             // 安全读取与默认值
console.log(names, cheaper, match, totalCent, name, quantity, first, displayName);
方法 / 语法返回什么 / 做什么常见误区
map每项变换后的新数组箭头函数用大括号函数体时,别忘了 return
filter满足条件的所有项组成的新数组空结果是 [],不是 null
find第一个匹配项,找不到是 undefined使用结果前要考虑找不到的情况
reduce把数组累积成一个结果求和时给初始值 0,让空数组也有明确结果
?.左边为 null 或 undefined 时停止继续读取只防护写了可选链的位置,不是任意错误的兜底
??左边为 null 或 undefined 时才用右边与 || 不同,它会保留 0、false、空字符串

map / filter 返回新数组,但数组中的对象仍可能与原数组共享引用,不等于“深拷贝”。动手:给 items 加一项,再预测 totalCent 并运行核对。

DOM 与事件:读懂计数器的 4 行逻辑

Hello World 里的代码实际发生什么
document.querySelector('#add')从浏览器解析得到的 DOM 树中查找第一个匹配元素;找不到返回 null
addEventListener('click', ...)注册回调;不是现在执行,而是用户点击后执行
count += 1把内存中的数值加 1
countElement.textContent = String(count)把新数值转成文本并显示到页面;textContent 不会把文本解析成 HTML

DOM(Document Object Model,文档对象模型)可理解为浏览器里可被代码操作的页面树。页面里的每个元素都是树中的节点。后面学 Vue 3 时,你主要修改状态,由 Vue 负责把变化更新到对应的 DOM。

动手:在计数器的按钮后加入 <button id="reset" type="button">清零</button>,再将下面代码加到同一份文件现有 script 的末尾、结束标签之前。

代码示例
const resetButton = document.querySelector('#reset');
resetButton.addEventListener('click', () => {
  count = 0;
  countElement.textContent = String(count);
});

验收:点三次加一,再点清零,应显示 0。若出现“读取 null 的 addEventListener”之类错误,检查 id 拼写、元素是否存在,以及脚本是否在元素解析后执行。

import / export:把代码拆成模块

一个项目不会把所有逻辑都塞进同一文件。export 把能力导出,import 引入别的模块。下面是浏览器原生模块示例:将三个文件放在同一目录,并通过本地 HTTP 开发服务器访问 index.html(例如编辑器的 Live Server)。不要用双击文件的 file:// 方式验证模块加载。

代码示例
// money.js
export function formatYuan(cent) {
  return `¥${(cent / 100).toFixed(2)}`;
}

// main.js(另建这个文件,不要和上面合并)
import { formatYuan } from './money.js';
document.querySelector('#price').textContent = formatYuan(1800);
代码示例
<!-- index.html:模块练习单独新建文件夹,避免覆盖前面的练习 -->
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>模块练习</title></head>
<body>
  <p id="price">等待脚本运行</p>
  <script type="module" src="./main.js"></script>
</body>
</html>

预期页面显示 ¥18.00。这里的 ./ 表示相对于当前文件;命名导入的名称要与导出对应。浏览器模块脚本默认延后到文档解析后执行。详见 MDN · JavaScript 模块。

异步:等接口时,页面还要能继续响应

Promise 表示一项未来可能成功或失败的异步结果。async 函数总会返回 Promise;await 暂停当前异步函数的后续步骤,等结果出来再继续,不会让整个页面停住。try / catch 用于处理抛出的错误。

先做不依赖服务器的练习:保存为 async-demo.js,执行 node async-demo.js,或在浏览器 Console 执行。

代码示例
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function run() {
  console.log('1. 开始加载');
  await wait(800);
  console.log('3. 加载完成');
}
run();
console.log('2. 后面的同步代码先继续');

接着认识真实请求的形状。下面代码用于已启动本地 HTTP 服务器的网页:把 JSON 保存为同目录的 products.json,再把 JavaScript 放进页面 script 或在该页面 Console 执行。它读取本地测试文件,不需要真实后端。

代码示例
[{"id":1,"name":"拿铁","priceCent":1800}]
代码示例
async function loadProducts() {
  try {
    const response = await fetch('./products.json');
    if (!response.ok) {
      throw new Error(`请求失败:HTTP ${response.status}`);
    }
    const data = await response.json();
    if (!Array.isArray(data)) {
      throw new Error('返回格式错误:预期是商品数组');
    }
    console.log('商品列表:', data);
  } catch (error) {
    console.error('加载商品失败:', error);
  }
}
loadProducts();

fetch 得到 Response,response.json() 才把响应体解析为 JavaScript 值。HTTP 404 / 500 通常不会让 fetch 自动进入 catch,所以要检查 response.ok;网络失败或解析失败等也要处理。真实页面还需要加载中、空结果、失败重试等状态。详见 MDN · 使用 Fetch。

CHAPTER 07主要语言、版本与选型

本手册的学习组合:HTML + CSS + 现代 JavaScript → Vue 3 + Vite;熟悉 JS 后加入 TypeScript。示例统一使用 Vue 3 的组合式 API(Composition API)与 <script setup>。

层次学什么 / 如何选版本它解决什么问题
内容与结构现代 HTML;常被口语称为 HTML5 / H5标题、段落、表单、按钮、表格等内容组织
外观与布局现代 CSS,优先掌握 Flexbox 与 Grid尺寸、颜色、间距、横竖排列和不同屏幕的适配
交互逻辑JavaScript,先会 ES2015(ES6)及常用后续语法事件、数据计算、网络请求;ECMAScript 按年份演进,不是安装一个「JS 6」
类型约束TypeScript;跟随项目工具链选择兼容版本在开发期发现参数、属性和返回值类型错误
界面框架Vue 3把页面拆为组件,通过状态驱动界面更新
开发工具的运行环境Node.js 24 LTS 的最新补丁;本教程取 24.12.0 或以上的 24.x执行 npm、脚手架、构建和开发服务器;LTS 表示长期支持
项目与构建官方 create-vue 创建项目,使用 Vite本地开发、热更新、把源码转换为浏览器可使用的产物
页面切换 / 共享状态需要多页面时加 Vue Router;跨组件共享状态复杂时加 Pinia管理 URL 与页面映射、共享业务状态
UI 组件库先练原生元素,再按项目需要选择与 Vue 3 兼容的库复用表单、弹窗、日期选择器;桌面管理端和移动端需求不同

版本核对:2026-10-12。Node.js 官方发布表列出 24 为 LTS;Vue 快速上手当前要求 Node ^22.18.0 || >=24.12.0。本机已有符合要求的 22.x 也可练习。工具的最低要求会变化,安装时同时核对官方文档与项目的 engines。

新练习和已有仓库,版本策略不同新练习可用 npm create vue@latest;已有仓库先看 README、package.json、锁文件和 Node 版本约束,不要直接升级到最新版。把最终安装版本记录在锁文件中,便于在不同环境中复现。

前端代码可以运行在哪里

形态运行位置与本手册的关系
Web 网站 / H5 页面电脑或手机浏览器HTML、CSS、JS 的直接应用;本手册重点
App 内嵌 H5Android WebView / iOS WKWebView 等基本知识相通,需考虑容器版本、桥接接口和真机表现
桌面 Web 界面Electron 或其他 Web 容器页面知识可复用;系统与外设能力由宿主提供
原生 Android / iOS / Windows 界面各自原生框架组件、布局、事件、状态等思想相通,具体语法与运行机制不同

Vue 负责 Web 界面。是否能读文件、开钱箱或调用扫码设备,取决于浏览器 API、系统权限和宿主桥接,不能只凭一个 Vue 页面保证外设可用。

CHAPTER 08搭建并运行 Vue 工程

第一个 HTML 不需要安装一整套工具

准备一个现代浏览器和文本编辑器即可完成《HTML:页面的结构》。将文件保存为 UTF-8 的 index.html,用浏览器打开。注意不是 index.html.txt,也不要用富文本格式保存代码。

开发 Vue:编辑器、Node.js 与 npm

  1. 使用 VS Code 或你已有的代码编辑器,安装 Vue - Official 扩展,获得 .vue 文件提示。
  2. 从 Node.js 官网安装前述 24 LTS 版本。Mac M 系列选择 arm64,Intel 选择 x64;Windows 按系统架构选择安装包。
  3. 安装完成后重新打开终端。在 VS Code 中可使用「终端 → 新建终端」。逐行执行:
终端 · Mac / Windows
node --version
npm --version

两条命令都应输出版本号。node 是运行环境,npm 是随 Node 常规安装提供的包管理工具,负责安装依赖、执行脚本。

创建学习项目

先在终端进入你自己的学习目录,再逐行执行;这些命令在 macOS 终端与 Windows PowerShell 中通用:

终端
mkdir frontend-learning
cd frontend-learning
npm create vue@latest

如果询问是否下载并运行 create-vue,确认包名后输入 y。项目名输入 frontend-hello。这是一次需要联网的脚手架创建过程。

脚手架选项(提示文字可能变化)第一次练习
TypeScript / JSX先不选,使用 JavaScript
Vue Router / Pinia先不选,《组件、路由与共享状态》按需加入
单元测试 / 端到端测试先不选,先把界面跑通
ESLint / Prettier初次可不选,理解后再加入代码检查与格式化
实验性选项先不选
终端
cd frontend-hello
npm install
npm run dev

在浏览器打开终端实际输出的 Local 地址,通常类似 http://localhost:5173/。端口被占用时可能变成 5174,按终端输出为准。开发服务器运行期间不要关掉这个终端;用 Ctrl+C 停止。

首次验收看到 Vue 欢迎页 → 修改 src/App.vue 的文字并保存 → 浏览器出现新文字。后续把 src/App.vue 换成《Vue 3 商品页实战》的完整练习。

不同文件的打开方式

你拿到的文件如何运行
本学习手册、纯内联 HTML/CSS/JS 示例可直接双击打开
包含本地 ES 模块或 fetch 的练习通过本地 HTTP 开发服务器访问,避免 file:// 的限制
.vue 单文件组件放入 Vue 工程,由 Vite 等工具转换;不能双击 .vue 来运行

官方参考:Vue 快速上手、Vite 入门。手册中的 npm 指令用于新建的练习目录;已有仓库应按它自己的启动方式操作。

CHAPTER 09Vue 3:数据驱动界面

原生 JavaScript 可以直接找到按钮、监听点击、修改文字。页面变复杂后,手动同步每一处 DOM 容易漏改。Vue 是构建界面的 JavaScript 框架:你描述“什么数据对应什么页面”,数据变化时,Vue 负责更新相关界面。HTML、CSS、JavaScript 的基础知识仍然全部适用。

用户点击→修改状态→Vue 更新 DOM→看到新界面

先认清几个术语

术语一句话解释在页面中的例子
组件 Component一个有自己逻辑、结构和样式的界面单元商品卡片、数量选择器、订单列表
状态 State界面当前依赖的数据选中了哪些商品、每件买多少
响应式 Reactivity数据改变后,相关计算和界面能够自动更新数量改成 2,合计自动改变
SFC 单文件组件一个 .vue 文件封装一个组件App.vue 是根组件
Composition API 组合式 API用 ref、computed 等函数组织组件逻辑本手册统一采用这种写法
<script setup>组合式 API 在 SFC 中的简洁写法,由构建工具编译顶层变量和函数可直接用于模板

一个 SFC 常见三块:<script setup> 写行为,<template> 写结构,<style scoped> 写主要作用于当前组件的样式。scoped 是编译后的选择器约束,不是安全隔离机制。.vue 文件不能当普通 HTML 双击运行,需在 Vue 项目中编译。参见 Vue 官方介绍与 script setup 文档。

第一个组件:点击计数

在前面创建的 Vue 项目中,将 src/App.vue 整个替换为以下代码。保存后浏览器会自动更新。

Vue 单文件组件
<script setup>
import { ref } from 'vue'
const count = ref(0)
function add() { count.value += 1 }
</script>

<template>
  <button @click="add">已点击 {{ count }} 次</button>
</template>

<style scoped>
button { padding: 10px 18px; color: #2945b4; }
</style>

ref(0) 创建一个初始值为 0 的响应式引用。脚本里通过 count.value 读写;模板里这里的顶层 count 会自动解包,所以写 {{ count }}。@click="add" 把按钮点击交给 add 函数,双大括号把值显示成文本。

立刻练习把每次加 1 改成每次加 2。验收:连续点击三次,页面显示 6;刷新页面后回到 0。刷新会重新创建组件,内存状态没有自动保存。

ref、reactive、computed、watch 怎么选

API适合做什么初学时记住
ref保存数字、字符串、布尔值,也能保存对象或数组脚本通常用 .value;对象和数组内部也能响应变化
reactive把对象变成响应式对象,如一个表单直接 form.keyword;不能用来包装单独的数字
computed根据已有状态计算新值,如商品合计、筛选列表回调应只做计算;不要在计算中发请求或改其他状态
watch状态变化后执行副作用,如日志、持久化或请求默认在数据发生变化后执行;简单合计不必用 watch 手动同步
Vue 单文件组件
// 放在 <script setup> 内;合并已有 import,避免重复声明。
import { ref, reactive, computed, watch } from 'vue'
const qty = ref(1)
const priceCents = ref(1800)
const totalCents = computed(() => qty.value * priceCents.value)
const form = reactive({ keyword: '', onlyAvailable: true })
form.keyword = '咖啡' // reactive 对象直接读写属性,不加 .value

watch(qty, (newQty, oldQty) => {
  console.log('数量变化:', oldQty, '→', newQty)
}) // 监听 ref 本身,不是 watch(qty.value, ...)
watch(() => form.keyword, keyword => {
  console.log('搜索词变化:', keyword)
}) // 监听 reactive 的某个属性,传入取值函数

在模板中写 {{ totalCents }} 就能显示计算结果。计算属性会跟踪它读取的响应式依赖,并缓存结果;相关依赖变化后才重新计算。普通函数则适合“点击确认”之类的动作。参见 计算属性与 侦听器。

三个常见误区

① let count = 0 是普通变量,不会仅因赋值就触发 Vue 更新。② const { keyword } = form 拿到的是当时的基本类型值,这个变量不会继续跟随 form.keyword。③ 模板自动解包有边界,例如响应式数组中的 ref 元素不能一概省略 .value。新手先使用清晰的顶层 ref 和普通对象属性,不必嵌套 ref。

响应式对象应保留原来的引用再修改属性;如果经常需要整体替换数据,使用 ref 包装对象通常更直观。详细规则见 响应式基础。

模板语法速查:看懂页面最常见的八种写法

写法意思示例或注意点
{{ message }}把表达式的值显示成文本用户输入默认按文本显示,不当作 HTML 执行
:disabled="busy"v-bind:disabled 的简写,动态绑定属性busy 为 true 时按钮禁用;title="name" 则是普通字符串
@click="save"v-on:click 的简写,监听事件@submit.prevent 阻止表单默认提交跳转
v-model="name"把表单输入和状态关联起来输入框改动更新变量;变量改动也更新输入框
v-if="show"条件为真才创建这一段界面条件为假会销毁;再次显示会重新创建其中的组件
v-show="show"一直保留元素,用 CSS 切换显示适合频繁显示/隐藏;仍有首次渲染成本
v-for="item in items"根据数组重复渲染搭配 :key="item.id",用稳定唯一的业务 ID
:class="{ active: selected }"按状态应用类名选中时添加 active,样式仍由 CSS 决定

key 帮助 Vue 识别“还是原来哪一项”。列表可增删、排序或含输入框时,不要随手拿数组下标当 key;也不要每次生成随机 key。同一个元素上尽量不要同时放 v-if 和 v-for,可先用 computed 筛选列表。参见 列表渲染。

v-model.number 会尝试把输入转换为数字,但清空输入后仍可能得到空字符串。HTML 的 min/max 也不能代替业务校验,《Vue 3 商品页实战》会显式检查整数和范围。v-else / v-else-if 要紧邻前一个条件分支。参见 表单输入绑定与 条件渲染。

生命周期:什么时候开始,什么时候收尾

onMounted 在组件挂载后执行,常用于依赖真实 DOM 的初始化。onUnmounted 在组件卸载时执行,适合清理手动创建的定时器、事件监听和连接。以下代码可独立替换 App.vue;钩子在 script setup 顶层同步注册。

Vue 单文件组件
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const seconds = ref(0)
let timer
onMounted(() => {
  timer = window.setInterval(() => { seconds.value += 1 }, 1000)
})
onUnmounted(() => {
  window.clearInterval(timer)
})
</script>

<template><p>页面已显示 {{ seconds }} 秒</p></template>

浏览器切换路由或父组件用 v-if 移除组件时,都可能触发卸载。自己使用 addEventListener 添加的监听,也要保存函数引用并在卸载时用 removeEventListener 移除。参见 生命周期 API。

遇到 Vue 2 项目,先辨认再修改

Vue 3 仍支持 Options API(data、methods 等),所以“有没有 data()”不能用来断定版本。Vue 2.7 也有组合式 API 相关能力;请直接看 package.json 中的 vue 版本及锁文件。新学习项目按本手册用 Vue 3;Vue 2 的官方维护已于 2023 年 12 月 31 日结束。

观察点Vue 2 中常见Vue 3 中常见
创建应用new Vue(...)createApp(...)
组织逻辑Options APIOptions API 与 Composition API 都支持
组件模板根节点通常要求单根节点支持多个根节点,属性透传需明确处理
迁移工作还要核对路由、状态管理、UI 库、构建工具和组件 API;不能只把版本号从 2 改为 3

参见 Vue 版本说明和 Vue 3 迁移指南。

本章过关标准能解释“改状态 → 更新界面”;知道脚本里的 ref 为什么写 .value;能从模板找出点击事件、双向绑定、条件渲染和列表渲染。

CHAPTER 10Vue 工程逐文件导览

第一次打开工程,不用从上到下看完所有文件。先沿着一条线找:浏览器打开哪份文档 → JavaScript 从哪里开始 → 哪个组件搭页面 → 按钮的逻辑写在哪里。看懂这条线,再去修改一个标题、一条计算规则、一处布局。

本节学习目标给你一个 Vue 3 工程,你能找到入口、定位组件、预测改动会影响哪里,并通过浏览器验证。下面的文件是一套相互配合的小工程,可边阅读源码边运行验证。

先找到这几类文件

下面基于常见的 create-vue + JavaScript + Vite 工程整理,使用 npm;创建时暂不选择 TypeScript、Router、Pinia 等可选功能。脚手架模板会随版本和选项变化,你实际看到的示例组件、配置文件可能更多,文件名也可能不同。不要为了对齐这棵树删除不理解的项目配置。官方入口:Vue 快速上手。

代码示例
vue-first-app/
├─ index.html                 # 浏览器先取得的入口文档
├─ package.json               # 项目说明、依赖清单、可执行脚本
├─ package-lock.json          # npm 生成的依赖锁文件
├─ vite.config.js             # Vite 开发与构建配置
├─ public/                    # 可选:保留文件名直接提供的静态资源
├─ src/
│  ├─ main.js                 # JavaScript 入口:创建并挂载 Vue 应用
│  ├─ App.vue                 # 根组件:组织整张界面的骨架
│  ├─ components/
│  │  └─ CounterPanel.vue     # 下面练习用的可复用计数面板
│  └─ assets/
│     └─ main.css             # 由入口导入的全局样式
├─ node_modules/              # npm install 安装出来的依赖
└─ dist/                      # npm run build 生成的发布产物
文件 / 目录它负责什么通常什么时候改
index.html网页外壳、标签页标题、Vue 挂载容器、入口脚本改网页标题、语言、元信息时;通常不在这里堆业务界面
src/main.js创建 Vue 应用、接入全局能力、挂到容器里初始化应用或引入全局样式时
src/App.vue根组件,组织其他组件,搭起页面或应用外框改整体内容结构;它不是每个页面都必须重复创建的文件
src/components/可复用的界面单元;目录名是团队约定改按钮面板、商品卡片、数量选择器等具体区域
src/assets/需要通过 import、CSS url 等参与构建的资源增加或修改样式、插图、字体等
public/需要保留原文件名、直接提供访问的静态文件如下载说明、固定路径图标;文件可选,不是必需的业务代码目录
package.json记录项目元信息、依赖要求和 npm 脚本安装依赖会更新它;修改启动、检查或构建命令时会读它
package-lock.jsonnpm 记录解析出的依赖树、版本和校验信息由 npm 管理并提交到版本库;不手改,不随意删除来“修问题”
node_modules/已安装的第三方包文件,体积通常很大通常不直接改、不提交;依赖清单与锁文件才是安装依据
vite.config.js配置开发服务器、构建工具和插件启用 Vue 文件编译、配置路径别名或开发代理等
dist/生产构建后的 HTML、JS、CSS 等构建与发布时查看;不要在这里修改后再以为源代码也变了

src、components 和 assets 这些名字帮助人组织代码;不会仅因把文件放进去就自动执行。真正把文件连接起来的是 script src、import 和模板里的组件引用。

用客户端经验理解,但不机械套用

熟悉的概念在这个前端工程里可这样理解需要保留的区别
应用入口 / 启动代码index.html 把浏览器带到 main.js,由后者创建应用启动主体是浏览器加载文档和脚本,不是操作系统直接执行一个原生应用入口
窗口或页面App.vue 可先理解为当前界面的总装配处Vue 组件与 Windows 窗口、Android Activity 或 iOS 视图控制器并非一一对应;浏览器页面和组件有各自的生命周期
控件 / 自定义 ViewCounterPanel.vue 把一块界面的结构、状态和行为封装起来一个 Vue 组件可以渲染多个 DOM 元素,不保证对应一个原生控件
布局文件 + 事件逻辑SFC 的 template、style、script 分别表达结构、样式、逻辑它们使用 Web 的布局和事件模型,不能逐行照搬 XAML 或 Android XML
资源目录assets 与 public 分别承载两种资源处理方式这里最终产生浏览器可以请求的 URL,不是 Android 的资源 ID
项目配置 / 构建任务package.json 与 Vite 配置合起来描述依赖和工具流程作用上有些像项目描述和构建脚本,但与 csproj 或 Gradle 并不一一对应

沿着入口走一遍

index.html→src/main.js→App.vue→CounterPanel.vue→浏览器 DOM
  1. 浏览器请求入口:访问 Vite 输出的本地地址,读取 index.html。此时有一个空的 <div id="app">。
  2. 加载入口脚本:script 指向 /src/main.js。开发服务器按需要转换源文件,把浏览器能够执行的模块交给浏览器。
  3. 创建并挂载:createApp(App) 指定根组件;mount('#app') 找到 HTML 中的容器,在其中渲染界面。
  4. 组合界面:App.vue 导入 CounterPanel.vue,并用 <CounterPanel /> 把它放进自己的模板。
  5. 形成页面:Vue 执行组件逻辑并渲染模板,CSS 决定视觉效果;用户看到标题、数字和两个按钮。

这里有两条并行的关系:import 说明代码依赖,模板中的组件标签说明界面嵌套。只导入一个组件却没有在模板中使用,它就不会因此显示出来。Vite 的入口说明可帮助理解开发服务器与 HTML 的关系。

一套能跑通的最小文件

直接定位文件点路径,查看整份源码和紧邻它的解释。

先按前面的环境章节完成 npm create vue@latest、安装依赖并启动项目,再将以下文件按标注路径新建或替换。保存你原来的练习后再替换;它与商品选择例程是两个独立示例。这六段是完整文件内容,路径和导入互相匹配。没有额外 UI 组件库或图片依赖。

① index.html:保留空容器和入口脚本。这里的 title 修改浏览器标签页标题;屏幕内的标题写在组件里。

index.html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>前端项目入门</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

② src/main.js:'vue' 指第三方包;'./App.vue' 指当前目录内文件;CSS 的 import 没有接收变量,而是让其样式生效。'#app' 与上面 HTML 的 id 必须一致。

src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './assets/main.css'

createApp(App).mount('#app')

③ src/App.vue:只负责页面骨架和组件组装。这里导入的名字是 CounterPanel,模板里就用同名组件标签。它与原生 button 标签的区别,是背后还有一份由我们编写的组件实现。

src/App.vue
<script setup>
import CounterPanel from './components/CounterPanel.vue'
</script>

<template>
  <main class="page">
    <h1>前端入门示例</h1>
    <p>这是 App.vue 组织的页面,下面是一个独立组件。</p>
    <CounterPanel />
  </main>
</template>

<style scoped>
.page { max-width: 720px; margin: 0 auto; padding: 32px 20px; }
h1 { font-size: 28px; }
</style>

④ src/components/CounterPanel.vue:这个文件才拥有计数器的状态和行为。下面三个修改练习都在这里完成。

src/components/CounterPanel.vue
<script setup>
import { computed, ref } from 'vue'

const title = ref('我的第一个 Vue 组件')
const step = ref(1)
const count = ref(0)
const doubled = computed(() => count.value * 2)

function increment() {
  count.value += step.value
}
function reset() {
  count.value = 0
}
</script>

<template>
  <section class="counter">
    <h2>{{ title }}</h2>
    <div class="metrics" aria-live="polite" aria-atomic="true">
      <p>当前数值 <strong>{{ count }}</strong></p>
      <p>它的两倍 <strong>{{ doubled }}</strong></p>
    </div>
    <div class="actions">
      <button type="button" @click="increment">增加 {{ step }}</button>
      <button type="button" @click="reset">清零</button>
    </div>
  </section>
</template>

<style scoped>
.counter { padding: 20px; border: 1px solid #d5dfeb; border-radius: 16px; background: white; }
h2 { margin-top: 0; font-size: 22px; }
.metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.metrics p { margin: 0; padding: 16px; background: #edf3ff; border-radius: 10px; }
.metrics strong { display: block; font-size: 28px; color: #2945b4; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; }
button { padding: 10px 18px; border: 0; border-radius: 8px; background: #2945b4; color: white; cursor: pointer; }
button:focus-visible { outline: 3px solid #d6880b; outline-offset: 3px; }
</style>

⑤ src/assets/main.css:为整份应用提供基础样式,由 main.js 导入。它与组件内 style scoped 的职责不同:这里管全局的字体、背景与盒模型;组件管自己的卡片、数字和按钮。

src/assets/main.css
* { box-sizing: border-box; }
body {
  margin: 0;
  background: #f4f7fb;
  color: #203042;
  font: 16px/1.6 system-ui, -apple-system, "PingFang SC", sans-serif;
}
button, input { font: inherit; }

⑥ vite.config.js:这是此例所需的最小配置。@vitejs/plugin-vue 让 Vite 处理 Vue SFC。保留 create-vue 已生成的 package.json 依赖版本,确保其中已经安装 vue、vite 与 @vitejs/plugin-vue;如果已有配置,也可以保留它继续运行本例。

vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()]
})
首次运行的正确结果页面上方显示“前端入门示例”,卡片内显示“我的第一个 Vue 组件”;两块数值都是 0。点击“增加 1”后,左侧为 1,右侧为 2;点击“清零”后,都回到 0。

把单文件组件拆成三块看

代码块读代码时问什么对应屏幕效果
<script setup>存哪些状态?计算哪些结果?点击会调用哪个函数?title 决定文案,count 决定数字,increment 决定增量
<template>有哪些元素?哪些位置读取数据?哪里监听操作?{{ count }} 是数据出现的位置;@click 连接按钮与函数
<style scoped>哪种选择器控制这块区域?改的是尺寸、排列还是颜色?.metrics 决定两个指标横排还是竖排

setup 让这段逻辑按组件实例执行,顶层声明可直接用于模板;scoped 通过编译后的选择器限制样式影响范围,并不是 Shadow DOM 或安全隔离。三块写在同一文件是为了把同一组件的责任放在一起,不是 HTML、JavaScript、CSS 变成了同一种语言。浏览器最终执行的仍是构建工具处理后的 Web 代码。参见 单文件组件。

一次点击,逐步发生了什么

顺序看到的代码实际发生的事
1. 用户操作<button @click="increment">浏览器产生 click 事件,Vue 注册的处理逻辑调用 increment
2. 修改状态count.value += step.value读取步长,把新的数字写进 count 这个 ref
3. 依赖变更computed(() => count.value * 2)Vue 知道 doubled 依赖 count;count 改变后,计算结果在需要读取时重新求值
4. 更新视图{{ count }}、{{ doubled }}Vue 将依赖这些值的界面更新到 DOM;不需要我们再 querySelector 找数字
5. 浏览器绘制对应的 HTML 元素与 CSS浏览器把变化显示出来,用户看到数值从 0 / 0 变成 1 / 2

响应式来自 ref 与依赖跟踪,不是来自变量名,也不是所有 JavaScript 赋值都能自动更新页面。脚本里通过 .value 读写 ref;本例这些顶层 ref 在模板中自动解包,所以写 {{ count }}。computed 负责从已有状态推导新值,适合“两倍值”或商品合计;它的计算函数不应再去修改其他状态。

const count = ref(0) 中的 const 固定的是“这个 ref 引用”,仍允许改 count.value。Vue 会批量安排 DOM 更新,因此“状态已经变了”不代表同一行之后真实 DOM 已立即更新;以后需要读取更新后的 DOM 时再学习 nextTick。参见 响应式基础与 计算属性。

两种“页面更新”要分清

运行时:用户点击 → ref 变化 → Vue 更新界面,源代码文件没有被改写。开发时:你在编辑器改源文件并保存 → Vite 热更新 → 浏览器显示新版组件;这可能保留状态,也可能重新创建组件。验证预期结果前先点击“清零”或刷新,避免把旧数值算进去。

三个代码修改练习

以下三项在上面创建的本地 Vue 工程中完成,修改 src/components/CounterPanel.vue。每次只改一处,先预测结果,再保存并观察浏览器热更新。出现问题先撤回刚才那一处修改,查看终端和 Console 的首条报错。

任务与位置把什么改成什么如何判定成功借此说明什么
改标题
CounterPanel.vue 的 script
ref('我的第一个 Vue 组件') → ref('你好,前端')卡片内 h2 文字变化;页面上方 h1 与浏览器标签页标题保持各自原文标题出现在哪一层,决定要改哪份文件;同叫“标题”不代表同一个值
改步长
CounterPanel.vue 的 script
const step = ref(1) → const step = ref(2)按钮显示“增加 2”;先清零,再点三次,左侧为 6,右侧为 12同一份状态可以同时影响按钮文案和操作规则;派生值不需要手动同步
改布局
CounterPanel.vue 的 style
grid-template-columns: 1fr 1fr → grid-template-columns: 1fr两个指标从左右并排变成上下排列;清零后再点一次,数值仍按当前步长变化CSS 改排列,JavaScript 的计数规则不需要跟着改

想直接在手册里练习,可打开实验 04 · Vue 响应式。它把界面放在一个 App.vue 中,没有 CounterPanel.vue:改 title 中的文字;将 const step = 1 改成 const step = 2;将 .numbers 的 grid-template-columns: 1fr 1fr 改成 1fr。点击「运行代码」后,标题更新、指标上下排列;连续点击三次增加按钮,数量应为 6、两倍值为 12。在线实验每次运行都会重新创建预览,数值从 0 开始。

本地多文件工程用来学习入口与组件分工,在线单文件实验用来快速观察相同原理。练习时对照修改的代码与对应效果,说清楚改的是文案、逻辑还是布局。

回到商品选择页:商品名类似这里的标题,数量类似 count,合计类似 doubled;商品卡片的横排或竖排仍由 CSS 决定。看懂简单计数器之后,再去找商品示例中的 products、totalCents 和 .product,这时已经能把业务需求对应到代码位置。

资源目录为什么有两个

做法例子构建时发生什么
从 src/assets 导入import logoUrl from './assets/logo.svg',模板用 <img :src="logoUrl" alt="品牌名称" />资源进入构建依赖关系;按配置可能生成带哈希的文件名或内联为数据 URL,由工具处理最终地址
从 public 按 URL 访问public/guide.pdf 在默认根路径部署时可用 <a href="/guide.pdf">下载说明</a>文件按原样复制到输出目录;URL 不包含 public 这一层

以上是两种写法的说明,资源文件需自己提供;本节计数器没有依赖它们。一般业务代码使用的图片优先放到 src 并导入;必须保留名称或无需构建处理的文件可放 public。放进 src/assets 并不意味着一定出现在 dist,它还要被项目引用。应用部署在子路径时需配合 Vite 的 base 处理资源地址,不要直接假定 / 就是应用目录。参见 Vite 静态资源处理。

看懂依赖与运行命令

打开项目根目录的 package.json。它是 JSON,字符串用双引号,不写注释。常见的 scripts 字段如下;这只是其中一个字段,不是完整 package.json,不要用它覆盖整个文件。实际命令以你的工程为准。

代码示例
"scripts": {
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview"
}
命令 / 字段含义常见误解
npm run dev执行 scripts.dev,启动本地开发服务器;按终端给出的地址访问服务器会持续占用这个终端,Ctrl+C 可停止;它不是把网站发布给其他人
npm run build执行生产构建,把源代码转换成 dist 中的产物构建通过不等于所有业务逻辑正确,仍要验收操作效果
npm run preview在 build 之后本地预览构建产物不替代正式生产服务器,也不会自动把 dist 上传到线上
dependencies应用代码所依赖的包,本例有 vue依赖名不是文件夹别名;具体安装版本同时看锁文件
devDependencies开发、检查、构建所用的包,如 vite 与 Vue 编译插件构建前通常也需要安装这些包;不能只装运行依赖就期待本地打包成功
package-lock.jsonnpm 把版本范围解析为具体安装方案后记录下来使用 npm 时保留 npm 锁文件;不要同时混用不同包管理器生成的锁文件

拿到已有 npm 工程后,先看 README 与 Node 版本要求;已有匹配的锁文件时,团队或持续集成常用 npm ci 按锁文件安装。新增依赖通常用 npm install 包名,让清单和锁文件一同更新。业务源码在 src,第三方实现主要在 node_modules,生成结果在 dist:修页面先定位 src,不要从 node_modules 或 dist 开始改。

学习小结index.html 留下容器并加载入口;main.js 创建 Vue 应用;App.vue 组装组件;组件通过 ref 保存状态、computed 推导结果、事件修改状态;Vue 更新 DOM,CSS 决定排列和外观;Vite 帮我们开发和构建。

CHAPTER 11Vue 3 商品页实战

这一页把前面的概念串起来:选择商品、输入数量、自动合计、处理空状态和错误输入。它只使用 Vue 3 与 JavaScript,无需组件库、路由或状态管理依赖。

准备与运行

  1. 回到《搭建并运行 Vue 工程》创建的 Vue JavaScript 项目目录;在项目根目录运行 npm run dev,打开终端显示的本地地址。
  2. 将下面完整代码替换 src/App.vue。文件名大小写保持一致。
  3. 将 src/main.js 改为下面的最小入口,去掉脚手架示例使用的全局样式导入,避免其居中或限宽规则影响练习布局。
  4. 保存后,在浏览器中勾选商品并修改数量;如果没有更新,先看终端和浏览器 Console 的首条报错。
src/main.js:本页练习的最小入口
// src/main.js:本页练习的最小入口
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
金额与业务边界本例把 ¥18.00 保存为 1800 分,用整数计算,再在展示时除以 100。实际多币种系统必须按币种的小数位定义最小单位;订单金额最终由服务端校验。本例按钮只确认本地选择,不调用订单或支付接口。

完整 src/App.vue(可直接复制)

展开完整源码 · 184 行
Vue 单文件组件
<script setup>
import { computed, ref } from 'vue'

// 金额保存为整数分;输入数量保留原值,以便展示校验提示。
const products = ref([
  { id: 'coffee', name: '美式咖啡', priceCents: 1800, qty: 1, selected: false },
  { id: 'tea', name: '茉莉绿茶', priceCents: 1200, qty: 1, selected: false },
  { id: 'bread', name: '黄油面包', priceCents: 850, qty: 1, selected: false }
])
const message = ref('')
const maxQty = 99

function validQty(qty) {
  return Number.isInteger(qty) && qty >= 1 && qty <= maxQty
}

function money(cents) {
  return `¥${(cents / 100).toFixed(2)}`
}

const selectedProducts = computed(() =>
  products.value.filter(product => product.selected)
)
const hasInvalidQty = computed(() =>
  selectedProducts.value.some(product => !validQty(product.qty))
)
const totalQty = computed(() =>
  selectedProducts.value.reduce((sum, product) =>
    sum + (validQty(product.qty) ? product.qty : 0), 0)
)
const totalCents = computed(() =>
  selectedProducts.value.reduce((sum, product) =>
    sum + (validQty(product.qty) ? product.priceCents * product.qty : 0), 0)
)
const canConfirm = computed(() =>
  selectedProducts.value.length > 0 && !hasInvalidQty.value
)

function confirmSelection() {
  if (!canConfirm.value) return
  message.value = `已确认 ${totalQty.value} 件商品,合计 ${money(totalCents.value)}。`
}

function reset() {
  products.value.forEach(product => {
    product.selected = false
    product.qty = 1
  })
  message.value = ''
}
</script>

<template>
  <main class="shop">
    <header>
      <p class="eyebrow">我的第一个 Vue 3 页面</p>
      <h1>选一份下午茶</h1>
      <p>勾选商品,填写数量,观察合计如何自动变化。</p>
    </header>

    <form @submit.prevent="confirmSelection" @input="message = ''">
      <ul class="products">
        <li v-for="product in products" :key="product.id" class="product">
          <label class="choice">
            <input type="checkbox" v-model="product.selected" />
            <span>
              <strong>{{ product.name }}</strong>
              <small>{{ money(product.priceCents) }} / 件</small>
            </span>
          </label>

          <div class="quantity">
            <label :for="`qty-${product.id}`">数量</label>
            <input
              :id="`qty-${product.id}`"
              v-model.number="product.qty"
              type="number"
              min="1"
              :max="maxQty"
              step="1"
              required
              :disabled="!product.selected"
              :aria-invalid="product.selected && !validQty(product.qty)"
              :aria-describedby="`error-${product.id}`"
            />
            <small
              :id="`error-${product.id}`"
              v-show="product.selected && !validQty(product.qty)"
              class="error"
            >请输入 1–{{ maxQty }} 的整数</small>
          </div>
        </li>
      </ul>

      <section class="summary" aria-labelledby="summary-title">
        <h2 id="summary-title">当前选择</h2>
        <p v-if="selectedProducts.length === 0">还没有选择商品。</p>
        <p v-else-if="hasInvalidQty" class="error">
          请先修正数量,合计暂不展示。
        </p>
        <p v-else class="total" aria-live="polite">
          {{ selectedProducts.length }} 种 / {{ totalQty }} 件
          <strong>{{ money(totalCents) }}</strong>
        </p>
        <div class="actions">
          <button type="submit" :disabled="!canConfirm">确认选择</button>
          <button type="button" class="secondary" @click="reset">重置</button>
        </div>
        <p role="status" class="message">{{ message }}</p>
      </section>
    </form>
    <p class="note">练习页面:确认只显示文字,不创建订单或付款。</p>
  </main>
</template>

<style scoped>
.shop {
  max-width: 760px;
  margin: 32px auto;
  padding: 24px;
  color: #182b3b;
  font: 16px/1.6 system-ui, sans-serif;
}
h1 { margin: 0; font-size: 32px; }
h2 { margin-top: 0; font-size: 20px; }
.eyebrow { color: #5366ca; font-weight: 700; }
.products {
  display: grid;
  gap: 12px;
  padding: 0;
  list-style: none;
}
.product {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 20px;
  padding: 16px;
  border: 1px solid #d9e1ea;
  border-radius: 12px;
}
.choice { display: flex; align-items: center; gap: 12px; cursor: pointer; }
.choice input { width: 20px; height: 20px; }
.choice small { display: block; color: #566573; }
.quantity { max-width: 220px; }
.quantity label { margin-right: 8px; }
.quantity input {
  box-sizing: border-box;
  width: 84px;
  padding: 8px;
  font: inherit;
  border: 1px solid #93a4b5;
  border-radius: 6px;
}
.quantity small { display: block; }
.summary { padding: 20px; background: #f1f5fb; border-radius: 12px; }
.total { display: flex; justify-content: space-between; gap: 16px; }
.total strong { font-size: 24px; color: #2945b4; }
.actions { display: flex; gap: 12px; flex-wrap: wrap; }
button {
  padding: 10px 18px;
  border: 1px solid #2945b4;
  border-radius: 8px;
  font: inherit;
  background: #2945b4;
  color: white;
  cursor: pointer;
}
button.secondary { color: #2945b4; background: white; }
button:disabled { opacity: .5; cursor: not-allowed; }
input:focus-visible, button:focus-visible {
  outline: 3px solid #e29212;
  outline-offset: 3px;
}
.error { color: #b42318; }
.message { min-height: 1.6em; margin-bottom: 0; }
.note { font-size: 14px; color: #566573; }
@media (max-width: 520px) {
  .shop { margin: 0 auto; padding: 16px; }
  .product { flex-direction: column; align-items: stretch; gap: 12px; }
  .quantity { max-width: none; }
  .total { align-items: baseline; flex-wrap: wrap; }
}
</style>

按数据流读代码

步骤对应代码你应该能说清楚什么
保存原始数据products = ref([...])每项有固定 id、名称、单价、数量和是否选中
让输入改变数据v-model / v-model.number勾选框改 selected,数量框改 qty
从数据推导结果selectedProducts / totalCentsfilter 选出商品,reduce 累加整数分
决定页面状态v-if / v-else-if / :disabled未选择、数量非法、可确认是不同状态
处理动作confirmSelection / reset确认显示文字;重置恢复初始状态

合计是“由商品状态算出来的数据”,因此用 computed。确认提示是“用户操作后才产生的消息”,因此用 ref 保存、在函数里修改。输入变化时清空旧确认消息,避免显示过期结果。

带结果的验收清单

自己改三处,不只复制成功

  1. 新增商品:在数组中加“拿铁”,价格为 2200 分,id 使用 latte。验收:无需修改模板就出现第四项,选两杯合计 ¥44.00。
  2. 更改限制:把最大数量从 99 改为 20。验收:输入 20 合法,21 非法,提示也同步变为 1–20。
  3. 增加筛选:新增搜索词 ref,用 computed 按名称筛选,并让 v-for 遍历筛选结果。验收:搜索“茶”只展示绿茶;已选但暂时隐藏的商品是否参与合计,需要先确定产品规则再实现。

CHAPTER 12组件、路由与共享状态

props、emits、slot:组件之间怎么配合

术语方向 / 用途对应例子
props父组件把数据传给子组件父组件告诉数量选择器当前数量是 2
emits子组件声明并发出事件,父组件决定怎么处理子组件报告“用户希望改为 3”,父组件更新状态
slot 插槽子组件预留内容位置,由父组件填充同一个数量选择器可以显示不同商品标题

先掌握一个规则:状态归谁所有,就由谁负责修改。子组件不要直接给 props.quantity 赋值;收到对象类型 prop 也不要偷偷修改其内部字段。defineProps 和 defineEmits 是 script setup 的编译宏,无需 import。参见 Props、组件事件及 插槽。

两个文件的完整练习

沿用《Vue 3 商品页实战》最小 src/main.js。先新建 src/components/QuantityControl.vue,再替换 src/App.vue;这是一份独立的组件通信实验。

src/components/QuantityControl.vue
<!-- src/components/QuantityControl.vue -->
<script setup>
const props = defineProps({
  quantity: { type: Number, required: true }
})
const emit = defineEmits(['change'])

function adjust(delta) {
  const next = props.quantity + delta
  if (next >= 1 && next <= 99) emit('change', next)
}
</script>

<template>
  <section>
    <h2><slot>商品数量</slot></h2>
    <button type="button" :disabled="quantity <= 1" @click="adjust(-1)">减一</button>
    <output aria-live="polite">{{ quantity }}</output>
    <button type="button" :disabled="quantity >= 99" @click="adjust(1)">加一</button>
  </section>
</template>

<style scoped>
button, output { margin-right: 12px; }
button { padding: 8px 16px; }
</style>
src/App.vue:本小实验替换本页练习的 App.vue
<!-- src/App.vue:本小实验替换本页练习的 App.vue -->
<script setup>
import { ref } from 'vue'
import QuantityControl from './components/QuantityControl.vue'
const qty = ref(1)

function updateQty(next) {
  if (Number.isInteger(next) && next >= 1 && next <= 99) {
    qty.value = next
  }
}
</script>

<template>
  <main>
    <h1>父组件管理商品</h1>
    <QuantityControl :quantity="qty" @change="updateQty">
      美式咖啡的数量
    </QuantityControl>
    <p>父组件保存的数量:{{ qty }}</p>
  </main>
</template>

执行顺序:点击子组件“加一” → emit('change', next) → 父组件的 updateQty(next) → 更新 qty → 新值通过 prop 传回子组件。<slot> 里的“商品数量”是默认内容;父组件提供“美式咖啡的数量”后会替换它。

练习与验收点击加一,子组件和父组件的数字应同步从 1 变成 2;减到 1 后减一禁用。把父组件插槽文字改成“茉莉绿茶的数量”,子组件逻辑无需改动。

什么时候才需要 Vue Router 和 Pinia

遇到的需求选择边界
一个页面里的商品勾选与合计组件内 ref / computed不需要为了入门先引入所有生态库
父子组件需要交换数据props / emits关系明确时最直接
商品页、订单页各有 URL,支持浏览器前进后退Vue Router负责路径与组件对应关系;不是后端接口路由
多个互不相邻页面共享购物车、登录用户等状态Pinia共享内存状态;不会自动持久化,也不是数据库

可选实验 A:两个可切换页面

以下是独立的路由实验,可复制到新的 Vue JavaScript 项目,或先保存现有练习再替换对应文件。安装依赖后,创建下面五个文件;src/views 目录不存在时先创建。

终端 · 项目目录
npm install vue-router
src/main.js
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')
src/router.js
// src/router.js
import { createRouter, createWebHashHistory } from 'vue-router'
import ProductsView from './views/ProductsView.vue'
import AboutView from './views/AboutView.vue'

export default createRouter({
  history: createWebHashHistory(),
  routes: [
    { path: '/', component: ProductsView },
    { path: '/about', component: AboutView }
  ]
})
src/App.vue
<!-- src/App.vue -->
<template>
  <nav aria-label="页面导航">
    <RouterLink to="/">商品</RouterLink>
    <span> | </span>
    <RouterLink to="/about">关于</RouterLink>
  </nav>
  <main><RouterView /></main>
</template>
src/views/ProductsView.vue
<!-- src/views/ProductsView.vue -->
<template><h1>商品页</h1><p>这里可以放商品列表。</p></template>
src/views/AboutView.vue
<!-- src/views/AboutView.vue -->
<template><h1>关于页面</h1><p>这是一个 Vue Router 练习。</p></template>

RouterLink 负责导航,RouterView 是当前页面的显示位置。此处使用 hash 模式,地址类似 /#/about,便于静态托管练习。若改成 createWebHistory(),部署服务器需要把页面路由回退到应用入口,否则直接刷新 /about 可能得到 404。参见 Router 入门及 历史模式与部署。

验收点击“关于”时 URL 出现 #/about,正文显示“关于页面”;浏览器后退能回到商品页。练习:新增第三个帮助页,并加入对应路径和链接。

可选实验 B:共享一份购物车状态

此实验独立于上面的路由实验,只需要以下三个文件;它会替换 main.js 和 App.vue。如果以后同时使用 Router 和 Pinia,在同一个应用实例上依次 use(router)、use(createPinia()),最后只调用一次 mount。

终端 · 项目目录
npm install pinia
src/main.js
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')
src/stores/cart.js
// src/stores/cart.js
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
  state: () => ({ count: 0 }),
  actions: {
    add() { this.count += 1 }
  }
})
src/App.vue
<!-- src/App.vue -->
<script setup>
import { useCartStore } from './stores/cart.js'
const cart = useCartStore()
</script>

<template>
  <h1>共享购物车状态</h1>
  <button @click="cart.add">加一件</button>
  <p>共 {{ cart.count }} 件</p>
</template>

state 定义状态,actions 定义修改状态的动作;'cart' 是这份 store 的唯一标识。另一个组件再调用 useCartStore(),在同一个应用的 Pinia 实例中拿到的就是同一份状态。直接读取 cart.count;如要解构响应式字段,使用 Pinia 的 storeToRefs,不要直接 const { count } = cart。参见 Pinia 入门和 定义 Store。

验收点击加一,数量递增;刷新后回到 0。扩展练习:新建一个只显示数量的组件,也调用 useCartStore(),两个组件应同步显示。只有真正需要刷新保留时,再设计存储和恢复策略。

CHAPTER 13TypeScript 入门

TypeScript(TS)在 JavaScript 的基础上增加类型系统,让编辑器和类型检查器在运行前发现一部分错误。先能读懂 JS 的变量、函数、对象和异步,再学习 TS,理解会顺得多。文件通常使用 .ts,Vue 单文件组件里常写 <script setup lang="ts">。

类型不等于接口数据校验普通类型注解在转换成 JavaScript 时会被移除,不会自动在浏览器里验证服务端返回值。写 as User 只是告诉类型检查器“按 User 看待它”,不会检查数据真的有这些字段。来自接口或用户输入的数据仍需要运行时校验。参见 TypeScript 官方手册 · 基础。

常用类型与一段完整示例

运行环境:打开 TypeScript Playground,粘贴下面完整代码,观察类型提示和生成的 JavaScript,再点击 Run。也可放进后续创建的 Vue + TypeScript 项目中学习,但不要直接把这些类型语法粘到普通浏览器 Console。

代码示例
interface User {
  id: number;
  name: string;
  nickname?: string; // 可选字段:可能没有
}

type OrderStatus = 'pending' | 'paid'; // 联合类型:只允许这两个值

function greet(user: User): string {
  return `你好,${user.nickname ?? user.name}`;
}

function first<T>(items: T[]): T | undefined {
  return items[0]; // 泛型 T 保留输入与输出之间的类型关系
}

const users: User[] = [{ id: 1, name: '小林' }];
const orderStatus: OrderStatus = 'pending';
const user = first(users);
if (user) {
  console.log(greet(user), orderStatus); // 你好,小林 pending
}

const raw: unknown = '来自外部的数据';
if (typeof raw === 'string') {
  console.log(raw.toUpperCase()); // 判断之后,才能按字符串安全使用
}

// 动手:取消下一行的注释,观察类型错误,再把 '1' 改成 1。
// const wrongUser: User = { id: '1', name: '小林' };
export {}; // 将示例作为模块,避免与浏览器的同名全局变量冲突
写法含义什么时候用
name: string、id: number文本、数字类型参数与数据结构的约束;明显的初始值通常可以自动推断
User[]元素为 User 的数组用户列表、商品列表
interface User描述对象应有的属性结构组件参数、业务实体、请求参数
type给类型起名字,也可描述联合类型和对象订单状态、组合类型;与 interface 有重叠,按团队约定用
nickname?: string属性可以缺省读取时考虑 undefined
T / <T>泛型参数:在调用时确定具体类型不丢失类型信息的通用函数和容器
any对此值放弃大部分类型检查谨慎使用;到处写 any 会失去 TS 的主要价值
unknown尚不清楚类型的值,使用前必须缩小范围或检查外部输入、待校验的数据

TS 的类型推断能从 const count = 0 看出这是数字,不必每个变量都手写类型。入门先会“给函数参数写类型、给对象定义结构、识别可能为空”,再逐步学复杂泛型。更多示例见 TypeScript 官方手册 · 常用类型。

编译、类型检查、运行是三件相关但不同的事

转换 / 构建把 TS、Vue 文件等处理成浏览器可以加载的产物;类型检查找出类型不匹配;运行是真正执行生成的 JavaScript。一些开发工具会快速移除类型并运行代码,但不会在这一步完成全部类型检查,所以“页面能打开”不等于“类型检查通过”。Vue 项目应按 package.json 中的脚本运行检查和构建。

基础知识自测能说明 HTML / CSS / JS 分工;能找出按钮的点击回调;能读懂数组对象和 import;知道 await 在等什么;知道 TypeScript 的类型在运行前提供帮助。把这些基础与已经完成的 Vue 练习对照起来,再逐步为组件和接口数据补充类型。TypeScript 不是运行前面 JavaScript 示例的前置条件。

CHAPTER 14真实项目阅读方法

先找入口,再顺着页面看组件、状态和接口。不要一开始就逐个阅读所有文件。下面是常见目录示意,并非每个项目都会自动生成全部目录。

目录示意
frontend-hello/
├── index.html             # HTML 入口,提供挂载容器
├── package.json           # 项目信息、依赖、运行脚本
├── package-lock.json      # npm 锁文件,记录解析后的依赖版本
├── vite.config.js         # 开发与构建配置
├── public/                # 原样提供的静态文件
├── src/
│   ├── main.js            # 创建并挂载 Vue 应用
│   ├── App.vue            # 根组件
│   ├── assets/            # 参与构建的图片、样式等
│   ├── components/        # 可复用组件
│   ├── views/             # 页面组件(项目约定)
│   ├── router/            # 路由配置(按需)
│   ├── stores/            # Pinia 状态(按需)
│   └── api/               # 业务请求封装(项目约定)
├── node_modules/          # 安装后的依赖,不手动修改
└── dist/                  # 构建后产生的文件,不在这里写业务源码
index.html 的 #app←main.js 创建应用→App.vue→页面与子组件

package.json 先看这三处

JSON · 字段示意,不替换整个文件
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": { "vue": "^3.5.0" },
  "devDependencies": { "vite": "...项目实际版本..." }
}

上面仅说明字段位置;Vue 的版本号用于说明语义版本,不代表当前最新版,Vite 处是占位说明。不要把这段替换到项目中。

  • scripts:npm run dev 就是执行此处名为 dev 的脚本。
  • dependencies:业务代码需要的依赖;devDependencies:构建、检查等开发依赖。是否最终进入浏览器,还取决于导入和打包方式。
  • ^3.5.0:允许符合范围的后续兼容版本;锁文件记录这次实际解析到的版本。安装并不总等于「永远拿最新」。
命令什么时候用
npm install新建工程安装依赖,或有意变更依赖;可能更新锁文件
npm ci已有匹配的 package-lock.json 时,按锁文件做干净安装;CI 常用
npm run dev本地开发和热更新
npm run build生成生产产物,顺带发现部分编译问题
npm run preview本地预览已构建的产物;先 build,再 preview
npm ls vue vite查看当前项目实际安装的 Vue / Vite 版本
一个仓库使用它约定的包管理器看到 pnpm-lock.yaml 或 yarn.lock 时,先读 README 与 packageManager 配置。不要随手混用 npm、pnpm、Yarn 生成多套锁文件;也不要遇到安装报错就删除锁文件。

接手页面时,按这条线查

  1. 页面入口在哪:路由配置指向哪个组件?
  2. 界面在哪:模板里的表单、按钮和列表分别属于哪个组件?
  3. 数据在哪:来自 props、本地 ref、store,还是接口返回?
  4. 行为在哪:按钮事件调用哪个函数,函数是否发请求或改状态?
  5. 验收在哪:复现步骤、接口约定、测试和发布环境分别是什么?

CHAPTER 15接口与加载状态

前端负责收集输入、展示状态和交互;后端负责可信的业务处理、数据存储与权限校验。一个完整功能通常是两边共同完成的。

用户操作→前端发 HTTP 请求→后端处理并返回 JSON→更新状态与界面

先约定接口,再写请求

要确认的事例子
地址、方法、参数GET /api/products?keyword=咖啡;查询通常用 GET,创建通常用 POST
返回结构和字段类型返回数组还是 { data: [...] }?id 是字符串还是数字?
单位与业务语义价格是元还是分?库存为 0 代表售罄吗?
登录和权限Cookie 会话还是 Authorization 头,失效时如何处理?
失败和边界空列表、参数错误、重复提交、超时和网络断开如何展示?

不依赖后端的 fetch 小实验

在《搭建并运行 Vue 工程》项目中创建 public/products.json,内容如下。Vite 会把它提供为根路径下的 /products.json。

public/products.json
[
  { "id": "coffee", "name": "咖啡", "priceCent": 1800 },
  { "id": "tea", "name": "红茶", "priceCent": 1200 }
]

下面是独立练习:先另存《Vue 3 商品页实战》的 App.vue,再用以下完整内容替换 src/App.vue。通过 npm run dev 的地址访问。

src/App.vue · 完整请求练习
<script setup>
import { ref, onMounted } from 'vue'

const products = ref([])
const loading = ref(false)
const error = ref('')

async function loadProducts() {
  if (loading.value) return
  loading.value = true
  error.value = ''
  products.value = []
  try {
    const response = await fetch(`${import.meta.env.BASE_URL}products.json`)
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    const data = await response.json()
    const valid = Array.isArray(data) && data.every(item =>
      item !== null && typeof item === 'object' &&
      typeof item.id === 'string' &&
      typeof item.name === 'string' &&
      Number.isSafeInteger(item.priceCent) && item.priceCent >= 0
    )
    if (!valid) throw new Error('数据结构不符合约定')
    products.value = data
  } catch (err) {
    error.value = err instanceof Error ? err.message : '加载失败'
  } finally {
    loading.value = false
  }
}

onMounted(loadProducts)
</script>

<template>
  <main>
    <h1>商品列表</h1>
    <button type="button" :disabled="loading" @click="loadProducts">
      {{ loading ? '加载中…' : '重新加载' }}
    </button>
    <p v-if="loading" role="status">正在请求商品…</p>
    <p v-else-if="error" role="alert">加载失败:{{ error }}</p>
    <p v-else-if="products.length === 0">暂无商品</p>
    <ul v-else>
      <li v-for="item in products" :key="item.id">
        {{ item.name }} · ¥{{ (item.priceCent / 100).toFixed(2) }}
      </li>
    </ul>
  </main>
</template>

fetch 得到响应,response.json() 异步读取并解析响应体;HTTP 404 / 500 通常不会自动进入 catch,所以要检查 response.ok。import.meta.env.BASE_URL 使用 Vite 的部署基础路径,本地默认是 /,部署到子目录时也能正确定位这个文件。这里的字段检查是最小运行时校验,正式项目还要考虑 ID 唯一性等业务规则。

动手验收四种状态正常:看到两项商品。空:把 JSON 改为 [] 后重新加载。失败:把 JSON 暂改为无效 JSON 后重试,再恢复。加载:在浏览器 Network 中选择慢速网络,确认按钮禁用且显示加载提示。真实项目还需考虑请求取消、超时和搜索请求的返回顺序。

CORS 为什么会出现

源由协议、主机、端口共同决定。页面在 http://localhost:5173,接口在 http://localhost:8080,端口不同也属于跨源。浏览器会根据服务端的 CORS 响应头判断是否允许前端读取响应。

开发阶段可以配置 Vite 的代理;生产阶段需要服务端正确的跨源配置或同源网关。mode: 'no-cors' 会让常见跨源响应不可读,并不能让页面正常取得 JSON。

三个真实业务边界

  • 前端隐藏按钮只是界面控制,权限必须由服务端验证。
  • 页面显示的总价是交互反馈,真实订单的价格、优惠、库存和支付结果由可信服务端校验与确认。
  • 用户输入不要直接拼进 innerHTML 或 v-html;普通文本优先用 textContent 或 Vue 插值显示。

官方参考:MDN:使用 Fetch、MDN:跨源资源共享。

CHAPTER 16调试、构建与上线

浏览器开发者工具先会用四个面板

浏览器菜单里找到「开发者工具」,或右键页面选择「检查」。Chrome / Edge 常用快捷键:Mac 为 ⌘⌥I,Windows 为 Ctrl+Shift+I。

面板拿它回答什么问题马上练一次
Elements / 元素元素为什么偏移?哪条 CSS 生效?选中按钮,修改 padding,看盒模型尺寸;此处临时修改刷新后会丢失
Console / 控制台有无 JS 错误,变量值是什么?执行 document.title;读第一条红色错误和文件行号
Network / 网络请求有没有发出去,参数、状态码、响应内容是什么?重新加载《接口与加载状态》示例,打开 products.json 请求详情
Sources / 源代码代码执行到了哪里?在事件函数里打断点,点击按钮后逐步执行,查看变量

Vue DevTools 可辅助观察组件树和状态。定位时先分清:数据不对、DOM 没更新、还是 DOM 正确但 CSS 把它遮住了。

不只验证「能显示」

  • 正常数据、空数据、加载中和失败时都有合理界面。
  • 按钮可以用 Tab 聚焦并用键盘触发;输入框有清楚的 label。
  • 窄屏不把主内容挤出屏幕;长商品名和长译文能换行。
  • 连续点击、删除最后一项、输入空值/负数/小数时逻辑正确。
  • 涉及移动端时在实际浏览器或 WebView 上验收;桌面模拟不能覆盖所有真机问题。

从开发环境走向生产产物

终端 · 在项目目录运行
npm run build
npm run preview

构建一般生成 dist/,打开 preview 输出的地址检查产物。vite preview 用于本地预览,不是正式生产服务器。正式部署通常由静态托管服务、Web 服务器或项目发布平台提供访问。

发布前确认为什么
资源路径和部署子目录部署在 /demo/ 等路径时要配置相应的 Vite base,避免 JS/CSS 404
路由回退使用 History 路由时,服务器须让前端页面路径回退到 index.html,不能把 API 和不存在的资源一概吞掉
环境变量Vite 的 VITE_ 变量会暴露给客户端,构建时替换;不能存服务端密钥
接口与登录确认目标环境、HTTPS、代理、Cookie 和跨源配置
构建与功能验收build 成功不等于业务正确,仍需回归关键交互和目标浏览器

官方参考:Vite:静态部署、Vite:环境变量与模式。

CHAPTER 17和 AI 协作

把需求缩小到一个可观察的结果,告诉 AI 文件位置、已有技术和验收标准。拿到代码后先读说明,再运行,最后自己改一个值验证理解。

从零创建时这样提问

可复制提示词
我是前端零基础,正在用 Vue 3 + Vite + JavaScript 学习。
请只修改 src/App.vue,使用 <script setup>,不加第三方 UI 库。
做一个商品练习:显示商品名、单价(数据用整数分)、数量和合计。
数量只允许 1–99 的整数;没有选择商品时显示空状态。
先解释 template、ref、computed、@click 各负责什么,
再给完整代码、运行命令和 5 条我能手动验证的步骤。
请明确代码应该放在哪个文件里。

遇到报错时给事实

可复制提示词
当前技术:Vue 3 + Vite,Node 版本是(填写实际输出)。
运行命令:(填写实际命令)。
期望:点击“增加”后数量和总价都变化。
实际:数量变化,总价不变。
复现步骤:打开页面 → 选择咖啡 → 点击增加。
控制台第一条错误:(粘贴错误和文件行号;没有则说明没有)。
相关代码:(贴 ref/computed/事件处理函数和对应模板)。
请先判断原因,再做最小修改,并解释如何证明修复有效。

接受代码前的三个问题

  1. 我能说清状态在哪里、谁在修改它、哪些地方显示它吗?
  2. 我知道这是完整文件还是局部片段,是否增加了依赖吗?
  3. 我亲手验证了正常、空值、错误和边界情况吗?

向 AI 提供代码和日志时移除口令、令牌及真实个人数据。读不懂时继续追问一小段,而不是一次生成整个系统。

CHAPTER 18阶段验收

下面的勾选会尽可能保存在当前浏览器。换电脑或重新复制文件后,进度可能不会同步。

0 / 12 项完成
自测:这 5 个问题你能答上来吗?
  1. HTML 能定义按钮,为什么还需要 JS?
  2. Vue 脚本中把普通 let count = 0 改掉,模板是否一定跟着更新?
  3. 能从 quantity 推出 total 时,为什么通常选 computed?
  4. fetch 收到 404 时为什么仍要检查 response.ok?
  5. TS 声明了 Product 类型,接口一定会返回合法 Product 吗?

参考答案:按钮行为需要事件逻辑;普通变量不是响应式状态;computed 表达派生值并跟踪依赖;HTTP 错误状态通常不会使 fetch 自动拒绝;类型检查在开发期,外部数据仍需运行时校验。

CHAPTER 19进阶学习路线

阶段重点可展示的结果
第 1 阶段:页面基础HTML 语义、CSS 布局、JS 数组对象与事件自己写一张商品卡片,并适配窄屏
第 2 阶段:Vue 单页响应式、组件、表单、条件和列表渲染完成带校验的商品选择页
第 3 阶段:多页与接口Router、Pinia、请求封装、加载和失败状态列表 / 详情 / 订单页之间切换并展示接口数据
第 4 阶段:工程质量TypeScript、Git、代码检查、业务单元测试与端到端测试一次可审查的修改,带复现步骤和测试证据
第 5 阶段:真实业务可访问性、性能、国际化、权限、容器兼容在目标设备验证完整业务流程

不用第一次就学完 SSR、复杂打包配置和所有状态库。先能读懂一个组件,再把它接入一个小页面,然后接上真实接口。

CHAPTER 20知识回顾与实践路线

用可验证的结果检查理解按《学习目标与阅读路径》选择起点,再通过下面的练习回顾核心知识。五个在线实验可直接在本页运行;准备好开发环境后,可继续完成本地 Vue 工程。TypeScript、接口、Router 与 Pinia 按实际需要逐步学习。

从概念到完整功能

先预测代码修改后的结果,再运行验证。遇到不确定的概念,返回对应章节阅读;将尚未通过的验收项记录下来,作为下一次练习的重点。

阶段练习验收
基础分工回看《HTML、CSS、JS 各负责什么》与《跨平台界面概念对照》能说明页面结构、布局和交互分别由哪一层负责,理解不同平台的类比边界
页面结构在《HTML:页面的结构》实验中改标题和按钮文字分清 title 与 h1,能找到按钮所在的 DOM 结构
布局与适配在《CSS:布局与样式》实验中调整间距和列数,缩窄浏览器页面布局随视口变化,内容清晰且可操作
事件与状态在《JavaScript:事件与数据》实验中改步长,再对照《Vue 3:数据驱动界面》能指出点击回调、状态值与界面更新的位置,解释两种实现的区别
综合实践运行《Vue 3 商品页实战》,选择商品、修改数量、观察合计正常输入时合计正确;空状态和非法数量有明确反馈
独立复盘按《阶段验收》检查已完成的练习,记录尚未理解的知识点能独立修改一个需求,并解释“事件 → 状态 → 界面”的完整路径
核心概念回顾

Web 前端负责用户在浏览器中看到并操作的界面。HTML 定义内容,CSS 决定外观和布局,JavaScript 处理交互。Vue 3 在这些基础之上组织组件,通过状态描述界面。由一个按钮扩展到商品选择页时,可以始终沿着用户操作、状态变化、界面更新这条路径分析功能。

CHAPTER 21常见问题

H5、HTML、JavaScript、Vue 是同一种东西吗?

不是。H5 在业务交流中常指 Web 页面或移动网页;HTML 定义内容结构,JS 负责逻辑,Vue 是建立在 Web 技术上的界面框架。需要具体说清你讨论的是网页形态、语言还是框架。

JavaScript 和 Java 有什么关系?

它们是不同语言,工具链和运行环境也不同。名字接近不代表 Java 代码可以直接放进网页脚本里运行。

为什么打开 .vue 文件没效果?

浏览器不直接理解 SFC 格式。把它放进 Vue 工程,通过 npm run dev 打开的地址查看。纯内联 HTML 示例才适合直接双击。

npm 提示找不到命令、版本不支持或缺少脚本怎么办?

先检查 node / npm 是否安装并重新打开终端;版本不兼容则核对《主要语言、版本与选型》和项目 engines。缺少 dev 脚本时检查当前是否在有 package.json 的项目目录,以及 scripts 里实际叫什么。PowerShell 脚本策略阻止 npm.ps1 时,可在同目录尝试 npm.cmd,不需要为了学习关闭全局安全策略。

安装依赖失败,是否应该删除锁文件或用 sudo?

先读第一条真实错误,区分网络、权限、版本和依赖冲突;检查项目包管理器与 Node 版本。不要默认删除锁文件、强行忽略依赖冲突或使用管理员权限安装。

页面白屏,先看哪里?

先看浏览器 Console 的第一条错误,再看开发服务器终端输出;检查文件路径、导入名称、挂载容器和模板语法。如果资源 404,再看 Network 和部署路径配置。

改了变量,页面为什么没有变?

检查它是否是 ref / reactive 管理的数据,脚本里 ref 是否使用 .value,是否错误地把响应式对象拆成普通变量,或者修改的是另一个组件中的同名数据。

v-for 为什么要 key?能永远用数组下标吗?

key 帮 Vue 识别列表项身份。数据会插入、删除、排序,或内部有输入状态时,应使用稳定唯一的业务 ID;数组下标可能让界面状态对应错对象。

组件库能代替 CSS 和 HTML 吗?

组件库提供现成 UI,但布局、表单语义、可访问性和样式排错仍依赖基础知识。先掌握按钮、输入框和布局,再学组件库会更快。

Vue 2 项目能直接照搬这里的代码吗?

不能默认照搬。手册使用 Vue 3 和 script setup;维护 Vue 2 项目时先确认项目版本、构建插件与依赖,并参考迁移指南。API 风格和 Vue 主版本也不是同一个概念。

页面上算出的价格能直接拿去收款吗?

练习价格只用于展示界面数据流。真实价格、优惠、订单状态及支付结果应由服务端校验。客户端不能作为可信交易依据。

离线打开手册有什么限制?

正文、代码、目录、原生计数器与内置 Vue 商品预览均可离线阅读或操作。打开官方文档、首次安装 npm 依赖和访问真实接口需要联网。复制、进度保存受浏览器策略影响;复制失败时可以手动选中代码。

CHAPTER 22官方资料与术语速查

先按章节完成练习,遇到不熟悉的词再查官方资料。以下来源用于核对定义、API 与工具链要求;版本信息核对日期为 2026-10-12。

主题官方入口
HTML / CSS / JavaScriptMDN Web 开发学习区
现代 HTML 标准WHATWG HTML Living Standard
JavaScript 语言标准ECMAScript 语言规范
Vue 3Vue 中文指南 · 互动教程 · 演练场
TypeScriptTypeScript Handbook
路由与共享状态Vue Router · Pinia
开发与构建Vite 官方指南 · Node.js 发布与支持周期 · npm ci

DOM 文档对象模型 · SFC 单文件组件 · SPA 单页应用 · HMR 热模块替换 · CLI 命令行工具 · LTS 长期支持 · Props 组件输入 · Emit 触发组件事件 · Store 共享状态容器 · CORS 跨源资源共享 · SSR 服务端渲染 · CSR 客户端渲染

维护建议:应用到具体项目时,在《主要语言、版本与选型》、《搭建并运行 Vue 工程》、《真实项目阅读方法》与《调试、构建与上线》中分别补入真实仓库的版本约束、启动命令、目录约定与发布流程;不要把练习工程的配置直接当作实际项目标准。

↑ 顶部