普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月1日技术

JavaScript原型链

作者 前端小阳
2026年4月1日 18:24

随记:JavaScript原型链

前言

小编学习前端的随记,个人理解,欢迎大佬纠错

正文

在认识JS原型链之前我们先认识几个概念:显示原型、原型对象、实例对象、对象原型(隐式原型)

显示原型

所有概念都出自于函数对象中,而函数有许多的属性,其中就有这么一个特殊的属性叫显示原型:prototype

原型对象
function Person(){}
console.log( Person.prototype );

控制台输出

{
    constructor: ƒ Person(),
    __proto__: {
        constructor: ƒ Object(),
        hasOwnProperty: ƒ hasOwnProperty(),
        isPrototypeOf: ƒ isPrototypeOf(),
        propertyIsEnumerable: ƒ propertyIsEnumerable(),
        toLocaleString: ƒ toLocaleString(),
        toString: ƒ toString(),
        valueOf: ƒ valueOf()
    }
}

上面这个对象,就是大家常说的原型对象

可以看到,原型对象有⼀个自有属性constructor,这个属性指向该函数。这是形成原型链的一步。

实例对象
// 1. 构造函数
function Person(name) {
    this.name = name; // 每个实例独有的属性
}

// 2. 创建“实例对象”
const p1 = new Person("张三");

console.log(p1)

控制台输出

{"name":"张三"}

通过new关键字创建的对象,就叫做实例对象。

可以看到new出来的p1对象身上继承了其原Person对象的name属性。它可以有自己的属性和方法,也包括继承而来的属性和方法。

对象原型(隐式原型)
// 1. 构造函数
function Person(name) {
    this.name = name; // 每个实例独有的属性
}

// 2. 创建“实例对象”
const p1 = new Person("张三");

// --- 验证关系 ---

// 实例的“对象原型” 指向 构造函数的“原型对象”
console.log(p1.__proto__ === Person.prototype); // true

对象原型也可以叫做隐式原型,以下我们都叫做隐式原型。

是实例对象的一个属性——__proto__注意:这里每个都是两个下划线。它是的作用是通过原型链来使用Person.prototype中的函数方法。

那么它们有什么作用呢?

我们可以把原型对象Person.protoype比作一个开源项目,而它的作者就是构造函数Person。相当于作者把自己的项目——构造函数,进行了开源,其中包括项目中的所有属性和方法。那么只要是这个作者的粉丝——实例对象person ,都可以使用这个项目。

注意:new关键字只能继承构造函数的属性,而构造函数中的函数方法,是通过__proto__来实现的。

原型链

了解了上述四个概念之后,那么接下来就是原型链的一个关系图。

去除图片水印.png

下面来解释一下

  1. 作者:构造函数Person
  2. constructor:它是Person.prototype的一个属性,指回该函数本身。
  3. 粉丝:person是由Person new出来的实例对象,它继承了Person所有的属性——开源项目的属性。
  4. 开源项目的方法:原型对象Person.prototype,存放着Person的函数方法。
  5. __proto__:当person想使用Person中的函数方法时,就会通过隐式原型__proto__顺着原型链指向Person.prototype来使用其方法。
  6. 而在JavaScript中“万物皆对象”。Person.prototype本身也是一个存在属性和方法的对象,它也会有__proto__的属性,并且会向上查找到内置对象,默认是由Object构造函数的prototype属性来创建的。但是原型链终有末端,内置对象最终指向null。此处的ObjectJavaScript自带的默认构造函数,它保证了所有函数的默认属性和方法。

就此,原型链搭建完成。

为什么叫"链“?
  1. 先看 person 自己有没有?没有。
  2. 顺着 person.__proto__ 找到 Person.prototype,有没有?没有。
  3. 顺着 Person.prototype.__proto__ 找到 Object.prototype,有没有?
  4. 如果还没找到,返回 undefined
    这种层层递进的查找关系,才是“链”的精髓。

到这,你已经理解了图中的上半部分——原型链的大半部分,已足够日常使用。下半部分是扩展内容,大家可以去自行搜索了解一下。

总结

  1. 每个函数都有一个 prototype(显式原型),指向它的原型对象。
  2. 每个对象都有一个 __proto__(隐式原型),指向创建它的构造函数的 prototype
  3. 原型链就是由 __proto__ 串联起来的查找路径。
  4. Object.prototype.__proto__ === null 是原型链的终点。

别再手写代码了!2026 前端 5 个 AI 杀招,直接解放 80% 重复劳动(附工具+步骤)

作者 前端Hardy
2026年4月1日 18:16

你还在手动搭项目、手写组件、熬夜调 Bug 吗?2026 年的前端开发,AI 已经接管 80% 重复工作——从项目初始化、UI 生成、Bug 修复到代码重构,全流程智能化。

今天这篇,不讲虚的,直接带工具、带步骤、带实战指令,照着做,今天就能少加班 50%。


一、AI 一键搭项目:1 分钟搞定 Vue/React 工程(VS Code + Copilot)

以前搭项目:装依赖、配路由、装状态库、调 ESLint……半天没了。 现在用 GitHub Copilot(VS Code 必装),一句话生成完整工程。

工具安装(5 分钟)

  1. 安装 VS Code(最新版)

image.png

  1. 扩展商店搜:GitHub Copilot + GitHub Copilot Chat(安装)

image.png

  1. 点击左下角 Copilot 图标 → 登录 GitHub → 授权成功(图标变绿)

image.png

实战步骤(1 分钟出项目)

  1. 新建空文件夹 → 用 VS Code 打开
  2. 快捷键 Ctrl+Shift+I(Win)/ Cmd+Shift+I(Mac)打开 Copilot Chat

image.png

  1. 直接发指令(复制可用):

    生成一个 Vue3 + Vite + Pinia + VueRouter + Tailwind CSS 项目,包含:

    • 完整目录结构
    • ESLint + Prettier 规范配置
    • 请求封装(axios)
    • 路由守卫
    • 自适应布局基础
    • 自动安装依赖
  2. 等待 30 秒 → AI 自动生成所有文件、安装依赖、写好 README

image.png

效果对比

  • 以前:1 天工作量
  • 现在:1 分钟,零配置、零报错

二、AI 组件工厂:一句话生成生产级 UI(Cursor 编辑器)

前端最耗时:写页面、调样式、做响应式、加交互。 Cursor(AI 原生编辑器) 是前端 UI 生成神器,比 VS Code 更智能,支持跨文件、自动处理样式依赖。

工具安装

  1. 官网下载:www.cursor.so/

image.png

  1. 安装 → 首次启动用 GitHub 登录 → 导入 VS Code 配置

安装

  1. 设置中文:Ctrl+Shift+P → 搜索 Configure Display Language → 选中文

设置中文

实战步骤(生成电商商品卡片)

  1. 新建 ProductCard.vue
  2. 快捷键 Ctrl+K(Win)/ Cmd+K(Mac)打开 AI 指令

打开 AI

  1. 输入(复制可用):

    用 Vue3 + TS + Tailwind CSS 生成电商商品卡片组件,要求:

    • 包含:商品图、标题、原价、现价、折扣标签、加入购物车按钮
    • hover 上浮动效、过渡动画
    • 移动端响应式(375px 适配)
    • 带 TS 类型定义
    • 支持自定义主题色
    • 加注释、符合 ESLint 规范
  2. 回车 → 直接生成完整代码(复制即用)

完整代码

进阶:Figma 转代码

  1. 打开 Figma 设计稿 → 复制链接
  2. Cursor 指令:

    把这个 Figma 设计稿转成 Vue3 代码:[粘贴链接],带响应式、TS 类型、可直接运行


三、AI 自动改 Bug:秒定位+修复,告别熬夜(Copilot Chat)

前端最痛:白屏、样式错乱、报错、兼容问题。 Copilot Chat 能直接读代码+报错,自动定位根因+给修复方案

实战步骤(修复白屏 Bug)

  1. 遇到报错:Uncaught TypeError: Cannot read properties of undefined (reading 'xxx')
  2. 选中报错代码 → 右键 → Copilot → Explain This Error

修改Bug

  1. 或直接在聊天框发:

    分析这段代码和报错,找出根因,给修复代码+解释: 【粘贴报错】 【粘贴代码】

  2. AI 秒回:

  • 错误原因(如:变量未初始化、异步时序问题)
  • 完整修复代码
  • 优化建议(如:加可选链、错误捕获)

image

常见前端 Bug 指令(直接复制)

  • 样式兼容:修复 iOS 微信浏览器样式错乱问题
  • 性能卡顿:分析页面滚动卡顿,优化 FPS,给代码方案
  • 接口报错:修复 axios 跨域+超时+错误重试

四、AI 代码重构:老项目一键升级(文心快码)

维护 jQuery/老 Vue2 项目?手动重构太痛苦。 文心快码(国产 AI,前端重构最强) 能批量升级、补 TS、优化性能。

工具安装(VS Code 插件)

  1. 扩展商店搜:文心快码(Baidu Comate) → 安装

Baidu Comate

  1. 用百度账号登录 → 免费额度够用

实战步骤(jQuery 转 Vue3)

  1. 打开老代码文件

  2. 打开文心快码聊天 → 发指令:

    把这段 jQuery 代码重构成 Vue3 组合式 API + TS,要求:

    • 保留原功能
    • 加类型定义
    • 用 Pinia 管理状态
    • 优化性能、移除冗余
    • 符合团队规范
  3. AI 自动生成新代码 → 对比确认 → 直接替换

进阶:批量重构

分析整个项目,把所有 Vue2 组件升级到 Vue3,统一 TS 规范


五、AI 全链路工程化:从接口到部署一条龙(v0 + Copilot)

不止写代码,接口、类型、测试、部署 AI 全包。 v0(Vercel 出品)+ Copilot 前端全链路最强组合。

1. 接口 + TS 类型自动生成

Copilot 指令:

根据这份接口文档,生成:

  • axios 请求封装
  • TS 接口类型定义
  • Mock 数据
  • API 调用示例

2. UI 生成(v0 最强)

  1. 打开:v0.dev/

image

  1. 输入:生成一个后台管理系统列表页,带筛选、分页、操作按钮,用 React + Tailwind
  2. 10 秒出页面 → 复制代码到项目

3. 自动写测试 + 部署

Copilot 指令:

为这个组件写 Vitest 单元测试,覆盖:渲染、交互、边界情况 再生成 Dockerfile + CI/CD 部署脚本


2026 前端 AI 工具选型表(直接抄)

场景 最佳工具 价格 上手难度
日常编码、补全 GitHub Copilot $19/月
UI 组件、页面生成 Cursor、v0 $20/月(Cursor) ⭐⭐
老项目重构、升级 文心快码 免费额度+付费
Bug 修复、调试 Copilot Chat 含在 Copilot 内
全栈项目、原型 Bolt.new 免费试用 ⭐⭐

最后:AI 不淘汰前端,淘汰不用 AI 的人

2026 年的前端竞争:

  • ❶ 不会 AI:天天手写、加班、被淘汰
  • ❷ 会用 AI:少写 80% 重复代码、早下班、涨薪更快

今天就行动

  1. 装 VS Code + Copilot + Cursor
  2. 把本文指令复制试用
  3. 把重复工作丢给 AI,专注架构、业务、价值

别再手写代码了,AI 时代,拼的是会不会用工具,不是手速!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端工程师必备的 10 个 AI 万能提示词(Prompt),复制直接用,效率再翻倍!

作者 前端Hardy
2026年4月1日 17:51

你是不是也有这种困扰? 用 Copilot、Cursor 写代码,明明想让 AI 帮你省时间,结果指令发出去,AI 瞎编代码、答非所问,反而更费劲儿?

不是 AI 不好用,是你没找对“说话方式”——前端 AI 高效开发的核心,从来不是“让 AI 写代码”,而是“让 AI 精准懂你的需求”。

很多前端每天用 AI,却不知道:一句好的 Prompt(提示词),能让效率直接翻 3 倍,少写 80% 重复代码、少踩 90% 的坑。

今天这篇,不搞虚的,直接给大家整理了 10 个前端专属 AI 万能提示词,覆盖前端开发全场景——组件开发、Bug 修复、代码重构、样式优化、工程化配置,全部复制就能用,不用自己琢磨,新手也能轻松上手。

不管你用的是 Copilot、Cursor、文心快码,还是 Claude Code,这些 Prompt 都通用,今天用,今天就能省时间、少加班!

先划重点:前端 AI Prompt 万能公式(记牢更省心)

所有好用的前端 Prompt,都离不开这 4 个核心要素,记下来,以后自己也能自定义:

明确场景 + 技术栈 + 具体需求 + 输出要求

举个反例:“帮我写个按钮组件”(模糊,AI 易瞎编) 举个正例:“用 Vue3 + TS + Tailwind CSS 写一个按钮组件,包含默认/禁用/高亮三种状态,hover 有过渡动画,带类型定义和注释,符合 ESLint 规范”(精准,AI 直接出可用代码)

下面这 10 个 Prompt,全部按照这个公式编写,复制粘贴,替换括号里的内容,就能直接用!

一、组件开发类(最常用,每天都能用到)

前端每天都要写组件,这 2 个 Prompt,覆盖 80% 的组件开发场景,不用再手动写样式、写逻辑。

Prompt 1:基础组件生成(复制即用)

用【Vue3/React】+【TS】+【Tailwind CSS/Element Plus/Ant Design】生成【组件名称,如:登录表单/商品卡片/分页组件】,要求:
1. 包含【具体功能,如:表单校验/分页切换/hover 动效】;
2. 支持【自定义属性,如:自定义颜色/尺寸/回调函数】;
3. 带完整 TS 类型定义、详细注释,符合 ESLint 规范;
4. 适配移动端响应式,兼容主流浏览器;
5. 输出完整可运行代码,复制就能直接导入项目。

示例替换:用 Vue3 + TS + Element Plus 生成登录表单,要求:1. 包含账号密码校验、记住密码、忘记密码功能;2. 支持自定义提交按钮文本;3. 带完整 TS 类型定义、详细注释,符合 ESLint 规范;4. 适配移动端响应式,兼容主流浏览器;5. 输出完整可运行代码,复制就能直接导入项目。

Prompt 2:复杂组件封装(复制即用)

帮我封装一个【复杂组件名称,如:树形表格/弹窗表单/下拉搜索选择器】,技术栈【Vue3/React + TS】,要求:
1. 核心功能:【详细描述功能,如:树形表格支持勾选、展开/折叠、搜索筛选;弹窗表单支持表单联动、提交校验】;
2. 性能优化:【如:懒加载、防抖节流、避免重复渲染】;
3. 可扩展性:支持插槽、自定义事件、Props 传参,方便后续二次开发;
4. 附带使用示例、TS 类型说明、常见问题备注;
5. 代码结构清晰,分模块编写,便于维护。

二、Bug 修复类(前端救星,告别熬夜改 Bug)

遇到 Bug 不用慌,不用再翻 Stack Overflow、不用瞎试代码,这 2 个 Prompt,让 AI 秒定位、秒修复,还能告诉你问题根源。

Prompt 3:报错快速修复(复制即用)

帮我分析以下前端报错和对应代码,要求:
1. 报错信息:【粘贴完整报错信息,如:Uncaught TypeError: Cannot read properties of undefined (reading 'value')】;
2. 对应代码:【粘贴报错相关的完整代码片段】;
3. 请找出报错根因,给出详细解释,然后提供完整的修复代码;
4. 补充优化建议,避免以后再出现类似问题;
5. 修复后的代码要符合项目技术栈【Vue3/React + TS】规范,可直接替换使用。

Prompt 4:兼容性/Bug 排查(复制即用)

我遇到一个前端问题:【详细描述问题,如:iOS 微信浏览器样式错乱、页面滚动卡顿、接口请求跨域失败、组件渲染异常】;
项目技术栈:【Vue3/React + TS + 具体框架/工具】;
请帮我:
1. 分析可能的问题原因,列出所有可能性;
2. 给出每一种原因的解决方案和完整代码;
3. 提供预防措施,避免后续出现类似兼容性/性能问题;
4. 方案要简单易操作,不用复杂配置,直接能落地。

三、代码重构类(老项目救星,提升代码质量)

维护老项目、接手烂代码,手动重构太费时间?这 2 个 Prompt,让 AI 帮你优化代码、升级版本,不用自己逐行修改。

Prompt 5:代码优化/重构(复制即用)

帮我重构以下前端代码,项目技术栈【Vue3/React + TS】,要求:
1. 原始代码:【粘贴需要重构的代码片段】;
2. 重构目标:优化代码结构、移除冗余代码、修复潜在 Bug、提升代码可读性和可维护性;
3. 保留原有的所有功能,不改变业务逻辑;
4. 加入 TS 类型定义(如果没有),补充必要注释,符合 ESLint 规范;
5. 给出重构前后的对比说明,解释优化的原因和好处。

Prompt 6:版本升级迁移(复制即用)

帮我将【旧版本技术,如:Vue2 组件/Vue3 旧语法/jQuery 代码】迁移到【新版本技术,如:Vue3 组合式 API/TS/React 函数组件】,要求:
1. 原始代码:【粘贴需要迁移的代码片段/文件】;
2. 迁移要求:完全保留原业务功能,兼容原有项目配置,不引入新的依赖;
3. 遵循新版本的最佳实践,如:Vue3 组合式 API 规范、React Hooks 规范;
4. 补充迁移说明,列出需要注意的细节和可能出现的问题及解决方案;
5. 输出完整的迁移后代码,可直接替换使用。

四、样式/交互类(告别调样式的痛苦)

调样式、做交互,最费时间还容易出错?这 2 个 Prompt,让 AI 帮你写样式、做动效,不用再反复调试。

Prompt 7:样式快速生成/优化(复制即用)

帮我写/优化【元素/组件】的样式,技术栈【Tailwind CSS/CSS3/SCSS】,要求:
1. 样式需求:【详细描述,如:居中显示、圆角、阴影、hover 动效、响应式适配(375px/768px/1200px)、深色模式兼容】;
2. 样式规范:符合项目设计规范,避免样式冲突,代码简洁可复用;
3. 优化要求:减少冗余样式,提升样式加载速度,兼容主流浏览器;
4. 输出完整的样式代码,可直接复制到项目中使用,并给出使用说明。

Prompt 8:交互效果实现(复制即用)

帮我实现【交互效果,如:下拉菜单动画、弹窗淡入淡出、滚动加载、拖拽排序、表单联动】,技术栈【Vue3/React + JS/TS】,要求:
1. 交互细节:【详细描述,如:弹窗点击遮罩关闭、下拉菜单hover展开、拖拽时显示提示、滚动加载到底部自动请求数据】;
2. 性能要求:避免卡顿、防抖节流处理,不影响页面其他功能;
3. 兼容性:适配移动端和PC端,兼容主流浏览器;
4. 输出完整的代码(HTML/CSS/JS/TS),复制就能用,附带使用说明和注意事项。

五、工程化/工具类(提升全链路效率)

除了写代码,工程化配置、接口请求、测试用例也能让 AI 帮你做,这 2 个 Prompt,覆盖前端全链路开发。

Prompt 9:接口请求/类型生成(复制即用)

根据以下接口文档,生成【Vue3/React】项目的接口请求代码,要求:
1. 接口信息:【粘贴接口文档,包含请求地址、请求方式、参数、返回值】;
2. 技术栈:【Axios + TS】;
3. 输出内容:
   - 完整的接口请求函数封装(包含请求拦截、响应拦截、错误处理);
   - 所有接口参数和返回值的 TS 类型定义;
   - Mock 数据生成(用于本地调试);
   - 接口调用示例;
4. 代码符合项目规范,可直接导入项目使用。

Prompt 10:测试用例/工程化配置(复制即用)

帮我生成【组件/函数】的测试用例,或【工程化配置文件】,要求:
1. 目标:【如:为登录组件写单元测试、生成 ESLint 配置、生成 Vitest 配置、生成 Dockerfile】;
2. 技术栈:【Vitest/Jest/ESLint/Docker】;
3. 具体要求:【如:测试用例覆盖渲染、交互、边界情况;配置文件适配 Vue3/React + TS 项目,包含常用配置】;
4. 输出完整的代码/配置文件,可直接复制到项目中使用,并给出配置说明和使用方法。

关键提醒:这 3 个小技巧,让 Prompt 效果再翻倍

  1. 越具体,AI 越精准:不要说“帮我写个表单”,要明确技术栈、功能、样式,甚至是兼容要求,避免 AI 瞎编;
  2. 分场景使用:不同的 AI 工具(Copilot/Cursor)适配性略有差异,但以上 Prompt 全部通用,复制后可根据工具微调;
  3. 善用追问:如果 AI 输出不符合预期,直接追问“修改一下,让组件支持自定义颜色”“修复这个代码里的语法错误”,不用重新发指令。

写在最后:AI 提效,Prompt 是关键

2026 年的前端开发,拼的不是手速,是“用 AI 的能力”——同样是用 AI,会写 Prompt 的人,每天能多省 1-2 小时,少加很多班;不会写的人,反而被 AI 拖累。

以上 10 个 Prompt,覆盖了前端开发的全场景,不管你是新手还是资深前端,复制就能用,不用自己琢磨、不用记复杂语法。

建议你 收藏本文,转发给身边还在瞎用 AI、天天加班的前端同事,一起省时间、提效率、早下班。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Webpack & Vite 深度解析

作者 伊步沁心
2026年4月1日 17:07

学习目标: 彻底掌握现代前端打包工具的核心原理,从 Webpack 的底层机制到 Vite 的革命性设计,再到 Rollup/esbuild 的各自定位,建立完整的工程化认知体系。


一、Webpack 核心原理

1.1 整体工作流程(5个阶段详解)

Webpack 的构建过程可以分为 5 个主要阶段,每个阶段都有对应的钩子(Hook)供插件介入。

阶段一:初始化(Initialization)

webpack.config.js
      ↓
  读取配置(merge 默认配置 + 用户配置 + CLI 参数)
      ↓
  创建 Compiler 对象(核心编译器实例,全局唯一)
      ↓
  注册所有内置 Plugin(如 HtmlWebpackPlugin、DefinePlugin)
      ↓
  调用 compiler.hooks.initialize

核心操作:

// Webpack 内部伪代码
function webpack(config) {
  // 1. 合并配置(Shell 参数 > 用户配置 > 默认配置)
  const mergedConfig = mergeConfig(defaultConfig, config, shellArgs);

  // 2. 创建 Compiler 对象(继承自 Tapable,拥有完整的 hooks 系统)
  const compiler = new Compiler(mergedConfig.context, mergedConfig);

  // 3. 注册所有插件(调用每个 plugin 的 apply 方法)
  mergedConfig.plugins.forEach(plugin => plugin.apply(compiler));

  // 4. 初始化完毕,返回 compiler
  return compiler;
}

Compiler 对象职责:

  • 保存完整的 webpack 配置
  • 管理文件系统(inputFileSystem / outputFileSystem)
  • 触发各阶段 hooks(beforeRun → run → beforeCompile → compile → make → finishMake → afterCompile → emit → done)
  • 负责文件监听(watch 模式下的 watchRun)

阶段二:编译(Compilation)

这是 Webpack 最核心的阶段,从入口文件出发,递归构建完整的依赖图。

compiler.hooks.make.callAsync()
      ↓
  创建 Compilation 对象(当次编译的快照,包含 modules/chunks/assets)
      ↓
  从 entry 配置中确定入口模块
      ↓
  递归构建依赖图(Dependency Graph)
      ↓
  对每个模块:解析 → 加载(Loader)→ 构建 → 分析依赖
// 从 entry 开始递归构建的伪代码
class Compilation {
  buildModule(module, callback) {
    // 1. 读取文件内容
    const source = this.readFile(module.resource);

    // 2. 依次执行 Loader 链,转换源码
    const transformedSource = runLoaders(this.loaders, source);

    // 3. 用 acorn 解析 AST,找出所有 import/require
    const ast = acorn.parse(transformedSource);
    const dependencies = extractDependencies(ast);

    // 4. 递归处理每个依赖
    dependencies.forEach(dep => {
      this.buildModule(dep);
    });
  }
}

阶段三:模块解析(Module Resolution + Loader 链)

每个模块的构建过程:

import './foo.css'Resolver(解析模块路径:相对/绝对/node_modules 三种策略)
      ↓
  匹配 module.rules(确定使用哪些 Loader)
      ↓
  Loader 链(从右到左执行 pitch,从左到右执行 normal)
      ↓
  返回 JavaScript 字符串(Webpack 只认识 JS/JSON)
      ↓
  Parser(acorn 解析 AST,找依赖)

阶段四:生成(Seal / Emit)

所有模块构建完毕
      ↓
  seal:冻结 Compilation,不再接受新模块
      ↓
  分组:按 entry + dynamic import 分割成 Chunk
      ↓
  优化:Tree Shaking / splitChunks / minification
      ↓
  template:将 Chunk 渲染成最终 Bundle 字符串
      ↓
  生成 assets(key = 文件名, value = 文件内容)

阶段五:写入磁盘(Emit)

compiler.hooks.emit.callAsync(compilation)
      ↓
  遍历 compilation.assets,通过 outputFileSystem 写文件
      ↓
  compiler.hooks.afterEmit
      ↓
  compiler.hooks.done(构建完成)

完整 hooks 时序图:

beforeRun → run → normalModuleFactory(工厂创建)
  → beforeCompile → compile → make(递归构建)
    → finishMake → afterCompile → shouldEmit
      → emit → afterEmit → done

1.2 核心概念深度

Module / Chunk / Bundle 三者关系

这是 Webpack 最容易混淆的三个概念:

概念 含义 对应文件 生成时机
Module 每一个被解析的文件 任意格式(JS/CSS/图片…) 编译阶段(make)
Chunk 一组 Module 的集合 逻辑上的代码块 Seal 阶段分组
Bundle 最终输出的文件 dist/*.js Emit 阶段写磁盘

关系示意:

源码文件(Module)
  app.js (Module)
  ├── utils.js (Module)
  ├── lodash (Module × N 个子模块)
  └── route-a.js (dynamic import → 独立 Chunk)

       ↓ Seal 阶段

Chunk 分组:
  main-chunk = [app.js + utils.js + lodash]
  route-a-chunk = [route-a.js]

       ↓ Emit 阶段

Bundle 输出:
  main.bundle.js
  route-a.bundle.js

关键规则:

  • 一个 Entry 至少产生一个 initial Chunk
  • dynamic import() 产生 async Chunk
  • 一个 Chunk 可以包含多个 Module
  • 一个 Module 可以属于多个 Chunk(splitChunks 提取公共模块时)

Dependency Graph(依赖图)构建过程

Webpack 通过 AST 静态分析构建有向无环图(DAG):

1. 入口文件 → 添加到待处理队列
2. 取出队首模块 → 读取文件 → Loader 转换
3. 用 acorn 解析 AST → 遍历所有 ImportDeclaration / require()
4. 对每个依赖:
   a. 解析路径(Resolver)
   b. 如果未处理过 → 加入队列
   c. 如果已处理 → 直接引用(避免循环依赖死循环)
5. 为当前模块记录依赖关系(parentModule → childModule)
6. 重复 2-5 直到队列为空

循环依赖处理:

// a.js
import { foo } from './b.js';
export const bar = 'bar';

// b.js
import { bar } from './a.js'; // 循环!
export const foo = 'foo';

Webpack 会正常构建,但运行时 barb.js 首次执行时为 undefined(因为 a.js 还没执行完)。这是 ES Module 的"活绑定"(live binding)特性决定的。

Tree Shaking 原理(为何 CJS 不支持)

ESM 静态分析 vs CJS 动态特性:

// ✅ ESM - 静态结构,可静态分析
import { add } from './math';  // 编译时确定,import 的是哪个具体绑定

// ❌ CJS - 动态结构,无法静态分析
const { add } = require('./math');  // 运行时才知道取哪个属性
const method = 'add';
require('./math')[method]();  // 完全动态,无法预知

Tree Shaking 实现原理(三步走):

Step 1: 标记(Mark)
  Webpack  entry 出发,遍历所有 ESM 模块
  对每个 export,标记它是否被实际使用(used / unused)

Step 2: 分析副作用(Side Effects)
  package.json sideEffects: false  告知所有模块无副作用,可安全删除
  sideEffects: ["*.css"]  CSS 文件有副作用(改全局样式),保留

Step 3: 清除(Shake)
  production 模式下,Terser/esbuild 进行 DCE
  删除所有 unused export 的代码

为什么 CJS 不支持:

  1. require() 是函数调用,可以动态传参
  2. module.exports 可以在任何地方被赋值
  3. 无法在编译时确定哪些导出会被使用
  4. 必须在运行时才能确定完整的导出对象

ESM 为什么支持:

  1. import 是语法关键字,不是函数调用
  2. 导入绑定是静态的(编译时确定)
  3. export 声明必须在模块顶层
  4. 工具链可以在不运行代码的情况下分析出哪些导出被使用

Code Splitting(splitChunks 配置详解 + dynamic import)

两种分割方式:

方式1:Dynamic Import(动态导入)

// 点击按钮时才加载路由组件
button.addEventListener('click', async () => {
  const { default: RouteA } = await import('./route-a');
  // import() 返回 Promise<Module>
  // Webpack 会自动将 route-a 拆分成独立 Chunk
});

// React 中的应用
const LazyComp = React.lazy(() => import('./HeavyComponent'));

Webpack 处理 dynamic import 的原理:

// 原始代码
const mod = await import('./foo');

// Webpack 编译后(简化版)
// 1. 将 foo.js 编译成独立 chunk(foo.bundle.js)
// 2. 运行时通过 JSONP/import() 加载
const mod = await __webpack_require__.e(/* chunkId */ "foo")
  .then(__webpack_require__.bind(__webpack_require__, "./foo.js"));

方式2:SplitChunksPlugin(自动分割)

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      // all:对所有 chunks 生效(推荐)
      // initial:只对同步 chunks
      // async:只对异步 chunks(默认)
      chunks: 'all',

      // 最小文件大小(字节),小于此值不分割
      minSize: 20000,

      // 最大文件大小,超过会继续分割
      maxSize: 0,

      // 最少被几个 chunk 引用才分割(默认1)
      minChunks: 1,

      // 最大并发请求数(HTTP/1.1 时代有意义)
      maxAsyncRequests: 30,
      maxInitialRequests: 30,

      // 分割出来的 chunk 名称分隔符
      automaticNameDelimiter: '~',

      // 缓存组(精细控制)
      cacheGroups: {
        // 将 node_modules 的代码单独打包
        vendors: {
          test: /[\/]node_modules[\/]/,
          priority: -10,  // 优先级(数字越大越优先)
          reuseExistingChunk: true,  // 如果已有相同内容的 chunk,复用
          name: 'vendors',
          filename: 'js/[name].[contenthash:8].js',
        },
        // 公共模块(被多处引用的)
        common: {
          minChunks: 2,   // 至少被 2 个 chunk 引用
          priority: -20,
          reuseExistingChunk: true,
          name: 'common',
        },
        // React 单独打包(长期缓存)
        react: {
          test: /[\/]node_modules[\/](react|react-dom|scheduler)[\/]/,
          name: 'react-vendor',
          priority: 10,
          chunks: 'initial',
        },
      },
    },
  },
};

HMR 热更新原理(WebSocket + module.hot.accept 全流程)

HMR 是 Webpack 开发体验的核心,整个流程如下:

文件修改(保存)
      ↓
  1. Webpack 监听到文件变化(watch 模式)
      ↓
  2. 重新编译变化的模块(增量编译,非全量)
      ↓
  3. 生成两个文件:
     - [hash].hot-update.json(描述哪些模块更新了)
     - [chunkId].[hash].hot-update.js(更新的模块代码)
      ↓
  4. webpack-dev-server 通过 WebSocket 通知浏览器
     消息格式:{ type: 'hash', data: 'abc123' }
              { type: 'ok' }
      ↓
  5. 浏览器端 HMR Runtime 收到通知
      ↓
  6. 通过 JSONP 请求拉取 hot-update.js7. 执行 module.hot.accept 回调(模块自我更新)
      ↓
  8. 如果模块没有注册 accept → 向上冒泡
     如果冒泡到顶层还没有 accept → 全页面刷新(fallback)

module.hot.accept 使用:

// React Fast Refresh 的简化原理
if (module.hot) {
  // 接受自身更新
  module.hot.accept();

  // 接受某个依赖更新,并提供回调
  module.hot.accept('./store', () => {
    // 当 store 模块更新时,重新渲染根组件
    const newStore = require('./store').default;
    ReactDOM.render(<App store={newStore} />, document.getElementById('root'));
  });

  // 模块销毁时清理(避免内存泄漏)
  module.hot.dispose((data) => {
    clearInterval(timer);
    data.lastValue = someState; // 传递给下一个版本的模块
  });
}

WebSocket 通信协议:

// webpack-dev-server 发送的消息类型
{ type: 'hash', data: 'newHash' }    // 新的编译 hash
{ type: 'ok' }                        // 编译成功
{ type: 'errors', data: [...] }       // 编译错误
{ type: 'warnings', data: [...] }     // 编译警告
{ type: 'close' }                     // 服务器关闭

Source Map 生成原理(VLQ 编码 + 7种 devtool 选项对比)

Source Map 本质: 一个 JSON 文件,记录了"编译后代码的某个位置"→"源码的某个位置"的映射关系。

VLQ 编码(Variable-Length Quantity):

原理:用 Base64 字符表示可变长度整数,压缩映射数据
每个段由 4-5 个 VLQ 数字组成:
  [生成文件列偏移, 源文件索引, 源文件行偏移, 源文件列偏移, 名称索引]

示例:mappings 字段 "AAAA;AACA"
  AAAA → 第一行第一个映射(0,0,0,0 四个都是0)
  ; → 换行
  AACA → 第二行第一个映射

7种 devtool 选项对比:

devtool 值 构建速度 重构建速度 质量 适用场景
false / 无 最快 ⚡⚡⚡ 最快 ⚡⚡⚡ 无映射 生产环境(配合独立 map 文件上传监控)
eval 快 ⚡⚡ 最快 ⚡⚡⚡ 低(转换后代码) 开发初期,追求速度
eval-source-map 快 ⚡⚡ 高(原始代码) 开发推荐 ✅
eval-cheap-source-map 较快 ⚡⚡ 快 ⚡⚡ 中(无列信息) 开发,稍差质量换速度
eval-cheap-module-source-map 中 ⚡ 高(Loader 转换前) 开发推荐(含 Babel 前源码)✅
source-map 最慢 最高 生产环境(需要调试)
hidden-source-map 最慢 最高(不暴露) 生产环境(Sentry 错误监控)✅
nosources-source-map 最慢 只有位置 生产环境(保护源码安全)

最佳实践:

// 开发环境
devtool: 'eval-cheap-module-source-map',

// 生产环境(上传到 Sentry,不暴露给用户)
devtool: 'hidden-source-map',
// 同时配置 webpack.SourceMapDevToolPlugin 上传到错误监控平台

1.3 Loader 机制

Loader 本质

Loader 是一个纯函数:

// 最简单的 Loader
module.exports = function(source, sourceMap, meta) {
  // source: 前一个 Loader 传来的字符串或 Buffer
  // sourceMap: 上一个 Loader 传来的 source map
  // meta: 元数据

  // 处理 source...
  const result = transform(source);

  // 同步返回
  return result;

  // 或者异步返回
  // const callback = this.async();
  // callback(null, result, sourceMap, meta);
};

// Loader 上下文(this)提供了大量工具方法
// this.getOptions()        获取 Loader 配置
// this.async()             异步模式
// this.emitFile()          输出文件
// this.addDependency()     添加文件依赖(watch 时监听)
// this.cacheable(false)    关闭缓存
// this.resourcePath        当前处理文件的绝对路径
// this.rootContext          项目根目录

pitch 阶段 vs normal 阶段

假设配置了 use: ['a-loader', 'b-loader', 'c-loader']

执行顺序:

pitch 阶段(从左到右):
  a-loader.pitchb-loader.pitch → c-loader.pitch

normal 阶段(从右到左):
  c-loader → b-loader → a-loader

完整流程:
  a.pitchb.pitch → c.pitch → 读文件 → c → ba

pitch 中断机制:

// b-loader.js(pitch 阶段)
module.exports.pitch = function(remainingRequest, precedingRequest, data) {
  // 如果 pitch 返回了值,就中断后续 loader 的 pitch 和 normal 阶段
  // 直接将返回值交给前一个 loader 的 normal 阶段处理
  if (someCondition) {
    return `module.exports = 'cached result'`;
    // 返回后:只有 a.normal 还会执行,b/c 的 normal 和 c 的 pitch 都跳过
  }
};

pitch 的实际用途(style-loader 经典案例):

// style-loader 通过 pitch 中断,将 css-loader 的结果注入 <style>
module.exports.pitch = function(remainingRequest) {
  // 返回一段 JS 代码,运行时动态加载 CSS
  return `
    var content = require(${loaderUtils.stringifyRequest(this, '!!' + remainingRequest)});
    require(${loaderUtils.stringifyRequest(this, require.resolve('./addStyles'))});
    // ...
  `;
  // pitch 返回后,css-loader 的 normal 不再执行
  // 而是在运行时通过 require 动态调用
};

常用 Loader 原理解析

babel-loader:

// 本质:调用 @babel/core.transform()
module.exports = function(source) {
  const options = this.getOptions(); // 读取 .babelrc 或 babel.config.js
  const { code, map } = babel.transformSync(source, {
    ...options,
    filename: this.resourcePath,
    inputSourceMap: this.sourceMap,
  });
  this.callback(null, code, map);
};

css-loader:

  • 解析 CSS 中的 @importurl() 依赖
  • 将 CSS 转换为 JS 模块(导出 CSS 字符串 + 依赖列表)
  • 支持 CSS Modules(将类名替换为哈希值)
// css-loader 处理后的输出(简化)
// 原始:.foo { color: red; }
// 输出 JS 模块:
module.exports = [
  [module.id, '.foo { color: red; }', '']
];
module.exports.locals = {}; // CSS Modules 类名映射

style-loader:

  • 将 css-loader 的输出注入到 <style> 标签
  • 支持 HMR(通过 module.hot.accept 动态更新样式)
  • 只适合开发环境,生产环境用 MiniCssExtractPlugin
// style-loader 运行时注入
function insertStyleElement(options) {
  const style = document.createElement('style');
  const target = document.querySelector(options.target) || document.head;
  target.appendChild(style);
  return style;
}

file-loader vs url-loader:

// file-loader:将文件输出到 output 目录,返回文件路径
module.exports = function(source) {
  const url = loaderUtils.interpolateName(this, '[contenthash].[ext]', { content: source });
  this.emitFile(url, source);
  return `module.exports = ${JSON.stringify(url)}`;
};

// url-loader:小于 limit 时转成 base64 Data URL,大于 limit 时降级到 file-loader
module.exports = function(source) {
  const limit = this.getOptions().limit || 8192; // 默认 8KB
  if (source.length < limit) {
    const base64 = source.toString('base64');
    const mimeType = mime.getType(this.resourcePath);
    return `module.exports = "data:${mimeType};base64,${base64}"`;
  }
  // fallback 到 file-loader
  return fileLoader.call(this, source);
};

手写一个注释剥离 Loader

// strip-comment-loader.js
/**
 * 功能:移除 JS 文件中的所有注释(单行注释、多行注释)
 * 注意:正则方案简单但不完美(字符串中的 // 也会被误删)
 *       生产级别应该用 AST(如 babel 的 removeComments 选项)
 */
const { validate } = require('schema-utils');

// 定义选项 schema
const schema = {
  type: 'object',
  properties: {
    preserveLicense: {
      type: 'boolean',
      description: '是否保留 License 注释(/*!...*/)',
    },
    stripStrings: {
      type: 'boolean',
      description: '是否连字符串中的注释也一起删除(默认 false)',
    },
  },
  additionalProperties: false,
};

module.exports = function stripCommentLoader(source) {
  // 获取并校验配置
  const options = this.getOptions() || {};
  validate(schema, options, { name: 'strip-comment-loader' });

  const { preserveLicense = true, stripStrings = false } = options;

  let result = source;

  if (stripStrings) {
    // 简单正则方案(会误删字符串中的注释,谨慎使用)
    result = result
      .replace(//*[\s\S]*?*//g, (match) => {
        // 如果保留 License 注释(以 /*! 开头)
        if (preserveLicense && match.startsWith('/*!')) return match;
        return '';
      })
      .replace(///.*/g, '');
  } else {
    // 更安全的方案:用状态机跳过字符串和模板字符串
    result = stripCommentsSafe(source, { preserveLicense });
  }

  // 清理多余空白行
  result = result.replace(/\n{3,}/g, '\n\n');

  return result;
};

/**
 * 状态机版本:安全地移除注释(不影响字符串内容)
 */
function stripCommentsSafe(source, { preserveLicense }) {
  let output = '';
  let i = 0;
  const len = source.length;

  while (i < len) {
    const ch = source[i];
    const next = source[i + 1];

    // 跳过单引号字符串
    if (ch === "'" || ch === '"') {
      const quote = ch;
      output += ch;
      i++;
      while (i < len && source[i] !== quote) {
        if (source[i] === '\') { output += source[i++]; } // 转义
        output += source[i++];
      }
      output += source[i++] || '';
      continue;
    }

    // 跳过模板字符串
    if (ch === '`') {
      output += ch;
      i++;
      while (i < len && source[i] !== '`') {
        if (source[i] === '\') { output += source[i++]; }
        output += source[i++];
      }
      output += source[i++] || '';
      continue;
    }

    // 单行注释 //
    if (ch === '/' && next === '/') {
      while (i < len && source[i] !== '\n') i++;
      continue; // 吃掉整行注释
    }

    // 多行注释 /* */
    if (ch === '/' && next === '*') {
      const commentStart = i;
      i += 2;
      while (i < len - 1 && !(source[i] === '*' && source[i + 1] ==='/' )) { i++; }
      i += 2; // 跳过 */
      const comment = source.slice(commentStart, i);
      // License 注释(/*!)保留
      if (preserveLicense && comment.startsWith('/*!')) {
        output += comment;
      }
      continue;
    }

    output += ch;
    i++;
  }

  return output;
}

// 使用示例(webpack.config.js)
// {
//   test: /.js$/,
//   use: [
//     'babel-loader',
//     {
//       loader: path.resolve('./loaders/strip-comment-loader'),
//       options: { preserveLicense: true }
//     }
//   ]
// }

1.4 Plugin 机制

Tapable 事件系统

Webpack 的整个插件系统建立在 tapable 库之上,本质是一个发布-订阅模式。

核心 Hook 类型:

const {
  SyncHook,           // 同步,按注册顺序执行
  SyncBailHook,       // 同步,返回非 undefined 则停止
  SyncWaterfallHook,  // 同步,上一个返回值传给下一个
  SyncLoopHook,       // 同步,返回非 undefined 则重新执行

  AsyncSeriesHook,      // 异步串行,依次执行
  AsyncSeriesBailHook,  // 异步串行,某个返回值则停止
  AsyncSeriesWaterfallHook, // 异步串行瀑布
  AsyncParallelHook,    // 异步并行,同时执行所有
  AsyncParallelBailHook, // 异步并行,某个有值则停止
} = require('tapable');

// 使用示例
const hook = new AsyncSeriesHook(['compiler', 'options']);

// 注册(订阅)
hook.tapAsync('MyPlugin', (compiler, options, callback) => {
  doSomethingAsync(() => callback()); // 完成后调用 callback
});

hook.tapPromise('AnotherPlugin', async (compiler, options) => {
  await doAsync();
  // 返回 Promise 即可
});

// 触发(发布)
hook.callAsync(compiler, options, () => {
  console.log('所有监听者执行完毕');
});

tap / tapAsync / tapPromise 区别:

// 同步注册(只能用于 SyncHook)
hook.tap('Plugin', (arg1, arg2) => { /* 同步 */ });

// 异步注册(回调方式)
hook.tapAsync('Plugin', (arg1, arg2, callback) => {
  setTimeout(() => callback(), 100);
});

// 异步注册(Promise 方式)
hook.tapPromise('Plugin', (arg1, arg2) => {
  return new Promise(resolve => setTimeout(resolve, 100));
});

Compiler vs Compilation 对象

维度 Compiler Compilation
生命周期 整个 webpack 进程 每次编译(watch 模式每次文件变更)
实例数量 唯一(单例) 每次构建创建一个新的
职责 全局配置、文件系统、Plugin 注册 模块构建、依赖图、Chunk 分割、资源生成
访问方式 plugin.apply(compiler) compiler.hooks.make → compilation

关键 hooks 详解

class MyPlugin {
  apply(compiler) {
    // beforeRun: webpack 首次启动前(watch 模式不触发)
    compiler.hooks.beforeRun.tapAsync('MyPlugin', (compiler, callback) => {
      console.log('即将开始构建');
      callback();
    });

    // run: 开始读取 records(序列化构建状态)
    compiler.hooks.run.tapAsync('MyPlugin', (compiler, callback) => {
      callback();
    });

    // emit: 生成文件到 output 目录前(可以在这里修改 assets)
    compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
      // 修改输出文件
      Object.keys(compilation.assets).forEach(filename => {
        if (filename.endsWith('.js')) {
          const content = compilation.assets[filename].source();
          // 可以修改 content...
          compilation.assets[filename] = {
            source: () => content,
            size: () => content.length,
          };
        }
      });
      callback();
    });

    // done: 构建完成(包含成功和失败)
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      if (stats.hasErrors()) {
        console.error('构建失败');
      } else {
        console.log('构建成功!耗时:', stats.endTime - stats.startTime, 'ms');
      }
    });

    // watchRun: watch 模式,每次文件变更触发
    compiler.hooks.watchRun.tapAsync('MyPlugin', (compiler, callback) => {
      const changedFiles = compiler.modifiedFiles; // Set<string>
      console.log('变化的文件:', [...changedFiles]);
      callback();
    });
  }
}

手写一个生成 filelist.md 文件的 Plugin

// FileListPlugin.js
class FileListPlugin {
  constructor(options = {}) {
    this.options = {
      filename: 'filelist.md',  // 默认输出文件名
      ...options,
    };
  }

  apply(compiler) {
    compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
      const assets = compilation.assets;

      // 生成文件列表 Markdown
      let content = '# 构建产物清单\n\n';
      content += `> 构建时间:${new Date().toLocaleString()}\n\n`;
      content += '| 文件名 | 大小 |\n';
      content += '|--------|------|\n';

      // 按文件大小排序
      const sortedAssets = Object.entries(assets)
        .sort(([, a], [, b]) => b.size() - a.size());

      let totalSize = 0;
      sortedAssets.forEach(([filename, asset]) => {
        const size = asset.size();
        totalSize += size;
        content += `| ${filename} | ${formatSize(size)} |\n`;
      });

      content += `\n**总计:${sortedAssets.length} 个文件,${formatSize(totalSize)}**\n`;

      // 将文件添加到 assets(会被写入 output 目录)
      compilation.assets[this.options.filename] = {
        source: () => content,
        size: () => Buffer.byteLength(content),
      };

      callback();
    });
  }
}

function formatSize(bytes) {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}

module.exports = FileListPlugin;

// 使用
// new FileListPlugin({ filename: 'assets-report.md' })

常用插件原理解析

HtmlWebpackPlugin:

  • compiler.hooks.emit 阶段,读取 HTML 模板
  • 分析 compilation.assets 中的 JS/CSS 文件
  • 自动注入 <script><link> 标签(带 contenthash)
  • 支持 EJS 模板语法,可传入自定义变量

MiniCssExtractPlugin:

  • 在 Loader 阶段:将 CSS 内容从 JS 模块中"抽离",记录到 compilation 的 CSS 模块图
  • 在 Plugin 阶段(compiler.hooks.emit):将收集的 CSS 合并,生成独立 .css 文件
  • 与 style-loader 互斥(一个运行时注入,一个编译时提取)

DefinePlugin:

// 原理:在编译时做字符串替换(不是真正的全局变量注入)
new webpack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify('production'),
  '__DEV__': JSON.stringify(false),
});

// 编译后:if (false) { /* dead code,会被 Tree Shaking 删除 */ }
// if (__DEV__) { ... }  →  if (false) { ... }

BannerPlugin:

// 在每个 Chunk 文件头部添加注释
new webpack.BannerPlugin({
  banner: '/*! My App v1.0.0 | MIT License */\n',
  raw: true,    // true: 直接插入(不包裹 /* */entryOnly: false, // false: 所有 chunk 都添加
});

1.5 代码注释/无用代码去除(重点!)

Dead Code Elimination(DCE)原理

DCE 是编译器优化技术,分为两类:

1. 语义级 DCE(Tree Shaking):

  • 依赖 ESM 静态结构
  • 找出"永远不会被调用的 export"
  • 由打包工具(Webpack/Rollup)在 bundle 阶段完成

2. 代码级 DCE(Minifier DCE):

  • 处理 if(false)/三元运算/永远为真的条件
  • 删除不可达代码(unreachable code)
  • 由压缩工具(Terser/UglifyJS/esbuild)完成
// 典型 DCE 场景
if (process.env.NODE_ENV === 'production') {
  console.log('生产环境代码');
} else {
  console.log('开发环境代码');  // 生产构建中会被删除
}

// DefinePlugin 替换后:
if ('production' === 'production') {  // 常量折叠
  console.log('生产环境代码');
} else {
  console.log('开发环境代码');  // 不可达代码,DCE 删除
}

// Terser 最终输出:
console.log('生产环境代码');

Terser 压缩:如何识别并删除注释

Terser 是 Webpack 5 内置的 JS 压缩工具(替代 UglifyJS)。

注释处理相关配置:

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        // 是否将 License 注释提取到独立文件
        extractComments: false, // true: 提取到 xxx.LICENSE.txt
        // extractComments: /^**!|@preserve|@license|@cc_on/i,

        terserOptions: {
          format: {
            // 注释处理策略(核心配置)
            comments: false,
            // comments: 'all'              // 保留所有注释
            // comments: 'some'             // 保留特殊注释(默认)
            // comments: false              // 删除所有注释 ✅ 推荐生产
            // comments: /特定正则/         // 匹配正则的注释保留
            // comments: (node, comment) => {
            //   return comment.value.includes('@preserve');
            // }
          },
          compress: {
            // 删除不可达代码(if(false){})
            dead_code: true,
            // 删除 console.xxx 调用
            drop_console: true,  // ['log', 'warn'] 可以精细控制
            // 删除 debugger 语句
            drop_debugger: true,
            // 常量折叠
            evaluate: true,
            // 删除无用变量赋值
            unused: true,
          },
          mangle: {
            // 变量名混淆
            toplevel: true,       // 顶层变量名也混淆
            keep_classnames: false,
            keep_fnames: false,
          },
        },
      }),
    ],
  },
};

保留 License 注释 vs 删除所有注释

场景1:开源库 + 法律合规(必须保留 License)

new TerserPlugin({
  extractComments: {
    // 匹配需要提取的注释(License 类型)
    condition: /^**!|@preserve|@license|@cc_on/i,
    filename: (fileData) => {
      return `${fileData.filename}.LICENSE.txt`;
    },
    banner: (licenseFile) => {
      return `License information can be found in ${licenseFile}`;
    },
  },
  terserOptions: {
    format: {
      comments: false, // 内联注释全删,License 已提取到单独文件
    },
  },
});

场景2:内部项目(全部删除)

new TerserPlugin({
  extractComments: false,  // 不生成 LICENSE 文件
  terserOptions: {
    format: { comments: false },  // 删除所有内联注释
  },
});

UglifyJS vs Terser vs esbuild 压缩能力对比

维度 UglifyJS Terser esbuild
语言支持 ES5(不支持 ES6+) ES2020+ ✅ ES2022+ ✅
压缩率 中(稍逊于 Terser)
速度 慢(JS 实现) 极快(Go 实现)⚡⚡⚡
注释处理 支持 支持(更细粒度)✅ 支持(较简单)
Mangle 支持 支持(更多选项)✅ 支持
Source Map 支持 支持 支持
维护状态 已停更 ❌ 活跃维护 ✅ 活跃维护 ✅
Webpack 集成 uglifyjs-webpack-plugin(废弃) 内置 TerserPlugin ESBuildMinifyPlugin

速度比较(典型项目,单位 ms):

Terser:  800ms ~ 2000ms  (纯 JS 实现,单线程)
esbuild: 50ms ~ 200ms    (Go 实现,多线程,快 10~40x)
SWC:     100ms ~ 400ms   (Rust 实现,居中)

代码混淆:变量名替换、属性名缩短

// 原始代码
function calculateUserDiscount(userLevel, purchaseAmount) {
  const discountRate = userLevel === 'premium' ? 0.2 : 0.1;
  return purchaseAmount * discountRate;
}

// Terser mangle 后(变量名单字母化)
function a(b, c) {
  const d = b === 'premium' ? 0.2 : 0.1;
  return c * d;
}

// Terser mangle.properties 属性名混淆(激进,慎用!)
// 注意:会混淆所有属性名,可能破坏与外部 API 的交互

属性名混淆注意事项:

terserOptions: {
  mangle: {
    properties: {
      // 只混淆以 _ 开头的属性(私有约定)
      regex: /^_/,
      // 保留特定属性名
      reserved: ['__esModule', '__webpack_require__'],
    }
  }
}

Pure annotation(/*#__PURE__*/)与 Tree Shaking 配合

/*#__PURE__*/ 是一个特殊注释,告诉打包工具:"这个函数调用没有副作用,如果结果不被使用,可以安全删除。"

// 问题:Webpack 无法判断 React.createElement 是否有副作用
// 所以默认不删除,即使 Button 未被使用
const Button = React.createElement(BaseButton, { type: 'button' });

// 解决:加上 /*#__PURE__*/ 注解
const Button = /*#__PURE__*/ React.createElement(BaseButton, { type: 'button' });
// 现在 Webpack 知道:如果 Button 没被使用,可以整个删掉

// Babel 会自动为 JSX 添加 #__PURE__ 注解
const element = <Button />
// 编译后:
const element = /*#__PURE__*/ React.createElement(Button, null);

// 类方法装饰器也需要
class MyClass {
  @memoize
  getValue() { return 42; }
}
// 编译后,Babel 会加注解确保 Tree Shaking 有效

实际效果验证:

// utils.js(有副作用的库)
console.log('模块加载时执行'); // 副作用!即使不使用也会执行
export const expensiveOp = /*#__PURE__*/ createHeavyObject();
//                         ^^^^^^^^^^^ 告诉 Webpack:createHeavyObject() 可安全省略

// main.js
import { expensiveOp } from './utils'; // 如果 expensiveOp 未使用
// 无注解:两者都保留在 bundle 中
// 有注解:expensiveOp 被删除,但 console.log 因有副作用仍保留

二、Vite 核心原理

2.1 开发模式(No-bundle 革命)

No-bundle 理念:为什么不预打包

传统 Webpack Dev Server 的问题:

项目启动时:
  1. 从 entry 开始,解析所有模块(可能有 1000+ 个)
  2. 每个模块都要经过 Loader 转换
  3. 生成完整的 bundle.js
  时间:中型项目 30s ~ 2min

文件修改时:
  1. 找出受影响的模块(依赖链条很长)
  2. 重新打包受影响的 chunk
  时间:3s ~ 20s(热更新)

Vite 的方案(利用原生 ESM):

项目启动时:
  1. 只做依赖预构建(esbuild 处理 node_modules,快!)
  2. 启动 dev server(Koa HTTP 服务 + WebSocket HMR)
  时间:< 500ms(超快!)

文件修改时:
  1. 只有被请求的模块才编译
  2. 模块粒度的 HMR(精确更新)
  时间:< 100ms

基于原生 ESM 的模块加载

<!-- Vite dev 模式下,index.html 中的 script -->
<script type="module" src="/src/main.ts"></script>
// 浏览器发起请求:GET /src/main.ts
// Vite Dev Server 接收,实时编译,返回 JS

// 原始 main.ts
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

// Vite 编译后(简化)返回给浏览器:
import { createApp } from '/@fs/.../node_modules/vue/dist/vue.esm-bundler.js'
import App from '/src/App.vue'
createApp(App).mount('#app')
// 浏览器再根据这些 import 发起后续请求

关键:浏览器原生 ESM 是按需加载的,只有被 import 的模块才会发起请求。

依赖预构建(esbuild 预打包 node_modules)

为什么需要预构建:

  1. CJS/UMD → ESM 转换: 大量 npm 包只有 CommonJS 版本,浏览器不支持 require()
  2. 模块合并: lodash-es 有 600+ 个小模块,每个 import 都是一个 HTTP 请求,太慢
  3. 深层依赖树: 某些包有数百个内部 require(),需要合并成单文件
// 预构建过程(简化)
import esbuild from 'esbuild';

// 分析 package.json 和入口文件,找出所有依赖
const deps = scanDependencies('./src/main.ts');

// 用 esbuild 批量预构建
await esbuild.build({
  entryPoints: Object.keys(deps),
  bundle: true,
  format: 'esm',  // 输出 ESM 格式
  outdir: './node_modules/.vite/deps',  // 缓存目录
  splitting: true,  // 代码分割(共享依赖提取)
});

// 预构建结果被缓存,重启不需要重新构建(除非 node_modules 变化)

预构建缓存策略:

  • 缓存目录:node_modules/.vite/deps/
  • 缓存有效期:基于 lock file、node_modules 时间戳、vite.config 内容的 hash
  • 手动清除:vite --force 或删除 .vite 目录

按需编译:只有请求到的模块才编译

用户访问 http://localhost:5173
  ↓
Vite Dev Server(Koa 中间件)
  ↓
请求 /src/main.ts
  ↓ (首次请求才编译)
ts → js(esbuild transform)
  ↓
返回编译后的 JS
  ↓
浏览器解析 import,发起新请求
  ↓
请求 /src/App.vue
  ↓ (首次请求才编译)
.vue → js(vite:vue 插件处理)
  ↓
只有用户实际访问到的路由/组件才会被编译

HMR 原理(Vite vs Webpack,更快的原因)

Webpack HMR 局限:

文件修改 → 找出受影响的所有模块 → 重新生成受影响的 chunk → 推送给浏览器
问题:依赖链很深时,可能一个小修改导致重新处理很多模块

Vite HMR 优势:

文件修改(精确到模块)
  ↓
查找 HMR 边界(向上找最近的 accept())
  ↓
只有边界内的模块需要重新请求
  ↓
通过 WebSocket 推送精确的 update 消息:
  { type: 'update', updates: [{ path: '/src/Counter.vue' }] }
  ↓
浏览器直接 import() 新版本模块
  ↓
Vue/React Fast Refresh 框架层面执行更新

Vite 更快的核心原因:

  1. 原生 ESM 粒度更细:每个文件是独立模块,HMR 边界更小
  2. 无需重新打包 chunk:不需要重新生成任何 bundle
  3. 浏览器缓存:未修改的模块有 HTTP 304 缓存
  4. esbuild 编译:即使需要重新编译,esbuild 比 babel 快 20~100x

2.2 生产模式(Rollup 打包)

为何生产仍用 Rollup 打包

直接用 ESM + 浏览器加载的问题:

  1. HTTP 请求过多(数百个模块 = 数百个请求,即使 HTTP/2 也有性能损耗)
  2. 无法合并小模块,代码重复
  3. Tree Shaking 效果不理想(浏览器不执行 Tree Shaking)
  4. 无法进行 code splitting 最优化
  5. CSS 处理复杂(需要合并和 code splitting 对应)

Rollup 的优势(适合生产打包):

  • ESM 输出最纯净(无运行时 runtime 代码)
  • Tree Shaking 最彻底(静态分析能力强)
  • Scope Hoisting(内联模块,减少闭包开销)
  • 成熟的 code splitting(dynamic import)

Rolldown 替换 Rollup(Vite 8+)

Rolldown = Rust 重写的 Rollup 兼容实现

性能提升:
  Rollup(JS)   → 构建 1000 个模块:~800ms
  Rolldown(Rust)→ 构建 1000 个模块:~100ms
  提速:5~10x(甚至更高)

兼容性:
  - 完全兼容 Rollup 插件 API
  - 输出格式相同
  - 正在逐步替换(Vite 8 中 Rolldown GA)

构建流程:analyze → bundle → optimize → emit

// Vite 生产构建(vite build)内部流程

// Phase 1: Analyze(分析入口)
const bundle = await rollup.rollup({
  input: resolveEntry(viteConfig),
  plugins: [
    ...vitePlugins,
    ...rollupPlugins,
  ],
  // Rollup 分析依赖图
});

// Phase 2: Bundle(代码分割 + 合并)
const { output } = await bundle.generate({
  format: 'es',
  chunkFileNames: 'assets/[name]-[hash].js',
  entryFileNames: 'assets/[name]-[hash].js',
  assetFileNames: 'assets/[name]-[hash][extname]',
  manualChunks: viteConfig.build.rollupOptions?.output?.manualChunks,
});

// Phase 3: Optimize(压缩 + 内联)
for (const chunk of output) {
  if (chunk.type === 'chunk') {
    chunk.code = await minify(chunk.code);  // esbuild/terser 压缩
  }
}

// Phase 4: Emit(写入磁盘)
await writeOutputFiles(output, viteConfig.build.outDir);

2.3 Vite 插件系统

Vite 插件 = Rollup 插件超集

// Rollup 插件(可直接在 Vite 中使用)
const myRollupPlugin = {
  name: 'my-rollup-plugin',

  // Rollup 标准 hooks(dev + build 都执行)
  resolveId(id, importer) { /* 解析模块路径 */ },
  load(id) { /* 加载模块内容 */ },
  transform(code, id) { /* 转换模块代码 */ },
  buildStart(options) { /* 构建开始 */ },
  buildEnd(error) { /* 构建结束 */ },
  generateBundle(options, bundle) { /* 生成 bundle */ },
};

// Vite 特有 hooks(仅 dev 模式)
const myVitePlugin = {
  name: 'my-vite-plugin',

  // 访问解析后的 Vite 配置
  configResolved(config) {
    console.log('当前模式:', config.command); // 'serve' | 'build'
  },

  // 修改 index.html(注入脚本/样式/meta)
  transformIndexHtml(html) {
    return html.replace(
      '<head>',
      `<head><meta name="build-time" content="${new Date().toISOString()}">`
    );
    // 或者返回数组格式(更精细控制注入位置)
    return {
      html,
      tags: [
        {
          tag: 'script',
          attrs: { src: '/analytics.js' },
          injectTo: 'body',
        },
      ],
    };
  },

  // 处理 HMR 更新(精细控制哪些文件的 HMR 行为)
  handleHotUpdate({ file, server, modules }) {
    if (file.endsWith('.json')) {
      // JSON 文件变化,触发全量重载
      server.ws.send({ type: 'full-reload' });
      return []; // 返回空数组表示自己处理了,不走默认逻辑
    }
    // 返回 undefined 走默认 HMR 逻辑
  },

  // Dev Server 配置(添加自定义路由/中间件)
  configureServer(server) {
    server.middlewares.use('/api/mock', (req, res) => {
      res.setHeader('Content-Type', 'application/json');
      res.end(JSON.stringify({ mock: true }));
    });
  },
};

插件执行顺序(enforce: pre/normal/post)

// Vite 插件执行顺序(关键!)

// 1. enforce: 'pre' 插件(最先执行)
// 用途:需要在其他插件处理之前修改代码(如 @vitejs/plugin-vue)

// 2. 普通 Vite 插件(无 enforce)

// 3. enforce: 'post' 插件(最后执行)
// 用途:需要在其他转换完成后处理(如分析器、压缩器)

// 实际示例
export default defineConfig({
  plugins: [
    // pre: 最先处理 .vue 文件
    { ...vuePlugin, enforce: 'pre' },

    // normal: 正常顺序
    reactPlugin(),

    // post: 最后执行(可看到所有转换后的最终结果)
    { ...analyzerPlugin, enforce: 'post' },
  ],
});

// 完整执行顺序:
// pre.config → normal.config → post.config
// pre.configResolved → normal.configResolved → post.configResolved
// pre.resolveId → normal.resolveId → post.resolveId
// pre.load → normal.load → post.load
// pre.transform → normal.transform → post.transform

三、Rollup 核心原理

3.1 为何适合库打包(ESM 输出纯净,无 runtime)

Webpack 打包库的问题:

// Webpack 打包后,即使是一个简单的库,也会包含大量 runtime 代码:
/******/ (() => { // webpackBootstrap
/******/   var __webpack_modules__ = ({
/******/     "./src/index.js": ((__unused_webpack_module, __webpack_exports__) => {
// ... 大量 runtime 代码
/******/ })();

Rollup 打包库的输出(极其纯净):

// rollup 输出(ESM 格式)
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;

export { add, multiply };
// 就这么干净!没有任何 runtime 开销

Rollup 为什么适合库:

  1. 无 runtime 代码(Webpack 有 __webpack_require__ 等运行时)
  2. 输出格式灵活(ESM/CJS/UMD/IIFE,一次构建多个格式)
  3. 天然 Tree Shaking(所有文件默认 ESM 处理)
  4. 用户可以基于 Rollup 输出再做优化

3.2 Scope Hoisting(模块内联,减少闭包)

// 有两个模块
// math.js
export const PI = 3.14159;
export const circumference = radius => 2 * PI * radius;

// main.js
import { circumference } from './math';
console.log(circumference(5));

Webpack 打包(有 module 闭包):

// 每个模块都包裹在函数中(为了模拟 CommonJS 作用域)
__webpack_modules__["./math.js"] = (module, exports) => {
  const PI = 3.14159;
  const circumference = radius => 2 * PI * radius;
  exports.circumference = circumference;
};
// 主模块
const math = __webpack_require__("./math.js");
console.log(math.circumference(5));

Rollup 打包(Scope Hoisting):

// 模块被"内联"到同一作用域
const PI = 3.14159;
const circumference = radius => 2 * PI * radius;
console.log(circumference(5));
// 完全扁平化,无额外函数调用开销

性能好处:

  • 减少函数调用(JS 引擎优化更容易)
  • 减少闭包(内存占用更少)
  • 代码体积更小
  • V8 内联优化(inline)效果更好

3.3 Tree Shaking 比 Webpack 更彻底的原因

原因一:Scope Hoisting 让死代码更容易识别

// math.js
export const add = (a, b) => a + b;         // 被使用
export const subtract = (a, b) => a - b;    // 未被使用

// main.js
import { add } from './math';
console.log(add(1, 2));

Rollup 的处理:

// 内联后,subtract 从来没有被引用 → 直接删除
const add = (a, b) => a + b;
console.log(add(1, 2));
// subtract 彻底消失!

Webpack 的处理:

// 标记 subtract 为 unused,由 Terser 最终删除
// 但仍然有 module 闭包的间接引用,Terser 要分析才能删除

原因二:Rollup 的 Statement 级别分析

Rollup 可以精确到每一条语句(statement)的副作用分析,粒度比 Webpack 更细。

原因三:整个项目默认 ESM

Rollup 在一开始就假设所有模块都是 ESM,不需要处理 CommonJS 的动态性。


四、esbuild 核心原理

4.1 Go 语言实现,为何这么快

速度对比(构建一个中等规模项目):

webpack(5):  12,000 ms
rollup:         9,000 ms
parcel 2:       8,000 ms
esbuild:          200 ms  ← 快 40~60 倍!

为什么这么快(四大原因):

1. Go 语言编译为原生机器码

JS(Node.js)→ V8 JIT 编译 → 机器码(动态,有 JIT 开销)
Go           → 静态编译   → 原生机器码(无 JIT 开销)
性能差距:原生代码通常比 JIT 快 3~10 倍

2. 并行处理(利用多核 CPU)

// esbuild 内部伪代码
func buildAll(files []string) {
  // 用 goroutine 并行处理所有文件
  var wg sync.WaitGroup
  results := make(chan Result, len(files))

  for _, file := range files {
    wg.Add(1)
    go func(f string) {
      defer wg.Done()
      results <- parseAndTransform(f)  // 并行!
    }(file)
  }

  wg.Wait()
  // JS/Python 受 GIL 限制,真并行很难做到
  // Go 原生支持轻量级 goroutine(M:N 线程模型)
}

3. 没有过度的抽象层

Webpack 处理一个文件:
  Plugin A → Plugin B → Loader C → Loader D → 多次 AST 转换 → ...
  每次转换:代码 → AST → 代码 → AST(来回多次)

esbuild 处理一个文件:
  一次 Parse(AST)→ 一次 Transform → 一次 Print
  整个过程只做一次 AST 解析,减少了大量序列化/反序列化

4. 高效的内存使用

Go 的内存分配效率远高于 JS
GC 压力更小(Go 的 GC 针对低延迟优化)
缓存利用率高(数据结构紧凑,CPU cache 友好)

4.2 功能边界

esbuild 有意保持简单,不支持:

功能 支持情况 原因
HMR 热更新 ❌ 不支持 需要 dev server,超出 bundler 范畴
复杂代码分割 ⚠️ 基础支持 动态 import 支持,但 chunk 分组策略简单
CSS Modules ❌ 不支持 复杂性高,交给 PostCSS 等工具
插件生态 ✅ 支持(API 简单) 但比 Rollup/Webpack 少
TypeScript 类型检查 ❌ 仅转换,不检查 转换很快,但 tsc 检查交给 IDE/CI
Vue SFC ❌ 无官方支持 需要第三方插件,功能有限

esbuild 擅长的:

  • 极速 JS/TS/JSX 转换(Transform,不是 Build)
  • 简单项目的全量打包(无复杂分割需求)
  • 作为其他工具的编译器内核(Vite 用它做预构建)
  • 压缩(Minify,速度远超 Terser)

4.3 在 Vite 中的角色

Vite Dev Server
  ├── 依赖预构建:esbuild(将 node_modules CJS → ESM,合并小包)
  ├── 单文件转换:esbuild(TypeScript/JSX → JS,速度快)
  └── HMR/路由/插件系统:Vite 自己实现

Vite Build(生产)
  ├── 打包:Rollup(或 Rolldown)← 不用 esbuild,原因见下
  └── 压缩:esbuild(可选,比 Terser 快 10-20x)

为什么 Vite 生产不用 esbuild 打包:
  - esbuild 的 code splitting 不够完善
  - CSS 处理能力有限
  - Tree Shaking 效果不如 Rollup
  - 缺少 Rollup 丰富的插件生态

五、横向对比

维度 Webpack 5 Vite 8 Rollup 4 esbuild
冷启动速度 慢(全量打包,10s~2min) 极快(< 1s,ESM按需)⚡⚡⚡ 中(5s30s) 极快(< 1s)⚡⚡⚡
热更新速度 中(需重新打包chunk,3~20s) 极快(模块级HMR,< 100ms)⚡⚡⚡ 无(库打包,无dev server) 无(需自行实现)
生产构建速度 慢(大项目 2~5min) 快(Rolldown GA后 5~10x提速)⚡⚡ 中等 ⚡ 最快(10~40x)⚡⚡⚡
开发体验 配置复杂,上手成本高 开箱即用,配置简单 ⭐⭐⭐ 配置手动,适合库开发 无完整 Dev 体验
生产优化 功能最全(splitChunks/scope hoisting/多种优化)⭐⭐⭐ Rollup/Rolldown 生产,优化能力强 ⭐⭐⭐ 输出最纯净,无 runtime ⭐⭐⭐ 基础优化,压缩超快 ⭐⭐
代码分割 最强(splitChunks 精细配置)⭐⭐⭐ 依赖 Rollup(manualChunks)⭐⭐ 基于 dynamic import,简洁 ⭐⭐ 基础 dynamic import ⭐
Tree Shaking 支持(需 ESM + production 模式)⭐⭐ 依赖 Rollup(更彻底)⭐⭐⭐ 最彻底(Scope Hoisting)⭐⭐⭐ 支持(效果中等)⭐⭐
插件生态 最成熟(数千个 loader/plugin)⭐⭐⭐ 兼容 Rollup 插件 + 专有插件 ⭐⭐⭐ 生态丰富,库开发覆盖完整 ⭐⭐ 生态较少,API 简单 ⭐
TypeScript 需要 ts-loader 或 babel-loader 内置支持(esbuild 转换)⭐⭐⭐ 需插件(@rollup/plugin-typescript)⭐⭐ 原生支持(极快)⭐⭐⭐
CSS 处理 功能最完整(CSS Modules/PostCSS/预处理器)⭐⭐⭐ 内置(PostCSS/CSS Modules/预处理器)⭐⭐⭐ 需插件,基础支持 ⭐ 基础CSS,无 Modules ⭐
SSR 支持 支持(较复杂)⭐⭐ 内置 SSR 模式 ⭐⭐⭐ 不直接支持 ⭐ 不支持
学习曲线 陡峭(配置项多)❗❗❗ 平缓(约定大于配置)✅ 中等(主要配置 input/output/plugins)✅ 简单(API 极少)✅
适用场景 大型复杂应用(遗留项目/微前端/特殊需求) 新项目首选(应用开发/SSR/微前端)⭐ 库/组件包发布首选 ⭐ 工具链内核(如 Vite 预构建)⭐
典型用户 大厂存量项目/CRA 新项目/Vue3/React新项目 React/Vue 生态库作者 Vite/Bun/Deno 内部

六、实战配置

6.1 Webpack 5 完整配置(含注释剥离、压缩、splitChunks)

// webpack.config.js(完整生产配置)
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

const isDev = process.env.NODE_ENV === 'development';

module.exports = {
  mode: isDev ? 'development' : 'production',

  // 入口(支持多入口)
  entry: {
    main: './src/index.tsx',
    // polyfill: './src/polyfill.ts', // 可拆分 polyfill
  },

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: isDev ? 'js/[name].js' : 'js/[name].[contenthash:8].js',
    chunkFilename: isDev ? 'js/[name].chunk.js' : 'js/[name].[contenthash:8].chunk.js',
    assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
    clean: true, // 构建前清空 dist
    publicPath: '/',
  },

  // Source Map
  devtool: isDev ? 'eval-cheap-module-source-map' : 'hidden-source-map',

  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.jsx', '.json'],
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
    // 优先使用 ESM 版本(Tree Shaking 更好)
    mainFields: ['module', 'browser', 'main'],
  },

  module: {
    rules: [
      // TypeScript / JavaScript
      {
        test: /.[jt]sx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                ['@babel/preset-env', {
                  useBuiltIns: 'usage',
                  corejs: 3,
                  targets: '> 0.5%, not dead',
                }],
                '@babel/preset-typescript',
                ['@babel/preset-react', { runtime: 'automatic' }],
              ],
              // 禁用注释(生产环境,babel 不保留)
              comments: isDev,
              plugins: [
                isDev && require.resolve('react-refresh/babel'),
              ].filter(Boolean),
            },
          },
        ],
      },

      // CSS
      {
        test: /.css$/,
        use: [
          isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              modules: {
                // CSS Modules:文件名带 .module.css
                auto: /.module.css$/,
                localIdentName: isDev
                  ? '[path][name]__[local]'
                  : '[contenthash:8]',
              },
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: ['autoprefixer', 'postcss-preset-env'],
              },
            },
          },
        ],
      },

      // SCSS
      {
        test: /.s[ac]ss$/,
        use: [
          isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'sass-loader',
        ],
      },

      // 图片(Webpack 5 内置 asset modules,不需要 file-loader)
      {
        test: /.(png|jpe?g|gif|webp|svg)$/,
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 8 * 1024, // < 8KB 转 base64
          },
        },
      },

      // 字体
      {
        test: /.(woff2?|eot|ttf|otf)$/,
        type: 'asset/resource',
      },
    ],
  },

  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
      favicon: './public/favicon.ico',
      // 注入时,自动添加 contenthash 的 script/link 标签
      minify: isDev ? false : {
        removeComments: true,        // 删除 HTML 注释
        collapseWhitespace: true,
        removeAttributeQuotes: false,
      },
    }),

    !isDev && new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].chunk.css',
    }),

    // 分析产物大小(按需开启)
    process.env.ANALYZE && new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
    }),
  ].filter(Boolean),

  optimization: {
    minimize: !isDev,
    minimizer: [
      // JS 压缩(含注释剥离)
      new TerserPlugin({
        parallel: true,           // 多进程压缩
        extractComments: false,   // 不生成 .LICENSE.txt(内部项目)

        terserOptions: {
          ecma: 2020,
          format: {
            // 🔑 核心:删除所有注释
            comments: false,
            // 保留 License:comments: /^**!|@preserve|@license/i
          },
          compress: {
            drop_console: true,   // 删除 console.log
            drop_debugger: true,
            dead_code: true,      // 删除不可达代码
            evaluate: true,       // 常量折叠
            passes: 2,            // 多次压缩(更彻底,稍慢)
            pure_funcs: ['console.info', 'console.debug', 'console.warn'],
          },
          mangle: {
            safari10: true,       // 修复 Safari 10 bug
          },
        },
      }),

      // CSS 压缩
      new CssMinimizerPlugin({
        minimizerOptions: {
          preset: ['default', {
            discardComments: { removeAll: true }, // 删除所有 CSS 注释
          }],
        },
      }),
    ],

    // 代码分割(精细配置)
    splitChunks: {
      chunks: 'all',
      minSize: 20000,       // 最小 20KB 才分割
      maxSize: 244000,      // 最大 244KB(超过继续分割)
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      cacheGroups: {
        // React 核心库单独打包(长期缓存)
        reactVendors: {
          test: /[\/]node_modules[\/](react|react-dom|react-router|react-router-dom|scheduler)[\/]/,
          name: 'react-vendors',
          chunks: 'initial',
          priority: 30,
          enforce: true,  // 忽略 minSize/maxSize
        },

        // 其他 node_modules
        vendors: {
          test: /[\/]node_modules[\/]/,
          name(module) {
            // 按包名分组(更细粒度的缓存)
            const packageName = module.context.match(
              /[\/]node_modules[\/](.*?)([\/]|$)/
            )[1];
            return `npm.${packageName.replace('@', '')}`;
          },
          priority: 20,
          reuseExistingChunk: true,
        },

        // 公共业务代码(被 2+ chunk 引用)
        common: {
          minChunks: 2,
          name: 'common',
          priority: 10,
          reuseExistingChunk: true,
        },
      },
    },

    // 将 webpack runtime 单独提取(避免每次内容变化影响 vendors hash)
    runtimeChunk: {
      name: 'runtime',
    },

    // Tree Shaking 相关
    usedExports: true,      // 标记 used exports
    concatenateModules: true, // Scope Hoisting
    innerGraph: true,       // 追踪模块内部依赖(更精确的 Tree Shaking)
    sideEffects: true,      // 读取 package.json 中的 sideEffects
  },

  // 开发服务器
  devServer: {
    port: 3000,
    hot: true,
    historyApiFallback: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },

  // 性能预算
  performance: {
    hints: isDev ? false : 'warning',
    maxEntrypointSize: 512 * 1024,  // 500KB
    maxAssetSize: 512 * 1024,
  },
};

6.2 Vite 完整配置(含自定义插件、构建优化)

// vite.config.ts
import { defineConfig, loadEnv, Plugin } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { visualizer } from 'rollup-plugin-visualizer';

// 自定义插件:自动注入构建信息到 window.__BUILD_INFO__
function buildInfoPlugin(): Plugin {
  return {
    name: 'build-info',
    enforce: 'post',

    // 构建开始时生成构建信息
    buildStart() {
      this.buildInfo = {
        time: new Date().toISOString(),
        version: process.env.npm_package_version,
        commit: process.env.COMMIT_SHA || 'dev',
      };
    },

    // 在 index.html 中注入构建信息
    transformIndexHtml(html) {
      return {
        html,
        tags: [{
          tag: 'script',
          attrs: { type: 'text/javascript' },
          children: `window.__BUILD_INFO__ = ${JSON.stringify(this.buildInfo)};`,
          injectTo: 'head-prepend',
        }],
      };
    },
  };
}

// 自定义插件:移除生产环境中的 console.log
function removeConsolePlugin(): Plugin {
  return {
    name: 'remove-console',
    transform(code, id) {
      // 只处理生产环境的 JS 文件
      if (process.env.NODE_ENV !== 'production') return;
      if (!id.match(/.[jt]sx?$/)) return;
      if (id.includes('node_modules')) return;

      // 简单方案:正则替换(生产中应用 esbuild 选项,更可靠)
      return code.replace(/console.(log|debug|info)(.*?);?/g, '');
    },
  };
}

export default defineConfig(({ mode }) => {
  // 加载环境变量(.env, .env.production 等)
  const env = loadEnv(mode, process.cwd(), '');
  const isProd = mode === 'production';

  return {
    // 全局别名
    resolve: {
      alias: {
        '@': resolve(__dirname, 'src'),
        '@components': resolve(__dirname, 'src/components'),
        '@hooks': resolve(__dirname, 'src/hooks'),
        '@utils': resolve(__dirname, 'src/utils'),
        '@assets': resolve(__dirname, 'src/assets'),
      },
    },

    // 全局 CSS 变量注入
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: `@import "@/styles/variables.scss";`,
        },
      },
      modules: {
        // CSS Modules 类名格式
        generateScopedName: isProd
          ? '[hash:base64:8]'
          : '[name]__[local]__[hash:base64:5]',
      },
    },

    // 插件
    plugins: [
      react({
        // React Fast Refresh(dev)/ 自动 JSX runtime
        babel: {
          plugins: [
            // 只在开发环境开启 Fast Refresh
            ...(!isProd ? [['babel-plugin-react-refresh', {}]] : []),
          ],
        },
      }),

      buildInfoPlugin(),
      isProd && removeConsolePlugin(),

      // Bundle 大小分析(ANALYZE=true vite build)
      process.env.ANALYZE && visualizer({
        open: true,
        gzipSize: true,
        brotliSize: true,
        filename: 'dist/stats.html',
      }),
    ].filter(Boolean),

    // 环境变量(暴露给前端)
    define: {
      '__APP_VERSION__': JSON.stringify(env.npm_package_version),
      '__API_BASE__': JSON.stringify(env.VITE_API_BASE_URL),
    },

    // Dev Server
    server: {
      port: 5173,
      host: true,  // 允许局域网访问
      open: true,
      proxy: {
        '/api': {
          target: env.VITE_API_PROXY_TARGET,
          changeOrigin: true,
          rewrite: (path) => path.replace(/^/api/, ''),
        },
      },
    },

    // 预构建(依赖预构建优化)
    optimizeDeps: {
      // 手动添加需要预构建的依赖(通常 Vite 自动检测)
      include: ['lodash-es', 'axios', 'dayjs'],
      // 排除不需要预构建的(纯 ESM 的库)
      exclude: ['@vueuse/core'],
    },

    // 生产构建
    build: {
      target: 'es2020',
      outDir: 'dist',
      assetsDir: 'assets',
      sourcemap: isProd ? 'hidden' : true,  // 生产用 hidden(Sentry 上传用)

      // Rollup 配置
      rollupOptions: {
        output: {
          // 手动分割 chunks(精细控制 vendor 缓存)
          manualChunks: (id) => {
            // React 生态单独 chunk
            if (id.includes('/node_modules/react') ||
                id.includes('/node_modules/react-dom') ||
                id.includes('/node_modules/scheduler')) {
              return 'react-vendor';
            }
            // 路由
            if (id.includes('/node_modules/react-router')) {
              return 'router';
            }
            // 工具库
            if (id.includes('/node_modules/lodash') ||
                id.includes('/node_modules/dayjs') ||
                id.includes('/node_modules/axios')) {
              return 'utils-vendor';
            }
            // 其他 node_modules(统一打包)
            if (id.includes('/node_modules/')) {
              return 'vendor';
            }
          },

          // 文件命名
          chunkFileNames: 'js/[name]-[hash].js',
          entryFileNames: 'js/[name]-[hash].js',
          assetFileNames: ({ name }) => {
            if (/.(png|jpe?g|gif|svg|webp)$/.test(name || '')) {
              return 'images/[name]-[hash][extname]';
            }
            if (/.(css)$/.test(name || '')) {
              return 'css/[name]-[hash][extname]';
            }
            if (/.(woff2?|eot|ttf|otf)$/.test(name || '')) {
              return 'fonts/[name]-[hash][extname]';
            }
            return 'assets/[name]-[hash][extname]';
          },
        },
      },

      // 压缩配置
      minify: 'esbuild',   // 'terser' | 'esbuild' | false
      // esbuild 比 terser 快 10~20x,但压缩率略低

      // esbuild 压缩选项(minify: 'esbuild' 时有效)
      // esbuildOptions 通过 vite 的 esbuild 选项配置
      // 注意:移除注释通过 esbuild 的 legalComments 控制

      // CSS 代码分割(每个 async chunk 提取独立 CSS)
      cssCodeSplit: true,

      // 静态资源 inline 阈值
      assetsInlineLimit: 8192,  // 8KB 以下 inline

      // chunk 大小警告阈值
      chunkSizeWarningLimit: 1000,  // 1000KB

      // 是否生成 manifest.json(用于后端路由集成)
      manifest: isProd,
    },

    // esbuild 转换配置(dev + build 都生效)
    esbuild: {
      // 删除 console 和 debugger
      drop: isProd ? ['console', 'debugger'] : [],
      // 删除注释
      legalComments: isProd ? 'none' : 'inline',
      // JSX 注入
      jsxImportSource: 'react',
    },
  };
});

6.3 如何验证 Tree Shaking 有效

方法一:Bundle Analyzer 可视化

# Webpack
ANALYZE=true webpack --config webpack.config.js
# 打开 http://localhost:8888 查看交互式图表

# Vite
ANALYZE=true vite build
# 打开 dist/stats.html

使用 webpack-bundle-analyzer:

// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    process.env.ANALYZE && new BundleAnalyzerPlugin({
      analyzerMode: 'server',  // 启动本地服务器
      // analyzerMode: 'static', // 生成静态 HTML
      openAnalyzer: true,
      generateStatsFile: true,
      statsFilename: 'stats.json',
    }),
  ].filter(Boolean),
};

Vite / Rollup:

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

plugins: [
  visualizer({
    open: true,
    gzipSize: true,
    brotliSize: true,
    template: 'treemap',  // 'treemap' | 'sunburst' | 'network'
  }),
]

方法二:source-map-explorer 精确分析

npm install -g source-map-explorer

# Webpack(需要开启 source-map)
npx source-map-explorer dist/js/main.*.js
# 会打开浏览器显示每个依赖的实际大小

方法三:手动验证(小实验)

// math.js(测试库)
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;   // 故意不使用
export const multiply = (a, b) => a * b;   // 故意不使用

// main.js(只用 add)
import { add } from './math';
console.log(add(1, 2));
# 构建后,搜索 bundle 中是否包含 subtract/multiply
grep -r "subtract" dist/
grep -r "multiply" dist/

# 如果 Tree Shaking 有效:找不到这两个函数
# 如果无效:能找到(说明 Tree Shaking 失效)

方法四:sideEffects 验证

// package.json(告知所有文件无副作用)
{
  "sideEffects": false
}
// 验证方法
// 引入一个文件,但不使用任何导出
import './utils/logger'; // 只是 import,没有用任何东西

// 如果 sideEffects: false,这个 import 应该被 Tree Shaking 删除
// 检查 bundle 中是否包含 logger 的代码

方法五:Webpack Stats JSON 分析

// webpack.config.js
module.exports = {
  stats: {
    // 详细统计信息
    optimizationBailout: true,  // 显示为什么 Tree Shaking 失败的模块
  },
};
webpack --json > stats.json
# 用 https://webpack.github.io/analyse/ 上传 stats.json 分析

常见 Tree Shaking 失效原因排查:

问题 原因 解决方案
import * as xxx from 命名空间导入,无法确定用哪些 改为具名导入 import { add } from
CommonJS 依赖 require() 动态,无法静态分析 找 ESM 版本(lodash-es 替代 lodash
sideEffects 未配置 Webpack 保守策略,不删除 package.json 配置 sideEffects: false
Babel 编译 ESM → CJS 某些旧配置会把 ESM 转成 CJS 确保 @babel/preset-envmodules: false
副作用代码 模块顶层有副作用(如修改全局对象) /*#__PURE__*/ 或移除副作用

七、常见面试题精选

Q1:Webpack 的 Loader 和 Plugin 有什么区别?

Loader: 文件转换器,专注于单文件的转换(非 JS/JSON → JS)

  • 在模块加载阶段运行
  • 是一个函数,输入 source,输出转换后的 source
  • 有序执行(pitch 从左到右,normal 从右到左)

Plugin: 功能扩展器,可以介入构建的任意阶段

  • 基于 Tapable 事件系统
  • 可以访问 Compiler 和 Compilation 对象
  • 能做任何 Loader 不能做的事(生成文件、修改 bundle、添加资源等)

Q2:为什么 Vite 开发环境这么快,生产用 Rollup?

开发快: 利用浏览器原生 ESM,无需打包 = 零构建时间,只有 esbuild 预构建 node_modules
生产用 Rollup:

  • 浏览器加载 1000+ 个 ESM 文件网络开销巨大(即使 HTTP/2)
  • Rollup Tree Shaking 更彻底,输出更纯净
  • 成熟的 code splitting 和插件生态

Q3:如何彻底删除所有代码注释?

// Webpack + TerserPlugin
new TerserPlugin({
  extractComments: false,
  terserOptions: {
    format: { comments: false },
  },
})

// Vite + esbuild
esbuild: {
  legalComments: 'none',  // 删除所有注释包括 License
}
//  build.minify: 'terser' + terserOptions

Q4:Tree Shaking 的必要条件是什么?

  1. 使用 ESM(import/export 语法,不是 require
  2. Webpack 的 mode: 'production'optimization.usedExports: true
  3. package.json 配置 sideEffects: false(或列出有副作用的文件)
  4. Babel 不把 ESM 转成 CJS(modules: false
  5. 依赖库提供 ESM 版本(lodash-es 而不是 lodash

Q5:HMR 和 live reload 的区别?

live reload(全量刷新): 文件改变 → 整页刷新 → 应用状态丢失
HMR(模块热替换): 文件改变 → 只更新修改的模块 → 保留组件状态

HMR 需要:

  • 开发服务器支持(WebSocket 推送)
  • 框架集成(React Fast Refresh / Vue HMR)
  • module.hot.accept 注册更新回调

📝 学习总结: 打包工具的核心设计哲学是"在开发体验和生产优化之间找到最佳平衡"。Webpack 选择了灵活性(插件生态)、Vite 选择了开发体验(No-bundle)、Rollup 选择了纯净输出(库打包)、esbuild 选择了极速(工具链内核)。理解各自的设计权衡,才能在实际项目中做出正确选择。

前端进阶之路:从性能优化到响应式布局的实战指南(Tailwindcss)

作者 Lee川
2026年4月1日 16:45

前端进阶之路:从性能优化到响应式布局的实战指南(Tailwindcss)

在现代前端开发的宏大叙事中,我们往往容易迷失在纷繁复杂的框架和库中。然而,剥开React、Vue或Tailwind CSS的外衣,其核心往往回归到对DOM操作的深刻理解、对性能的极致追求以及对用户体验的细腻把控。今天,我们将串联起三个看似独立却内在逻辑紧密相连的知识点:原生DOM操作的性能基石——DocumentFragment,React组件化开发的“隐形斗篷”——Fragment,以及现代CSS布局的利器——Tailwind CSS的响应式哲学。

一、性能的基石:理解DocumentFragment

在JavaScript直接操作DOM的时代,性能优化是一个绕不开的话题。浏览器渲染页面的过程是昂贵的,每一次DOM的增删改查都可能触发重排和重绘。让我们通过两段代码的对比,来窥探原生性能优化的奥秘。

假设我们需要向页面中的一个容器内插入两个段落元素。

直接追加的“笨重”方式:

const container = document.querySelector('.container');
const p1 = document.createElement('p');
p1.textContent = '111';
const p2 = document.createElement('p');
p2.textContent = '222';

container.appendChild(p1); // 触发一次重排/重绘
container.appendChild(p2); // 再次触发重排/重绘

这种方式虽然直观,但效率极低。每执行一次appendChild,浏览器就需要重新计算样式、布局,并更新画面。如果你需要插入成百上千个节点,页面就会出现明显的卡顿甚至闪烁。

使用DocumentFragment的“聪明”方式:

const container = document.querySelector('.container');
const p1 = document.createElement('p');
p1.textContent = '111';
const p2 = document.createElement('p');
p2.textContent = '222';

const fragment = document.createDocumentFragment(); // 创建内存中的碎片
fragment.appendChild(p1); // 内存操作,无渲染消耗
fragment.appendChild(p2); // 内存操作,无渲染消耗
container.appendChild(fragment); // 一次性插入,仅触发一次重排/重绘

DocumentFragment就像一个存在于内存中的“隐形容器”。它不属于DOM树,因此对它的操作不会触发页面的渲染更新。我们可以把所有的子节点先组装到这个碎片中,最后一次性将其内容“倾倒”进真实的DOM树。这就像搬家时,与其每拿一个箱子就跑一趟新车,不如先把所有箱子装上一辆大卡车(Fragment),然后一次性运达目的地。这种批量操作的思维,是现代前端性能优化的原点。

二、React的“隐形斗篷”:Fragment组件

随着React等声明式框架的普及,我们不再直接操作DOM,但DocumentFragment的思想在React中以另一种形式得到了升华——那就是Fragment组件。

在React中,组件的render函数或函数组件本身必须返回一个单一的根元素。这是由React内部虚拟DOM树的协调机制决定的。

痛点场景:

//  错误写法:返回了多个根节点,React会报错
function MyComponent() {
  return (
    <h1>标题</h1>
    <p>内容</p>
  );
}

为了解决这个问题,新手往往会用一个无意义的div包裹起来:

// ️ 不理想的写法:引入了多余的DOM节点
function MyComponent() {
  return (
    <div>
      <h1>标题</h1>
      <p>内容</p>
    </div>
  );
}

这种做法虽然能跑通,但会带来“DOM污染”。多余的div会破坏CSS布局(比如Flexbox的父子关系),增加DOM树的深度,甚至导致HTML结构语义错误(例如在table的tr中插入div)。

Fragment的解决方案:

React提供了Fragment(简写为<>...</>),它就像一个“隐形斗篷”。它满足了React对单一根节点的要求,但在最终生成的HTML中,它自身会消失,只留下它的子元素。

//  完美写法:使用Fragment,DOM结构纯净
function MyComponent() {
  return (
    <>
      <h1>标题</h1>
      <p>内容</p>
    </>
  );
}

在列表渲染中,Fragment更是不可或缺。它允许我们将一组相关的元素(如术语dt和描述dd)组合在一起,而不破坏父级列表的结构。

// 在列表中组合多个元素,保持语义化
{items.map(item => (
  <Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.description}</dd>
  </Fragment>
))}

虽然React的Fragment和原生的DocumentFragment在实现机制上有所不同(前者是虚拟DOM层面的概念,后者是真实DOM API),但它们的精神内核是一致的:高效组织节点,避免冗余,拒绝不必要的渲染开销。

三、布局的艺术:Tailwind CSS与移动端优先

当我们构建好纯净的DOM结构后,如何高效地为其赋予样式?Tailwind CSS提供了一种原子化的解决方案,而“移动端优先”则是其响应式布局的核心哲学。

让我们看一段典型的Tailwind代码:

export default function App() { 
    return (
        <div className="flex flex-col md:flex-row gap-4">
            <main className="bg-blue-100 p-4 md:w-2/3">
                主内容
            </main>
            <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
        </div>
    )
}

这段代码精妙地展示了如何适配不同设备。

第一阶段:移动端(默认样式) 当我们在手机上(屏幕宽度小于768px)浏览时,Tailwind会忽略所有带前缀(如md:)的类名。

  • flex flex-col:容器启用Flex布局,且方向为垂直堆叠。
  • gap-4:元素间保持间距。 此时,主内容和侧边栏是上下排列的单列布局,非常适合手机阅读。

第二阶段:PC端(md:断点生效) 当屏幕变宽(≥768px),md:前缀的样式被激活。

  • md:flex-row:布局方向瞬间切换为水平排列。
  • md:w-2/3md:w-1/3:主内容占据2/3宽度,侧边栏占据1/3。 页面平滑地过渡为经典的两栏布局。

这种“先写死移动端,再修饰大屏”的策略,避免了复杂的媒体查询嵌套,让响应式逻辑变得清晰可见。它要求开发者首先关注核心内容的呈现(移动端),然后再考虑在大屏幕上如何利用富余空间(PC端),这是一种非常健康的设计思维。

结语

从原生JavaScript中利用DocumentFragment减少重排,到React中使用Fragment保持DOM树的语义化和纯净,再到利用Tailwind CSS的原子类快速构建响应式布局,这三者共同构成了一个现代前端开发者的核心素养。

它们分别解决了结构、逻辑和表现层面的关键问题:

  • DocumentFragment教会我们敬畏浏览器的渲染性能
  • React Fragment教会我们追求代码结构的逻辑纯净
  • Tailwind CSS教会我们以高效的方式驾驭复杂的UI设计

掌握这些工具背后的原理,而不仅仅是语法,才是通往高级前端工程师的必经之路。

⚡Pretext: 无 DOM 布局回流的快速文本测量库

2026年4月1日 16:40

omQIhBr6VEk3ypQ79KiFq0pbBZ4Ljuu34M6xvp2B.gif

Pretext 是一个纯 JavaScript 文本测量库,通过 Canvas API 缓存字符宽度,支持在不改动 DOM 的情况下快速计算文本高度和行数。适合虚拟列表、动态排版等性能敏感场景。

为什么需要 Pretext?

前端开发中,文本测量是虚拟列表、自适应布局等功能的基石。传统方案需要:

  1. 创建隐藏的 DOM 元素
  2. 插入文本
  3. 读取 offsetHeight/getBoundingClientRect()
  4. 触发浏览器布局计算(Layout)

这种方式在大数据量或频繁更新时性能堪忧。

Pretext 的解决方案: 用 Canvas API 一次性测量所有字符宽度,后续计算纯算术完成,不触发布局回流。

hnLViGKk1B4CVR6S6t7l2uIoVK3VaSBvr8b9cFQk.gif

核心 API

1. prepare + layout — 快速测量

最基础的用法,适合只需要总高度和行数的场景。

import { prepare, layout } from '@chenglou/pretext'

const text = 'AGI 春天到了. Howe est? 🚀'
const font = '16px Inter'
const lineHeight = 24

// 一次性分析文本,返回不透明句柄
const prepared = prepare(text, font)

// 纯算术计算,不触发布局
const result = layout(prepared, 300, lineHeight)

console.log(result.height)     // 总高度
console.log(result.lineCount)  // 总行数
// 输出示例
// { height: 48, lineCount: 2 }

使用场景: 虚拟列表的 item 高度计算、聊天气泡的自适应高度。

2. prepareWithSegments + layoutWithLines — 获取行详情

需要知道每行具体内容的场景。

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const text = 'Hello World! This is Pretext.'
const prepared = prepareWithSegments(text, '16px Inter')

// 返回每行的详细信息
const { height, lineCount, lines } = layoutWithLines(prepared, 200, 24)

lines.forEach((line, i) => {
  console.log(`Line ${i + 1}: "${line.text}" (${line.width}px)`)
})
// 输出示例
// Line 1: "Hello World!" (81px)
// Line 2: "This is" (42px)
// Line 3: "Pretext." (60px)

使用场景: 文本编辑器行号显示、代码高亮的行对齐。

3. walkLineRanges — 回调遍历

需要逐行处理,每行触发一次回调。

import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext'

const prepared = prepareWithSegments(text, '16px Inter')

// 遍历每一行,执行自定义逻辑
const lineWidths: number[] = []
walkLineRanges(prepared, 300, 24, (line) => {
  lineWidths.push(line.width)
  console.log(`"${line.text}" starts at ${line.start}, ends at ${line.end}`)
})

console.log('All widths:', lineWidths)

使用场景: 查找最长行、收集行统计信息。

4. layoutNextLine — 迭代器模式

逐行获取,可从任意位置开始,适合流式布局。

import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext'

const prepared = prepareWithSegments(text, '16px Inter')
let cursor = { paragraph: 0, secondLine: 0 }

while (true) {
  const line = layoutNextLine(prepared, cursor, 300)
  if (line === null) break

  console.log(`"${line.text}" (${line.width}px)`)
  cursor = line.end  // 关键:使用上一行的结束位置继续
}

使用场景: 流式文本渲染、增量加载文本。

5. whiteSpace: 'pre-wrap' 选项

保留换行和缩进。

const codeText = `function hello() {
  console.log('Hello')
}`

const prepared = prepare(codeText, '14px "Fira Code"', { whiteSpace: 'pre-wrap' })
const { height, lineCount } = layout(prepared, 300, 20)

Vue 3 集成示例

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { prepare, layout, prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const content = ref('')
const containerWidth = ref(400)
const lineHeight = 24
const font = '16px Inter'

// 文本内容变化时重新计算
function calculateHeight() {
  const prepared = prepare(content.value, font)
  return layout(prepared, containerWidth.value, lineHeight)
}

// 获取行详情
function getLines() {
  const prepared = prepareWithSegments(content.value, font)
  return layoutWithLines(prepared, containerWidth.value, lineHeight)
}

const height = ref(0)
const lineCount = ref(0)
const lines = ref([])

function update() {
  const result = calculateHeight()
  height.value = result.height
  lineCount.value = result.lineCount
  lines.value = getLines().lines
}

onMounted(update)
</script>

<template>
  <div>
    <textarea v-model="content" @input="update" />
    <p>高度: {{ height }}px, 行数: {{ lineCount }}</p>
    <div v-for="(line, i) in lines" :key="i">
      {{ i + 1 }}: {{ line.text }} ({{ line.width }}px)
    </div>
  </div>
</template>

demo预览

somnai-dreams.github.io/pretext-dem…

性能对比

方案 1000 次测量耗时 是否触发布局
原生 DOM (offsetHeight) ~800ms
Pretext (首次 prepare) ~50ms 一次性
Pretext (后续 layout) ~1ms

Pretext 的首次 prepare 稍慢(需测量字符),但后续 layout 调用极快(纯算术)。

注意事项

  1. font 字符串必须匹配:确保 prepare() 的 font 参数与实际 CSS 渲染完全一致,包括字号、字重、字体族。

  2. lineHeight 必须一致layout() 的 lineHeight 参数需与 CSS line-height 声明值相同。

  3. 不支持的 CSS 特性:不支持 letter-spacingword-spacing 扩展、部分 Unicode 字符可能测量不准。

适用场景

  • 虚拟列表/虚拟滚动
  • 聊天应用的消息气泡
  • 动态排版系统
  • 任何需要提前知道文本尺寸的场景

不适用场景

  • 包含 letter-spacing/word-spacing 的文本
  • 复杂的富文本(图片、链接混排)
  • 需要像素级精确的场景(建议实测验证)

安装

npm install @chenglou/pretext

总结

Pretext 通过将文本测量从「运行时查询 DOM」转变为「一次性测量 + 缓存算术」,为性能敏感的文本布局场景提供了可行方案。API 设计简洁,分层清晰,从基础的高度查询到细粒度的行迭代都有覆盖。


Further Reading

Fumadocs 基础概念:从内容源到页面渲染

作者 Kellen
2026年4月1日 16:26

导航速览

  1. 简介
  2. 整体工作链路
  3. Fumadocs 的组成
  4. 核心文件详解
  5. app/docs/layout.tsxapp/docs/[[...slug]]/page.tsx
  6. mdx-components.tsxexamples/*
  7. 几个常见误区
  8. 总结与复盘
  9. 参考资料

1. 简介

Fumadocs 是一个围绕 Next.js App Router 构建的文档系统方案。它不只是"一个文档主题",而是由 fumadocs-corefumadocs-ui、内容源(如 fumadocs-mdx)和 Next.js App Router 组合构成的完整能力组合,更适合被理解成"文档系统组合方案",而不是单一的黑盒框架。

对于刚接触 Fumadocs 的同学,最难的往往不是 API 本身,而是搞清楚:

  • 文档内容到底放在哪里
  • .source 是什么,谁生成的
  • source.config.tslib/source.ts 分别做什么
  • meta.json 如何影响左侧导航
  • mdx-components.tsx 怎么把组件接进文档

这篇文章会按照实际接入链路,把这些基础概念串起来。先建立稳定的心智模型,比一开始死记 API 更重要。

:::tip 建议按「整体链路 → 核心文件职责 → 路由渲染 → 误区复盘」的顺序阅读。 :::


2. 整体工作链路

flowchart LR
  A["content/docs\n写文档"] --> B["source.config.ts\n定义规则"]
  B --> C[".source\n自动生成中间层"]
  C --> D["lib/source.ts\n包装成 source API"]
  D --> E["app/docs/layout.tsx\n文档站外壳"]
  D --> F["app/docs/[[...slug]]/page.tsx\n单页渲染"]
  G["mdx-components.tsx\n组件映射"] --> F
  H["examples/*\n文档示例"] --> G

理解这条链路的顺序比记文件名更重要:

  1. 先有文档内容(content/docs
  2. 再有内容源配置(source.config.ts
  3. 然后自动生成中间层(.source
  4. 再把中间层包装成运行时 source(lib/source.ts
  5. 最后由 Next.js 路由和页面消费

最小可运行示例

如果你更习惯从代码入手,可以先看这组最小链路:

// source.config.ts
import { defineConfig, defineDocs } from "fumadocs-mdx/config";

export const docs = defineDocs({
  dir: "content/docs",
});

export default defineConfig({});
// lib/source.ts
import { loader } from "fumadocs-core/source";
import { docs } from "../.source/server";

export const source = loader({
  baseUrl: "/docs",
  source: docs.toFumadocsSource(),
});
// app/docs/layout.tsx
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import { source } from "@/lib/source";

export default function Layout({ children }: { children: React.ReactNode }) {
  return <DocsLayout tree={source.pageTree}>{children}</DocsLayout>;
}
// app/docs/[[...slug]]/page.tsx
import { DocsPage } from "fumadocs-ui/page";
import { source } from "@/lib/source";

export default async function Page({
  params,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const { slug } = await params;
  const page = source.getPage(slug);

  if (!page) return null;

  const Content = page.data.body;

  return (
    <DocsPage toc={page.data.toc}>
      <Content />
    </DocsPage>
  );
}

这四段代码合起来,就已经构成了一条完整的 docs 渲染链路。


3. Fumadocs 的组成

3.1 Fumadocs Core

fumadocs-core 是 Fumadocs 的 headless 库,提供服务端函数和无样式的 headless 组件,可以在 Next.js 等任意 React 框架上构建文档站。它不绑定任何 UI 样式,核心能力包括:

  • 文档搜索(内置 Orama、Algolia 支持)
  • Breadcrumb、Sidebar、TOC 等 headless 组件
  • Remark/Rehype 插件
  • Source API(统一处理内容源的接口)

官方详解:Fumadocs Core / Headless

3.2 Fumadocs UI

fumadocs-ui 是 Fumadocs 的默认主题,提供一套精心设计的文档站外观,内置大量交互组件和布局(如 DocsLayoutDocsPageCalloutCardsTabsFiles / Folder / File、TOC UI 等),主打低维护成本、持续获得 UI 更新。

如果你需要完全掌控组件样式,也可以通过 Fumadocs CLI 把组件安装到本地后自行定制。

官方详解:Fumadocs UI

3.3 Content Source(Fumadocs MDX)

fumadocs-mdx 是 Fumadocs 的官方内容源,本质是一个将内容文件转换为类型安全数据的编译/处理层,定位类似 Content Collections,但专为 React 框架设计。

它的核心概念是 Collection:你在 source.config.ts 里定义 collection,Fumadocs MDX 就会把对应目录下的 .md / .mdx 文件编译成可在应用里直接使用的类型安全数据(包含 frontmatter、TOC、结构化数据等)。

Fumadocs 不强制使用 fumadocs-mdx,你也可以接入 CMS 或其他自定义数据层作为内容源,但本文对应的项目使用的是本地 content/docs 目录 + fumadocs-mdx

官方详解:Fumadocs MDX

3.4 Fumadocs CLI

CLI 是自动化安装组件和配置的工具,不是文档站运行的核心链路,但在搭建和扩展时很有帮助。常见用途:

  • add:从 Fumadocs GitHub 仓库拉取最新版本的 UI 组件,安装到本地(参考了 shadcn/ui 的设计思路)
  • customise:快速定制 Fumadocs 布局
  • tree:为 Files / Folder / File 这类目录展示组件生成树形数据

官方详解:Fumadocs CLI


4. 核心文件详解

4.1 content/docsmeta.json

content/docs 是文档正文的存放区域,里面的 .mdx 文件就是最终会被渲染成文档页面的内容。

.mdx 可以理解成 Markdown + React 组件,既可以写普通文档,也可以在文档中直接插入 CalloutTabsFiles 或自定义 example 组件,特别适合写组件文档和带交互示例的说明页。

meta.json 不是正文内容,它更像是当前这一层目录的导航说明书,主要负责:

  • 定义当前 section 的标题
  • 控制当前层直接子节点的顺序
  • 参与构建左侧导航的 pageTree

常见可配置项包括 titleicondescriptiondefaultOpencollapsiblerootpages,最常用的仍然是 titlepages

{
  "title": "Components",
  "pages": ["button", "card"]
}

这里有一个重要的理解点:pages 控制的是当前层的直接子节点顺序,不是跨层的全局排序。 同目录下的 .mdx 页面和子文件夹都属于当前层,meta.json 只负责这一层,不负责越级组织整棵树。

一个基本结构示例:

content/
  docs/
    index.mdx
    meta.json          ← 根级导航
    components/
      button.mdx
      card.mdx
      meta.json        ← components/ 这一层的导航

详见官方页面约定文档:Page Conventions / Meta


4.2 source.config.ts.sourcelib/source.ts

这三个文件最容易混淆,用一句话区分:

文件 职责
source.config.ts 规则定义处
.source 规则执行后的生成结果
lib/source.ts 运行时消费入口

source.config.ts 是 Fumadocs 的内容源配置入口,负责告诉系统文档目录在哪里、定义 docs collection、配置 MDX 的处理规则(代码高亮、remark/rehype 插件等)。如果需要更严格的 frontmatter 校验,也在这里配置 schema。

import { defineConfig, defineDocs } from "fumadocs-mdx/config";

export const docs = defineDocs({
  dir: "content/docs",
});

export default defineConfig({
  mdxOptions: {
    rehypeCodeOptions: {
      themes: {
        light: "github-light",
        dark: "github-dark",
      },
    },
  },
});

你通常会在修改文档根目录、调整 MDX 处理规则、配置代码高亮主题或增加 remark/rehype 插件时改动这个文件。

.source 是自动生成的中间层,在执行 pnpm devpnpm build 时由系统生成,通常包含 server.tsbrowser.tsdynamic.ts。它把 source.config.ts 里的配置和 content/docs 里的扫描结果整理成可直接 import 的映射文件。不建议手改,它是编译产物。

lib/source.ts 的职责是把自动生成的 docs 集合包装成站点运行时真正使用的 source 对象:

import { loader } from "fumadocs-core/source";
import { docs } from "../.source/server";

export const source = loader({
  baseUrl: "/docs",
  source: docs.toFumadocsSource(),
});

loader() 不负责扫目录,它消费一个已准备好的 source,并生成这些运行时能力:

source.getPages()       // 拿到所有页面
source.getPage(slug)    // 根据 slug 取某一页
source.pageTree         // 左侧导航树
source.generateParams() // 给 Next.js 静态生成页面参数

文档内容访问链路

当用户打开 /docs/guides/quickstart 时,大致链路如下:

sequenceDiagram
  participant U as 用户
  participant R as docs路由
  participant S as source
  participant M as quickstart.mdx
  participant P as DocsPage

  U->>R: 打开 /docs/guides/quickstart
  R->>S: 用 slug = ["guides","quickstart"] 查页面
  S->>M: 找到对应的 MDX 文档
  M-->>P: 返回标题、描述、正文、TOC
  P-->>U: 渲染成完整文档页

5. app/docs/layout.tsxapp/docs/[[...slug]]/page.tsx

这两个文件属于 Next.js App Router 的路由层,职责分工很清晰:

  • layout.tsx:负责文档站的"外壳"(顶部导航、左侧导航、搜索入口、统一布局容器)
  • page.tsx:负责单篇文档页的渲染(根据 slug 找到具体页面,取出 title、description、body、toc 交给 UI 组件展示)
// layout.tsx 最小示例
<DocsLayout tree={source.pageTree} nav={{ title: "TM UI", url: "/" }}>
  {children}
</DocsLayout>

这里有一个重要边界:generateStaticParams()generateMetadata() 属于 Next.js App Router 特殊导出,不是 Fumadocs 专属 API;source.getPage()source.pageTree 才是 Fumadocs 提供的数据入口。

如果对这些文件不熟悉,建议参考 Next.js 官方文档:Layouts and Pages


6. mdx-components.tsxexamples/*

6.1 mdx-components.tsx

mdx-components.tsx 是 MDX 的组件映射表,负责告诉系统文档里的 <Tabs /><Files /><ButtonBasicExampleShowcase /> 分别对应哪个 React 组件。没有这层映射,MDX 只能识别普通 Markdown,不认识这些组件标签。

mdx-components.tsx 是"MDX 标签 → React 组件"的桥梁。

项目里通常会把 fumadocs-ui/mdx 提供的默认 MDX 组件、项目自定义的 docs 组件和 examples 合并导出:

import type { MDXComponents } from "mdx/types";
import defaultMdxComponents from "fumadocs-ui/mdx";
import { File, Files, Folder } from "fumadocs-ui/components/files";
import { ButtonBasicExampleShowcase } from "@/examples/button";

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...defaultMdxComponents,
    File,
    Files,
    Folder,
    ButtonBasicExampleShowcase,
    ...components,
  };
}

注意:CardsCallout、Code Block、Heading 往往已在默认 MDX 组件里;Files / Folder / FileTabsStepsTypeTable 等,很多时候需要项目自己显式补进映射。

6.2 examples/*

examples/* 存放的是文档示例组件,不是通用 UI 组件,而是用来演示组件如何使用、给文档页提供 preview 的。一个 example 目录里可能同时包含预览实现、代码字符串、展示包装器和说明文件:

examples/
  button/
    basic-example.tsx          ← 预览实现
    basic-example.code.ts      ← 代码字符串
    basic-example-showcase.tsx ← 文档展示包装器
    index.ts
    readme.md

6.3 components/docsexamples 的区别

这是一个值得单独说明的边界:

  • components/docs:文档专用的辅助组件(帮助文档排版和组织内容)
  • examples:组件使用示例(演示组件如何使用)

一个实用判断标准:如果它是"真正可复用的业务/设计系统组件",继续放 components/uicomponents/feature


7. 几个常见误区

  • meta.json 不是正文内容,它是导航说明书
  • .source 不是手写文件,而是自动生成的中间层
  • pages 不会把整棵树拍平,它优先控制当前层的直接子节点顺序
  • generateStaticParams()generateMetadata() 属于 Next.js App Router 特殊导出,不是 Fumadocs 专属 API
  • examples/* 通常是项目自己的组织约定,不是 Fumadocs 的强制目录规范

8. 总结与复盘

学习 Fumadocs 时,最重要的不是一开始背 API,而是先把层次理解清楚:

  1. content/docs 负责真正的文档内容
  2. meta.json 负责当前层导航规则
  3. source.config.ts 负责内容源规则定义
  4. .source 负责自动生成中间层(不要手改)
  5. lib/source.ts 负责生成运行时 source API
  6. app/docs/layout.tsxpage.tsx 负责页面展示
  7. mdx-components.tsx 负责组件映射
  8. examples/* 负责文档示例

用图复盘一遍:

flowchart TD
  A["content/docs/*.mdx\n写内容"] --> B["meta.json\n定义当前层导航"]
  A --> C["source.config.ts\n定义规则"]
  C --> D[".source\n自动生成"]
  D --> E["lib/source.ts\n拿到 getPage/pageTree"]
  E --> F["app/docs/layout.tsx\n渲染文档站外壳"]
  E --> G["app/docs/[[...slug]]/page.tsx\n渲染具体页面"]
  H["mdx-components.tsx\n组件映射"] --> G
  I["examples/*\n示例组件"] --> H

只要把这条链路理解清楚,Fumadocs 的整体心智模型就会稳定很多。


9. 参考资料

Symbol 产生的背景以及应用场景

作者 海浪浪
2026年4月1日 15:29

注:本文是在我理解的基础上让豆包整理后生成后,再自己修正的。

一、Symbol 产生的核心需求/背景

在 ES6 引入 Symbol 之前,JavaScript 中对象的属性名只能使用字符串或数字,这就导致了一系列问题,Symbol 的出现正是为了解决这些痛点,主要满足三个核心需求:

  1. 解决对象属性命名冲突问题,避免属性名重写覆盖;
  2. 实现非可迭代对象的迭代化,比如普通对象默认无法使用 for...of 遍历,Symbol 可解决这一问题;
  3. 实现对象的不可枚举、半隐藏属性,让某些属性不被常规遍历方法暴露。

二、Symbol 应用场景

Symbol 的所有作用都围绕上述需求展开,每个作用对应具体的实战场景,以下结合代码示例详细说明:

1. 解决属性命名冲突

核心逻辑:Symbol 是全局唯一的值,即使描述符相同,两个 Symbol 也不相等,用它作为对象属性名,可彻底避免命名冲突。

// 示例:两个描述符相同的 Symbol,作为属性名不会冲突
const age1 = Symbol('age');
const age2 = Symbol('age'); // 与 age1 描述相同,但互不相等

const obj = {};
obj[age1] = 123; // 给 obj 添加 age1 对应的属性
obj[age2] = 456; // 给 obj 添加 age2 对应的属性

console.log(obj[age1]); // 123(不会被 age2 覆盖)
console.log(obj[age2]); // 456
console.log(age1 === age2); // false(证明两个 Symbol 不相等)

2. 实现不可枚举、半隐藏属性

核心逻辑:Symbol 作为对象属性名时,默认不可枚举,不会被 for...in、Object.keys() 遍历到,也无法被 JSON 序列化、普通拷贝方法复制,实现属性的半隐藏 注意:可通过 Object.getOwnPropertySymbols() 获取。

const sym = Symbol('privateProp');
const obj = {
  name: '张三',
  [sym]: '这是半隐藏属性' // Symbol 作为属性名
};

// 1. 无法被 for...in 遍历
for (let key in obj) {
  console.log(key); // 只输出 name,不会输出 sym 对应的属性名
}

// 2. 无法被 Object.keys() 获取
console.log(Object.keys(obj)); // ['name']

// 3. 无法被 JSON 序列化
console.log(JSON.stringify(obj)); // {"name":"张三"}(看不到 Symbol 属性)

// 4. 无法被 Object.assign 拷贝(浅拷贝也不会复制 Symbol 属性)
const newObj = Object.assign({}, obj);
console.log(newObj[sym]); // undefined

// 5. 可通过 Object.getOwnPropertySymbols() 获取(并非完全私有)
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(privateProp)]

3. 实现非可迭代对象的迭代化(Symbol.iterator)

核心逻辑:Symbol.iterator 是 Symbol 类的静态属性,相当于一个“迭代标记”。给普通对象的 obj[Symbol.iterator] 赋值一个生成器函数,就能让该对象变成可迭代对象,支持 for...of 遍历,遍历逻辑由生成器函数定义。

先明确两个关键知识点(结合代码理解):

  • 生成器函数:用 *function 声明,返回一个生成器对象,内部可使用 yield 关键字,每次调用生成器对象的 next() 方法,就会执行到下一个 yield 处;
  • next() 方法返回值:格式为 { value: 此次 yield 的值, done: 布尔值 },done 为 true 表示迭代结束,false 表示仍有后续值。
// 示例:让普通对象变成可迭代对象,支持 for...of 遍历
const person = {
  name: '张三',
  age: 20,
  hobbies: ['游戏', '跑步', '看书'],
  // 给对象添加 Symbol.iterator 属性,赋值生成器函数
  [Symbol.iterator]: function* () {
    // 遍历对象的所有值,依次 yield 出去
    yield this.name;
    yield this.age;
    yield* this.hobbies; // yield* 用于迭代数组(批量 yield)
  }
};

// 此时 person 是可迭代对象,可使用 for...of 遍历
for (let item of person) {
  console.log(item); // 依次输出:张三、20、游戏、跑步、看书
}

// 单独调用生成器对象的 next() 方法(单步迭代)
const generator = person[Symbol.iterator]();
console.log(generator.next()); // { value: '张三', done: false }
console.log(generator.next()); // { value: 20, done: false }
console.log(generator.next()); // { value: '游戏', done: false }
console.log(generator.next()); // { value: '跑步', done: false }
console.log(generator.next()); // { value: '看书', done: false }
console.log(generator.next()); // { value: undefined, done: true }(迭代结束)

4. 内置符号改变语言默认行为

除了 Symbol.iterator,JavaScript 还有多个内置 Symbol(Well-known Symbols),可用于重写语言的默认行为,比如 Symbol.hasInstance 可自定义 instanceof 的判断逻辑,以下是最常用的示例:

// 示例:用 Symbol.hasInstance 自定义 instanceof 判断
class MyClass {
  // 静态方法,重写 Symbol.hasInstance
  static [Symbol.hasInstance](instance) {
    // 自定义判断逻辑:只要实例有 name 属性,就认为是 MyClass 的实例
    return instance.hasOwnProperty('name');
  }
}

const obj1 = { name: '张三' };
const obj2 = { age: 20 };

console.log(obj1 instanceof MyClass); // true(符合自定义逻辑)
console.log(obj2 instanceof MyClass); // false(不符合自定义逻辑)

其他常用内置符号(简单说明):

  • Symbol.asyncIterator:用于实现异步迭代,支持 for await...of 遍历;
  • Symbol.toPrimitive:自定义对象转原始值(如 +obj、String(obj))的逻辑;
  • Symbol.toStringTag:自定义 Object.prototype.toString() 的返回标签(如 [object MyClass])。

三、总结

Symbol 的核心价值的是“唯一标识”和“可定制化语言行为”,主要解决对象属性冲突、实现半隐藏属性、让普通对象可迭代,再结合内置符号,能灵活改写 JavaScript 的默认逻辑,是前端开发中处理对象和迭代场景的重要工具。

Vuex 项目实战完整模板

2026年4月1日 15:11

Vuex 项目实战完整模板

一、文档说明

本文档为企业级 Vue2 + Vuex3 实战标准模板,所有代码可直接复制到项目中运行,包含核心功能:Vuex 模块化规范、用户登录与Token持久化、用户信息与权限管理、Axios请求封装、路由权限守卫、组件内标准使用方式。

适配PDF排版要求,层级清晰、代码块独立、重点内容突出,可直接复制至WPS、Word等编辑器,导出为PDF文件使用。

二、环境准备与依赖安装

本模板适配 Vue2 版本,对应 Vuex3,需先安装以下依赖:

# 安装 Vuex3(Vue2 专用)和 Axios(接口请求)
npm install vuex@3 axios --save

三、项目目录结构

请按照以下目录结构创建文件,确保代码引入路径正确(核心目录集中在 src 文件夹下):

src/
├── store/                  # Vuex 核心目录
│   ├── index.js            # Vuex 入口文件(组装模块)
│   └── modules/            # 模块化拆分目录
│       ├── user.js         # 用户模块(登录、信息、Token、权限)
│       └── app.js          # 全局配置模块(侧边栏、主题)
├── router/
│   └── index.js            # 路由配置 + 全局权限守卫
├── api/
│   └── user.js             # 用户相关接口封装
├── utils/
│   └── request.js          # Axios 封装(请求/响应拦截器)
└── main.js                 # 项目入口文件(挂载 Vuex、Router)

四、Vuex 核心配置(重点)

4.1 store/index.js(Vuex 入口文件)

作用:引入 Vuex、注册各个模块,统一暴露 Store 实例,供 main.js 挂载。

import Vue from 'vue'
import Vuex from 'vuex'
// 引入各个模块
import user from './modules/user'
import app from './modules/app'

// 安装 Vuex 插件
Vue.use(Vuex)

// 导出 Store 实例
export default new Vuex.Store({
  modules: {
    user,  // 注册用户模块
    app    // 注册全局配置模块
  }
})

4.2 store/modules/user.js(用户模块,最常用)

核心功能:管理用户 Token、用户信息、登录/退出/获取用户信息等操作,包含 State、Getters、Mutations、Actions 完整配置,开启命名空间避免冲突。

// 引入用户相关接口(后续会配置)
import { login, getUserInfo } from '@/api/user'

// 1. State:存储用户相关全局数据
const state = {
  token: localStorage.getItem('token') || '',  // Token 持久化(刷新不丢失)
  userInfo: {},                                // 存储用户详细信息
  roles: []                                    // 存储用户权限角色(用于权限控制)
}

// 2. Getters:对 State 数据进行加工(类似组件的 computed)
const getters = {
  isLogin: (state) => !!state.token,           // 判断是否登录(转化为布尔值)
  userId: (state) => state.userInfo.id || '',  // 获取用户ID(默认空字符串)
  userName: (state) => state.userInfo.name || ''// 获取用户名(默认空字符串)
}

// 3. Mutations:唯一修改 State 的地方(必须同步操作)
const mutations = {
  // 保存 Token
  SET_TOKEN(state, token) {
    state.token = token
    localStorage.setItem('token', token)  // 持久化到本地存储
  },

  // 保存用户信息
  SET_USER_INFO(state, info) {
    state.userInfo = info
    state.roles = info.roles || []        // 同步存储用户角色
  },

  // 退出登录:清空用户数据
  CLEAR_USER(state) {
    state.token = ''
    state.userInfo = {}
    state.roles = []
    localStorage.removeItem('token')      // 清除本地存储的 Token
  }
}

// 4. Actions:处理异步操作(如接口请求),通过 commit 调用 Mutations
const actions = {
  // 登录操作(异步)
  async login({ commit }, userData) {
    const res = await login(userData)     // 调用登录接口
    commit('SET_TOKEN', res.token)        // 提交 Mutations 保存 Token
    return res                            // 返回接口结果,供组件使用
  },

  // 获取用户信息(异步)
  async getUserInfo({ commit }) {
    const res = await getUserInfo()       // 调用获取用户信息接口
    commit('SET_USER_INFO', res)          // 提交 Mutations 保存用户信息
    return res
  },

  // 退出登录(同步,也可放在 Mutations,此处统一放在 Actions 便于管理)
  logout({ commit }) {
    commit('CLEAR_USER')                  // 调用 Mutations 清空用户数据
  }
}

// 导出模块,开启命名空间(必须开启,避免模块间方法/数据冲突)
export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}

4.3 store/modules/app.js(全局配置模块)

作用:管理全局公共配置(如侧边栏状态、主题切换),可根据项目需求扩展。

// State:全局配置数据
const state = {
  sidebarOpen: true,  // 侧边栏默认开启
  theme: 'light'      // 默认主题(亮色)
}

// Mutations:修改全局配置(同步)
const mutations = {
  // 切换侧边栏状态(打开/关闭)
  TOGGLE_SIDEBAR(state) {
    state.sidebarOpen = !state.sidebarOpen
  },
  // 设置主题
  SET_THEME(state, val) {
    state.theme = val
  }
}

// Actions:暂无异步操作,留空可后续扩展
const actions = {}

// 导出模块,开启命名空间
export default {
  namespaced: true,
  state,
  mutations,
  actions
}

五、接口与请求封装

统一封装 Axios,处理请求拦截(添加 Token)、响应拦截(处理 401 未登录等异常),同时封装用户相关接口,便于维护。

5.1 api/user.js(用户接口封装)

集中管理用户相关接口,避免接口地址分散在各个组件中,便于后续修改。

// 引入封装好的 Axios 实例
import request from '@/utils/request'

// 登录接口(POST 请求,传递用户名、密码)
export function login(data) {
  return request({
    url: '/login',    // 接口地址(实际项目替换为后端真实地址)
    method: 'post',   // 请求方式
    data              // 请求参数(用户名、密码等)
  })
}

// 获取用户信息接口(GET 请求,需要 Token 授权)
export function getUserInfo() {
  return request({
    url: '/user/info',// 接口地址(实际项目替换为后端真实地址)
    method: 'get'     // 请求方式
  })
}

5.2 utils/request.js(Axios 封装)

核心:创建 Axios 实例,配置请求基础路径、超时时间,添加请求/响应拦截器,统一处理 Token 和异常。

import axios from 'axios'
import store from '@/store'  // 引入 Vuex Store,用于获取 Token、退出登录

// 创建 Axios 实例
const service = axios.create({
  baseURL: '/api',    // 接口基础路径(实际项目替换为后端接口前缀)
  timeout: 10000      // 超时时间(10秒)
})

// 1. 请求拦截器:发送请求前,给请求头添加 Token(授权用)
service.interceptors.request.use(
  (config) => {
    const token = store.state.user.token  // 从 Vuex 中获取 Token
    if (token) {
      // 添加 Token 到请求头(格式根据后端要求调整,此处为 Bearer 格式)
      config.headers.Authorization = `Bearer ${token}`
    }
    return config  // 返回配置好的请求
  },
  (error) => {
    // 请求失败(如网络异常),返回错误
    return Promise.reject(error)
  }
)

// 2. 响应拦截器:接收响应后,统一处理异常(如 401 未登录)
service.interceptors.response.use(
  (res) => res.data,  // 直接返回响应体(简化组件中获取数据的操作)
  (error) => {
    // 401 状态码:未登录(Token 过期、无效)
    if (error.response?.status === 401) {
      store.dispatch('user/logout')  // 调用退出登录,清空用户数据
      location.reload()              // 刷新页面,跳转到登录页
    }
    // 返回错误,供组件捕获处理
    return Promise.reject(error)
  }
)

// 导出封装好的 Axios 实例
export default service

六、路由配置与权限守卫

配置项目路由,添加全局路由守卫,实现“未登录跳转登录页、已登录不允许访问登录页、已登录自动获取用户信息”的权限控制逻辑。

import Vue from 'vue'
import VueRouter from 'vue-router'
import store from '@/store'  // 引入 Vuex Store,用于判断登录状态

// 安装 VueRouter 插件
Vue.use(VueRouter)

// 配置路由规则
const routes = [
  {
    path: '/login',          // 登录页路径
    component: () => import('@/views/Login')  // 懒加载登录组件(优化性能)
  },
  {
    path: '/',               // 首页路径
    component: () => import('@/views/Home'),  // 懒加载首页组件
    meta: { requiresAuth: true }  // 标记:需要登录才能访问
  },
  {
    path: '/user',           // 用户中心路径
    component: () => import('@/views/User'),  // 懒加载用户中心组件
    meta: { requiresAuth: true }  // 标记:需要登录才能访问
  }
]

// 创建路由实例
const router = new VueRouter({ routes })

// 全局路由守卫(每次跳转路由前执行)
router.beforeEach(async (to, from, next) => {
  // 1. 获取 Token,判断是否登录
  const hasToken = store.state.user.token

  // 2. 未登录:只能访问登录页,其他页面跳转登录页
  if (!hasToken) {
    if (to.path === '/login') return next()  // 访问登录页,放行
    return next('/login')                   // 访问其他页,跳转登录页
  }

  // 3. 已登录:不允许访问登录页,跳转首页
  if (to.path === '/login') return next('/')

  // 4. 已登录,但未获取用户信息(如页面刷新),自动获取用户信息
  const hasUser = Object.keys(store.state.user.userInfo).length
  if (!hasUser) {
    await store.dispatch('user/getUserInfo')  // 调用接口获取用户信息
  }

  // 5. 所有校验通过,放行
  next()
})

// 导出路由实例
export default router

七、项目入口挂载(main.js)

将 Vuex Store 和 Router 挂载到 Vue 实例,使整个项目可以使用 Vuex 和路由功能。

import Vue from 'vue'
import App from './App.vue'
import store from './store'  // 引入 Vuex Store
import router from './router'// 引入 Router

// 关闭 Vue 生产环境提示
Vue.config.productionTip = false

// 创建 Vue 实例,挂载 Store 和 Router
new Vue({
  store,   // 挂载 Vuex
  router,  // 挂载 Router
  render: h => h(App)       // 渲染根组件 App
}).$mount('#app')           // 挂载到页面 #app 元素

八、组件内使用 Vuex 示例(实战常用)

以下为组件内使用 Vuex 的标准写法,使用辅助函数(mapState、mapGetters 等)简化代码,提高开发效率。

8.1 登录页面(Login.vue)

功能:实现用户名、密码输入,调用登录接口,登录成功后跳转首页。

<template>
  <div class="login-container">
    <!-- 用户名输入框 -->
    <input 
      v-model="username" 
      placeholder="请输入用户名" 
      class="login-input"
    />
    <!-- 密码输入框 -->
    <input 
      v-model="password" 
      placeholder="请输入密码" 
      type="password" 
      class="login-input"
    />
    <!-- 登录按钮 -->
    <button @click="handleLogin" class="login-btn">登录</button>
  </div>
</template>

<script>
// 引入 Vuex 辅助函数 mapActions(用于调用 Actions)
import { mapActions } from 'vuex'

export default {
  data() {
    return {
      username: '',  // 用户名
      password: ''   // 密码
    }
  },
  methods: {
    // 映射 user 模块的 login 方法(简化调用)
    ...mapActions('user', ['login']),
    
    // 登录点击事件
    async handleLogin() {
      try {
        // 调用登录接口(传递用户名、密码)
        await this.login({
          username: this.username,
          password: this.password
        })
        // 登录成功,跳转首页
        this.$router.push('/')
      } catch (err) {
        // 登录失败,打印错误信息(可根据需求添加提示)
        console.log('登录失败:', err)
      }
    }
  }
}
</script>

8.2 首页/头部组件(Home.vue)

功能:展示用户信息、退出登录、切换侧边栏状态,演示多模块(user、app)的使用。

<template>
  <div class="home-header">
    <!-- 展示用户信息(登录状态下显示) -->
    <div class="user-info" v-if="isLogin">
      欢迎您,{{ userName }}
      <button @click="handleLogout" class="logout-btn">退出登录</button>
    </div>

    <!-- 侧边栏状态展示与切换 -->
    <div class="sidebar-control">
      侧边栏状态:{{ sidebarOpen ? '开启' : '关闭' }}
      <button @click="toggleSidebar" class="sidebar-btn">切换侧边栏</button>
    </div>
  </div>
</template>

<script>
// 引入 Vuex 辅助函数(多模块使用)
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'

export default {
  computed: {
    // 映射 user 模块的 state(userInfo)
    ...mapState('user', ['userInfo']),
    // 映射 user 模块的 getters(isLogin、userName)
    ...mapGetters('user', ['isLogin', 'userName']),
    // 映射 app 模块的 state(sidebarOpen)
    ...mapState('app', ['sidebarOpen'])
  },
  methods: {
    // 映射 user 模块的 actions(logout)
    ...mapActions('user', ['logout']),
    // 映射 app 模块的 mutations(TOGGLE_SIDEBAR)
    ...mapMutations('app', ['TOGGLE_SIDEBAR']),

    // 退出登录事件
    handleLogout() {
      this.logout()  // 调用退出登录方法
      this.$router.push('/login')  // 跳转登录页
    },

    // 切换侧边栏状态
    toggleSidebar() {
      this.TOGGLE_SIDEBAR()  // 调用 mutations 方法
    }
  }
}
</script>

九、Vuex 原始调用方式(备用)

若不想使用辅助函数,可直接通过 $store 调用 Vuex 的数据和方法(适合简单场景,代码相对繁琐)。

// 1. 获取 State 数据
this.$store.state.user.token          // 获取 user 模块的 token
this.$store.state.app.sidebarOpen     // 获取 app 模块的侧边栏状态

// 2. 获取 Getters 加工后的数据
this.$store.getters['user/isLogin']   // 获取 user 模块的 isLogin

// 3. 提交 Mutations(同步修改 State)
this.$store.commit('user/SET_TOKEN', 'xxx')  // 调用 user 模块的 SET_TOKEN
this.$store.commit('app/TOGGLE_SIDEBAR')     // 调用 app 模块的 TOGGLE_SIDEBAR

// 4. 分发 Actions(异步操作)
this.$store.dispatch('user/login', { username, password })  // 调用登录
this.$store.dispatch('user/logout')                         // 调用退出登录

十、Vuex 最佳实践(避坑指南)

  1. 模块化必须开启 namespaced: true,避免不同模块的方法、数据名称冲突。

  2. 严格遵循“只能通过 Mutations 修改 State”,禁止在组件或 Actions 中直接修改 State,便于调试和维护。

  3. 所有异步操作(接口请求、定时器等)必须放在 Actions 中,Mutations 只做同步操作。

  4. Vuex 只存储“多组件共享的数据”(如 Token、用户信息、全局配置),组件私有数据(如单个组件的输入框值)无需放入 Vuex。

  5. 关键数据(如 Token)需做 localStorage 持久化,避免页面刷新后数据丢失。

  6. 路由守卫配合 Vuex 做权限控制,统一管理页面访问权限,避免重复判断。

十一、备注

  1. 本文档所有代码均为可运行版本,实际项目中需替换接口地址(baseURL、接口路径)为后端真实地址。

  2. 组件(Login.vue、Home.vue、User.vue)需自行创建,代码可直接复制使用,样式可根据项目需求调整。

  3. 若使用 Vue3,需替换为 Vuex4 或 Pinia(Vue3 推荐使用 Pinia),可联系获取对应版本模板。

(注:文档部分内容可能由 AI 生成)

前端面试必问 Git 通关指南:常用命令速查 + merge/rebase 深度辨析,看完再也不慌

2026年3月31日 21:31

本文面向前端面试场景,覆盖从日常开发高频命令,到面试官必问的核心原理辨析,所有内容均来自面试实战考点,无冗余废话,面试前刷一遍直接通关。

开篇前言

Git 是前端开发的必备工具,也是几乎所有公司一面必问的基础考点。但很多同学日常开发只会用add/commit/push/pull,一被问到mergerebase的区别、代码回滚方案、分支管理规范就卡壳。

本文基于我备战前端实习的面试笔记整理,先补全优化前端开发必背的全量高频命令,再深度拆解面试最高频的merge vs rebase考点,最后补充面试常问的附加题,帮大家彻底搞定 Git 面试。


一、前端开发 & 面试必背 Git 常用命令大全

我将所有命令按开发场景做了模块拆分,保留了基础核心用法,同时补全了面试常考的进阶参数和注意事项,可直接当作面试速查手册。

1. 仓库初始化与远程关联

表格

命令 核心作用 面试注意事项
git init 在当前目录初始化一个全新的 Git 本地仓库 初始化后会生成.git隐藏目录,存储 Git 所有的版本和配置信息
git clone <远程仓库地址> 克隆远程仓库到本地,自动完成远程关联 面试常考:支持 HTTPS 和 SSH 两种地址,SSH 需提前配置密钥
git remote add origin <远程仓库地址> 给本地仓库关联远程仓库,origin 是远程仓库的默认别名 必用场景:本地 init 的仓库首次推送到远程前,必须先执行此命令
git remote -v 查看当前仓库关联的远程仓库地址详情 排查远程仓库配置问题的核心命令

2. 分支管理核心命令(面试超高频)

表格

命令 核心作用 面试注意事项
git branch 查看所有本地分支,带*的是当前所在分支 基础必背,面试官常以此为起点延伸分支相关问题
git branch -r 查看所有远程分支
git branch -a 查看所有分支(本地 + 远程)
git checkout <分支名> 切换到已存在的本地分支 高频快捷操作:git checkout - 一键切换到上一个分支
git checkout -b <新分支名> 创建并立即切换到新分支 等价于git branch <新分支名> + git checkout <新分支名>,开发最高频用法
git checkout -b <新分支名> <起点> 基于指定起点(远程分支 / 历史提交 / 标签)创建新分支 示例:git checkout -b hotfix/v1.0 origin/main,面试常考场景化用法
git branch -d <分支名> 安全删除本地分支 仅能删除已经合并到当前分支的分支,未合并的分支会报错,防止误删代码
git branch -D <分支名> 强制删除本地分支 无论分支是否合并,都会直接删除,仅用于废弃的功能分支,面试常问-d-D的区别
git push origin --delete <远程分支名> 删除远程分支

补充:Git 2.23+ 官方推出语义更清晰的git switch替代checkout的分支操作功能,面试可提:

  • 切换分支:git switch <分支名>
  • 创建并切换新分支:git switch -c <新分支名>解决了checkout功能过载、容易误操作的问题。

3. 代码提交与暂存核心命令

表格

命令 核心作用 面试注意事项
git status 查看当前工作目录和暂存区的状态,显示未跟踪、已修改、已暂存的文件 开发必用,切换分支、提交代码前建议必执行,避免误操作
git add <文件路径/文件名> 将指定文件的修改 / 新增添加到暂存区
git add . 将当前目录所有修改、新增的文件添加到暂存区 面试常考:不会处理已删除的文件,仅新增和修改
git add -u 仅将已跟踪文件的修改、删除添加到暂存区,不包含新增文件 高频场景:只想提交已有文件的改动,不想提交新增的临时文件
git commit -m "提交描述信息" 将暂存区的内容提交到本地仓库,生成一条提交记录 核心要求:提交信息必须语义化,面试常问提交规范
git commit -am "提交描述信息" 等价于git add -u + git commit -m,一步完成已跟踪文件的提交 注意:对新增的未跟踪文件无效
git commit --amend 修改上一次的提交信息,或补充漏提交的文件,不会生成新的提交 面试高频考点:仅适用于未推送到远程的本地提交,已推送的提交修改后会重写历史,需要强制推送,有极高风险

4. 代码拉取与推送核心命令

表格

命令 核心作用 面试注意事项
git push 将本地当前分支的提交推送到已关联的远程分支 首次推送必须加-u参数设置上游分支:git push -u origin <分支名>,后续可直接用git push
git push -f 强制推送本地分支覆盖远程分支 高危操作!仅能在自己的私有分支使用,绝对禁止在公共分支执行,会直接覆盖远程历史,导致团队代码丢失
git pull 拉取远程当前分支的最新代码,并自动合并到本地分支 面试核心考点:git pull = git fetch + git merge,默认用 merge 方式合并,会生成合并提交
git pull --rebase 拉取远程最新代码,并用 rebase 方式合并到本地分支 多人协作高频用法,避免生成多余的合并提交,保持本地历史线性
git fetch 仅拉取远程仓库的所有最新提交到本地,不会自动合并 面试常问和git pull的区别:更安全,可先查看远程改动,再手动决定是否合并,不会直接修改本地工作区

5. 临时存储 stash 全命令(面试高频)

表格

命令 核心作用 面试注意事项
git stash 将当前分支未提交的改动(工作区 + 暂存区)临时保存到堆栈中,清空工作区 高频场景:正在开发功能,突然需要切换分支改 bug,又不想提交半成品代码
git stash save "存储备注信息" 给 stash 记录添加备注,方便后续识别 多个 stash 记录时必用,避免分不清存储的内容
git stash list 查看堆栈中所有的 stash 存储记录
git stash pop 恢复堆栈中最新的一条 stash 记录,并删除该条记录 恢复后会自动从堆栈中移除,对应git stash的反向操作
git stash apply 恢复最新的 stash 记录,但不会从堆栈中删除 场景:需要把同一份改动恢复到多个分支
git stash drop 删除堆栈中最新的一条 stash 记录
git stash clear 清空堆栈中所有的 stash 记录

6. 提交历史查看命令

表格

命令 核心作用 面试注意事项
git log 查看当前分支的完整提交日志记录,包含提交哈希、作者、时间、提交信息
git log --oneline 一行显示一条提交记录,仅展示简短提交哈希和提交信息 高频用法,快速查看提交历史,面试必提
git log --graph 图形化展示分支的合并历史和分叉情况 配合--oneline使用效果极佳,直观看到分支合并轨迹
git log -p 查看提交日志的同时,显示每次提交的具体代码改动内容 排查问题、代码溯源高频用法

7. 工作区修改撤销与文件恢复

表格

命令 核心作用 面试注意事项
git checkout -- <文件路径/文件名> 用暂存区的版本覆盖工作区的文件,撤销未暂存的修改 ⚠️ 高危操作:修改不可逆,本地未暂存的改动会永久丢失
git checkout -- . 撤销当前目录所有未暂存的修改
git checkout <提交哈希/分支名> -- <文件路径> 用指定提交 / 分支的文件版本,覆盖当前工作区和暂存区的对应文件 场景:恢复某个文件到历史版本,不影响其他文件

补充:Git 2.23+ 官方推出git restore替代checkout的文件恢复功能,语义更清晰:

  • 撤销工作区未暂存修改:git restore <文件名>
  • 撤销暂存区的修改:git restore --staged <文件名>

二、前端面试最高频考点:git merge vs git rebase 深度辨析

这是 Git 面试的必问题,90% 的面试官都会问到,很多同学只能答出 “一个会生成合并提交,一个不会”,但想要拿到高分,必须从原理、区别、优缺点、场景、禁忌全维度讲透。

1. 核心相同点

git mergegit rebase核心目标完全一致:将一个分支的代码变更,整合到另一个分支中,是 Git 中最核心的两种分支合并方案。

2. 核心原理(面试答题先讲原理,直接拉开差距)

我们用一个最常见的开发场景举例:

主分支main有提交记录 A→B→C,我们从C切出功能分支feature开发,提交了D→E;此时main分支有了新的提交F→G,现在需要把main的最新代码合并到feature,或者把feature合并到main

git merge 原理

git merge采用三方合并策略,执行git merge feature时会做 3 件事:

  1. 找到两个分支的最近共同祖先 C
  2. 基于共同祖先,将两个分支的变更做三方合并对比;
  3. 最终生成一个全新的合并提交 H,这个提交有两个父提交,分别指向两个分支的最新提交GE,同时完整保留两个分支的所有原始提交历史。

最终合并后的main分支历史:A→B→C→F→G→H(合并提交)feature分支的D、E提交完整保留,时间线是分叉的。

git rebase 原理

rebase直译是变基,核心是改变分支的基准,执行git rebase main时会做 4 件事:

  1. 找到两个分支的最近共同祖先 C
  2. 提取feature分支上从C之后的所有提交D、E,临时保存起来;
  3. feature分支的基准指针,直接指向main分支的最新提交G
  4. 按顺序将临时保存的D、E,逐个重放应用到新的基准G上,生成新的提交D'、E'

最终变基后的feature分支历史:A→B→C→F→G→D'→E',形成了完全线性的提交记录,没有任何合并提交,原始的D、E提交会被废弃,提交历史被重写。

3. 全维度对比表(面试分点答,逻辑拉满)

表格

对比维度 git merge git rebase
核心逻辑 三方合并,生成全新的合并提交 变基重放,逐个应用提交,重写提交历史
提交历史 完整保留所有分支的原始提交,时间线分叉,上下文完整 重写提交历史,形成线性记录,无多余合并提交,原始上下文丢失
冲突处理 合并时仅需解决 1 次冲突,解决后生成合并提交即可,成本极低 变基过程中,每个提交重放时都可能产生冲突,需要逐个解决,提交越多成本越高
操作安全性 极高,不会修改现有提交历史,所有操作都有记录可追溯,不会丢失代码 高危,会重写提交历史,操作失误极易丢失提交,可通过git reflog恢复,但有门槛
代码溯源 完整的合并轨迹,可精准定位 bug 是哪个分支、哪次提交引入的,排查问题效率高 提交历史被重写,原始提交的上下文丢失,问题溯源难度大幅提升
公共分支兼容性 完全兼容,是公共分支合并的标准方案 绝对禁止在公共分支使用,会导致团队成员分支历史不一致,引发灾难性冲突

4. 优缺点详解

git merge 优缺点

✅ 优点:

  1. 操作简单、上手门槛低,符合 Git 分布式设计的初衷,全程安全无风险;
  2. 完整保留所有分支的开发上下文和提交历史,方便后续代码审计、问题回溯、版本回滚;
  3. 冲突处理简单,仅需解决一次冲突,不会出现重复处理冲突的情况;
  4. 支持快进合并(Fast-Forward),当目标分支无新提交时,可直接移动分支指针,无需生成合并提交。

❌ 缺点:

  1. 多人协作频繁合并时,会产生大量的合并提交,导致提交历史分叉严重,可读性变差;
  2. 对于追求简洁线性提交历史的团队,多余的合并提交会显得冗余,不利于版本管理。

git rebase 优缺点

✅ 优点:

  1. 最终会形成干净、无分叉的线性提交历史,提交日志可读性极强,方便版本迭代回溯;
  2. 支持交互式变基git rebase -i,可在合并前整理本地提交(合并零散提交、修改提交信息、删除无用提交),让提交记录更规范;
  3. git pull --rebase拉取远程代码,可避免生成多余的合并提交,保持本地分支的线性历史。

❌ 缺点:

  1. 操作风险高,重写提交历史的操作不可逆,一旦失误极易丢失代码;
  2. 冲突处理成本高,多个提交重放时需要逐个解决冲突,重复操作多;
  3. 重写历史后,原始提交的上下文丢失,出现问题时很难精准定位 bug 引入的节点;
  4. 在公共分支使用会给团队带来灾难性后果,所有成员都需要强制同步重写后的历史,极易出现代码丢失、冲突爆炸。

5. 最佳实践 & 使用场景(面试必答,体现你的实战经验)

git merge 推荐使用场景

  1. 将功能分支合并到公共主分支(main/master、develop)时,必须使用 git merge,建议搭配--no-ff参数(禁用快进合并),强制生成合并提交,完整保留分支合并的上下文,方便后续追溯和回滚;
  2. 多人协作的公共分支之间的合并,保证所有团队成员的提交历史一致,不会出现历史混乱;
  3. 合并到上线分支、生产分支时,必须使用 merge,保证所有操作可追溯,出问题可快速回滚;
  4. 需要完整保留分支开发上下文,用于代码审计、合规检查的场景。

git rebase 推荐使用场景

  1. 本地私有功能分支的提交历史整理,比如自己开发的 feature 分支,在合并到公共分支之前,用git rebase -i HEAD~n整理零散的提交,让提交记录语义化、规范化;
  2. 本地分支拉取远程公共分支的最新代码时,用git pull --rebase代替默认的git pull,避免生成多余的合并提交,保持本地分支的线性历史;
  3. 给开源项目提交 PR/MR 时,绝大多数开源项目要求提交历史是线性的,需要用 rebase 基于上游最新分支整理提交,避免合并冲突和冗余的合并提交;
  4. 个人独立开发的项目,想要保持干净的线性提交历史,可自由使用 rebase。

6. 黄金法则(面试答出来直接加分)

永远不要在已经推送到远程的公共分支上,执行 git rebase 操作!

公共分支是所有团队成员的开发基准,你 rebase 之后会重写公共分支的提交历史,其他成员的本地分支还是基于原来的历史,当他们拉取代码时,会出现两个版本的历史,合并后会产生大量重复的提交和无法解决的冲突,最终导致代码仓库的历史彻底混乱,甚至丢失核心代码。


三、前端面试 Git 高频附加题

除了核心的 merge/rebase,这些考点也是面试官常问的,补充在这里帮大家全面通关:

1. git reset 和 git revert 的区别?

核心区别:是否重写提交历史,是否可逆

  • git revert:生成一个新的提交,反向撤销目标提交的所有改动,不会修改现有提交历史,安全,适合公共分支的代码回滚,所有操作可追溯;
  • git reset:直接移动分支指针,删除目标提交之后的所有提交,会重写提交历史,分为--soft(保留改动到暂存区)、--mixed(默认,保留改动到工作区)、--hard(彻底丢弃所有改动,高危),仅适合本地私有分支的回滚,绝对禁止在已推送的公共分支使用。

2. 什么是分离头指针(detached HEAD)?有什么风险?

  • 定义:执行git checkout <提交哈希/标签名>时,HEAD 指针不再指向任何一个命名分支,而是直接指向一个具体的提交记录,此时就进入了分离头指针状态;
  • 风险:此状态下的提交,属于匿名分支上的提交,一旦切换到其他分支,这些提交会被 Git 的垃圾回收机制清理,极易丢失;
  • 解决方案:如果需要在此状态下保留修改,立即执行git checkout -b <新分支名>,创建新分支保存这些提交。

3. .gitignore 文件不生效怎么办?

  • 根本原因:.gitignore只能忽略未被跟踪的文件,如果文件已经被提交到 Git 仓库,就不会被 ignore 规则匹配;

  • 解决方案:

    1. 先把本地需要忽略的文件备份,避免丢失;
    2. 执行git rm -r --cached .,清除所有文件的本地跟踪缓存;
    3. 重新执行git add .,此时.gitignore规则会生效,忽略指定文件;
    4. 提交修改到仓库即可。

4. 不小心把账号密码、密钥等敏感信息提交到 Git 仓库了,怎么办?

  • 第一步:立即修改敏感信息的密码 / 密钥,杜绝泄露风险;

  • 第二步:清理 Git 仓库中的敏感信息:

    • 如果是仅本地提交、未推送到远程:用git reset --soft HEAD~1回滚提交,修改后重新提交即可;
    • 如果已经推送到远程公共仓库:用git filter-repo(官方推荐)或 BFG 工具彻底清理历史记录,清理后需要强制推送重写历史;
  • 第三步:如果是开源仓库,建议联系平台仓库管理员,彻底清理缓存记录。


结尾总结

Git 作为前端开发的必备工具,面试考察的核心从来不是你背了多少命令,而是你是否理解每个操作背后的原理,是否知道不同操作的风险和最佳实践。

核心记住两点:

  1. 公共分支永远用merge,保证安全和可追溯;私有分支可以用rebase整理提交历史,保持简洁;
  2. 所有会重写提交历史的操作(rebasereset --hardcommit --amend),绝对不要用在已经推送到远程的公共分支上。

这篇文章整理了我备战前端实习面试的 Git 核心笔记,希望能帮到同样在找工作的同学。如果觉得有用,欢迎点赞、收藏、评论,我会持续更新前端面试的干货内容~

Flutter与Rust混合开发入门指南

作者 Xiao正
2026年3月31日 14:05

1 Flutter Rust Bridge

FRB(Flutter Rust Bridge) 是一个 Flutter/Dart 与 Rust 之间的绑定生成器,让你可以在 Flutter 应用中直接调用 Rust 代码,利用 Rust 的性能和安全性。

1.1 核心特性

  • 自动生成桥接代码:无需手动编写 FFI(Foreign Function Interface),只需编写正常的 Rust 代码
  • 支持任意类型:Struct、Enum、Option、Result、泛型等
  • 异步编程支持:async/await、线程池、Stream
  • 双向调用:Dart 调用 Rust,Rust 也可以调用 Dart
  • 多平台支持:Android、iOS、macOS、Windows、Linux

1.2 工作原理

  1. 代码生成阶段
    • 分析 Rust 代码中的 #[frb] 标记,自动生成 Dart 绑定代码和 Rust 胶水代码
    • 处理类型转换和内存管理
  2. 运行时绑定
    • 通过 FFI 实现通信
    • 自动处理内存分配和释放,支持零拷贝数据传输

2 鸿蒙适配

2.1 flutter_rust_bridge_ohos

FRB 的鸿蒙平台扩,提供鸿蒙系统的运行时适配。

2.2 frb_plugin_tool_ohos

快速生成 FRB 插件项目的命令行工具,支持多平台,包括鸿蒙。

3 环境搭建

3.1 Rust 编译 Target 安装

# Android
rustup target add aarch64-linux-android    # ARM64,主流设备
rustup target add armv7-linux-androideabi  # ARM32,老设备
rustup target add x86_64-linux-android     # x86_64,模拟器
rustup target add i686-linux-android       # x86,老模拟器

# iOS
rustup target add aarch64-apple-ios        # ARM64 真机
rustup target add aarch64-apple-ios-sim    # ARM64 模拟器(Apple Silicon Mac)
rustup target add x86_64-apple-ios         # x86_64 模拟器(Intel Mac)

# 鸿蒙
rustup target add aarch64-unknown-linux-ohos  # ARM64 真机
rustup target add x86_64-unknown-linux-ohos   # x86_64 模拟器

3.2 fvm 安装

fvm(Flutter Version Management)是一个用于管理多个 Flutter SDK 版本的命令行工具,它能让开发者在不同项目间轻松切换和使用指定的 Flutter 版本。

安装命令:

dart pub global activate fvm

常用命令:

# 查看版本
fvm --version

# 查看帮助
fvm --help

# 安装 Flutter 版本
fvm install xxx

# 查看已安装版本,该命令可能会比较慢
fvm list

# 使用指定版本
fvm use xxx

通过 fvm 安装的 Flutter 在 ~/fvm/versions 目录下,之前已有的也可以移动到该目录。

对于非官方的版本(例如鸿蒙适配的版本),需要改为 custom_xxx,避免出现问题。

3.3 flutter_rust_bridge_codegen_ohos 安装

flutter_rust_bridge_codegen_ohos 是 FRB 的代码生成工具。

安装命令:

cargo install flutter_rust_bridge_codegen_ohos

常用命令:

# 查看版本
flutter_rust_bridge_codegen_ohos --version

# 查看帮助
flutter_rust_bridge_codegen_ohos --help

# 生成绑定代码
flutter_rust_bridge_codegen_ohos generate

3.4 frb_plugin_tool_ohos 安装

安装命令:

cargo install frb_plugin_tool_ohos

常用命令:

# 查看版本
frb_plugin_tool_ohos --version

# 查看帮助
frb_plugin_tool_ohos --help

3.5 鸿蒙开发环境配置

frb_plugin_tool_ohos presetup \
  -s <脚本目录> \
  -o <SDK 路径> \
  [-f]

参数:

  • -s, --script-path: 脚本存放目录(必需)
    • 例如:-s ~/.ohos/script
    • 如果目录不存在会自动创建
  • -o, --openharmony-path: OpenHarmony SDK 路径,可单独安装也可使用 DevEco Studio 里面的(必需)
    • 例如:-o /Applications/DevEco-Studio.app/Contents/sdk/default/openharmony
    • 这是 SDK 的 native 目录的父路径
  • -f, --force: 强制替换现有配置(可选)
    • 默认:false
    • 如果检测到已有配置,使用此选项强制覆盖

示例:

普通模式(检测已有配置):

frb_plugin_tool_ohos presetup \
  -s ~/.ohos/script \
  -o /Applications/DevEco-Studio.app/Contents/sdk/default/openharmony

强制模式(覆盖现有配置):

frb_plugin_tool_ohos presetup \
  -s ~/.ohos/script \
  -o /Applications/DevEco-Studio.app/Contents/sdk/default/openharmony \
  -f

该命令会自动:

  1. 创建编译脚本(在 -s 参数指定的目录下):
    • aarch64-unknown-linux-ohos-clang.sh
    • aarch64-unknown-linux-ohos-clang++.sh
    • x86_64-unknown-linux-ohos-clang.sh
    • x86_64-unknown-linux-ohos-clang++.sh
    • 自动设置可执行权限(755)
  2. 配置 Cargo~/.cargo/config.toml):
    • 添加 aarch64-unknown-linux-ohos target 配置
    • 添加 x86_64-unknown-linux-ohos target 配置
    • 配置 linker 和 ar 工具路径
    • 设置 CC/CXX 环境变量
  3. 智能处理
    • 检测已有配置(普通模式)
    • 强制替换旧配置(强制模式)
    • 自动创建必要的目录

4 Hello World 实战

4.1 创建插件项目

命令:

frb_plugin_tool_ohos create -n demo -f custom_3.22.0-ohos

参数说明:

  • -n, --name: 插件名称(必需),如:demo
  • -f, --fvm-flutter-version: Flutter 版本(必需),如:custom_3.22.0-ohos

注意:如果创建失败或者慢,尝试开启代理。

4.2 修改 flutter_rust_bridge_ohos 版本

创建完成后,修改 pubspec.yaml 中 flutter_rust_bridge_ohos 的版本为 2.11.2,2.11.1 在非鸿蒙上有问题。

2.11.1 中使用 Platform.isOhos 判断是否为鸿蒙,在非鸿蒙平台上调用 Platform.isOhos 会直接报错。

4.3 修改 flutter_rust_bridge 配置

修改 flutter_rust_bridge.yaml,内容如下:

# 需要生成dart的rust代码文件
rust_input: rust/src/api/**/*.rs

# rust生成的基础代码目录
dart_output: lib/src

4.4 编写 Rust 函数

编辑文件: rust/src/api/simple.rs

use flutter_rust_bridge::frb;

/// 问候函数
///
/// # 参数
/// * `name` - 要问候的名称
///
/// # 返回值
/// 返回问候字符串
#[frb(sync)]
pub fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}

说明:

  • #[frb(sync)] 标记表示生成同步调用的绑定代码
  • 默认情况下(不添加标记)生成异步绑定代码
  • 函数参数和返回值会自动进行类型转换

4.5 生成绑定代码

命令:

flutter_rust_bridge_codegen_ohos generate

生成内容:

  • lib/src/api/simple.dart - Dart 绑定代码
  • lib/src/frb_generated.dart - Dart 端生成代码
  • rust/src/frb_generated.rs - Rust 端生成代码

4.6 Flutter 端调用

import 'package:flutter/material.dart';
import 'package:hpt_tracking/hpt_tracking.dart';

Future<void> main() async {
  // 初始化
  await RustLib.init();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // 调用Rust
    final hello = greet(name: "World");

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Flutter + Rust Demo')),
        body: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(hello, textAlign: TextAlign.center),
          ],
        ),
      ),
    );
  }
}

5 参考链接

# 从"会用 AI"到"架构 AI":高级前端的认知升级

作者 DanCheOo
2026年3月31日 09:55

高级前端的 AI 焦虑:你的经验到底还值不值钱

本文是【高级前端的 AI 架构升级之路】系列第 01 篇。 下一篇:从"会用 AI"到"架构 AI":高级前端的认知升级


写在前面

这个系列是写给5 年左右经验的前端开发者的。

你可能已经是团队里的技术骨干,写过组件库、搭过微前端、做过性能优化、带过几个人的小团队。你对前端这套东西已经很熟了,甚至有点腻了。

然后 AI 来了。

2025 年,你每天打开技术社区看到的都是 AI 相关的内容。Cursor 颠覆了写代码的方式,各种 AI Agent 在自动完成越来越复杂的任务,老板开始在周会上问"我们产品能不能加点 AI 功能"。

你的焦虑可能比初级前端还大——因为初级前端可以说"我还年轻,学就是了",而你在想:我花了 5 年积累的经验,在 AI 时代还值钱吗?

这篇文章不给你灌鸡汤,而是帮你冷静分析:哪些能力确实在贬值,哪些能力在升值,以及 5 年前端在 AI 时代最值得走的三个方向。


你的 5 年积累了什么

先做个盘点。5 年经验的前端,技能树大概长这样:

硬技能:

  • 框架深度:Vue 或 React 全家桶,源码级别的理解
  • 工程化:Webpack/Vite、CI/CD、代码规范、monorepo
  • 架构能力:组件设计、状态管理、微前端、BFF
  • 性能优化:首屏加载、运行时性能、内存泄漏排查
  • 跨端方案:小程序、Electron、React Native/Flutter

软技能:

  • 需求分析:拿到 PRD 能快速评估技术方案和工时
  • 技术决策:在多个方案中选择最合适的那个
  • 代码质量:Review 别人代码时一眼看出问题
  • 团队协作:和后端、产品、设计高效沟通
  • 项目管理:排期、风险把控、资源协调

这些加起来,是一个高级前端和一个初级前端的核心差距。问题是——AI 对这两类能力的影响完全不同。


AI 让哪些能力贬值了

先说坏消息。以下这些能力,AI 确实在侵蚀它们的市场价值:

1. 组件编写

以前写一个复杂的表单组件、一个虚拟滚动列表,需要丰富的经验。现在你在 Cursor 里描述一下需求,几十秒就能生成一个可用的版本。

这不是说组件编写不重要了——而是说这项能力的稀缺性大幅下降了。一个用了半年 Cursor 的初级开发者,在组件产出效率上可能已经接近你。

2. 样式调试

CSS 是 AI 最擅长的领域之一。"帮我做一个响应式卡片布局"、"这个动画效果有卡顿帮我优化"——AI 处理这些问题的速度和质量已经相当高。

3. 简单业务逻辑

CRUD 表单、列表筛选、基础数据处理——这些"搬砖"类型的工作,AI 能做得越来越好。

4. 文档型知识

"Vue 3 的 Composition API 怎么用"、"React 的 useEffect 清理函数怎么写"——这类记忆型知识的价值在快速归零,因为 AI 随时能给你答案。

共同特点:这些都是"确定性高、模式化强"的能力。越是有固定模式、有标准答案的东西,AI 替代起来越快。


AI 让哪些能力升值了

再说好消息,而且是大好消息。

1. 系统设计能力

"这个产品需要接入 AI 功能,怎么在现有架构上设计?AI 模块放在哪一层?和现有的用户系统、权限系统怎么打通?"

这种问题 AI 给不了靠谱答案——因为它不了解你的系统上下文、团队现状和业务约束。在一个具体的系统里做架构决策,需要经验和判断力,这恰恰是 5 年经验给你的东西。

2. 技术决策能力

"用 LangChain 还是自己封装?用 SSE 还是 WebSocket?Prompt 放在前端还是后端?用 Dify 二次开发还是自建平台?"

每个问题都没有标准答案,取决于场景、成本、团队能力、时间窗口。AI 能列出 pros and cons,但最终做决定的是人。会做技术决策的人,在 AI 时代价值翻倍——因为可选方案变多了,但做出正确选择反而更难了。

3. 复杂度管理

AI 生成代码很快,但它生成的代码堆叠在一起,半年后就是一坨无法维护的 shit。

谁来设计模块边界?谁来定义接口协议?谁来确保系统的可维护性和可演进性?——是你,5 年经验的工程师。

AI 加速了代码产出,但也加速了系统变复杂的速度。管理复杂度的人,比以前更重要了。

4. 用户体验直觉

AI 聊天界面不是加个输入框就完事了。流式输出怎么渲染才不卡?AI 在"思考"的时候界面该展示什么?多个 AI Agent 同时工作时怎么给用户呈现进度?AI 回答错了怎么优雅地让用户纠正?

这些都是新的 UX 问题,没有现成的设计规范。对用户体验有直觉、能设计出好用的 AI 交互,是前端独有的优势。

5. 跨域沟通能力

AI 产品开发需要前端、后端、算法、产品经理、业务方紧密协作。你能不能用后端听得懂的语言描述前端的需求?能不能帮产品经理理解 AI 的能力边界?能不能在老板面前讲清楚 AI 方案的 ROI?

5 年职场积累的沟通协作能力,在 AI 项目的复杂协作中更加关键。


一个关键结论

把上面的分析总结成一句话:

AI 在吃掉"执行层"的价值,在放大"设计层"和"决策层"的价值。

你在"执行层"待了 5 年,积累的经验让你升到了"设计层"和"决策层"。这恰好是 AI 碰不到的地方。

所以答案是:你的经验不但值钱,而且比以前更值钱了——前提是你别继续在执行层卷。


高级前端 vs 初级前端:转 AI 的路线差异

同样是转 AI 方向,5 年经验和 1 年经验的人应该走完全不同的路。

维度 初级前端转 AI 高级前端转 AI
切入点 学 AI API 调用、写 Demo 设计 AI 在系统中的架构
技术重心 调通 API、做出效果 多模型路由、降级、成本控制
产出物 AI 聊天 UI、CLI 工具 AI 平台架构、Prompt 管理系统
学习方式 跟教程、做项目 看架构文章、分析开源项目
竞争力 "我会用 AI" "我能设计 AI 系统"
薪资定位 +20-30% +50-100%

初级前端转 AI 的核心挑战是"学新东西",高级前端转 AI 的核心挑战是"重新定位自己"。

你不需要从零学怎么调 AI API(那是两个小时的事)。你需要想清楚的是:如何把 5 年的工程化经验和 AI 能力结合,产生 1+1>2 的效果。


三个高价值方向

基于我自己的转型经验和对行业的观察,我推荐 5 年前端重点考虑这三个方向:

方向一:AI 应用架构师

做什么:设计 AI 功能在产品中的技术架构——模型选型、调用链路、降级策略、成本控制、流式架构、安全方案。

为什么适合你:你做了多年的前端架构设计,系统设计能力是现成的。AI 应用架构是传统架构能力的自然延伸,只是多了"非确定性"、"高延迟"、"按 token 计费"这几个新约束。

市场需求:几乎每家想做 AI 产品的公司都缺这个角色。会调 API 的人很多,能把 AI 能力稳定可靠地嵌入现有产品架构的人很少。

这个系列的重点就在这个方向。

方向二:AI 产品技术合伙人

做什么:和产品经理一起定义"产品哪里该用 AI、怎么用",兼顾技术可行性和用户体验。

为什么适合你:5 年经验让你有了产品直觉——你知道用户要什么,知道哪些需求是伪需求。这在 AI 时代特别重要,因为"能做"和"该做"之间的差距比以往任何时候都大。很多团队在 AI 功能上犯的错误不是技术问题,而是"不该加 AI 的地方加了 AI"。

市场需求:AI 产品的成功率极低,大部分失败不是因为技术不行,而是因为产品定义有问题。懂技术又懂产品的人,在 AI 创业公司和大厂 AI 团队都是稀缺角色。

方向三:AI Infra 工程师

做什么:搭建公司内部的 AI 基础设施——AI 网关、Prompt 管理平台、知识库系统、AI 监控和可观测性。

为什么适合你:这本质上就是"内部工具平台开发",和你之前做的组件库、脚手架、CI/CD 工具是一个思路。只是服务对象从"前端开发者"变成了"用 AI 的所有团队"。

市场需求:中大型公司(50 人以上技术团队)几乎都需要一个统一的 AI 平台,否则各团队各自调 API,成本、质量、安全全失控。


我的判断:高级前端是 AI 应用时代最被低估的角色

这个判断可能有些激进,但我是认真的。

当前 AI 应用开发的主力是两类人:

  1. 算法工程师:擅长模型,但对产品和工程化不敏感。他们做出来的东西经常是"技术上可行,但用户体验很烂"。
  2. 后端工程师:擅长系统设计,但对用户交互不敏感。他们设计的 AI 系统往往很强大,但前端界面是"能用就行"的水平。

而 AI 应用的核心战场,恰恰在用户侧——AI 的输出最终要展示给用户看、让用户用。流式渲染、AI 交互设计、生成式 UI、多 Agent 可视化……这些全都是前端的活。

一个 5 年经验的前端,有系统设计能力、有用户体验直觉、有工程化思维、有跨团队协作经验——再加上 AI 应用架构能力,这个组合在市场上极度稀缺。

你不是在转行,你是在升级。


总结

  1. AI 在吃掉执行层价值,放大设计层和决策层价值。 5 年经验让你已经站在了设计层。
  2. 组件编写、样式调试、简单逻辑在贬值,但系统设计、技术决策、复杂度管理、UX 直觉在升值。
  3. 高级前端转 AI 的路线和初级完全不同——不是学怎么调 API,而是学怎么设计 AI 系统。
  4. 三个高价值方向:AI 应用架构师、AI 产品技术合伙人、AI Infra 工程师。
  5. 高级前端是 AI 应用时代最被低估的角色——系统设计 + 用户体验 + 工程化思维的组合,在 AI 时代价值翻倍。

这个系列接下来的文章会围绕"AI 应用架构师"这个方向展开,从架构设计、平台开发、产品化到团队转型,帮你把 5 年前端经验升级为 AI 时代的核心竞争力。

下一篇,我们进入核心主题:从"会用 AI"到"架构 AI"——高级前端需要的认知升级到底是什么?


下一篇预告02 | 从"会用 AI"到"架构 AI":高级前端的认知升级


讨论话题:你做前端多少年了?AI 让你最焦虑的是什么?你觉得自己哪些经验在 AI 时代反而更值钱了?评论区聊聊。

每日一题-机器人碰撞🔴

2026年4月1日 00:00

现有 n 个机器人,编号从 1 开始,每个机器人包含在路线上的位置、健康度和移动方向。

给你下标从 0 开始的两个整数数组 positionshealths 和一个字符串 directionsdirections[i]'L' 表示 向左'R' 表示 向右)。 positions 中的所有整数 互不相同

所有机器人以 相同速度 同时 沿给定方向在路线上移动。如果两个机器人移动到相同位置,则会发生 碰撞

如果两个机器人发生碰撞,则将 健康度较低 的机器人从路线中 移除 ,并且另一个机器人的健康度 减少 1 。幸存下来的机器人将会继续沿着与之前 相同 的方向前进。如果两个机器人的健康度相同,则将二者都从路线中移除。

请你确定全部碰撞后幸存下的所有机器人的 健康度 ,并按照原来机器人编号的顺序排列。即机器人 1 (如果幸存)的最终健康度,机器人 2 (如果幸存)的最终健康度等。 如果不存在幸存的机器人,则返回空数组。

在不再发生任何碰撞后,请你以数组形式,返回所有剩余机器人的健康度(按机器人输入中的编号顺序)。

注意:位置  positions 可能是乱序的。

 

示例 1:

输入:positions = [5,4,3,2,1], healths = [2,17,9,15,10], directions = "RRRRR"
输出:[2,17,9,15,10]
解释:在本例中不存在碰撞,因为所有机器人向同一方向移动。所以,从第一个机器人开始依序返回健康度,[2, 17, 9, 15, 10] 。

示例 2:

输入:positions = [3,5,2,6], healths = [10,10,15,12], directions = "RLRL"
输出:[14]
解释:本例中发生 2 次碰撞。首先,机器人 1 和机器人 2 将会碰撞,因为二者健康度相同,二者都将被从路线中移除。接下来,机器人 3 和机器人 4 将会发生碰撞,由于机器人 4 的健康度更小,则它会被移除,而机器人 3 的健康度变为 15 - 1 = 14 。仅剩机器人 3 ,所以返回 [14] 。

示例 3:

输入:positions = [1,2,5,6], healths = [10,10,11,11], directions = "RLRL"
输出:[]
解释:机器人 1 和机器人 2 将会碰撞,因为二者健康度相同,二者都将被从路线中移除。机器人 3 和机器人 4 将会碰撞,因为二者健康度相同,二者都将被从路线中移除。所以返回空数组 [] 。

 

提示:

  • 1 <= positions.length == healths.length == directions.length == n <= 105
  • 1 <= positions[i], healths[i] <= 109
  • directions[i] == 'L'directions[i] == 'R'
  • positions 中的所有值互不相同

栈模拟

作者 424479543
2023年6月25日 13:05

周赛做这题的时候脑残了,竟然想着用线段树,以前都是靠T4拉分的,这次全靠T4掉分。记录一下耻辱。

###python3

class Solution:
    def survivedRobotsHealths(self, positions: List[int], healths: List[int], directions: str) -> List[int]:
        z = [list(x) for x in zip(positions,healths,directions,count() )  ] 
        z.sort() 
        st = [] 
        for i,x in enumerate(z):
            if x[2] == 'R':
                st.append(i) 
                continue 
            while st and z[i][1]:  #st里还有'R'活着,当前'L'还活着
                j = st[-1]
                if z[j][1] > z[i][1]:
                    z[j][1] -= 1   #左边R健康减1
                    z[i][1] = 0    #干掉当前L
                elif z[j][1] == z[i][1]:
                    z[st.pop()][1] = 0  #干掉左边R
                    z[i][1] = 0         #干掉当前L
                else : 
                    z[st.pop()][1] = 0  #干掉左边R
                    z[i][1] -= 1        #当前L健康减1
        z.sort(key = lambda x:x[-1]) 
        return [x[1] for x in z if x[1]]

用栈维护机器人(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2023年6月25日 12:16

推荐先完成本题的简单版本:735. 行星碰撞我的题解

从左到右遍历这些机器人(需要先按照位置排序),向右的机器人会和向左的机器人碰撞。

遍历到一个向左的机器人时,我们需要找到左边最近的未移除的机器人。这可以用一个栈维护。

如果当前机器人向右,那么直接入栈,继续向后遍历。

如果当前机器人向左,设其健康度为 $h$,栈顶机器人的健康度为 $\textit{top}$,分类讨论:

  • 如果 $\textit{top} > h$,那么移除当前机器人,$\textit{top}$ 减一。
  • 如果 $\textit{top} = h$,那么两个机器人都移除。
  • 如果 $\textit{top} < h$,那么移除栈顶机器人,$h$ 减一。
  • 如此循环,直到当前机器人被移除,或者栈顶为空。

注意:比大小的这两个健康度都是正整数,所以减一的那个健康度一定大于 $1$。所以减一后,健康度大于 $0$。

代码实现时,直接在 $\textit{healths}$ 上修改,移除机器人 $i$ 相当于把 $\textit{healths}[i]$ 置为 $0$。最后返回 $\textit{healths}$ 中的正数。

class Solution:
    def survivedRobotsHealths(self, positions: List[int], healths: List[int], directions: str) -> List[int]:
        # 创建一个下标数组,对下标数组排序,这样不会打乱输入顺序
        idx = sorted(range(len(positions)), key=lambda i: positions[i])

        st = []
        for i in idx:
            if directions[i] == 'R':  # 机器人 i 向右
                st.append(i)
                continue
            while st:  # 栈顶机器人向右
                j = st[-1]
                if healths[j] > healths[i]:  # 栈顶机器人的健康度大
                    healths[i] = 0  # 移除机器人 i
                    healths[j] -= 1
                    break
                if healths[j] == healths[i]:  # 健康度一样大,都移除
                    healths[i] = 0
                    healths[j] = 0
                    st.pop()
                    break
                # 机器人 i 的健康度大
                healths[i] -= 1
                healths[j] = 0  # 移除机器人 j
                st.pop()

        # 返回幸存机器人的健康度
        return [h for h in healths if h > 0]
class Solution {
    public List<Integer> survivedRobotsHealths(int[] positions, int[] healths, String directions) {
        int n = positions.length;
        // 创建一个下标数组,对下标数组排序,这样不会打乱输入顺序
        Integer[] idx = new Integer[n];
        for (int i = 0; i < n; i++) {
            idx[i] = i;
        }
        Arrays.sort(idx, (i, j) -> positions[i] - positions[j]);

        int[] st = new int[n];
        int top = -1;
        for (int i : idx) {
            if (directions.charAt(i) == 'R') { // 机器人 i 向右
                st[++top] = i;
                continue;
            }
            while (top >= 0) { // 栈顶机器人向右
                int j = st[top];
                if (healths[j] > healths[i]) { // 栈顶机器人的健康度大
                    healths[i] = 0; // 移除机器人 i
                    healths[j]--;
                    break;
                }
                if (healths[j] == healths[i]) { // 健康度一样大,都移除
                    healths[i] = 0;
                    healths[j] = 0;
                    top--;
                    break;
                }
                // 机器人 i 的健康度大
                healths[i]--;
                healths[j] = 0; // 移除机器人 j
                top--;
            }
        }

        // 返回幸存机器人的健康度
        List<Integer> ans = new ArrayList<>();
        for (int h : healths) {
            if (h > 0) {
                ans.add(h);
            }
        }
        return ans;
    }
}
class Solution {
public:
    vector<int> survivedRobotsHealths(vector<int>& positions, vector<int>& healths, string directions) {
        int n = positions.size();
        // 创建一个下标数组,对下标数组排序,这样不会打乱输入顺序
        vector<int> idx(n);
        ranges::iota(idx, 0); // idx[i] = i
        ranges::sort(idx, {}, [&](int i) { return positions[i]; });

        stack<int> st;
        for (int i : idx) {
            if (directions[i] == 'R') { // 机器人 i 向右
                st.push(i);
                continue;
            }
            while (!st.empty()) { // 栈顶机器人向右
                int j = st.top();
                if (healths[j] > healths[i]) { // 栈顶机器人的健康度大
                    healths[i] = 0; // 移除机器人 i
                    healths[j]--;
                    break;
                }
                if (healths[j] == healths[i]) { // 健康度一样大,都移除
                    healths[i] = 0;
                    healths[j] = 0;
                    st.pop();
                    break;
                }
                // 机器人 i 的健康度大
                healths[i]--;
                healths[j] = 0; // 移除机器人 j
                st.pop();
            }
        }

        // 返回幸存机器人的健康度
        vector<int> ans;
        for (int h : healths) {
            if (h > 0) {
                ans.push_back(h);
            }
        }
        return ans;
    }
};
int* _positions;

int cmp(const void* i, const void* j) {
    return _positions[*(int*)i] - _positions[*(int*)j];
}

int* survivedRobotsHealths(int* positions, int positionsSize, int* healths, int healthsSize, char* directions, int* returnSize) {
    int n = positionsSize;
    // 创建一个下标数组,对下标数组排序,这样不会打乱输入顺序
    int* idx = malloc(n * sizeof(int));
    for (int i = 0; i < n; i++) {
        idx[i] = i;
    }
    _positions = positions;
    qsort(idx, n, sizeof(int), cmp);

    int* st = malloc(n * sizeof(int));
    int top = -1;
    for (int k = 0; k < n; k++) {
        int i = idx[k];
        if (directions[i] == 'R') { // 机器人 i 向右
            st[++top] = i;
            continue;
        }
        while (top >= 0) { // 栈顶机器人向右
            int j = st[top];
            if (healths[j] > healths[i]) { // 栈顶机器人的健康度大
                healths[i] = 0; // 移除机器人 i
                healths[j]--;
                break;
            }
            if (healths[j] == healths[i]) { // 健康度一样大,都移除
                healths[i] = 0;
                healths[j] = 0;
                top--;
                break;
            }
            // 机器人 i 的健康度大
            healths[i]--;
            healths[j] = 0; // 移除机器人 j
            top--;
        }
    }

    free(idx);

    // 返回幸存机器人的健康度
    int* ans = st;
    *returnSize = 0;
    for (int i = 0; i < n; i++) {
        if (healths[i] > 0) {
            ans[(*returnSize)++] = healths[i];
        }
    }
    return ans;
}
func survivedRobotsHealths(positions []int, healths []int, directions string) (ans []int) {
// 创建一个下标数组,对下标数组排序,这样不会打乱输入顺序
idx := make([]int, len(positions))
for i := range idx {
idx[i] = i
}
slices.SortFunc(idx, func(i, j int) int { return positions[i] - positions[j] })

st := []int{}
for _, i := range idx {
if directions[i] == 'R' { // 机器人 i 向右
st = append(st, i)
continue
}
for len(st) > 0 { // 栈顶机器人向右
j := st[len(st)-1]
if healths[j] > healths[i] { // 栈顶机器人的健康度大
healths[i] = 0 // 移除机器人 i
healths[j]--
break
}
if healths[j] == healths[i] { // 健康度一样大,都移除
healths[i] = 0
healths[j] = 0
st = st[:len(st)-1]
break
}
// 机器人 i 的健康度大
healths[i]--
healths[j] = 0 // 移除机器人 j
st = st[:len(st)-1]
}
}

// 返回幸存机器人的健康度
for _, h := range healths {
if h > 0 {
ans = append(ans, h)
}
}
return
}
var survivedRobotsHealths = function(positions, healths, directions) {
    // 创建一个下标数组,对下标数组排序,这样不会打乱输入顺序
    const idx = Array.from({ length: positions.length }, (_, i) => i)
                     .sort((i, j) => positions[i] - positions[j]);

    const st = [];
    for (const i of idx) {
        if (directions[i] === 'R') { // 机器人 i 向右
            st.push(i);
            continue;
        }
        while (st.length > 0) { // 栈顶机器人向右
            const j = st[st.length - 1];
            if (healths[j] > healths[i]) { // 栈顶机器人的健康度大
                healths[i] = 0; // 移除机器人 i
                healths[j] -= 1;
                break;
            }
            if (healths[j] === healths[i]) { // 健康度一样大,都移除
                healths[i] = 0;
                healths[j] = 0;
                st.pop();
                break;
            }
            // 机器人 i 的健康度大
            healths[i] -= 1;
            healths[j] = 0; // 移除机器人 j
            st.pop();
        }
    }

    // 返回幸存机器人的健康度
    return healths.filter(h => h > 0);
};
impl Solution {
    pub fn survived_robots_healths(positions: Vec<i32>, mut healths: Vec<i32>, directions: String) -> Vec<i32> {
        // 创建一个下标数组,对下标数组排序,这样不会打乱输入顺序
        let mut idx = (0..positions.len()).collect::<Vec<_>>();
        idx.sort_unstable_by_key(|&i| positions[i]);

        let directions = directions.as_bytes();
        let mut st = vec![];

        for i in idx {
            if directions[i] == b'R' { // 机器人 i 向右
                st.push(i);
                continue;
            }
            while let Some(&j) = st.last() { // 栈顶机器人向右
                if healths[j] > healths[i] { // 栈顶机器人的健康度大
                    healths[i] = 0; // 移除机器人 i
                    healths[j] -= 1;
                    break;
                }
                if healths[j] == healths[i] { // 健康度一样大,都移除
                    healths[i] = 0;
                    healths[j] = 0;
                    st.pop();
                    break;
                }
                // 机器人 i 的健康度大
                healths[i] -= 1;
                healths[j] = 0; // 移除机器人 j
                st.pop();
            }
        }

        // 返回幸存机器人的健康度
        healths.into_iter().filter(|&h| h > 0).collect()
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{positions}$ 的长度。瓶颈在排序上。虽然我们写了个二重循环,但每个元素至多入栈出栈各一次,所以二重循环的循环次数是 $\mathcal{O}(n)$ 的。
  • 空间复杂度:$\mathcal{O}(n)$。

专题训练

见下面数据结构题单的「§3.3 邻项消除」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

模拟

作者 tsreaper
2023年6月25日 12:09

解法:模拟

假设 positions 已经是有序的,我们直接模拟机器人的相撞。

因为只有方向不同的机器人之间才会相撞,我们从左到右枚举每个机器人,并对每个 L 机器人,模拟与它左边所有 R 机器人的相撞情况。具体实现详见参考代码的注释。

因为每次碰撞都会消灭至少一个机器人,因此至多碰撞 $\mathcal{O}(n)$ 次。复杂度 $\mathcal{O}(n\log n)$,主要是给坐标排序的复杂度。

###c++

class Solution {
public:
    vector<int> survivedRobotsHealths(vector<int>& positions, vector<int>& healths, string directions) {
        int n = positions.size();
        // 给坐标排个序
        vector<int> ord;
        for (int i = 0; i < n; i++) ord.push_back(i);
        sort(ord.begin(), ord.end(), [&](int x, int y) {
            return positions[x] < positions[y];
        });

        // L:保存所有存活的 L 机器人
        // R:保存所有存活的 R 机器人
        vector<int> L, R;
        for (int i = 0; i < n; i++) {
            int idx = ord[i];
            if (directions[idx] == 'R') {
                // R 机器人直接放入 vector
                R.push_back(idx);
            } else {
                // L 机器人,考察和它左边所有 R 机器人的相撞情况
                bool win = true;
                // R vector 里的机器人刚好是按坐标从左到右排序的,因此每次肯定是最后一个机器人和当前机器人相撞
                while (!R.empty() && win) {
                    if (healths[R.back()] > healths[idx]) {
                        healths[R.back()]--;
                        win = false;
                    } else if (healths[R.back()] == healths[idx]) {
                        R.pop_back();
                        win = false;
                    } else {
                        R.pop_back();
                        healths[idx]--;
                    }
                }
                // 当前机器人成功存活,加入 L vector
                if (win) L.push_back(idx);
            }
        }

        // 输出答案
        vector<int> rem;
        for (int x : L) rem.push_back(x);
        for (int x : R) rem.push_back(x);
        sort(rem.begin(), rem.end());
        vector<int> ans;
        for (int x : rem) ans.push_back(healths[x]);
        return ans;
    }
};

亿元Cocos小游戏实战合集指南和答疑

2026年4月1日 12:42

引言

哈喽大家好,我是亿元程序员,一位有着8年游戏行业经验的主程。

今天这篇文章,是给《亿元Cocos小游戏实战合集》做一个完整的指南和答疑。

历时100天14篇实战文章,很多小伙伴说"收藏了就是学会了",结果连合集里有什么内容、能学到什么都搞不清楚。

那今天我就把家底都掏出来,不仅告诉你有什么,更告诉你能学到什么。

本文合集可在文末获取,小伙伴们自行前往。

实战合集学习指南

实战合集每一篇文章都不是简单的代码堆砌,而是带着你理解原理、掌握方法、举一反三。

下面开始从你能学到什么和适合什么人群逐篇回顾,建议收藏,点击可导航:

1. 热门买量游戏拆解之画线救狗

在这里插入图片描述

能学到什么

  • Graphics组件画线实现:触摸事件监听+动态绘制线条
  • 物理系统应用:动态添加RigidBody2DCollider2D组件
  • 碰撞矩阵配置:分组管理不同物体的碰撞关系
  • 蜜蜂AI实现applyForceToCenter施加力进行运动
  • 简单后撤效果linearVelocity线性速度控制

适合人群: 零基础入门、想完整跟练第一个项目的小伙伴。


2. 代码+教程:我的打螺丝游戏核心玩法全部分享给你

在这里插入图片描述

能学到什么

  • 螺丝抓取与移动:按下/拔起状态切换与动画
  • 铰链关节(HingeJoint2D):实现木板围绕螺丝点旋转
  • 动态关节管理:移除旧关节、生成新关节的代码实现
  • 圆心距离判断:检测木板孔与螺丝孔的位置关系
  • Tween震动动画:无法移动时的动画效果

适合人群:想做打螺丝类游戏、学习物理关节使用的小伙伴。


3. 敢不敢挑战用Cocos3.8复刻曾经很火的割绳子游戏?

在这里插入图片描述

能学到什么

  • 多段绳子物理连接:刚体+关节链式连接
  • 绳子切割检测:画线与绳子的碰撞判定
  • 糖果物理运动:重力+碰撞的物理解谜

适合人群:想掌握Cocos物理系统、做物理解谜类游戏的小伙伴。


4. 小伙伴说我的绳子要是有纹理就完美了,我就笑了...

在这里插入图片描述

能学到什么

  • Graphics组件局限性分析:为什么画出来像"棍子"
  • 自定义Assembler:实现纹理画线完整流程
  • UIRenderer + Assembler:渲染管线原理
  • 路径点展开成Mesh:法线展开、顶点生成、索引构建
  • fillBuffers数据打包:坐标转换、UV映射、颜色控制
  • 割绳子绳子纹理:自定义渲染实现

适合人群:想深入理解Cocos渲染管线、学习自定义Assembler实现高级效果的小伙伴。


5. 最近很火的一个拼图游戏,老板让我用Cocos3.8做一个...

在这里插入图片描述

能学到什么

  • 拼图游戏核心机制:拖拽+吸附算法
  • 拼图块位置判断:检测是否到达正确位置
  • 拼图完成检测:胜利条件判定逻辑
  • 2D拼图完整流程:从零实现拼图游戏

适合人群: 拼图游戏入门、想做基础版拼图的小伙伴。

6.大哥,你这拼图游戏的边框也太丑了...

在这里插入图片描述

能学到什么

  • Mask组件圆角裁剪GRAPHICS_STENCIL类型使用
  • Graphics绘制圆角边框:自定义形状绘制
  • 多边形内缩/外扩算法:边框层叠效果实现
  • quadraticCurveTo弧线:圆角绘制
  • 内角直角过渡:拼图块拼接处处理

适合人群:想实现圆角拼图效果、学习Mask+Graphics组合使用的小伙伴。


7. 老板说拼图游戏太卷了,让我用Cocos做个3d版本的...

模型拼图游戏

能学到什么

  • Cocos3D相机系统:相机控制与视角调整
  • 3D模型加载与交互:点击、拖拽模型
  • 2D玩法迁移到3D:拼图游戏3D化思路

适合人群:想入门Cocos3D、做3D拼图的小伙伴。

8. 小伙伴说我的拼图游戏用Mask不能合批...

Shader拼图游戏

能学到什么

  • Mask性能问题分析100张拼图404个DC的原因
  • 圆角Shader:替代Mask的渲染方案
  • 合批机制:材质共享、公共常量统一传递
  • DC优化实战:从404降到个位数

适合人群:想深入渲染优化、掌握Shader替代Mask方案的小伙伴。


9. 3d拼图我不会,老板:用Cocos做个会动的拼图总可以了吧!

spine拼图

能学到什么

  • RTT(RenderToTexture):将节点渲染到纹理
  • Spine骨骼动画动态分割RTT应用
  • 渲染纹理创建与使用RenderTexture实践
  • 多相机配合Spine动画渲染到纹理

适合人群:想学习RTT应用、实现Spine动画拼图的小伙伴。


10. 小伙伴们心心念念的倒水解谜游戏实战,终于来了...

倒水解谜游戏

能学到什么

  • Shader实现水的效果:颜色、分层、波纹、倾斜
  • Properties与uniform映射Shader变量传递机制
  • 动态传值到ShadersetProperty方法使用
  • cc_time时间变量:实现水面动画效果
  • UV坐标操作:水面倾斜与波动效果

适合人群:想学习Shader入门的小伙伴。


11. 大佬,现在AI游戏开发教程那么多,你不搞点卖给大学生吗?

挪猪游戏

能学到什么

  • AI工具:Cursor + Claude组合拳
  • AI辅助开发:写代码、Debug
  • "挪猪小游戏"全流程AI暴力开发实战

适合人群:想用AI工具提升开发效率、了解AI实战应用的小伙伴。


12. 俄罗斯方块谁不会做......啊?流沙版?

流沙方块游戏

能学到什么

  • 双网格系统设计:格子坐标系+沙粒网格(10×10细分100×200)
  • 碰撞检测算法:方块形状与沙粒逐个格子检测
  • 锁块机制:移动方块映射到固定网格
  • 沙粒物理模拟:从下往上遍历+随机列序+竖直/斜向下落
  • 消除规则:同色+四连通+横向贯通判定

适合人群:想搞骚操作、把经典游戏玩出新花样的小伙伴。


13. 这款值68亿的游戏,你不实战一下吗?安排!

在这里插入图片描述

能学到什么

  • 传送带像素消除类游戏:核心机制与玩法实现
  • 像素画资源管理:动态生成与加载
  • 像素画编辑器相关:编辑器

适合人群:想学习像素类游戏开发的小伙伴。


14. 老板说最近这款游戏很火让我抄,可是我连玩都玩不明白...

在这里插入图片描述

能学到什么

  • "小牛"解谜游戏:数独+扫雷混合玩法
  • 核心规则数据结构:颜色唯一性、行列唯一性、非相邻性
  • N皇后问题变种:回溯法生成合法关卡
  • 7种推导策略:找出单色、行列排除、邻居排除、单色占排、整排同色、多色共占、邻居互斥
  • 关卡编辑器设计:预编辑保证可解性

适合人群:想做益智解谜类游戏、学习算法在游戏中的应用的小伙伴。


合集常见答疑(Q&A)

Q1: 合集源码使用的Cocos引擎版本是多少?
A : Cocos Creator 3.8.7


Q2: 源码是否免费?
A : 文章中的代码截图都是核心片段,需要手敲,完整项目源码可在文末获取。


Q3: 适合新手吗?
A : 合集定位为**“实战进阶”**,建议有一定Cocos基础(会基本组件使用、懂TypeScript语法)的小伙伴阅读,但是部分文章比较细,源码有详细注释,也适合零基础入门,后面可能会出零基础系列(flag先立在这里)。


Q4: 合集还会更新吗?
A : 本套合集已经全部更新完毕,欢迎期待下一套合集。


Q5: 源码可以直接商用吗?
A : 合集源码均为博主原创,没有上架,可以二开上架商用。


Q6: 源码带编辑器吗?
A : 像素消除、找牛游戏附带编辑器(在根目录),其余游戏关卡只需要简单配置即可。


Q7: 有视频教程吗?
A : 目前以图文为主,部分复杂效果有配套动图演示。做视频太费时间了(其实就是懒),但如果后续大家呼声很高,可以考虑录制视频。


Q8: 是完整源码吗?
A : 游戏中核心玩法的源码,并非完整游戏。


Q9: 游戏有多少关?
A : 游戏是都核心玩法的演示,关卡一般为2-4关,部分随机关卡。


Q10: 能加入交流群吗?
A : 拥有合集的小伙伴可进实战群,遇到问题可以讨论,艾特博主探讨。


Q11: 接下来会有新的合集吗?
A : 会有的,已经在企划中。


Q12: 合集还会上调米数吗?
A : 会的,新合集上线后会再次上调。


结语

以上就是亿元Cocos小游戏实战合集的指南和答疑。

游戏开发这条路,说难不难,说简单也不简单。

简单的是开始,的是坚持。

合集获取(内含体验链接):亿元Cocos小游戏实战合集(已完结),再次感谢小伙伴们对创作的支持,我们下期见。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

SEO已死?我搭了个做GEO的独立站系统专门给AI投毒

作者 饼干哥哥
2026年4月1日 12:03

最近,Google SEO 已死的声音越来越大,尤其是在做业务的领域。

AI 时代,用户有问题大多都直接问 DeepSeek,问 ChatGPT。

流量的入口变了,规则自然也就变了。 现在的玩法,叫 GEO (Generative Engine Optimization),生成式引擎优化。 简单说,就是怎么让 AI 觉得你很牛,并在回答用户问题时,主动推荐你。

为了搞定这个流量密码,我要开展一个长期的流量实验。

我用 Wordpress + Go + 飞书,搭建了一套独立站系统。 目的只有一个: 当未来的客户问 AI “出海营销业务哪家强”时,AI 的回答里,赫然写着我和我的公司 NGS NextGrowSail。

正好今天把系统的 1.0 版本弄好了,我的网站是 bggg.tech

因为我只是为了喂 AI,所以我选择了最简单的模板

但背后的门道非常多,我折腾了一周,接下来把结论毫无保留的分享给大家。

Image

为什么要折腾这个?

这就得从我最近的一个发现说起了。 我为了测试 GEO 的逻辑,向多个 AI 问了很多垂直领域的业务问题。 比如“如何用 n8n 做自动化营销”。

结果很有意思。 AI 给出的引用来源(Citations),除了知乎、掘金、B站这些官方大平台外,竟然有很大一部分是独立站(WordPress 博客)。 尤其是在咱们这种 B2B 的垂直领域,一个结构清晰、内容专业的独立站,在 AI 眼里的权重,甚至比那些水文泛滥的大平台还要高。

Image

这让我意识到两件事: 第一,独立站的机会来了。 第二,平台太危险了。

大家在公众号、小红书上写东西。 但是,这本质上是在给平台做嫁衣:流量是平台的,商务是平台的。 万一哪天账号出点啥事,你几年的心血,瞬间归零。 细思极恐!!而且,公众号、小红书都有严格的反爬机制。你写在上面的干货,外部的 AI 爬虫根本进不去,也就看不到你。 AI 看不到你,自然就不会推荐你。

所以,结论很明显: 必须得有一个完全自主可控、且对全网 AI 开放的“内容根据地”。

图片

怎么搭?越简单越好

既然是做业务,不是搞科研,那技术选型就一个原则:性价比+稳。然后全程让 AI 帮你做决策和搭建就好了。

我的业务 NGS 是做 AI 出海营销的,服务的是国内想出海的老板们。 所以,服务器必须放在国内。 这样百度、Kimi 抓取才快,客户打开才秒开。 而国内服务器,有个麻烦的地方就是得做 ICP 备案,不做的话百度收录、AI 检索的权重都会下降。更别说要合规经营。

做网站的话,就得有服务器。

正常来说一个人用,直接上最便宜的2核 2G就行,跑个静态网页足够了。

但我的流量策略是做多网站互链,这样长期来说有流量权重加持。简单来说就是我和我合伙人都要在同一个服务器上做相互独立的网站。

所以,我选了腾讯云的轻量应用服务器,4核 4G,刚好双十一才 80 多元。 这里有个坑,就是CVM 专业版,比较贵,刚开始来说不太必要。

有服务器后就要解决技术架构,建议直接上 宝塔面板 + Docker。 宝塔只用来做个面板管理,真正的业务,全部跑在 Docker 容器里,而多个容器相互隔离,很适合我做多个独立的网站。 而且哪天我要搬家,直接把文件夹打包带走就行,不用重装环境,非常丝滑。

建站程序(CMS)优先选 WordPress

我知道很多技术人喜欢自己写代码,或者搞个 Hexo、Hugo 这种静态博客。 但我强烈建议:直接用 WordPress。 为什么? 因为没必要重复造轮子。

WordPress 是目前地球上最成熟的 CMS,生态强到令人发指。

想做 SEO?装个 Rank Math 插件,连 Schema 结构化数据都帮你自动生成好,AI 读起来不要太舒服。

Image

想生成目录?装个 LuckyWP TOC,一键搞定。

你想压缩图片?装个 Smush。

你想做安全防护?装个 Wordfence。

你需要任何功能,只要去插件市场一搜,点一下安装就完事了。 我们是做业务的,时间要花在内容上,而不是花在改代码上。

接着就是域名,优先选.com

虽然谷歌说所有顶级域名权重都是平等的,但也不建议选太小众的,有两个原因:

1 是流量除了技术,还有人为,用户在点击网站的时候,会潜意识认为.com的最专业,或者现在.ai、.tech这种科技领域用的比较多的也能被接受

2 是一些垃圾网站经常用极度小众的后缀,有可能会被误以为你的网站也是垃圾站。

把内容包装成 AI喜欢的样子

网站搭好了,只是个壳子。 重点是内容怎么写,AI 才能读得懂、愿意推。 这里面全是细节。

图片

  1. URL 别名 (Slug)

大家发文章,千万别用中文标题做链接。 比如 bggg.tech/出海营销怎么做。 这玩意儿复制出来是一串乱码 %e4%b8...,看着像病毒,AI 解析也费劲。

我有用 AI 把标题自动翻译成英文 Slug,比如 how-to-do-global-marketing。 干净、专业。

  1. 图片 Alt 文本

AI 是瞎的,它读不懂图片像素。 所以每张图,必须得写 Alt 替代文本,例如用于告诉 AI:“这是一张展示 Kimi 自动化流程的截图”。

这一步,绝大多数人都懒得做,你做了,你就赢了。

3. 分类与标签 (Categories & Tags)

这块很多人不在意,其实对 GEO 极其重要。 AI(特别是 Kimi、Perplexity)是基于知识图谱 (Knowledge Graph) 工作的。 你的文章如果只是乱发,对 AI 来说就是孤岛。

通过给文章打上精准的“标签”(实体名词,如 n8n、Agent、Reddit),你是在告诉 AI 这些概念之间的关联性。 当 AI 建立起这个图谱后,它在回答相关问题时,就更容易调用你的内容作为论据。

  1. 动态营销尾巴 (Marketing Footer)

这是我思考的一个独特的GEO 策略。 我在每篇文章的末尾,都加了一段动态的业务介绍。 “我是饼干哥哥,NGS 创始人,我们专注 AI 出海营销...” 利用 AI 的“实体共现” 原理。

Image

当 AI 读了一万遍“饼干哥哥”和“出海营销”同时出现的文章后。 它的神经网络里,就会把这两个词强行锁死。 下次有人问“出海营销”,AI 就会下意识地联想到我。

💡

我拉了个 AI SEO & GEO 的交流群,用于交流 GEO 的实践经验

关注公众号「饼干哥哥AGI」

后台回复「GEO」加入

自动化:飞书 -> WordPress

道理都懂,但执行起来太累了。 我有 300 多篇文章沉淀在飞书里。 让我一篇篇复制粘贴到 WordPress,还得改格式、传图片、写 Alt... 杀了我吧。。。

而且 WordPress 那个后台编辑器,难用得令人发指。。。

所以我一咬牙,把上一期分享的飞书转公众号的插件,升级成了飞书 2WordPress的自动化工具。

Image

目前前端页面还很丑陋哈哈

这玩意儿有多爽? 把我10 篇飞书文档的链接,放进去,点批量同步。

后台的AI 会:

  1. 1. 通读全文,理解内容。
  2. 2. 自动生成英文 URL Slug。
  3. 3. 自动写好一段“痛点+解决方案”的高点击率摘要(Excerpt)。
  4. 4. 自动提取实体名词,打好标签(Tags)。
  5. 5. 自动根据文章内容,生成那个“动态营销尾巴”。

Image

feishu2WordPress 的同步逻辑

对了,它还解决了一个史诗级巨坑:图片 502 报错。 飞书里的截图,经常是几 MB 的 PNG 大图。 直接传给 WordPress,Nginx 经常超时报错,或者 PHP 内存溢出。 我的 Go 程序里引入了个 imaging 库。 上传前,自动把图片缩放到 1200px 宽,转码成 JPG,压缩到 80% 质量。 体积瞬间减小 90%。 不仅上传快了,网站打开速度也飞起。 Google 的 Core Web Vitals 分数直接拉满。

目前我已经把feishu2wordpress部署上线了,但服务器很小,想用的话加入上面的交流群,在群里小范围用一下吧

这套系统跑通的那一刻,我长舒了一口气。

这种掌控感,是任何平台都给不了的。 手中有粮,心中不慌。

这不仅仅是一个博客,这是我给未来 3-5 年部署的一个AI 业务员:它会不知疲倦地把我的内容喂给全网的 AI,让 NGS 的品牌渗透到每一个大模型的神经元里。

我也强烈建议每一位内容创作者,尤其是做 B2B 业务的朋友。 别再犹豫了。 赶紧拥有一个属于自己的独立站吧。 在 AI 时代,这可能不是选择题,而是生存题。

今天只是第一期复盘。 后面我会持续更新这套 GEO 系统的实战效果,看看 AI 到底能不能给我带来精准客户。

感兴趣的朋友,别忘了关注我,咱们下期见。

前端 Monorepo 实战指南:仓库多到切疯?

2026年4月1日 11:16

fPfYvrzBf.jpeg

大家好~ 做前端开发越久,越能体会到“代码复用”和“协同效率”的重要性。尤其是中大型团队、多项目并行时,多仓库(Multirepo)来回切换、版本不一致、公共代码重复开发等问题,真的太影响效率了。

Monorepo(单体仓库)作为解决这些痛点的核心方案,已经成为前端工程化的主流选择。今天就从「概念解析→实战落地→优缺点拆解→避坑指南」,手把手教你玩转Monorepo,所有代码片段可直接复制使用,新手也能快速上手!

一、先搞懂:Monorepo到底是什么?

很多人对Monorepo的理解很模糊,其实一句话就能说透:Monorepo是一种代码管理架构,将多个相互关联的项目、组件库、工具包,统一放在同一个Git仓库中管理,实现“物理集中、逻辑拆分”

举个直观的例子:

❌ 多仓库(Multirepo):一个管理后台项目一个仓库、一个H5项目一个仓库、一个公共UI组件库一个仓库,来回切换Git仓库、协调版本,繁琐且易出错。

✅ Monorepo:把管理后台、H5项目、UI组件库、工具函数库,全都放进同一个Git仓库,每个模块目录独立、逻辑清晰,不用切换仓库,版本统一管理。

典型的Monorepo目录结构(前端主流),后面实战会直接复用这个结构:

my-monorepo/          # 根目录(统一仓库)
├── .gitignore        # 全局忽略配置
├── package.json      # 根配置(公共依赖、脚本)
├── pnpm-workspace.yaml # 工作空间配置(划定管理范围)
├── turbo.json        # 任务调度配置(构建、缓存)
├── apps/             # 业务应用目录(可多个)
│   └── web/          # 前端业务项目(React/Vue均可)
└── packages/         # 公共模块目录(可多个)
    ├── ui/           # 公共UI组件库
    └── utils/        # 通用工具函数库

二、实战落地:Monorepo怎么用?(前端主流方案:pnpm + Turborepo)

前端落地Monorepo,最成熟、最高效的组合是「pnpm + Turborepo」:pnpm负责管理子包依赖,Turborepo负责任务调度(构建、开发、缓存),步骤清晰,新手也能快速上手,每一步都附完整代码片段,可直接复制操作。

前置准备

确保本地安装:Node.js ≥ 18、pnpm(可通过 npm install -g pnpm 全局安装)、Git。

Step 1:初始化根仓库

先创建根目录,初始化Git和package.json,核心是将根项目设为私有,避免意外发布到npm。

# 1. 创建根目录并进入
mkdir my-monorepo && cd my-monorepo

# 2. 初始化Git(必做,版本控制)
git init

# 3. 初始化pnpm配置(生成package.json)
pnpm init -y

修改根目录 package.json,添加核心配置:

{
  "name": "my-monorepo",
  "private": true, // 关键:设为私有,禁止发布
  "version": "1.0.0",
  "scripts": {
    "dev": "turbo run dev",    // 启动所有项目的dev命令
    "build": "turbo run build",// 构建所有项目
    "lint": "turbo run lint",  // 校验所有项目代码
    "clean": "turbo run clean" // 清理所有构建产物
  },
  "devDependencies": {
    "turbo": "^2.1.0" // 任务调度核心工具
  },
  "engines": {
    "node": ">=18" // 指定Node版本,避免环境差异
  }
}

Step 2:配置pnpm Workspace(核心步骤)

pnpm Workspace的作用是「划定Monorepo的管理范围」,告诉pnpm哪些目录是子包/子项目,新建 pnpm-workspace.yaml 文件:

# pnpm-workspace.yaml
packages:
  - 'apps/*'    # 管理所有业务应用(apps目录下的所有子目录)
  - 'packages/*' # 管理所有公共模块(packages目录下的所有子目录)
  # 可选:排除不需要管理的目录
  - '!**/node_modules'
  - '!**/dist'

说明:apps/* 表示apps目录下的所有子目录(如web、admin)都属于业务应用;packages/* 同理,管理所有公共模块,这样pnpm就能自动识别子包,实现依赖联动。

Step 3:创建子包/业务应用(实战细节)

按「apps(业务)+ packages(公共)」的结构,创建具体的子模块,每个模块独立初始化,可单独开发、测试、构建。

3.1 创建公共工具包:packages/utils
# 创建utils目录并进入
mkdir -p packages/utils && cd packages/utils

# 初始化utils包的package.json
pnpm init -y

修改 packages/utils/package.json

{
  "name": "@my/utils", // 命名规范:@组织名/包名,避免冲突
  "version": "0.0.1",
  "type": "module", // 支持ES模块
  "main": "dist/index.js", // 构建后入口文件
  "types": "dist/index.d.ts", // TS类型文件(可选,TS项目必加)
  "scripts": {
    "dev": "tsc --watch", // 开发时监听TS编译
    "build": "tsc", // 构建TS代码到dist目录
    "clean": "rm -rf dist" // 清理构建产物
  },
  "devDependencies": {
    "typescript": "^5.0.0" // TS项目必备
  }
}

新建TS配置文件 packages/utils/tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext", // 目标ES版本
    "module": "ESNext", // 模块规范
    "moduleResolution": "Bundler", // 模块解析方式
    "strict": true, // 开启严格模式
    "esModuleInterop": true, // 兼容CommonJS模块
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist", // 构建输出目录
    "rootDir": "./src" // 源码目录
  },
  "include": ["src"], // 需要编译的文件
  "exclude": ["node_modules", "dist"] // 排除目录
}

添加测试代码 packages/utils/src/index.ts

// 通用工具函数示例,可直接复用
export const add = (a: number, b: number): number => a + b;

// 格式化时间
export const formatDate = (date: Date): string => {
  return date.toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit'
  });
};
3.2 创建公共UI组件包:packages/ui

UI组件包依赖utils包,演示「子包间本地依赖引用」,步骤和utils类似:

# 回到根目录,创建ui目录并进入
cd ../../ && mkdir -p packages/ui && cd packages/ui

# 初始化ui包的package.json
pnpm init -y

修改 packages/ui/package.json,重点关注本地依赖引用 "@my/utils": "workspace:*"

{
  "name": "@my/ui",
  "version": "0.0.1",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "dev": "tsc --watch",
    "build": "tsc",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@my/utils": "workspace:*" // 关键:本地引用utils包,不用发布npm
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "react": "^18.2.0", // UI组件依赖React(示例)
    "react-dom": "^18.2.0"
  },
  "peerDependencies": {
    "react": "^18.2.0" // 声明peer依赖,避免重复安装
  }
}

tsconfig.json 配置和utils一致,添加组件代码 packages/ui/src/Button.tsx

import { formatDate } from '@my/utils'; // 引用本地utils包

export function Button({ 
  children, 
  onClick 
}: { 
  children: React.ReactNode; 
  onClick?: () => void;
}) {
  return (
    <button 
      style={ '8px 16px', 
        border: 'none', 
        borderRadius: '4px', 
        backgroundColor: '#1677ff', 
        color: 'white',
        cursor: 'pointer'
      }}
      onClick={onClick}
    >
      {children}
      <span style={ marginLeft: '8px', fontSize: '12px' }}>
        {formatDate(new Date())}
      
  );
}

创建入口文件 packages/ui/src/index.ts

export * from './Button'; // 导出组件,供业务应用引用
3.3 创建业务应用:apps/web(React + TS + Vite)

业务应用引用ui和utils两个公共包,演示「业务项目如何使用本地公共模块」:

# 回到根目录,创建web应用目录并进入
cd ../../ && mkdir -p apps/web && cd apps/web

# 用Vite初始化React+TS项目(快速生成基础结构)
pnpm create vite@latest . --template react-ts

修改 apps/web/package.json,添加本地公共包依赖:

{
  "name": "web",
  "private": true, // 业务应用无需发布,设为私有
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite", // 启动开发服务
    "build": "tsc && vite build", // 构建项目
    "lint": "tsc --noEmit", // 代码校验
    "clean": "rm -rf dist" // 清理构建产物
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "@my/utils": "workspace:*", // 引用本地utils包
    "@my/ui": "workspace:*" // 引用本地ui包
  },
  "devDependencies": {
    "@types/react": "^18.2.37",
    "@types/react-dom": "^18.2.15",
    "@vitejs/plugin-react": "^4.2.0",
    "typescript": "^5.2.2",
    "vite": "^5.0.0"
  }
}

修改 apps/web/vite.config.ts,配置端口(可选,避免端口冲突):

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 3000, // 固定端口,方便开发
    open: true // 启动后自动打开浏览器
  }
});

修改 apps/web/src/App.tsx,使用公共包组件和工具函数:

import { useState } from 'react';
import { add } from '@my/utils';
import { Button } from '@my/ui';

function App() {
  const [count, setCount] = useState(0);

  return (<div style={Monorepo实战演示1 + 2 = {add(1, 2)}计数:{count}<Button onClick={() => setCount(count + 1)}>
        点击增加计数</Button>
    
  );
}

export default App;

Step 4:配置Turborepo(任务调度+缓存,提升效率)

Turborepo是核心工具,主要解决「多模块任务依赖」和「构建缓存」问题,比如构建web应用时,会自动先构建它依赖的utils和ui包,且第二次构建会复用缓存,秒级完成。

回到根目录,创建 turbo.json 配置文件:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "package.json",
    "pnpm-workspace.yaml",
    "turbo.json"
  ],
  "pipeline": {
    // 开发任务:不缓存,持续监听
    "dev": {
      "cache": false,
      "persistent": true // 持续运行(如vite dev、tsc --watch)
    },
    // 构建任务:缓存构建产物,依赖上级构建(^表示依赖所有子包的build)
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/**"] // 缓存的产物目录
    },
    // 校验任务:可缓存
    "lint": {},
    // 清理任务:不缓存
    "clean": {
      "cache": false
    }
  }
}

Step 5:常用命令(必记,日常开发高频使用)

所有命令都在根目录执行,统一管理所有子模块:

# 1. 安装所有子模块的依赖(一次性安装,无需逐个进入子目录)
pnpm install

# 2. 启动所有子模块的dev命令(web的vite、utils和ui的tsc --watch)
pnpm dev

# 3. 只启动web应用的dev(常用,不用启动所有模块)
pnpm dev --filter web

# 4. 构建所有子模块(自动按依赖顺序构建:utils → ui → web)
pnpm build

# 5. 只构建web应用(自动先构建依赖的utils和ui)
pnpm build --filter web

# 6. 校验所有子模块的代码
pnpm lint

# 7. 清理所有子模块的构建产物
pnpm clean

关键说明:--filter 是筛选命令,可指定操作某个子模块,避免不必要的资源消耗,日常开发用得最多。

三、深度解析:Monorepo的优缺点(避坑关键)

很多人盲目跟风用Monorepo,却没搞懂它的适用场景,最后反而增加了开发成本。下面结合实战经验,详细拆解优缺点,帮你判断是否适合自己的项目。

✅ 优点(核心价值,为什么要用)

  1. 极致的代码复用,降低维护成本 公共组件、工具函数、TS 类型这些,写一遍就能在所有项目里直接白嫖,不用费劲发到 npm,改一处全项目自动同步。再也不用在 N 个仓库里疯狂改版本、对代码,彻底告别 “你改你的、我改我的” 的重复造轮子惨案。

  2. 原子化变更,提升协同效率 跨模块修改再也不用仓库来回横跳了~ 以前改个组件要切 A 仓、改页面切 B 仓,版本对不上还得疯狂救火;现在一次 commit 全搞定,彻底告别 “东改西改、版本乱套” 的精神内耗。。

  3. 统一工程化规范,减少团队内耗 一套规范管住所有项目,ESLint、Prettier、TS 配置和依赖版本全都在根目录统一管理,不用每个项目单独配一套。再也不会出现 “千人千风格” 的代码,也不会因为依赖版本打架而抓狂,新人上手也快很多。

  4. 重构与联调更便捷,降低风险 修改公共模块后,所有关联的业务应用能立即验证效果,不用手动升级依赖、重启项目,大规模重构时,能清晰看到修改的影响范围,避免出现“改了一个地方,其他地方出问题”的情况。

  5. 简化CI/CD流程,提升构建速度

❌缺点🤯

别盲目跟风!结合实战踩过的坑,整理了5个核心避坑指南,少走90%弯路👇

1. 模块拆分:边界要清,别乱拆也别不拆(最关键)

❌ 踩坑点:要么所有代码堆一起,要么拆太细(一个按钮一个包),依赖乱成麻

✅ 正确操作:高内聚、低耦合,按业务/功能拆分,每个模块能单独测试、构建

2. 依赖管理:规范引用,别让版本“打架”

公共依赖放根目录统一管理,内部子包引用用workspace协议,慎用peer依赖,杜绝循环依赖

3. 性能优化:别让仓库“胖到卡顿”

仓库体积变大后,用Git稀疏检出按需拉取目录,搭配Turborepo缓存,按需构建/启动模块

4. 权限安全:敏感代码别乱塞

核心业务、机密代码单独建仓,用CODEOWNERS指定模块负责人,避免全仓可见泄露风险

5. 适用场景:不是所有项目都适配

✅ 适合:中大型团队、多项目关联紧密、需高频复用代码

❌ 不适合:小团队(1-3人)、完全独立项目、强权限隔离需求

  1. 仓库体积过大,影响性能随着项目迭代,代码量、历史提交记录会越来越多,导致Git克隆、拉取速度变慢,IDE加载索引耗时增加,甚至出现卡顿(尤其是Windows系统)。解决方案:后面注意事项会讲“Git稀疏检出”和“缓存优化”,可缓解这个问题,但无法完全避免。

  2. 权限管控困难,敏感代码难隔离Git不支持目录级权限控制,一旦加入Monorepo,所有成员都能看到整个仓库的代码,无法实现“部分成员只能访问某个子模块”的需求。比如核心业务代码、敏感接口密钥等,不适合放进Monorepo,否则会有安全风险。

  3. 学习与迁移成本高团队需要适应Monorepo的目录结构、工具链(pnpm、Turborepo),如果是老项目迁移,还需要拆解模块、解耦代码,前期工作量较大,小型团队可能难以承受。

  4. 构建复杂度提升,配置不当易出问题需要手动配置子模块间的依赖关系、Turborepo的缓存策略,一旦配置错误,会出现“构建顺序错乱”“缓存失效”“依赖循环”等问题,排查起来比较麻烦。

  • 按「业务/功能」拆分:apps放业务应用,packages放公共模块,每个模块只负责自己的功能(比如utils只放工具函数,ui只放UI组件)。

  • 保证独立可测:每个子模块能单独启动、测试、构建,不依赖其他模块(除了公共依赖)。

  • 避免循环依赖:比如ui依赖utils,utils不能再依赖ui,可通过madge工具检测循环依赖(安装:pnpm add -Dw madge,检测命令:madge --circular packages/)。

  • 公共依赖放根目录:React、TypeScript、ESLint等所有子模块共用的依赖,放在根目录的package.json中,统一版本,避免重复安装。

  • 内部依赖用workspace协议:子模块间引用,必须用"@my/utils": "workspace:*",不能写固定版本(比如0.0.1),否则修改公共包后,业务应用无法实时生效。

  • 慎用peer依赖:UI组件库等需要用户提供依赖的包,用peerDependencies声明(如实战中ui包的react),避免重复安装,减少体积。

  • Git稀疏检出:只拉取自己需要的目录,不用克隆整个仓库,适合大型Monorepo。命令示例(只拉取apps/web和packages/utils): 初始化Git git init my-monorepo && cd my-monorepo # 启用稀疏检出 git config core.sparseCheckout true

配置需要拉取的目录

```echo "apps/web/" >> .git/info/sparse-checkout
echo "packages/utils/" >> .git/info/sparse-checkout
```

关联远程仓库并拉取

```git remote add origin 你的仓库地址
git pull origin main
```
  • Turborepo缓存优化:确保turbo.json中配置了正确的outputs(构建产物目录),缓存会自动生效,第二次构建速度会提升80%以上。

  • 按需操作:开发时用--filter只启动需要的模块,构建时只构建变更的模块,避免不必要的资源消耗。

  • 敏感代码单独存放:核心业务、机密接口、密钥等,不要放进Monorepo,单独建一个私有仓库管理,只开放给核心成员。

  • 用CODEOWNERS做审批约束:在根目录创建CODEOWNERS文件,指定每个模块的负责人,修改模块代码时,必须经过负责人审批,避免误操作。示例: # CODEOWNERS文件

指定packages/ui模块的负责人

/packages/ui/ @ui负责人用户名

指定apps/web模块的负责人

/apps/web/ @web负责人用户名
  • 中大型团队,多项目并行,需要高频复用代码。

  • 前端、后端、组件库、工具包等关联紧密的项目群。

  • 需要统一工程化规范,提升协同效率的团队。

  • 小型团队(1-3人),项目简单,无需复用代码。

  • 完全独立的项目(比如一个项目和其他项目无任何关联)。

  • 有强权限隔离需求,需要隐藏敏感代码的项目。

✨ 总结

Monorepo不是“银弹”,但绝对是中大型前端团队的效率神器!核心是统一管理、代码复用,落地关键就3点:合理拆分模块、规范依赖、做好性能优化。

你踩过哪些Monorepo的坑?评论区交流!

❌
❌