阅读视图

发现新文章,点击刷新页面。

每日一题-到目标元素的最小距离🟢

给你一个整数数组 nums (下标 从 0 开始 计数)以及两个整数 targetstart ,请你找出一个下标 i ,满足 nums[i] == targetabs(i - start) 最小化 。注意:abs(x) 表示 x 的绝对值。

返回 abs(i - start)

题目数据保证 target 存在于 nums 中。

 

示例 1:

输入:nums = [1,2,3,4,5], target = 5, start = 3
输出:1
解释:nums[4] = 5 是唯一一个等于 target 的值,所以答案是 abs(4 - 3) = 1 。

示例 2:

输入:nums = [1], target = 1, start = 0
输出:0
解释:nums[0] = 1 是唯一一个等于 target 的值,所以答案是 abs(0 - 0) = 0 。

示例 3:

输入:nums = [1,1,1,1,1,1,1,1,1,1], target = 1, start = 0
输出:0
解释:nums 中的每个值都是 1 ,但 nums[0] 使 abs(i - start) 的结果得以最小化,所以答案是 abs(0 - 0) = 0 。

 

提示:

  • 1 <= nums.length <= 1000
  • 1 <= nums[i] <= 104
  • 0 <= start < nums.length
  • target 存在于 nums

【track & traning】思路简单,性能高效

方便快速学习算法与理解~

🌇 点赞 👍 收藏 ⭐留言 📝 一键三连 ~关注Jam,从你我做起!

兄弟会背叛你,女人会离开你,金钱会诱惑你,生活会刁难你,只有数学不会,不会就是不会
天才与否,取决于最终达到的高度。真正的天才是那些脚踏实地的人
静下心来好好做自己,走稳脚下每一步,就是最好的路,强者都是孤独的

推荐 python 算法的书籍,体系化学习算法与数据结构,用正确的方式成为offer收割机
leetcode —— 系统化快速学习算法,这不是内卷,这只是悄悄地努力,然后惊艳所有的人
image.png


求解思路

暴力破解

代码

###python3

class Solution:
    def getMinDistance(self, nums: List[int], target: int, start: int) -> int:
        return min(abs(i - start)  for i in range(len(nums)) if nums[i] == target)

Python双指针击败100%

解题思路

让$i,j$从给定的start位置分别往两边走,谁先走到值为target的位置停下,返回走过的长度。
image.png

代码

###python3

class Solution:
    def getMinDistance(self, nums: List[int], target: int, start: int) -> int:
        i = j = start
        n = len(nums)
        while i>=0 or j<n:
            if i>=0 and nums[i]==target:
                return start-i
                              
            if j<n and nums[j]==target:
                return j-start
            i -= 1
            j += 1
        return 0
                

5746.到目标元素的最小距离 简单的数组遍历

5746.到目标元素的最小距离

https://leetcode.cn/problems/minimum-distance-to-the-target-element/

难度:简单

题目:

给你一个整数数组 nums (下标 从 0 开始 计数)以及两个整数 target 和 start ,请你找出一个下标 i ,满足 nums[i] == target 且 abs(i - start) 最小化 。注意:abs(x) 表示 x 的绝对值。

返回 abs(i - start) 。

题目数据保证 target 存在于 nums 中。

提示:

  • 1 <= nums.length <= 1000
  • 1 <= nums[i] <= 104
  • 0 <= start < nums.length
  • target 存在于 nums 中

示例:

示例 1:

输入:nums = [1,2,3,4,5], target = 5, start = 3
输出:1
解释:nums[4] = 5 是唯一一个等于 target 的值,所以答案是 abs(4 - 3) = 1 。
示例 2:

输入:nums = [1], target = 1, start = 0
输出:0
解释:nums[0] = 1 是唯一一个等于 target 的值,所以答案是 abs(0 - 0) = 1 。
示例 3:

输入:nums = [1,1,1,1,1,1,1,1,1,1], target = 1, start = 0
输出:0
解释:nums 中的每个值都是 1 ,但 nums[0] 使 abs(i - start) 的结果得以最小化,所以答案是 abs(0 - 0) = 0 。

分析

循环数组,检查每个数值是否与target相等。
如果target相等,则获取最小值,最终返回结果即可

解题:

###python

class Solution:
    def getMinDistance(self, nums, target, start):
        q = float('inf')
        for i, j in enumerate(nums):
            if j == target:
                q = min(q, abs(i - start))
        return q

欢迎关注我的公众号: 清风Python,带你每日学习Python算法刷题的同时,了解更多python小知识。

有喜欢力扣刷题的小伙伴可以加我微信(King_Uranus)互相鼓励,共同进步,一起玩转超级码力!

我的个人博客:https://qingfengpython.cn

力扣解题合集:https://github.com/BreezePython/AlgorithmMarkdown

Hello 算法:贪心的世界

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

一轮轮的简单选择中,时刻追求自身成长的最大可能,逐步导向最佳答案。

本篇话题展开之前,先看个日常很常见的问题:零钱兑换

你去超市购物,给收银员100元,而你购买的商品只需要2元,他要怎样给你找钱?

很简单,会100内加减的都能轻易搞定,你会根据还剩余的找零额度,从可选择的币种中选择面值最大的,直至达到数额为止。

其实你会发现,“零钱”只是一种比较具象的表达,把“找零”这件事进一步抽象,可用于解决很多领域的问题。

它,就是“贪心算法”。

贪心算法

贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。

贪心算法简洁且高效,在许多实际问题中有着广泛的应用。

除了找零钱,它还可用于解决“分数背包问题”:

给一个背包,有容量限制,另有N件物品,每件物品都有它的重量和价值,每件物品只能选择一次,但可以选择物品的一部分,价值根据选择的重量比例计算

求:在限定背包容量下,背包中物品的最大价值。

这个问题的解答策略和“找零”类似:最大化背包内物品总价值,本质上是最大化单位重量下的物品价值

  1. 将物品按照单位价值从高到低进行排序。
  2. 遍历所有物品,每轮贪心地选择单位价值最高的物品。
  3. 若剩余背包容量不足,则使用当前物品的一部分填满背包。

注意:这里说的是“分数背包”,不是“0-1背包”。

代码实现

以“找零”为例,贪心算法的核心实现:

/* 零钱兑换:贪心 */
function coinChangeGreedy(coins, amt) {
    // 假设 coins 数组有序
    let i = coins.length - 1;
    let count = 0;
    // 循环进行贪心选择,直到无剩余金额
    while (amt > 0) {
        // 找到小于且最接近剩余金额的硬币
        while (i > 0 && coins[i] > amt) {
            i--;
        }
        // 选择 coins[i]
        amt -= coins[i];
        count++;
    }
    // 若未找到可行方案,则返回 -1
    return amt === 0 ? count : -1;
}

示意图如下:

微信图片_20260413001704_150.jpg

优点与局限

贪心算法的优点是操作直接、实现简单,通常效率也很高。

但不是所有分步解决的问题都适合使用贪心,什么样的问题适合呢?

主要关注两个性质。

  • 贪心选择性质:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
  • 最优子结构:原问题的最优解包含子问题的最优解。

关键词:最优解

还是找零问题,贪心能找到最优解的前提是,有足够的币值种类可选,如果没有,像下面这样:

给定币值:[1,20,50],目标值是 60,使用贪心策略,它会找到 50 + 1*10,总数是11,但实际更优的做法是 20 * 3,只需要3就可以。

所以可以理解为,贪心适合的场景是“想要多少(比如10),就有多少”,而不是妥协退而求其次。

其他应用

除了上面介绍的两种,贪心的适用场景还包括但不限于:

  • 区间调度问题:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
  • 股票买卖问题:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
  • 霍夫曼编码:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
  • Dijkstra 算法:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。

小结

贪心是必掌握的经典算法之一,实现也不难,重点是吃透它的使用场景。

下一篇,将是本系列的终篇,让我们一起期待会是什么。

更多好文第一时间接收,可关注公众号:“前端说书匠”

《前端周刊》尤大开源 Vite+ 全家桶,前端工业革命启动;尤大爆料 Void 云服务新产品,Vite 进军全栈开发;ECMA 源码映射规范......

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周全球 Web 开发圈的主要情报如下:

  • 🎉 尤大出席 Vue 大会,发表了关于 Vue & Vite 的重要讲话
  • ✨ Vue 生态“文艺复兴“,“蒸汽模式“公测,Pinia Colada 新品首发
  • ✅ TC39 工作组推进 ECMA 源码映射规范
  • 👍 Vite 生态“工业革命“,Vite+ 全家桶免费开源
  • 🙏 尤大爆料 Void 一键部署云平台,Vite 进军全栈开发

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 Vue 生态文艺复兴

近日,Vue 之父 & Vite 之父 & VoidZero CEO 尤雨溪出席了 Vue 阿姆斯特丹大会和 D2 技术大会,发表了重要讲话。

cover.png

本期我们就来回顾一下这位“前端之神“已公开的关于 Vite & Vue 生态的最新情报和未来规划。

Vue 3.6 Beta

Vue 是 GitHub 第二 UI 框架,也是唯一一个同时支持 SFC(单文件组件)/ JSX、集成 Signals(细粒度响应性)的渐进式框架。

Vue 目前发布了 v3.6-beta(公测版),主要包括:

  • 移植 alien-signals 重构 @vue/reactivity
  • 新增可选的 Vapor Mode 编译模式,这是一种专属于 <script setup> SFC 的无虚拟 DOM “蒸汽模式“
  • Vapor Mode 是 Vue 当前 API 的子集,所以部分功能受限,比如不支持 <Suspense /> 组件和 Options API

尤大最初在 2022 透露了 Vapor Mode(蒸汽模式),去年 Vue 从 Alpha 顺利晋升到 Beta,今年有望正式发布。但考虑到尤大还需要兼顾 Vite+ 等海量开源项目,暂不确定,敬请期待!

Vue Router 5.0

Vue Router 是 Vue 生态官方的客户端路由库,年初发布了 v5.0 主版本,主要包括:

  • 不再依赖 unplugin-vue-router,将其集成为 vue-router/unplugin,支持基于文件的路由
  • IIFE 构建不再包含 @vue/devtools-api,该模块升级到 v8.0 之后,不再提供 IIFE 格式
  • v5.0 是一个过渡版本,v6.0 将只支持纯 ESM 模块

Pinia Colada v1 首发

Pinia 近期没有主/次版本更新,但上线了新的产品 Pinia Colada。

pinia-colada.png

Pinia Colada 是基于 Pinia 的 Vue 专属异步状态管理库,第一个主版本正式发布,优点在于:

  • 支持缓存、去重、SWR(过期重验证)等高级功能,不用我们自己定义相关复杂逻辑
  • 无需手写 isLoading 等属性,内部封装后暴露这些接口
  • 消除数据请求的大量重复模板代码,更符合人体工学

colada-demo.png

Nuxt 4.4

Vue 生态的第一全栈元框架 Nuxt 发布了 v4.4 次版本,主要包括:

  • 新增 createUseFetch() 等工厂函数,支持组合拦截器定义高级实例,比如创建带有默认选项的 useApiFetch() 来替换 useFetch()
  • 新增 useAnnouncer() 组合函数和 <NuxtAnnouncer /> 组件,适用于页面内容动态变化、但焦点不变的场景,比如表单提交
  • 更棒的 import 保护,现在会显示建议和完整的追踪信息
  • 构建性能分析报告,显示构建阶段或打包插件的持续时间等数据,轻松诊断性能瓶颈

profiler.png

🎉 Vite 生态工业革命

而 Vite 生态,尤大所在的 VoidZero 团队掀起了一场前端“工业革命“。

上周我们提到了 Rust 驱动的第一个 Vite 稳定版 Vite 8 正式发布,替换 Rollup + esbuild,采用 Rolldown + Oxc,性能爆表。

此外,基于 Oxc 编译器的格式化神器 Oxfmt 发布了 beta(公测版),JavaScript 跟 TypeScript 的格式化功能 100% 兼容 Prettier,但性能比快了 30 倍。

本期补充更多 Vite 生态的进展,包括 Oxlint 和 Vitest。

Oxlint JS 插件 Alpha

ESLint 的 Rust 移植版 Oxlint 也有新进展,它的 JS 插件进入 Alpha 阶段,目前 100% 通过 ESLint 内置规则的官方测试套件。

oxc-test.png

具体而言,Oxlint 采用 Rust 重写了 650+ 多条代码质检规则,涵盖了 ESLint 的大部分规则。即使没有使用 Rust 重写的规则,Oxlint 也提供了 oxlint-plugin-eslint 插件来无缝迁移,使得 Oxlint 100% 兼容 ESLint 的所有内置规则。

性能方面,Oxlint 团队把 Node 源码库的 ESLint 替换掉,进行测评跑分,性能暴涨近 5 倍。

Oxlint JS 插件的成熟意味着目前 JS 生态现存的 ESLint 插件,比如非官方的社区插件,也能无缝迁移到 Oxlint 项目。这样用户无需重写插件,同时部分受益于 Rust 的原生性能。

Vitest 4.1

Vite 生态衍生的 Vitest 是 GitHub 前十的测试框架,近期也发布了 4.1 次版本,主要包括:

  • 采用新鲜出炉的 Vite 8
  • 测试标签分组,按标签设置或筛选测试,借鉴 pytest 筛选标签的自定义语法
  • 开发体验优化,比如自定义 UI 窗口配置,Playwright 追踪视图改进,自动生成 GitHub Actions Job 摘要
  • Vitest 的 VS Code 扩展现在支持 Deno,import 语句后会显示模块加载时间

vscode.png

🛜 官方情报

ECMA-426 源码映射格式规范

Source Map(源码映射)是一种特殊的 JSON 文件,用于在我们编写的源码和运行时代码之间进行映射。

举个栗子,我们在开发时可能编写一些强大的方言,比如 Sass 或 TypeScript,再把它们转换成 HTML、CSS 或 JavaScript,这样浏览器才能正常执行。

source-map.gif

问题在于,当我们使用 devtools(开发者工具) debug 时,我们希望直接定位到源码,而不是编译或压缩后人类不可读的代码。

这时就要用到 Source Map 了,这个 JSON 文件中保留了源码和运行时代码之间的映射关系,比如哪一行、哪一列等等。

过去,Source Map 并未被标准化,大家通过一份谷歌文档约定实现,但一些功能始终无法协调。

为此,彭博社成立了 TC39-TG4(源码映射工作组),制定了 ECMA-426(源码映射格式规范)。

ecma.png

近年来,它们标准化了更多功能,Scopes 和 Range Mappings 也即将上线 devtools!

React 文档更新

React 文档更新了 <ViewTransition /> 组件结合 <Activity /> 组件章节,如果你想让组件在保持状态的同时实现进场或出场动画,可以使用 <Activity /> 组件。

react-doc.png

Svelte 最佳实践

Svelte 文档更新了“最佳实践“章节,帮助大家编写快速健壮的 Svelte 应用,可以将 svelte-core-bestpractices 投喂给 AI 代理作为 Skills 使用。

svelte-doc.png

🛠️ 工具推荐

Vite+ 全家桶开源

随着 Vite 生态的各个工具逐渐成熟,尤大创立的 Void Zero 公司也官宣:Vite+ 进入 Alpha 阶段!

Vite+ 是将 Vite 生态所有开发工具叠加在一起的一体化工具链,由 Vite Task 任务运行器驱动,提供了 vp install / vp dev 等命令。

具体而言,Vite+ 把 Vite 生态的所有流行的开源软件 —— Vite、Vitest、Oxlint、Oxfmt、Rolldown 和 tsdown 都添加到一个全家桶,用于开发、测试、代码质检、格式化和构建生产环境项目。

此外,Vite+ 还支持管理 pnpm / Bun 等包管理器,甚至能管理 Node 的版本。同时,我们配置的 lint 或格式化规则,比如 eslint.config.js.prettierrc 等配置文件,可以整合到单一的 vite.config.js 中。

vite-plus.gif

之前,Vite+ 原本要求对企业用户付费授权,现在尤大直接开源,完全免费。不管是公司还是独立开发者,都能纵享丝滑了,感谢 Void Zero 的慷慨!

随着 Vite+ 官宣 Alpha 版本,很多项目开始试用,Vite+ 目前已经集成到 Vue 源码的相关分支,还有 Vue CLI create-vue,进一步投入到生产环境测评。

Void Cloud 全栈开发

尤大在 Vue 大会演讲的最后,致敬乔布斯经典的“One More Thing“环节放大招,透露了新产品 Void Cloud。

VOID 是 Vite+ / Optimized(优化)/ Isomorphic(同构)/ Deployment(部署)这几个设计理念的首字母缩写,这是一款 Vite 专属的云服务插件,也是一个也云服务平台,或者全栈元框架。

Void 内置了强大的后端 SDK(软件开发工具),包括数据库、键值存储、对象存储、AI 推理、身份认证等后端应用常见功能,可以按需采用。

void.gif

由于 Void 基于 Vite 生态,因此所有前端框架/元框架天然支持,比如 React、Vue、Nuxt 等,且支持静态站点生成或服务端渲染等不同渲染方式。

Void Cloud 旨在让 Vite 用户能够一键部署,直接上线全栈应用,标志着 Vite 生态将进军全栈开发领域。

Antdv Next 组件库

我一般不推荐组件库,因为 GitHub 有大量成熟的组件库供大家白嫖,容易选择困难。但最近新出了一个 Vue 3 的组件库,它就是 Antdv Next!

Antdv Next 是一套开箱即用的高质量 Vue3 企业级组件库,基于阿里系的蚂蚁设计系统构建。

阿里系之前 Ant Design Vue 是比较流行的组件库,虽然其源码仓库还有提交,但我发现 2024 之后就没有再发布新版本了,目测不会推出新功能了。Ant Design Vue 支持 Vue 2 和 Vue 3,而 Antdv Next 只服务于 Vue 3。

我粗略看了一下,Antdv Next 采用了现代化的技术栈,比如 Vite / Vitest / pnpm 等,可以集成 AI、UnoCSS、Tailwind CSS 等。

Vue 初学者最不习惯的应该是 Antd 系列的组件源码都是基于 TSX 来实现,而不是常见的 SFC,但这只影响开源贡献,不会影响我们以 SFC 的方式使用。

由于组件实现采用了 TSX,Antdv Next 的自定义主题相应地也采用了 CSS-in-JS,其主题是目前我个人比较喜欢的亮点之一。

antdv-next.gif

总之,Antdv Next v1.0 已经正式官宣,值得继续关注,欢迎大家去 GitHub star 支持一波~

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

重排、重绘与合成——浏览器渲染性能的底层逻辑

有一段时间我一直搞不明白一件事:同样是"移动一个元素",用 transform: translateX() 就很丝滑,用 left 就会掉帧——明明做的是同一件事,为什么差这么多?后来真正把浏览器渲染的这三个概念搞清楚之后,才发现这不是玄学,是完全可以用机制解释的。这篇是我的学习笔记。


一、先厘清一个容易混淆的概念

在进入正题前,必须先把这两件事分开:React re-render浏览器重排/重绘

它们经常被放在一起讨论,但其实是两个不同层的事情:

筛选条件变化
    ↓
React re-render(React 层)
→ 组件函数重新执行,生成新的虚拟 DOM
→ Diff 算出最小变更
→ 更新真实 DOM
    ↓
浏览器重排 / 重绘(浏览器层)
→ 浏览器感知到 DOM 变化,重新计算布局/绘制

React re-render 可能触发浏览器重排/重绘,但两者不是同一回事。React re-render 是 JS 层面的虚拟 DOM 计算,浏览器重排/重绘是渲染引擎层面的像素计算。优化方向也不同:useMemo/React.memo 减少的是 React re-render,transform 替代 top 优化的是浏览器渲染层。


二、浏览器渲染流程回顾

在"从 URL 到页面"的完整链路里,最后一段是浏览器拿到 DOM + CSSOM 之后的渲染工作:

DOM + CSSOM
    ↓
Render Tree(渲染树)
    ↓
Layout(重排)   ← 计算每个元素的位置和大小
    ↓
Paint(重绘)    ← 填充颜色、边框、阴影……
    ↓
Composite(合成)← 合并图层,输出到屏幕

重排、重绘、合成,是这条流水线的最后三步。理解它们的代价差异,是理解所有 CSS 性能优化的基础。


三、重排(Reflow):最贵的一步

什么是重排?

当元素的几何属性(位置、大小)发生变化,浏览器需要重新计算所有受影响元素的布局信息——这个过程叫重排,也叫 Reflow。

典型触发场景:

// 修改几何属性
element.style.width = '200px';
element.style.height = '100px';
element.style.margin = '20px';
element.style.padding = '10px';

// 改变元素显示状态
element.style.display = 'none';   // 从文档流移除,触发重排
element.style.display = 'block';  // 重新加入文档流,触发重排

// DOM 结构变化
document.body.appendChild(newElement);
parent.removeChild(child);

// 窗口大小变化
window.addEventListener('resize', handler);

为什么代价大?

重排的代价在于连锁反应。HTML 元素的布局是相互影响的——一个元素的宽度变了,它的兄弟元素可能需要重新排列,父元素的高度可能随之变化,父元素的父元素又可能受影响……

浏览器需要从受影响的节点开始,向上向下重新计算整棵子树的几何信息。如果变化发生在页面顶层,几乎等于重算整个页面布局。


四、重绘(Repaint):比重排轻,但不是没有代价

什么是重绘?

当元素的外观发生变化,但位置和大小没变,浏览器只需要重新绘制受影响区域的像素——这叫重绘,也叫 Repaint。

典型触发场景:

// 颜色类变化
element.style.color = '#333';
element.style.backgroundColor = '#f5f5f5';

// 装饰性变化
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
element.style.borderColor = 'red';
element.style.outline = '2px solid blue';

// 可见性(注意:visibility 不触发重排,display 触发)
element.style.visibility = 'hidden';

重绘不需要重新计算布局,只需要重新"上色"——所以比重排轻得多,但仍然有开销,不是免费的。


五、关键规律:三者的包含关系

重排 ⊃ 重绘 ⊃ 合成

重排一定触发重绘(几何变了,外观也要重画)
重绘不一定触发重排(外观变了,位置不一定变)
合成不触发重排和重绘(完全跳过前两步)

开销排序:重排 > 重绘 > 合成


六、合成层与 transform 为什么快

这是整篇文章最关键的部分。

三种操作的完整流程对比

操作 触发流程 性能
width / height / top / left 重排 → 重绘 → 合成 最差
color / background-color 重绘 → 合成 中等
transform / opacity 只合成 最好

transform 的工作原理

当浏览器发现一个元素使用了 transformopacity 动画,它会把这个元素提升到独立的合成层(Compositing Layer) ,交给 GPU 处理。

普通元素动画(left/top):
    修改样式
        ↓
    重新 Layout(计算位置)    ← CPU,影响其他元素
        ↓
    重新 Paint(绘制像素)     ← CPU,绘制整个区域
        ↓
    Composite(合成)          ← GPU

transform/opacity 动画:
    修改样式
        ↓
    Composite(合成)          ← GPU 直接处理
    (跳过 Layout 和 Paint)

关键在于:transform 是在已经绘制好的图层上做变换(平移、缩放、旋转),不改变元素在文档流中的实际位置,所以浏览器不需要重新计算布局,也不需要重新绘制像素——只需要 GPU 把这个图层的矩阵变换一下,直接合成输出。

实际代码对比

/* 触发重排 + 重绘,动画掉帧 */
.box-bad {
  position: absolute;
  left: 0;
  transition: left 0.3s ease;
}
.box-bad:hover {
  left: 200px; /* 每一帧都触发重排 */
}

/* 只触发合成,动画丝滑 */
.box-good {
  position: absolute;
  transform: translateX(0);
  transition: transform 0.3s ease;
}
.box-good:hover {
  transform: translateX(200px); /* 每一帧只触发合成,GPU 处理 */
}

视觉效果完全一样,但渲染代价天壤之别。这就是为什么 CSS 动画优先推荐使用 transform

主动触发合成层提升

除了 transformopacity,还可以通过 will-change 提示浏览器提前创建合成层:

/* 告诉浏览器:这个元素即将发生 transform 变化,提前准备合成层 */
.animated-card {
  will-change: transform;
}
// 动画结束后,记得移除(合成层有内存开销)
element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

will-change 不是越多越好——每个合成层都占用 GPU 内存,滥用反而会导致内存压力和性能下降。只在真正需要优化的动画元素上使用。


七、实际开发陷阱:循环里交替读写 DOM

这是一个在真实项目里很容易踩的坑,也是面试的高频考题。

为什么读取布局属性会触发强制重排?

当你读取 offsetHeightclientWidthgetBoundingClientRect() 等属性时,浏览器必须给你一个当前准确的值

如果在读取之前你刚刚写入了一些样式变化,而浏览器还没来得及执行重排,它就必须立即同步执行重排,才能返回准确数值。这叫强制同步重排(Forced Synchronous Layout)。

问题代码:循环内交替读写

// 每次循环都触发一次强制重排——100 次循环 = 100 次重排
const boxes = document.querySelectorAll('.box');

for (let i = 0; i < boxes.length; i++) {
  const height = boxes[i].offsetHeight;        // 读:强制触发重排,获取准确值
  boxes[i].style.height = height + 10 + 'px'; // 写:标记待重排
  // 下一次循环读 offsetHeight,又强制清算上面的标记
}

浏览器原本会把多次样式修改批量处理(一次重排),但读写交替打破了这个批处理——每次读取都迫使浏览器立即清算之前积累的修改。

修复:先批量读,再批量写

// 先读完所有值,再批量写——只触发 1 次重排
const boxes = document.querySelectorAll('.box');

// 第一步:批量读取(此时触发 1 次重排)
const heights = Array.from(boxes).map(box => box.offsetHeight);

// 第二步:批量写入(浏览器合并成 1 次重排处理)
boxes.forEach((box, i) => {
  box.style.height = heights[i] + 10 + 'px';
});

本质是:把读操作和写操作分离,让浏览器能够合批处理写操作。

如果修改逻辑更复杂,可以借助 requestAnimationFrame 把写操作推到下一帧的开头执行:

// 环境:浏览器
// 场景:确保在下一帧开始时批量执行所有 DOM 写操作
const heights = Array.from(boxes).map(box => box.offsetHeight);

requestAnimationFrame(() => {
  boxes.forEach((box, i) => {
    box.style.height = heights[i] + 10 + 'px';
  });
});

八、浏览器完整渲染流程总图

把前面所有内容串起来,完整看一遍:

URL 输入 → DNS → TCP → TLS → HTTP 请求/响应
                                    ↓
                              解析 HTML → DOM 树
                              解析 CSS  → CSSOM 树
                                    ↓
                              Render Tree(去掉不可见节点)
                                    ↓
┌───────────────────────────────────────────────────────────┐
│                    浏览器渲染流水线                         │
│                                                           │
│  Layout(重排)                                            │
│  触发条件:width/height/top/left/margin/display 等改变      │
│       ↓                                                   │
│  Paint(重绘)                                             │
│  触发条件:color/background/shadow/visibility 等改变        │
│       ↓                                                   │
│  Composite(合成)                                         │
│  所有操作最终都到这一步                                       │
│                                                           │
│  ✦ transform / opacity                                    │
│    → 元素提升为独立合成层,GPU 直接处理                        │
│    → 跳过 Layout 和 Paint,直达 Composite                   │
└───────────────────────────────────────────────────────────┘
                                    ↓
                               屏幕显示 🎉

延伸思考

梳理完这些,还有几个问题没完全搞清楚:

  1. 合成层的内存代价怎么量化? 什么情况下合成层的开销会超过它带来的性能收益,Chrome DevTools 里怎么观测?
  2. React 的批量更新(Batching)和浏览器的批量渲染是什么关系? React 18 的自动批处理,是不是某种程度上也在减少强制同步重排?
  3. CSS contain 属性是什么? 据说它可以把一个元素声明为"独立的渲染作用域",让重排影响范围收敛到局部——这个机制是怎么运作的?

🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. React re-render ≠ 浏览器重排:React re-render 是 JS 层虚拟 DOM 的重新计算,浏览器重排是渲染引擎的布局重算。前者可能触发后者,但优化手段不同,不要混淆。
  2. 三层开销排序:重排(Reflow)> 重绘(Repaint)> 合成(Composite)。重排必触发重绘,重绘不必触发重排,合成跳过前两步。
  3. transform 快的原因:浏览器把 transform/opacity 的元素提升到独立合成层,由 GPU 直接处理矩阵变换,完全跳过 Layout 和 Paint。left/top 每帧都触发重排,transform 每帧只做合成——这是动画性能差异的根源。
  4. 强制同步重排陷阱:读取 offsetHeightgetBoundingClientRect() 等属性会强制浏览器立即执行重排。循环内交替读写 DOM = 每次循环触发一次重排。解决:先批量读,再批量写。
  5. will-change 的正确用法:提前声明元素将发生 transform 变化,让浏览器预先创建合成层。但合成层有内存开销,不要滥用,动画结束后用 will-change: auto 释放。

参考资料

虚拟 DOM 与 Diff 算法——React 性能优化的底层逻辑

用了两三年 React,我一直对"虚拟 DOM 更快"这个说法半信半疑。直到有一次优化一个长列表卡顿问题,才真正逼着自己把这套底层逻辑摸清楚。这篇是我的学习笔记,试图用具体例子把"为什么"和"怎么做"说清楚,而不是把概念堆在一起。


一、为什么需要虚拟 DOM?

先从"直接操作真实 DOM 有什么问题"聊起。

真实 DOM 操作慢在哪?

上一篇聊浏览器渲染时提到过,每次修改 DOM,浏览器都要重跑一遍渲染流水线:

修改 DOM → 重新计算样式 → Layout(重排)→ Paint(重绘)→ Composite

这个流水线本身没问题,问题在于频率。如果你有一个复杂页面,状态变化触发了 100 次 DOM 修改,流水线就要跑 100 次。每次都是真实的浏览器渲染工作,代价不低。

那"每次重新渲染整个页面"呢?

你可能会想:干脆每次状态变化,把整个页面 innerHTML 全部重写,不就省事了?

理论上是"最简单"的方案,但问题是:

  1. :重建整个 DOM 树,触发全量 Layout + Paint,比局部更新慢得多
  2. 丢失用户状态:用户正在输入的文本框内容会被清空、滚动位置跳回顶部、当前 focus 的元素失焦——体验直接崩掉

虚拟 DOM 要解决的,正是这两个问题之间的矛盾:既不想每次手动挑出要更新的 DOM 节点,又不想粗暴地全量重建。


二、虚拟 DOM 是什么?

虚拟 DOM(Virtual DOM)本质上就是用普通 JS 对象来描述 DOM 结构

操作真实 DOM 慢,但操作 JS 对象快得多(快几百倍)。所以 React 的思路是:先在内存里用 JS 对象"演练"要做的改动,算出最小改动集,再一次性更新到真实 DOM。

来看一个具体的对应关系:

<!-- 真实 DOM -->
<div class="card">
  <h1>标题</h1>
  <p>描述内容</p>
</div>
// 对应的虚拟 DOM(JS 对象)
{
  type: 'div',
  props: { className: 'card' },
  children: [
    {
      type: 'h1',
      props: {},
      children: ['标题']
    },
    {
      type: 'p',
      props: {},
      children: ['描述内容']
    }
  ]
}

React 的 JSX 语法,本质上就是在写这样的对象描述,只是换了一套更好看的语法糖。

// 你写的 JSX
const element = (
  <div className="card">
    <h1>标题</h1>
    <p>描述内容</p>
  </div>
);

// Babel 编译后,等价于
const element = React.createElement(
  'div',
  { className: 'card' },
  React.createElement('h1', null, '标题'),
  React.createElement('p', null, '描述内容')
);

三、虚拟 DOM 的工作流程

有了虚拟 DOM,React 的渲染流程变成了这样:

状态变化(setState / useState)
        ↓
生成新的虚拟 DOM 树
        ↓
与上一次的旧虚拟 DOM 树做 Diff(对比)
        ↓
找出差异部分(patch)
        ↓
只把差异更新到真实 DOM

核心价值只有一句话:最小化真实 DOM 操作次数

举个例子——一个有 1000 个节点的页面,某次状态变化只影响了其中 3 个节点。

方案 真实 DOM 操作次数
全量重建 1000 次
手动精准更新 3 次(但需要你自己写逻辑)
虚拟 DOM + Diff 3 次(自动计算)

虚拟 DOM 让你享受到了"手动精准更新"的性能,但不需要你自己写那些繁琐的 DOM 操作逻辑。


四、Diff 算法:如何高效比较两棵树?

现在问题来了:比较两棵树,算出最小改动,怎么做?

理论最优解有多慢?

计算机科学中,对比两棵树的最优算法复杂度是 O(n³)

100 个节点?10⁶ = 100 万次计算。 1000 个节点?10⁹ = 10 亿次计算

每次状态更新都跑 10 亿次操作,页面直接冻住。这个路走不通。

React 的解法:三个假设,换来 O(n)

React 选择了一个工程上的妥协:基于三个在实际开发中几乎总是成立的假设,把复杂度降到 O(n)。


假设 1:不同类型的节点,直接替换

如果一个节点从 <div> 变成了 <p>,React 不会试图比较它们的内部差异——直接销毁整棵旧树,重建新树。

// 旧的虚拟 DOM
<div>
  <input value="用户输入的内容" />
  <span>子元素</span>
</div>

// 新的虚拟 DOM(根节点类型变了)
<p>
  <input value="用户输入的内容" />
  <span>子元素</span>
</p>

这种情况下,React 会:

  1. 卸载整个 <div> 及其所有子节点(包括 input 里用户输入的内容)
  2. 重新挂载整个 <p>

所以如果你的根节点类型频繁切换,会造成不必要的子组件销毁重建。这个假设告诉我们:组件的根节点类型,能稳定就稳定


假设 2:只比较同层节点,不跨层级

React 的 Diff 是逐层对比的,不会尝试找跨层移动的节点。

旧树                    新树

    A                       A
   / \                     / \
  B   C        →          B   C
 / \                           \
D   E                           E

如果你把节点 D 从 B 的子节点移动到了 C 的子节点下,React 看到的是:

  • B 层:少了 D → 删除 D
  • C 层:多了 D → 新建 D

它不会识别出"这是同一个节点在移动",而是执行一次删除 + 一次创建。

这意味着:跨层级移动 DOM 节点,在 React 里代价比你想象的高。在实际组件设计中,尽量避免通过条件渲染在不同层级之间"搬运"同一个组件。


假设 3:用 key 识别列表节点

这是三个假设里和日常开发最紧密的一个。

当对比一组子节点(列表)时,如果没有 key,React 只能按顺序逐一对比:

// 旧列表
<ul>
  <li>张三</li>   // 位置 0
  <li>李四</li>   // 位置 1
  <li>王五</li>   // 位置 2
</ul>

// 在开头插入"赵六"后的新列表
<ul>
  <li>赵六</li>   // 位置 0
  <li>张三</li>   // 位置 1
  <li>李四</li>   // 位置 2
  <li>王五</li>   // 位置 3
</ul>

没有 key,React 按位置对比:位置 0 内容变了(张三→赵六)→ 更新;位置 1 内容变了 → 更新;位置 2 内容变了 → 更新;位置 3 是新增 → 新建。改了 4 个节点,实际上只是新增了 1 个。

有了 key,React 能识别出哪些节点是"同一个",从而准确复用:

<ul>
  <li key="zhaoliu">赵六</li>   // 新增
  <li key="zhangsan">张三</li>  // 复用,不更新
  <li key="lisi">李四</li>      // 复用,不更新
  <li key="wangwu">王五</li>    // 复用,不更新
</ul>

只做 1 次插入操作,剩下三个节点直接复用。


五、为什么不能用 index 做 key?

这是 React 开发中最经典的"坑"之一,我觉得有必要把例子说完整。

场景:删除列表项

初始列表 [张三, 李四, 王五],用 index 做 key:

// 初始状态
<ul>
  <li key={0}>张三</li>
  <li key={1}>李四</li>
  <li key={2}>王五</li>
</ul>

现在删除张三,列表变成 [李四, 王五]

// 删除后
<ul>
  <li key={0}>李四</li>  // key=0,内容从"张三"变成了"李四"
  <li key={1}>王五</li>  // key=1,内容从"李四"变成了"王五"
                          // key=2 消失 → 删除
</ul>

React 看到的是:

  • key=0:内容变了 → 更新
  • key=1:内容变了 → 更新
  • key=2:消失了 → 删除

结果:3 次 DOM 操作。但我们实际上只删了 1 个元素,只需要 1 次 DOM 操作


改用唯一 ID 做 key:

// 初始状态
<ul>
  <li key="zhangsan">张三</li>
  <li key="lisi">李四</li>
  <li key="wangwu">王五</li>
</ul>

// 删除后
<ul>
  <li key="lisi">李四</li>   // key 没变,内容没变 → 跳过
  <li key="wangwu">王五</li> // key 没变,内容没变 → 跳过
                              // key="zhangsan" 消失 → 删除
</ul>

React 准确识别出只有"zhangsan"消失了:1 次 DOM 操作,完全正确。


更严重的 bug:输入框状态错乱

上面的例子只是性能问题,但下面这个是功能 bug

场景:列表每一项有一个输入框,用户在第一项(张三)的输入框里填了内容,然后删除第一项。

// 每一项带输入框的组件
function ListItem({ name }) {
  return (
    <li>
      <span>{name}</span>
      <input placeholder={`备注 ${name}`} />
    </li>
  );
}

// 用 index 做 key
{list.map((item, index) => (
  <ListItem key={index} name={item.name} />
))}

删除"张三"后,React 对 key=0 做的是更新(把 name prop 改成"李四"),而不是销毁重建。

React 复用了原来"张三"那个 DOM 节点,只更新了 name 属性——但输入框是非受控的,它的内部状态(用户输入的内容)跟着 DOM 节点走,不跟着数据走。

结果:删掉张三之后,李四的输入框里还显示着刚才给张三写的备注内容。数据删了,UI 状态还留着

这种 bug 在测试环境容易被漏掉,到了生产环境才被用户发现,排查起来也很头疼。


结论

✅ 用数据的唯一 ID 做 key(数据库主键、UUID 等)
❌ 不用 index 做 key(除非列表永远不会增删排序)
❌ 不用随机数做 key(每次渲染都会强制重建,比没有 key 更差)

六、整体流程回顾

用户交互 / 数据请求
        ↓
setState / useState 触发更新
        ↓
React 调用 render,生成新的虚拟 DOM 树
        ↓
┌─────────────────────────────────────┐
│           Diff 算法(O(n))          │
│                                     │
│  类型不同?→ 直接替换                  │
│  只比同层  → 不跨层                   │
│  有 key?  → 精准识别复用              │
└─────────────────────────────────────┘
        ↓
生成最小 patch(差异集合)
        ↓
批量更新到真实 DOM
        ↓
浏览器渲染(只有变化的部分触发重排/重绘)

延伸思考

梳理完这些,我产生了几个新问题,暂时还没完全搞清楚:

  1. React Fiber 和虚拟 DOM 是什么关系? Fiber 架构是 React 16 引入的,它把虚拟 DOM 的 Diff 过程变成了可中断的,这对长列表渲染有什么具体影响?
  2. Vue 的 Diff 和 React 的 Diff 有什么区别? 听说 Vue 3 的双端对比算法在某些场景下效率更高,是什么原理?
  3. React.memouseMemo 和虚拟 DOM 的 Diff 是什么关系? 它们是在 Diff 之前就跳过了,还是 Diff 之后的优化?

这些可能是下一篇的方向,也欢迎有研究的朋友交流。


🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. 虚拟 DOM 的本质:用 JS 对象描述 DOM 结构,在内存中做 Diff,最小化真实 DOM 操作次数,解决"全量重建"导致的慢和状态丢失问题。
  2. Diff 算法的三个假设:① 不同类型节点直接替换;② 只对比同层节点;③ 用 key 识别列表节点。三个假设把复杂度从 O(n³) 降到 O(n)。
  3. key 的作用:帮助 React 识别哪些节点是"同一个",从而在列表更新时准确复用,避免不必要的 DOM 操作。
  4. index 做 key 的两种问题:性能问题(删除头部节点会触发全量更新)+ 功能 bug(非受控组件的状态跟 DOM 节点走,不跟数据走,导致状态错乱)。
  5. key 的正确选择:用数据的唯一 ID(数据库主键、UUID 等),不用 index,不用随机数。

参考资料

从输入 URL 到页面显示——浏览器工作原理全解析

这篇文章的起因很朴素:被面试官问到"浏览器输入 URL 后发生了什么",我当时答得磕磕绊绊。事后复盘,发现自己其实每天都在和这条链路打交道,却从没认真梳理过它。所以这篇更多是我的学习笔记——不追求教科书式的完整,而是希望用对话感把每个概念说清楚。如果你也对这条链路模糊,欢迎一起往下读。


一、为什么要理解这条链路?

先说面试:这是前端面试里的"经典送命题"。问法很宽,可以从 DNS 聊到渲染,每个环节都能展开一个小时。

但比面试更重要的是:理解浏览器在帮你做什么

为什么 <script> 放底部?为什么 transformtop 流畅?为什么 HTTPS 比 HTTP 安全?这些问题的答案,都藏在这条链路里。

完整流程如下,我们逐段拆解:

URL 输入
  ↓
DNS 解析(域名 → IP)
  ↓
TCP 三次握手(建立连接)
  ↓
TLS 握手(HTTPS 加密,若有)
  ↓
HTTP 请求 / 响应
  ↓
浏览器解析渲染(HTML → 像素)
  ↓
页面显示

二、DNS 解析:找到服务器地址

域名是个"电话簿"

你输入的是 www.example.com,但网络层面真正认的是 IP 地址(比如 93.184.216.34)。域名只是给人看的别名。

DNS(Domain Name System)就是把域名翻译成 IP 的"电话簿"。

查询顺序:从近到远

浏览器不会每次都跑去问根服务器,它有一套缓存优先的查询链:

浏览器缓存
  ↓(没有?)
操作系统缓存(hosts 文件 / 系统 DNS 缓存)
  ↓(没有?)
路由器缓存
  ↓(没有?)
ISP(运营商)DNS 服务器
  ↓(没有?)
根域名服务器 → 顶级域服务器(.com)→ 权威域名服务器
  ↓
返回 IP 地址,逐层缓存

类比一下:你想找某个老同学的电话,你会先翻自己的手机通讯录,再问共同朋友,最后才去翻毕业纪念册。每一层都比下一层"近"。

TTL:为什么不能永久缓存?

DNS 记录带有 TTL(Time To Live,缓存有效期),过期后必须重新查询。

原因很简单:映射关系会变。比如网站迁移服务器,IP 换了,如果客户端永久缓存旧 IP,就再也找不到新服务器了。TTL 的存在,是在"缓存命中率"和"数据新鲜度"之间做权衡。


三、TCP 三次握手:建立可靠连接

找到 IP 之后,浏览器需要和服务器建立连接。HTTP 跑在 TCP 之上,而 TCP 是面向连接的协议——发数据之前,双方必须先"握手"确认线路通畅。

为什么是三次,不是两次或四次?

这是个很好的问题。我的理解是,三次握手需要确认三件事:

次序 方向 目的
第一次 客户端 → 服务器(SYN) 确认:客户端能发
第二次 服务器 → 客户端(SYN+ACK) 确认:服务器能收、能发
第三次 客户端 → 服务器(ACK) 确认:客户端能收

三次之后,双方都知道对方能收能发,通信信道建立完毕。

少一次(两次握手)的问题:客户端能收这件事没人确认,存在单向通道风险,且会引发"历史连接"问题(旧的延迟 SYN 包触发服务器建立无效连接)。

多一次没必要:四次就是冗余了,三次已经能确认所有需要确认的状态。

类比:打电话前的确认——"喂,你能听到我吗?"→"能,你能听到我吗?"→"能"。三句话,线路通畅,开始正式通话。


四、TLS 握手:加密 + 身份验证(HTTPS)

TCP 建好连接后,如果是 HTTPS,还要多一步:TLS 握手。

为什么需要它?

HTTP 是明文传输的。你发出去的每一个请求,路径上的任何节点(路由器、运营商、同一 WiFi 下的其他人)理论上都能看到完整内容。用户密码、信用卡号……全部裸奔。

TLS 解决了两个问题:

  • 身份验证:你连接的是真的 example.com,不是被人劫持的钓鱼站
  • 加密传输:内容只有你和服务器能读

握手流程(简化版)

1. 浏览器 → 服务器:我支持这些加密算法 [列表],给我你的证书

2. 服务器 → 浏览器:用这个算法,这是我的证书(含公钥)

3. 浏览器验证证书(向 CA 机构核实真实性)
   生成随机数,用服务器公钥加密后发过去

4. 服务器用私钥解密,得到随机数

5. 双方用这个随机数生成"会话密钥"(对称密钥)

6. 后续所有通信用会话密钥加密

两个角色分开理解

初学时我一直搞混"证书"和"加密",其实它们是两件事:

角色 类比 作用
证书 身份证 + 公证处盖章 证明"我真的是 example.com"
加密 双方约定的暗语本 保证通信内容只有双方能读

证书由 CA(证书颁发机构)签发,浏览器内置了受信任的 CA 列表。如果证书是自签名的或已过期,浏览器会弹出警告。

为什么不全程用公钥加密?

这是个常被忽略的细节。非对称加密(RSA)安全,但比对称加密(AES)慢约 100 倍

所以 TLS 的设计是:非对称加密只用于握手阶段安全交换密钥,真正的通信内容用对称密钥(AES)加密。兼顾了安全性和性能。

加密类型 代表算法 速度 用途
非对称加密 RSA、ECDH 密钥交换、签名
对称加密 AES 实际数据加密

TLS 管加密,Cookie 管身份

还有一个常见混淆点:TLS 建立的是加密信道,不等于"记住了你是谁"。

服务器如何区分不同用户?那是 HTTP 层面 Cookie / session_id 的事。TLS 每次连接都会重新握手(虽然有会话恢复机制),但识别"这个请求属于哪个用户",靠的是请求头里的 Cookie。


五、HTTP 请求与响应

握手完成,浏览器发出第一个 HTTP 请求:

GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 ...
Accept: text/html
Cookie: session_id=abc123
Cache-Control: no-cache

几个重要的请求头:

  • Host:告诉服务器你访问的是哪个域名(一台服务器可能托管多个域名)
  • Cookie:带上本地存储的会话标识
  • Cache-Control:告诉服务器/中间缓存怎么处理这个请求的缓存

服务器返回响应:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: max-age=3600

<!DOCTYPE html>...

常见状态码速查:

状态码 含义 常见场景
200 成功 正常响应
301 永久重定向 域名迁移
304 内容未修改 使用本地缓存
404 资源不存在 路径错误
500 服务器内部错误 后端异常

六、浏览器解析:从 HTML 到像素

拿到 HTML 之后,浏览器开始做"最后一公里"的工作:把代码变成屏幕上的像素。这个过程叫关键渲染路径(Critical Rendering Path)

Step 1:解析 HTML → DOM 树

浏览器从上到下解析 HTML,构建 DOM(Document Object Model)树。DOM 是页面结构的内存表示,每个标签对应一个节点。

<!-- 这段 HTML -->
<body>
  <div class="container">
    <p>Hello</p>
  </div>
</body>

<!-- 对应的 DOM 树(简化) -->
body
  └── div.container
        └── p
              └── "Hello"

Step 2:下载并解析 CSS → CSSOM 树

并行下载 CSS 文件,解析生成 CSSOM(CSS Object Model)树。结构和 DOM 类似,但存的是样式信息。

关键阻塞规则

这里有个绕不开的问题,很多性能优化都源于此:

资源类型 阻塞什么 原因
CSS 阻塞渲染 没 CSSOM 就没法确定元素最终样式
JS(无属性) 阻塞HTML 解析 JS 可能操作 DOM,所以得等 JS 执行完
JS(defer 不阻塞解析 延迟到 HTML 解析完才执行
JS(async 下载不阻塞,执行阻塞 下载完立刻执行

这就是为什么 <script> 推荐放在 </body> 前,或者使用 defer:避免阻塞 HTML 解析,提升首屏速度。

Step 3:DOM + CSSOM → Render Tree

合并 DOM 和 CSSOM,生成只包含可见节点的 Render Tree(渲染树)。

注意:display: none 的元素不进入 Render Tree(它不占空间、不显示);但 visibility: hidden 的元素会进入(它仍然占位)。

Step 4:Layout(重排 / Reflow)

基于 Render Tree,计算每个节点的精确位置和尺寸——相对视口的坐标、宽高、边距……

这步代价较高。任何改变元素几何属性的操作(改 widthmarginposition)都会触发 Reflow,浏览器需要重新计算布局。

Step 5:Paint(重绘 / Repaint)

按照布局结果,把每个元素"画"出来:填充颜色、绘制边框、阴影、文字……

Step 6:Composite(合成)

浏览器把不同图层合并,最终送到屏幕显示。

这里有个重要的性能优化点:

/* 只触发 Composite,性能最好 */
.card {
  transform: translateY(-4px);
  opacity: 0.9;
}

/* 触发 Layout + Paint + Composite,代价最高 */
.card {
  top: -4px; /* 改变几何属性 */
}

transformopacity 的变化不影响布局,浏览器可以直接在 GPU 层面处理,跳过 Layout 和 Paint,性能最优。这就是为什么 CSS 动画推荐优先用 transform


七、整条链路总结

用户输入 URL
        │
        ▼
┌──────────────────┐
│   DNS 解析        │  域名 → IP(电话簿查询,逐层缓存)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  TCP 三次握手     │  确认双方能收发(SYN → SYN+ACK → ACK)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  TLS 握手(HTTPS)│  证书验证 + 交换会话密钥(非对称→对称)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  HTTP 请求/响应   │  发送 Request,接收 HTML/CSS/JS
└──────────────────┘
        │
        ▼
┌──────────────────────────────────────────┐
│             浏览器渲染流水线              │
│  HTML → DOM  ┐                           │
│              ├→ Render Tree → Layout     │
│  CSS → CSSOM ┘         → Paint → 合成   │
└──────────────────────────────────────────┘
        │
        ▼
      页面显示 🎉

延伸与发散

在梳理这条链路的过程中,我产生了一些新的疑问,记录在这里:

  1. HTTP/2 和 HTTP/3 对这条链路的影响是什么? HTTP/2 的多路复用是不是意味着 TCP 握手的成本被摊薄了?HTTP/3 基于 UDP 的 QUIC 协议又是如何处理可靠性的?
  2. Service Worker 如何介入这条链路? PWA 的离线缓存是在哪个环节"截胡"请求的?
  3. 浏览器的预加载机制<link rel="preconnect"><link rel="prefetch">)是在提前做哪几步?

这些可能会是后续文章的方向,也欢迎有经验的朋友交流。


🧠 面试常问版(核心记忆点)

如果只有 5 分钟时间,记住这 5 条:

  1. DNS:域名→IP,查询链是浏览器缓存→OS→路由→ISP→根服务器,TTL 控制缓存时效。
  2. TCP 三次握手:确认双方能收发,三次刚好,少一次有安全隐患,多一次冗余。
  3. TLS:证书验证身份,非对称加密只用于交换密钥,实际内容用 AES(对称)加密,快 100 倍。
  4. 渲染阻塞:CSS 阻塞渲染,JS 阻塞 HTML 解析,所以 <script> 放底部或用 defer
  5. 渲染性能transform/opacity 只触发合成层,跳过 Layout 和 Paint,动画优先使用。

参考资料

从输入 URL 到页面:一个 Vue 项目的“奇幻漂流”

🧭 从 URL 到页面:一个 Vue 项目的“奇幻漂流”

这是一段你每天都可能经历的旅程:在浏览器输入一个地址,按下回车,几毫秒后,一个 Vue 单页应用就活生生地出现在屏幕上。这背后发生了什么?

Vue 的响应式系统、虚拟 DOM、编译器和“发布‑订阅”主角们——Observer、Dep、Watcher、Patch——是如何协作的?

让我们像侦探一样,一步步追踪这段旅程,用有趣但不失严谨的方式,把整个技术链路掰开揉碎。

🚀 第一站:浏览器 —— 资源的“快递小哥”

输入 URL → DNS 解析 → TCP 连接 → 请求 HTML → 接收响应

当你在地址栏敲下 https://my-vue-app.com,浏览器立刻化身快递调度中心:

  1. DNS 查询: 把域名变成 IP 地址(比如 192.0.2.1)。
  2. TCP 握手: 与服务器建立可靠连接。
  3. 发送 HTTP 请求: 告诉服务器“我要你的首页”。
  4. 服务器返回 HTML: 通常一个极简的 index.html,里面只有一个 <div id="app"></div> 和一串 <script src="/js/chunk-vendor.js"> 之类的标签。

这时 Vue 还没现身,只是一个空壳 HTML 被浏览器解析。但关键的 JS 文件已经开始下载——它们才是 Vue 的“灵魂”。

📦 第二站:Vue 实例诞生 —— “造物主”的仪式

当浏览器加载并执行完打包后的 JS 文件(通常由 Webpack/Vite 生成),Vue 的舞台正式搭好。

// main.js —— 一切从这里开始
new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

这行代码背后,Vue 内部展开了一场精密的初始化交响乐:

🎼 乐章一:合并选项 & 生命周期初始化

  • 将传入的 routerstorerender 等与默认配置合并。
  • 设置内部标志(如 _isMounted),调用 beforeCreate 钩子。

🎼 乐章二:数据响应式 —— Observer 的“大改造”

beforeCreate钩子执行完,执行initState 接着初始化 injectinitState(propsdatacomputedwatch)provide

function initState(vm) {
    initProps(vm, opts.props);
    initMethods(vm, opts.methods); // 处理 methods
    initData(vm);       // 调用 observe() 将 data 转为响应式
    initComputed(vm, opts.computed);// 处理 computed
    initWatch(vm, opts.watch); // 处理 watch
}

响应式data这是最精彩的部分。Vue 会遍历 data() 返回的对象,递归地把每一个属性变成响应式:

  • Vue 2:用 Object.defineProperty 重写 getter/setter,每个属性配一个专属的 Dep(依赖管理器)。
  • Vue 3:用 Proxy 代理整个对象,更强大(能监听属性添加/删除)。
    // 简化的响应式模型
    data() {
      return { count: 0, user: { name: 'Alice' } }
    }
    
    // ↓ 响应式数据 内部主要实现

    // 1. Observer(观察者)- 数据劫持
    /**核心工作:
     *  - 为对象添加 __ob__ 属性,指向 Observer 实例
     *  - 对数组:重写 push/pop/shift/unshift/splice/sort/reverse 方法
     *  - 对对象:调用 defineReactive 将每个属性转换为 getter/setter
     */
    class Observer {
        constructor(value, shallow = false, mock = false) {
            this.value = value;
            this.shallow = shallow;
            this.dep = new Dep();        // 每个 Observer 持有一个 Dep
            this.vmCount = 0;
            def(value, '__ob__', this);  // 在对象上标记 __ob__
    
            if (isArray(value)) {
                // 数组:拦截变异方法
                this.observeArray(value);
            } else {
                // 对象:遍历每个属性,转换为 getter/setter
                const keys = Object.keys(value);
                for (let i = 0; i < keys.length; i++) {
                    const key = keys[i];
                    defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock);
                }
            }
        }
    }
    function defineReactive(obj, key, val) {
        observe(val); // 递归处理嵌套对象
        const dep = new Dep(); // 每个属性有自己的依赖管理器
        Object.defineProperty(obj, key, {
            get() {
                if (Dep.target) { // 当前正在执行的 Watcher
                    dep.addSub(Dep.target); // 依赖收集
                }
                return val;
            },
            set(newVal) {
                if (newVal !== val) {
                    val = newVal;
                    observe(newVal); // 新值如果是对象,也需要转为响应式
                    dep.notify(); // 派发更新,通知所有 Watcher
                }
            }
        });
    }
    
    // Dep 类 是一个依赖收集器,充当发布-订阅模式的调度中心:
    class Dep {
        constructor() { this.subs = []; }
        addSub(watcher) { this.subs.push(watcher); }
        notify() { this.subs.forEach(w => w.update()); }
    }
    // ↓ 经过 Observer
    count 拥有了 getter/setter + 一个 Dep
    user 对象也被递归改造,name 同样拥有 getter/setter + Dep

同时,computedwatch 也会创建对应的 Watcher(观察者)。但此时它们都只是“预备役”,还没有真正去订阅数据。

🎼 乐章三:created 钩子触发

现在 datacomputedmethods 都已经可用,但 DOM 还不存在。你可以在 created 里发起异步请求、设置定时器,因为响应式数据已经 ready。

🛠️ 第三站:编译 —— 模板如何变成“渲染函数”?

Vue 有两种方式获得 render 函数:

  • 你直接提供了(比如单文件组件里的 <script> 导出 render)。
  • 或者 Vue 需要编译模板——这是最通用的方式。 假设我们有一个模板:
<div id="app">
  <p>{{ message }}</p>
  <button @click="count++">Click me</button>
</div>

Compiler 会做三件事:

  1. 解析(Parse): 把模板字符串转换成 AST(抽象语法树)。AST 就是一个 JS 对象,精准描述了 DOM 结构、指令、文本插值等。
  2. 优化(Optimize): 标记静态节点(比如没有绑定任何动态数据的纯文本)。这一步为后续虚拟 DOM 的 diff 减负。
  3. 代码生成(Codegen): 从 AST 生成一个可执行的 render 函数,类似:
function render() {
    with(this) {
        return _c('div', { attrs: { id: 'app' } }, [
            _c('p', [_v(_s(message))]),
            _c('button', { on: { click: () => count++ } }, [_v('Click me')])
        ])
    }
}

注意:编译阶段不会把 {{ message }} 替换成具体值,也不会为每个指令绑定更新函数。它只产出 render 函数,真正的数据替换要到运行时。

##🎬 第四站:首次渲染 —— 从数据到真实 DOM 的“首秀”

🎼 乐章四:mountComponent 组件挂载阶段

created执行结束,开始执行 $mount 进入组件挂载阶段。

$mount 现在 datacomputedmethods 都已经可用,但 DOM 还不存在。你可以在 created 里发起异步请求、设置定时器,因为响应式数据已经 ready。

$mount 函数被调用,Vue 创建了一个渲染 Watcher

Vue.prototype.$mount = function (el, hydrating) {
    // ...
    return mountComponent(this, el, hydrating)
}
function mountComponent(vm, el, hydrating) {
    vm.$el = el;

    callHook$1(vm, 'beforeMount');
    // 创建更新函数
    const updateComponent = () => {
        vm._update(vm._render(), hydrating);  // render 生成 vnode,update 更新 DOM
    };
    // 创建渲染 Watcher !!!!!!!!!!!!!在这呢~
    new Watcher(vm, updateComponent, noop, {
        before() {
            if (vm._isMounted && !vm._isDestroyed) {
                callHook$1(vm, 'beforeUpdate');
            }
        }
    }, true)
    if (vm.$vnode == null) {
        vm._isMounted = true;
        callHook$1(vm, 'mounted');
    }
    return vm;
}

class Watcher {
    constructor(vm, expOrFn, cb, options, isRenderWatcher) {
        this.vm = vm;
        this.deps = [];          // 当前依赖的 Dep 列表
        this.newDeps = [];       // 新一轮收集的 Dep 列表
        this.depIds = new Set(); // 避免重复添加
        this.getter = expOrFn;   // 获取值的函数(渲染函数或表达式)

        this.value = this.lazy ? undefined : this.get();
    }

    get() {
        pushTarget(this);  // 将自己设为 Dep.target
        let value;
        try {
            value = this.getter.call(this.vm, this.vm);  // 执行 getter,触发依赖收集
        } finally {
            popTarget();      // 恢复上一个 Dep.target
            this.cleanupDeps(); // 清理不再需要的依赖
        }
        return value;
    }

    addDep(dep) {
        const id = dep.id;
        if (!this.newDepIds.has(id)) {
            this.newDepIds.add(id);
            this.newDeps.push(dep);
            if (!this.depIds.has(id)) {
                dep.addSub(this);  // 双向绑定:Watcher 订阅 Dep
            }
        }
    }

    update() {
        if (this.lazy) {
            this.dirty = true;
        } else if (this.sync) {
            this.run();
        } else {
            queueWatcher(this);  // 异步队列更新
        }
    }
}

updateComponent 内部就是:vm._update(vm._render(), ...)。 渲染 Watcher 会立即执行一次,开启首次渲染之旅。

1️⃣ _render() —— 生成虚拟 DOM (VNode)

调用刚才生成的 render 函数。 在 render 执行过程中,this.messagethis.count 被读取 → 触发它们的 getter → 依赖收集开始!

  • 每个响应式属性的 Dep 会检查当前是否有活动的 Watcher(此时就是渲染 Watcher)。
  • 如果有,就把这个渲染 Watcher 添加到自己的订阅列表(subs)中。
// 伪代码:依赖收集
getter() {
  if (Dep.target) {
    dep.depend()  // 把 Dep.target(渲染 Watcher)加入 subs
  }
  return value
}

结果:messagecount 现在“认识”了渲染 Watcher。以后它们变了,就知道该通知谁。 render 最终返回一棵 VNode 树——一个轻量级的 JS 对象,描述了 DOM 结构。

2️⃣ _update() —— patch 挂载到真实 DOM

调用 __patch__ 函数,首次渲染时 oldVnode 是挂载点(真实 DOM 元素,比如 <div id="app">),vnode 是新 VNode。

patch 会递归地创建真实 DOM 元素,设置属性、事件监听(比如 @click 被绑定到真正的 click 事件),最后把生成的 DOM 插入到页面中。

页面终于显示了! 🎉 随后 mounted 钩子被调用,你可以在里面操作 DOM 了。

🔄 第五站:交互与响应式更新 —— “自动档”的魔法

用户点击了“Click me”按钮,count++ 被执行。

1️⃣ 数据变化

countsetter 被触发,内部调用 dep.notify()

2️⃣ 派发更新

dep.notify() 会遍历 subs 列表(里面目前有渲染 Watcher),调用每个 Watcher 的 update() 方法。

3️⃣ 异步调度

update() 不会立即重新渲染,而是调用 queueWatcher(this) 把渲染 Watcher 放入一个异步队列。 Vue 通过 nextTick(微任务或降级宏任务)来批量处理更新,避免同一个 Watcher 被重复添加(去重)。

4️⃣ 重新渲染与 Diff

在下一个 tick,队列被清空:

  • 渲染 Watcher 执行 run() → 再次调用 updateComponent。
  • 重新执行 render() 生成新 VNode(此时 count 已经变成新值,依赖收集会重新建立,旧依赖会被清理)。
  • 调用 _update() 执行 patch(oldVNode, newVNode)Diff 算法登场(Vue 2 双端比较 / Vue 3 快速 diff + 最长递增子序列):
  • 比较新旧 VNode 树,找出最小变化集。
  • 只更新变化的部分(比如按钮文本从 “Click me” 变成 “Click me (1)”),而不重新渲染整个列表。 最终真实 DOM 被高效更新,用户看到了新的数字。 随后 updated 钩子触发。

🗺️ 完整流程图

URL 输入
   ↓
DNS 解析 → TCP 连接
   ↓
HTML 加载 & 解析 JS
   ↓
new Vue() 
   ├─ 合并选项
   ├─ beforeCreate(inject → props → )
   ├─ initInjections → initState(methods → data → computed → watch)
   ├─── Observer 转换 data(响应式 + Dep)
   ├─── 初始化 computed / watch(创建 Watcher)
   ├─ created
   └─ $mount
        ├─ 编译模板 → render 函数(如果没提供)
        ├─ 创建渲染 Watcher(Vue 2) / Effect(Vue 3)
        │    ├─ 执行 _render() → 读取响应式数据 → 依赖收集(数据→Dep→Watcher) → 生成VNode
        │    └─ 执行 _update() → patch → 真实 DOM
        └─ mounted
   ↓
用户交互(修改数据)
   ├─ setter → dep.notify()
   ├─ 渲染 Watcher 被推入异步队列
   ├─ nextTick 执行队列
   │    ├─ 重新执行 _render() → 新 VNode
   │    └─ patch(oldVNode, newVNode) → Diff → 更新 DOM
   └─ updated

🧐 一些有趣的细节(常见疑问)

❓ “模板里没用到的数据,会不会也被依赖收集?”

不会。渲染 Watcher 只收集本次渲染实际访问到的数据。如果 v-if 为 false 导致某个分支从未进入,那分支里的数据就不会被收集。当条件变为 true 时,下一次渲染会自动订阅它们。

❓ “v-showv-if 在依赖收集上有什么不同?”

  • v-if:条件为 false 时,该分支根本不渲染 → 不读取内部数据 → 无依赖收集 → 内部数据变化不会触发更新。
  • v-show:只是 CSS 隐藏,DOM 一直存在 → 每次渲染都会读取内部数据 → 依赖始终存在 → 数据变化会触发重新渲染(即使看不见)。

❓ “Observer 在发布‑订阅里是什么角色?”

它是“装修工人”——在初始化时把普通数据改造成带 getter/setterDep 的响应式对象。它不直接参与发布或订阅,但它是整个系统能够运转的基础。

❓ “Vue 3 比 Vue 2 快在哪?”

  • Proxy 代替 Object.defineProperty,可监听属性添加/删除、数组索引等。
  • 编译优化:静态提升、补丁标记、块树 → 让 diff 跳过静态内容。
  • 快速 diff + 最长递增子序列 → 减少 DOM 移动次数。

🎯 总结:从 URL 到像素的“奇幻漂流”

阶段 核心角色 产出
资源加载 浏览器、HTTP HTML + JS
Vue 初始化 ObserverDepWatcher 响应式数据 + 实例
模板编译 Compiler render 函数
首次渲染 渲染 Watcherrenderpatch 真实 DOM
交互更新 setterDep.notify、调度器、patch + diff 最小化 DOM 更新

总结: 从输入 URL 到 Vue 项目渲染,整个链路是:

URL 输入 → 网络加载(HTML 加载) & 解析 JS → Vue实例初始化(响应式数据、编译)→ 首次渲染 Watcher → 执行 render 生成 VNode → patch 创建真实 DOM → 挂载完成 →用户交互 → 数据变化 → 响应式派发 → 重新渲染 → Diff 更新 DOM

这趟旅程中,Vue 的每一个设计都精妙地平衡了声明式编程的优雅与底层性能的极致。希望这次“共探”,能让你下次启动 Vue 项目时,看到的不只是一个页面,而是一整套精心编排的幕后舞剧。

手撕发布订阅与观察者模式:从原理到实践

前言

在JavaScript异步编程和组件通信中,发布订阅模式和观察者模式是两种至关重要的设计模式。

它们都能实现对象间的一对多依赖关系,但实现方式截然不同。

本文将通过两道手撕面试题代码,深入剖析这两种模式的核心原理、实现方式,以及它们之间的本质区别。

一、题目 FED19 发布订阅模式

描述

请补全JavaScript代码,完成"EventEmitter"类实现发布订阅模式。 注意:

  1. 同一名称事件可能有多个不同的执行函数
  2. 通过"on"函数添加事件
  3. 通过"emit"函数触发事件
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class EventEmitter {
                // 补全代码
                
            }
        </script>
    </body>
</html>

二 、发布订阅模式

发布/订阅模式的核心思想,是实现应用中那些彼此不相干的模块之间的轻松通信。

这种模式在 jQuery 插件生态和各类前端架构设计书籍中常有深入探讨,但需要说明的是,它并非 JavaScript 语言规范的一部分,所以在 MDN 等官方文档中并不会有直接的介绍。

原理

发布-订阅模式定义了一种一对多的依赖关系,当发布者(Publisher)对象的状态发生改变时,所有依赖它的订阅者(Subscriber)对象都会得到通知。它像一个“信息中介”,将消息的发送者和接收者彻底解耦,两者不需要知道对方的存在,只需要知道共同的“频道名称”。

它的工作原理可以拆解为以下几个角色:

  • 发布者 (Publisher):负责在特定“频道”上发送消息或事件,不关心谁会接收。
  • 订阅者 (Subscriber):负责订阅感兴趣的“频道”,并在频道有消息时执行相应的回调函数。
  • 事件调度中心 (Event Bus / PubSub):这是模式的核心,负责维护所有“频道”和订阅者的关系。它提供订阅(on / subscribe)、发布(emit / publish)、取消订阅(off / unsubscribe)等核心方法。

下面是一个极简的 JavaScript 实现:

// 创建一个事件中心 (Event Bus)
const eventHub = {
    // 用于存储事件和对应的回调函数
    topics: {},
    
    // 订阅方法
    subscribe: function(topic, listener) {
        if (!this.topics[topic]) this.topics[topic] = [];
        this.topics[topic].push(listener);
        
        // 返回一个可以用于取消订阅的函数
        return () => {
            const index = this.topics[topic].indexOf(listener);
            if (index !== -1) this.topics[topic].splice(index, 1);
        };
    },
    
    // 发布方法
    publish: function(topic, data) {
        if (!this.topics[topic]) return;
        this.topics[topic].forEach(listener => {
            listener(data);
        });
    }
};

// --- 使用示例 ---
// 模块A:订阅 'user-login' 事件
const unsubscribe = eventHub.subscribe('user-login', (userInfo) => {
    console.log(`模块A收到通知,用户 ${userInfo.name} 已登录。`);
});

// 模块B:发布 'user-login' 事件
eventHub.publish('user-login', { name: '张三' }); 
// 输出: 模块A收到通知,用户 张三 已登录。

// 当不再需要时,可以取消订阅
// unsubscribe(); 

经典实现

其实我觉得这个思想类似于浏览器的 addEventListener

浏览器 API 中的 window 对象上的事件机制,是发布-订阅模式的一种经典实现

DOM 事件系统(包括 window 上的事件)就是浏览器原生实现的、基于发布-订阅模式的事件架构

DOM 事件系统如何实现发布-订阅

让我们把浏览器的事件模型和标准的发布-订阅模式做个映射:

模式角色 DOM 事件系统中的对应实现 说明
事件调度中心 windowdocumentElement 等 DOM 节点 每个 DOM 节点都内置了事件管理能力
订阅 (Subscribe) addEventListener('eventName', callback) 订阅特定事件类型
发布 (Publish) 用户交互或代码触发:dispatchEvent(event)、点击等 触发事件,执行所有订阅的回调
取消订阅 (Unsubscribe) removeEventListener('eventName', callback) 移除事件监听,避免内存泄漏
事件通道 事件类型字符串,如 'click''resize''message' 类似发布-订阅中的"topic"

window 就是典型的事件总线

// ========== window 作为事件调度中心 ==========

// 1. 订阅 (Subscribe):监听一个自定义事件
window.addEventListener('user-logged-in', (event) => {
    console.log(`收到通知,用户 ${event.detail.name} 登录了`);
    // 可以触发任何行为
});

// 2. 发布 (Publish):在任意地方触发事件
function login() {
    // ... 登录逻辑 ...
    const customEvent = new CustomEvent('user-logged-in', {
        detail: { id: 1, name: '张三' }
    });
    window.dispatchEvent(customEvent);
}

// 3. 取消订阅 (Unsubscribe)
const handler = (event) => { console.log('只会执行一次'); };
window.addEventListener('once-event', handler);
// 不再需要时移除
window.removeEventListener('once-event', handler);

理解 window 事件是发布-订阅模式,对掌握浏览器 API 和设计模式有双重价值:

  1. 解释了很多原生 API 的行为

    • window.addEventListener('resize', handler) — 订阅窗口大小变化事件
    • window.addEventListener('online', handler) — 订阅网络状态变化
    • window.addEventListener('message', handler) — 订阅跨窗口消息(iframe 通信)
    • 这些都遵循同样的"先订阅、后触发、最后取消订阅"模式。
  2. 揭示了事件委托的原理: 由于事件会冒泡,在 windowdocument 上订阅一个事件,可以接收到任何子元素触发的事件。这正是利用了"一个调度中心可以接收所有发布"的特性。

    // 事件委托:在 window 上订阅,捕获所有点击
    window.addEventListener('click', (event) => {
        if (event.target.matches('.btn-delete')) {
            console.log('删除按钮被点击');
        }
    });
    

应用场景

理解原理后,更重要的是知道它在哪些场景下能真正派上用场。

  • 跨组件通信:在大型前端应用中,用于解决没有直接关系的组件(如兄弟组件、跨层级组件)之间的通信问题,可以避免通过父组件层层传递回调函数的麻烦。
  • 异步编程:在处理AJAX请求、图片加载、脚本加载等异步操作时,可以用发布-订阅模式来管理成功、失败、完成等不同状态下的回调,让代码更清晰。
  • 模块解耦:将一个复杂系统中的不同功能模块(如购物车、用户中心、商品展示)通过事件中心进行通信,可以显著降低模块间的直接依赖,使得各个模块可以独立开发、测试和维护。
  • MV 框架的底层实现*:Vue.js 中组件间的 $on / $emit 方法,本质上就是基于发布-订阅模式的实现。

注意事项

在使用这种模式时,有几个“坑”需要特别注意:

  • 内存泄漏:当一个组件或对象被销毁时,一定要记得调用 unsubscribeoff 方法,将它之前订阅的事件从事件中心移除。否则,事件中心的回调函数依然持有对已销毁对象的引用,导致其无法被垃圾回收,从而造成内存泄漏。
  • 过度使用:虽然模式好用,但过度使用会使应用中的数据流变得非常隐蔽和难以追踪。当一个事件的触发会引发一连串不可见的连锁反应时,代码的调试和维护会变得异常困难。对于简单的父子组件通信,直接传递 props 或调用方法仍是更清晰的选择。
  • 事件命名冲突:在大型项目中,事件名称容易重复,引发非预期的行为。建议使用一套清晰的命名规范,如 模块名:动作名(例如 user:login, cart:add)。

三、解法

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class EventEmitter {
                constructor (){
                    this.events = {};
                }

                on(eventName , callback ){
                    if (!this.events[eventName]){
                        this.events[eventName] = [] ;
                    }
                    this.events[eventName].push(callback);
                }

                emit(eventName , ...args){
                    const callbacks = this.events[eventName];
                    if (callbacks && callbacks.length){
                        callbacks.forEach(callback => {
                            callback(...args);
                        });
                    }
                }
                
            }
        </script>
    </body>
</html>

根据题目要求,我们需要实现一个 EventEmitter 类,支持:

  1. 同一名称事件可以有多个不同的执行函数
  2. on 方法添加事件监听
  3. emit 方法触发事件
class EventEmitter {
    constructor() {
        // 存储事件及其对应的回调函数列表
        this.events = {};
    }
    
    // 添加事件监听
    on(eventName, callback) {
        // 如果该事件还没有对应的回调数组,则初始化一个空数组
        if (!this.events[eventName]) {
            this.events[eventName] = [];
        }
        // 将回调函数添加到数组中
        this.events[eventName].push(callback);
    }
    
    // 触发事件
    emit(eventName, ...args) {
        // 获取该事件对应的回调函数列表
        const callbacks = this.events[eventName];
        // 如果存在回调函数,则依次执行
        if (callbacks && callbacks.length) {
            callbacks.forEach(callback => {
                callback(...args);
            });
        }
    }
}

使用示例

const emitter = new EventEmitter();

// 添加多个监听同一个事件
emitter.on('click', () => console.log('clicked 1'));
emitter.on('click', (msg) => console.log('clicked 2:', msg));
emitter.on('click', (msg) => console.log('clicked 3:', msg));

// 触发事件
emitter.emit('click', 'hello');
// 输出:
// clicked 1
// clicked 2: hello
// clicked 3: hello

代码说明

  1. constructor:初始化一个空对象 events 用于存储事件名和对应的回调函数数组

  2. on(eventName, callback)

    • 检查 events 对象中是否已存在该事件名的回调数组
    • 如果不存在,创建空数组
    • 将回调函数添加到数组中
  3. emit(eventName, ...args)

    • 获取该事件对应的回调函数数组
    • 如果存在,遍历数组并依次执行每个回调函数
    • 使用扩展运算符 ...args 将传入的参数传递给每个回调函数

这个实现满足题目的所有要求:支持同一事件的多个回调函数,通过 on 添加,通过 emit 触发。

四、题目 FED20 观察者模式

描述

请补全JavaScript代码,完成"Observer"、"Observerd"类实现观察者模式。要求如下:

  1. 被观察者构造函数需要包含"name"属性和"state"属性且"state"初始值为"走路"

  2. 被观察者创建"setObserver"函数用于保存观察者们

  3. 被观察者创建"setState"函数用于设置该观察者"state"并且通知所有观察者

  4. 观察者创建"update"函数用于被观察者进行消息通知,该函数需要打印(console.log)数据,数据格式为:小明正在走路。其中"小明"为被观察者的"name"属性,"走路"为被观察者的"state"属性

注意:"Observer"为观察者,"Observerd"为被观察者

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            // 补全代码
            class Observerd {
                
            }

            class Observer {
                
            }
        </script>
    </body>
</html>

五、观察者模式

在 JavaScript 中,经常会遇到一个问题:你需要一种方法来响应特定事件,并利用这些事件提供的数据来更新页面的某些部分。

例如,用户输入后,你需要将其应用到一个或多个组件中。这会导致代码中出现大量的推送和拉取操作,以保持所有内容的同步。

观察者模式正是在这种情况下发挥作用——它支持元素之间的一对多数据绑定。

这种单向数据绑定可以由事件驱动。借助这种模式,您可以构建可重用的代码,以满足您的特定需求。

核心概念

  • 被观察者(Observable):维护一组观察者,状态变化时自动通知它们
  • 观察者(Observer):订阅被观察者,当被通知时执行相应逻辑

被观察者的三个核心部分

EventObserver
│ 
├── subscribe: adds new observable events
│ 
├── unsubscribe: removes observable events
|
└── broadcast: executes all events with bound data
部分 作用
observers 数组,存储所有观察者
subscribe() 添加观察者
unsubscribe() 移除观察者
notify(data) 通知所有观察者

基础实现(ES6 Class)

class Observable {
  constructor() {
    this.observers = [];
  }
  subscribe(func) {
    this.observers.push(func);
  }
  unsubscribe(func) {
    this.observers = this.observers.filter(observer => observer !== func);
  }
  notify(data) {
    this.observers.forEach(observer => observer(data));
  }
}

观察者模式的实际应用

例如博客字数统计演示

创建一个博客文章输入框,系统自动统计字数。用户每次按键输入,都通过观察者模式触发同步更新。

  1. 观察者模式追踪文本区域的变化
  2. 字数统计实时显示在输入框下方
  3. 箭头函数实现单行事件绑定
  4. 广播事件驱动变更给所有订阅者

字数统计函数

const getWordCount = (text) => text ? text.trim().split(/\s+/).length : 0;

单元测试示例

// 准备
const blogPost = 'This is a blog \n\n  post with a word count.     ';

// 执行
const count = getWordCount(blogPost);

// 验证
assert.strictEqual(count, 9);

注:该函数能处理多种边界情况,包括换行、多个空格等。

DOM 集成步骤

  1. HTML 结构
<textarea id="blogPost" placeholder="Enter your blog post..." class="blogPost">
</textarea>
  1. JavaScript 实现
// 创建字数显示元素
const wordCountElement = document.createElement('p');
wordCountElement.className = 'wordCount';
wordCountElement.innerHTML = 'Word Count: <strong id="blogWordCount">0</strong>';
document.body.appendChild(wordCountElement);

// 创建观察者实例
const blogObserver = new EventObserver();

// 订阅更新
blogObserver.subscribe((text) => {
  const blogCount = document.getElementById('blogWordCount');
  blogCount.textContent = getWordCount(text);
});

// 绑定事件
const blogPost = document.getElementById('blogPost');
blogPost.addEventListener('keyup', () => blogObserver.broadcast(blogPost.value));

扩展:RxJS

RxJS 是一个库,它通过使用 observable 序列来编写异步和基于事件的程序。它提供了一个核心类型 Observable,附属类型 (Observer、 Schedulers、 Subjects) 和受 [Array#extras] 启发的操作符 (map、filter、reduce、every, 等等),这些数组操作符可以把异步事件作为集合来处理。

可以把 RxJS 当做是用来处理事件的 Lodash

ReactiveX 结合了 观察者模式迭代器模式使用集合的函数式编程,以满足以一种理想方式来管理事件序列所需要的一切。

RxJS 中用来解决异步事件管理的的基本概念是:

  • Observable (可观察对象): 表示一个概念,这个概念是一个可调用的未来值或事件的集合。
  • Observer (观察者): 一个回调函数的集合,它知道如何去监听由 Observable 提供的值。
  • Subscription (订阅): 表示 Observable 的执行,主要用于取消 Observable 的执行。
  • Operators (操作符): 采用函数式编程风格的纯函数 (pure function),使用像 mapfilterconcatflatMap 等这样的操作符来处理集合。
  • Subject (主体): 相当于 EventEmitter,并且是将值或事件多路推送给多个 Observer 的唯一方式。
  • Schedulers (调度器): 用来控制并发并且是中央集权的调度员,允许我们在发生计算时进行协调,例如 setTimeoutrequestAnimationFrame 或其他。

结合观察者模式 + 迭代器模式 + 函数式编程

示例:监听鼠标拖拽事件

merge(
  fromEvent(document, "mousedown").pipe(mapTo(false)),
  fromEvent(document, "mousemove").pipe(mapTo(true))
)
.pipe(sample(fromEvent(document, "mouseup")))
.subscribe(isDragging => console.log(isDragging));

优点

  • 关注点分离
  • 符合单一职责原则
  • 观察者和被观察者低耦合

缺点

  • 观察者过多时,通知所有观察者可能带来性能问题

六、解法

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class Observerd {
                constructor(name){
                    this.name = name ;
                    this.state = '走路' ;
                    this.observers = [];
                }

                setObserver(observer){
                    this.observers.push (observer);
                }

                setState(state){
                    this.state = state ;
                    this.notifyAllObservers();
                }     

                notifyAllObservers(){
                    this.observers.forEach(observer => {
                        observer.update(this);
                    });
                }
            }

            class Observer {
                update(observed){
                    console.log(`${observed.name}正在${observed.state}`);
                }
            }
        </script>
    </body>
</html>

根据题目要求,需要实现观察者模式。

以下是补全的代码:

class Observerd {
    constructor(name) {
        this.name = name;
        this.state = "走路";
        this.observers = [];
    }
    
    setObserver(observer) {
        this.observers.push(observer);
    }
    
    setState(state) {
        this.state = state;
        this.notifyAllObservers();
    }
    
    notifyAllObservers() {
        this.observers.forEach(observer => {
            observer.update(this);
        });
    }
}

class Observer {
    update(observed) {
        console.log(`${observed.name}正在${observed.state}`);
    }
}

代码说明:

  1. Observerd(被观察者)类

    • constructor(name):构造函数接收name参数,初始化name属性、state属性(初始值为"走路")和observers数组(存储观察者)
    • setObserver(observer):添加观察者到observers数组
    • setState(state):更新state属性,并通知所有观察者
    • notifyAllObservers():遍历所有观察者,调用每个观察者的update方法,传入当前被观察者实例
  2. Observer(观察者)类

    • update(observed):接收被观察者对象,按照指定格式打印日志(如:"小明正在走路")

使用示例:

const observed = new Observerd("小明");
const observer = new Observer();

observed.setObserver(observer);
observed.setState("跑步"); // 控制台输出:小明正在跑步

七、总结

观察者模式与发布者-订阅者模式有何不同?

虽然两种模式都涉及一对多依赖关系,但关键区别在于主体(或发布者)与其观察者(或订阅者)之间的通信方式。

  • 在观察者模式中,主体直接通知其观察者。

  • 在发布-订阅模式中,发布者将通知发送到中介(或通道),然后由中介将通知推送给订阅者。

这种额外的抽象层使得通知过程更加灵活和可定制。

原文:

How does the Observer Pattern differ from the Publisher-Subscriber Pattern?

While both patterns involve one-to-many dependencies, the key difference lies in how the subject (or publisher) communicates with its observers (or subscribers). In the Observer Pattern, the subject directly notifies its observers. In the Publisher-Subscriber Pattern, the publisher sends notifications to a mediator (or channel), which then pushes the notifications to the subscribers. This extra level of abstraction allows for greater flexibility and customization of the notification process.

发布订阅模式 观察者模式
有没有中间人 有(事件中心) 没有(直接通知)
双方知不知道对方存在 不知道(通过事件名交流) 知道(被观察者存着观察者列表)
生活类比 微信群:发消息的人不知道谁在看 你订阅了某人的微博:他更新了主动推给你

说实话,这两题面试手撕代码题实际上背负了很多抽象概念,单独的内容也都可以抽出来好好讲讲,难度并不低。

对于初学者建议按这个顺序来:

  1. 先熟悉上面的代码(应付面试)
  2. 然后自己手敲 3 遍(不要复制粘贴)
  3. 再去看本文的"应用场景"部分(这时候才有共鸣)
  4. 最后再去理解 RxJS、优缺点这些进阶内容

我也是初次深入学习一下这些概念,信息量大得有点懵。

但是回头看看我实际写过的项目代码,很多已经用到了这些思想,只是当时没有注意到这个模式。不妨现在好好回头去整理整理。

限于个人写作,文中若有疏漏,还请不吝赐教。

参考文档

发布订阅模式 vs 观察者模式:它们真的是一回事吗?本文深入解析发布订阅与观察者模式的核心差异:发布订阅通过事件中心实现 - 掘金

Node.js EventEmitter | 菜鸟教程

events 事件触发器 | Node.js v24 文档

观察者模式 - JavaScript 设计模式

JavaScript Design Patterns: The Observer Pattern — SitePoint

丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术

丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术

在使用 AI Agent 深度参与编程任务时,你一定遇到过这种窘境:起初 AI 反应敏捷,指哪打哪;但随着对话轮次增加,它似乎开始变得越来越笨。

上下文快用完的时候,AI会着急完成导致效果不佳,社区中称作 Context Anxiety (上下文焦虑),和我们人一样,着急就容易出错。为了维持对话,Agent 必须丢掉一部分记忆(压缩 - Compact)。但怎么丢、丢掉谁、丢掉后怎么补救,成了衡量一个 Agent 运行时(Runtime)是否成熟的分水岭之一。

今天笔者就带大家拆解三款主流 CLI Agent——Codex CLIClaude CodeOpenCode。看看它们在面对同一个登录 Bug 时,是如何施展各自的"压缩大法"的。

注:本文分析基于 Codex CLI 与 OpenCode 的开源仓库逻辑,以及社区逆向研究与泄露源码对 Claude Code 运行时的验证。

场景回放:一场价值 15,400 Tokens 的登录修复

假设你正在修复一个登录接口报 401 Unauthorized 的 Bug。你召唤了 AI Agent,并经历了一番激烈的排查。

下面是这段对话的完整记录:

编号 角色 内容摘要 预估 Token
#1 System 系统提示词(含 40+ 工具定义) ~800
#2 User "登录页面报 401,帮我排查下" ~100
#3 Assistant "我先搜一下认证相关的逻辑" ~150
#4 Tool Call grep "auth" --include="*.ts" ~50
#5 Tool Result (返回 50 处搜索结果) ~2,000
#6 Assistant "搜到几处,我看看 auth.ts" ~150
#7 Tool Call read_file "src/auth.ts" ~50
#8 Tool Result (完整文件内容,约 300 行) ~3,500
#9 Assistant "找到了,token 校验没处理过期" ~300
#10 User "好,但要兼容旧的 Session 方式" ~80
#11 Assistant "明白,我再看看 middleware" ~150
#12 Tool Call read_file "src/middleware.ts" ~50
#13 Tool Result (middleware 完整内容) ~2,500
#14 Assistant "middleware 也要改,我来处理" ~200
#15 Tool Call edit_file "src/auth.ts" (patch) ~100
#16 Tool Result "Successfully applied." ~30
#17 Tool Call edit_file "src/middleware.ts" (patch) ~100
#18 Tool Result "Successfully applied." ~30
#19 Assistant "代码改好了,跑个测试看看" ~100
#20 Tool Call bash "npm test" ~50
#21 Tool Result (3 个测试失败,含堆栈) ~3,000
#22 Assistant "有 3 个测试挂了,我修一下测试用例" ~200
#23 Tool Call edit_file "src/auth.test.ts" (patch) ~150
#24 Tool Result "Successfully applied." ~30
#25 Tool Call bash "npm test" ~50
#26 Tool Result (测试全部通过,含完整输出) ~1,500

看起来不过 26 条消息,但已经吃掉了约 15,400 tokens。其中加粗的五条工具结果(#5, #8, #13, #21, #26)合计约 12,500 tokens,占了 81% 。这些数据在排查时至关重要,但 Bug 修好后,它们就变成了上下文里沉重的负担。如果不处理,下一轮对话可能因为窗口溢出而丢掉系统提示词或用户的核心需求。

Codex CLI:写一份干练的"工作交接单"

OpenAI 的 Codex CLI(源码,Rust 实现)走的是一种非常符合人类直觉的路线:总结与替换

它的核心思想可以用一句话概括:把之前的全部对话交给 LLM 写一份"工作交接摘要",然后用这份摘要替换掉原始历史。

双路径设计

Codex 提供了两条压缩路径:

  1. 本地路径compact.rs):在客户端调用 LLM 生成摘要,适用于所有模型提供商。
  2. 远程路径compact_remote.rs):直接调用 OpenAI 的内部 API 端点 responses/compact,让服务器完成压缩。仅限 OpenAI 自家模型。

注意,这里的"本地"和"远程"指的不是是否需要调用 LLM——两条路径都需要 LLM 参与,区别在于 "生成摘要"这个核心步骤跑在哪里。本地路径下,客户端自己构造摘要 Prompt(从内置模板 templates/compact/prompt.md 加载)、通过 ModelClientSession 流式调用 LLM API、再处理返回结果,整个编排流程都在你的机器上完成,所以它能对接任意模型提供商。远程路径下,客户端把准备好的对话历史和工具定义发给 OpenAI 的 compact_conversation_history 端点,由服务器完成摘要生成——但客户端并非"甩手掌柜",它在调用前后仍然承担了大量工作:调用前要修剪过长的函数调用历史、构建包含工具规范和系统指令的完整 Prompt 对象;调用后要过滤返回结果(比如丢弃过时的 developer 角色消息、只保留真实的用户和助手内容)、恢复用于 /undo 功能的 ghost snapshots、以及重新计算 token 用量。

简单说,远程路径只是把 "压缩"这一步外包给了 OpenAI 服务器,前处理和后处理仍由客户端完成。这种设计的优势在于:OpenAI 服务端很可能对这个端点做了专门优化(比如使用更经济的模型或内部缓存),这些是客户端走通用 API 做不到的。这体现了 OpenAI 对自家基础设施的垂直整合。

压缩的具体流程

当走本地路径时,Codex 会先提取最近的用户消息(硬上限约 20,000 tokens),然后发送一段简短的 Summarization Prompt 给 LLM。这段 Prompt 只有 4 个核心要点:

你正在执行一次"上下文检查点压缩"。请为另一个将接续任务的 LLM 生成一份交接摘要,包含:当前进展和关键决策、重要的约束和用户偏好、剩余待办事项、继续工作所需的关键数据。

关键词是 "交接"(Handoff) ——它不是在写会议纪要,而是在写一份让下一个人(模型)能直接上手的工作简报。

用我们的登录 Bug 场景来看:

Codex CLI 压缩前后对比

思路拆解:

注意看压缩前后的变化——所有消息变成了 4 条。Codex 极其尊重"用户意图",它会物理删除所有的 Assistant 回复和 Tool 相关消息,但会原封不动地保留所有 User 消息(#2 和 #10)。

随后,它插入一条伪造的 Assistant 消息,内容是一份结构化的交接总结。这份总结包含了任务目标、已完成项、关键架构决策和剩余待办。对于新模型来说,它不需要看那些大段的文件内容和测试堆栈,它只需要知道"测试已经修好了"就足够了。

自动触发与兜底

当 Token 用量接近模型上下文窗口上限时,Codex 会自动触发压缩(不需要用户手动执行 /compact)。如果压缩后空间还是不够,它会退而采取更激进的"头部修剪"——直接从最早的消息开始砍,确保对话能继续下去。

笔者觉得 Codex 的方案最大的优点是直觉性:交接摘要这个概念每个职场人都能理解。缺点是它比较"一刀切"——所有 AI 回复和工具结果都被替换成一段摘要,如果那段摘要遗漏了某个关键细节,就真的找不回来了。

Claude Code:三层递进的"精密遗忘"

Anthropic 出品的 Claude Code 逻辑更为细腻。它不追求一步到位的物理删除,而是设计了三层逐级加强的清理机制——从轻到重,能不动 LLM 就不动 LLM。

注:Claude Code 非开源项目,以下分析基于社区逆向工程和公开资料,具体实现可能随版本变化。

第一层:工具结果修剪(无 LLM 开销)

这是最频繁、也最轻量的一层。不需要调用 LLM,纯粹是本地的规则引擎。它在每次请求前都会自动执行。

它的逻辑很简单:

  • 始终保护最近若干个工具调用的结果(正在用的东西不能删)
  • 超出保护范围的旧工具结果 → 替换为 [Old tool result content cleared] 占位符

用我们的场景来看:

Claude Code 第一层压缩

这种做法极其聪明:它维护了 AI 的"心流"。AI 记得自己搜过代码(#4 的 tool_call 还在),也记得自己读过文件(#7 的 tool_call 还在),只是不记得搜到了什么、文件内容是什么。如果它之后真的需要再次查看,它会自己重新发起 read_file

笔者认为这一层的设计极为精妙——它实现了 "选择性失忆"而非"全面遗忘" 。就像你记得去年读过一本好书,但忘了具体内容,需要的时候再翻就好。

第二层:缓存友好策略(Prompt Cache)

这是 Claude Code 的看家本领,也是三者中独有的差异化优势

Anthropic 的 API 支持 Prompt Cache——如果你发给 API 的消息前缀和上一次请求相同,服务器可以复用之前的计算结果,大幅降低成本和延迟。

这意味着什么?在清理消息时,Claude Code 会尽量避免修改消息序列的前半部分。它采用"手术式"方案:只在尾部进行修整,确保消息开头部分保持绝对一致。这样做的代价是清理效率略低,但换来的是缓存命中率的最大化

用我们的场景来看。假设经过第一层清理后,消息序列是 #1-#26(工具结果已替换为占位符)。现在上下文仍然超标,需要进一步裁剪。一个"朴素"的做法是从最早的消息开始删——但 Claude Code 不这么干

缓存策略对比

左边的朴素策略虽然删掉了最旧的消息,看起来很合理,但代价是整个前缀都变了——API 缓存全部失效,下次请求要从头计算。右边的 Claude Code 策略则相反:它宁可少删一些,也要保证消息序列的前缀部分和上一次请求完全相同,让 Anthropic API 的 Prompt Cache 能够命中。

在长时间运行的任务中(比如你连续让 AI 帮你重构一整个模块),这种策略能带来可观的成本节省——因为每次 API 请求的大部分内容都能命中缓存,只需要为新增的尾部内容付费。

第三层:9 部分结构化 LLM 总结(最后手段)

当前两层都无法阻止上下文继续膨胀时,系统触发最终的全量总结。根据源码,自动压缩的触发阈值为 有效上下文窗口 - 13,000 tokens(其中有效窗口 = 模型上下文窗口 - min(最大输出 tokens, 20,000))。

不过,即使达到了阈值,系统也不会直接跳到 LLM 总结。自动压缩触发时,系统会优先尝试 Session Memory Compact——利用 session memory(会话记忆)中已有的结构化信息来替代完整的 LLM 调用。这意味着大多数自动压缩甚至不需要 LLM 调用。只有当 session memory 路径不可用或不够时,系统才会回退到传统的 LLM 总结流程,生成一份包含 9 个固定部分的结构化摘要

  1. 用户的原始意图
  2. 核心技术概念
  3. 关注的文件和代码
  4. 遇到的错误及修复方式
  5. 解决问题的逻辑链
  6. 所有用户消息的摘要
  7. 待办事项
  8. 当前正在做什么
  9. 建议的下一步

这份摘要的要求极其严格——Prompt 中会要求模型直接引用原文关键短语,而不是全部用自己的话改写。这是为了防止"语境漂移"(模型在复述过程中微妙地偏离原意)。

用我们的场景来看:

Claude Code 第三层压缩

压缩完成后,Claude Code 还会做一系列善后工作,笔者把它叫做 "状态重构"

  • 在新对话开头注入引导语("本次会话延续自上一段对话...")
  • 自动重新读取最近编辑过的文件(最多 5 个文件,总预算 50,000 tokens,单文件上限 5,000 tokens),确保 AI 手里有最新代码
  • 重新声明工具和技能定义
  • CLAUDE.md 中的项目规范作为系统提示语的一部分,始终常驻,不受压缩影响

用户还可以在手动压缩时附加自定义指令,比如 /compact Focus on API changes,引导压缩侧重于特定方向。

此外,系统还有一条被动兜底路径:当 API 返回 prompt_too_long 错误时,系统会自动启动一次反应式压缩并重试请求,确保用户不会因为上下文溢出而直接遇到错误中断。同时,为防止压缩反复失败导致的死循环,连续 3 次自动压缩失败后系统会暂停自动压缩功能。

Claude Code 的方案是三者中最复杂的,但也是最"省钱"的——大多数时候它只需要执行第一层的规则引擎清理,或者通过 Session Memory 路径完成压缩,根本不需要额外的 LLM 调用。

OpenCode:先修剪,再摘要的"阶梯治理"

开源界的新秀 OpenCode(源码,TypeScript + Effect-TS 实现)则提供了一种更为平衡的策略。它在 session/compaction.ts 中实现了一套阶梯式的治理流程:先用低成本手段尽可能腾空间,实在不够再动用 LLM。

第一步:Prune(标记隐藏,非物理删除)

OpenCode 的第一个动作不是删除,而是"标记"。它的规则非常清晰:

  • 只有当修剪能释放超过 20,000 tokens 时才执行(小修小补不值得折腾)
  • 始终保留最近的 40,000 tokens 作为"安全垫"(正在进行的工作不能动)
  • skill 类型的工具输出永远不修剪(因为里面包含操作指令)
  • 保护最近 2 个用户回合的完整内容

关键设计:和 Claude Code 的占位符替换不同,OpenCode 的修剪不是物理删除,而是给旧消息打上一个 compacted = Date.now() 的时间戳标记,让它们在后续请求中"不可见"。数据其实还在数据库里,只是被隐藏了。

OpenCode Prune

关键点: 数据并没有真正丢掉。这为未来可能的历史回溯功能留下了空间——如果开发者需要审计,或者 Agent 触发了某种回溯逻辑,这些数据是可以被重新拉回上下文的。这是一个很有前瞻性的设计。

第二步:LLM 5 标题摘要

如果 Prune 之后还是太臃肿,OpenCode 会用一个隐藏的、专门的 Agent(不干扰用户当前的交互)来调用 LLM 生成一份摘要。这份摘要有一个固定的 5 标题结构:

OpenCode LLM 摘要

OpenCode 在摘要后有一个非常温馨的设计:它会自动重放最后一条用户消息。这能确保 Agent 的最后记忆点始终停留在用户的最新指令上,而不是停留在一段冷冰冰的摘要总结里。用户完全感知不到压缩的发生——你说的最后一句话会被重新发送,AI 继续回答,好像什么都没发生过。

另一个亮点:OpenCode 会跟随用户的语言。如果你一直用中文交流,它的摘要也会是中文的。这对非英语母语的开发者来说,是一个很友好的设计。

笔者觉得 OpenCode 的方案在三者中最"开发者友好"——代码全开源(TypeScript),架构现代(Effect-TS),非物理删除的设计为扩展留足空间。如果你想深度定制压缩行为,OpenCode 是最容易上手的。

三剑客同台竞技

我们将三者的方案放在一起并排观察:

输入:26 条消息, ~15,400 tokens(同一个"修登录 bug"场景)

三剑客对比

维度 Codex CLI Claude Code OpenCode
压缩层次 单层(摘要) 三层(修剪/缓存/摘要) 两层(隐藏/摘要)
LLM 调用 必须 仅在第三层 仅在第二步
用户消息 永久保留原始内容 摘要化(第三层) 摘要化 + 重放最后一条
工具结果处理 物理删除 占位符替换 时间戳标记隐藏
缓存优化 无特殊设计 深度集成 Prompt Cache 侧重减少重复读取
压缩后行为 被动等待 主动重读相关文件 自动重放最后指令

一些值得展开说的差异

关于"要不要保留用户原话" :Codex 选择保留用户消息、只压缩模型回复,这样做的好处是 AI 永远能回看你说过什么,但代价是当用户消息本身很长时,压缩效率会打折扣。Claude Code 和 OpenCode 则选择全部压缩为摘要,更激进但更节省空间。

关于缓存:这是 Claude Code 最独特的优势。其他两家在压缩后,API 请求的内容会发生很大变化,之前的缓存基本作废。而 Claude Code 刻意维持消息前缀的稳定性,使得压缩后的请求依然能复用之前的缓存。在长时间运行的任务中,这意味着可观的成本节省。

关于"非物理删除" :OpenCode 的时间戳标记方式是个很有前瞻性的设计。虽然当前版本并没有实现历史回溯功能,但数据没有真正丢失,为未来留下了可能性。而 Codex 和 Claude Code 的压缩都是不可逆的。

最后

如果用一个类比来形容这三位:

  • Codex CLI 像是一个写交接单的资深员工。他直接撕掉之前的草稿纸,给你一张写的清清楚楚的现状说明,虽然简单粗暴,但非常有效。
  • Claude Code 像是一个拥有精密遗忘能力的学者。他优先划掉书上的细碎批注,只有在书架实在堆不下时,才会把整本书浓缩成一页大纲。他非常在意翻书的效率(缓存)。
  • OpenCode 像是一个务实的阶梯治理者。他先给旧文件打包贴上标签(隐藏),实在不行才做总结。他最贴心的地方在于,总结完后还会提醒你:"你刚才最后说的是这件事对吧?"

归根结底,在 2026 年,最好的上下文管理并不是无止境地扩大 LLM 的记忆容量,而是学会如何精密地遗忘。毕竟,一个什么都记得住的 Agent,往往也最容易被噪音干扰。


参考来源:

TypeScript学习系列(二):高级类型篇

前言

在上一篇文章juejin.cn/post/762508… 中,介绍了 TypeScript 系统中的基础类型及其用法,本篇我们将进击 TypeScript 中一些高级类型,学完本篇,就能对 TypeScript 系统中的各大类型有个比较全面的理解了。

泛型

泛型可以理解为类型参数类型变量,在定义类型别名、接口、类、函数参数时都会用到。在上一篇文章中我们已经介绍过了,下边来看与它相关联的一些场景。

extends 约束

extends 关键字主要用于泛型约束中,例如:

type IsNumber<T> = T extends number ? T : never

IsNumber 类型接收泛型 T 作为类型参数,随后使用 extends 关键字

  • T extends number:表示 T 能赋值于 numberextends 表示 赋值于
  • T extends number ? T : never:这里运用条件类型(类似 js 的三元表达式),意思是,T 如果能赋值于 number 类型,那么返回结果就是 T 类型,反之就返回 never 忽略类型。

那上边这个例子就很好理解,主要使用 extends 关键字来约束泛型在满足 number 类型时再返回它。

内置工具约束

在了解了泛型extends 和基本的条件类型,我们可以来看 TypeScript 提供的一些内置工具,主要用于约束泛型,比如 Partial<T>Required<T>,都是工具名称 + <T> 的组合,专为约束泛型 T 而生。

Partial

Partial 允许我们将传入泛型中的所有属性变为可选的属性,例如:

type Person = {
  name: string,
  age: number
}

type MyPartialProperties = Partial<Person>

image.png

所有属性名旁边都加上了 ? 符号,表示可选属性。

Required

Required 将传入类型中的所有属性变为必传的属性,例如:

type Person = {
  name?: string,
  age: number
}

type MyRequiredProperties = Required<Person> // Person 上的所有属性都是必须的,? 会去掉

image.png

可以看到原本可选 name 属性旁边的 ? 被去掉了,变成了必须项。

ReadOnly

ReadOnly 可以将类型上的属性指定为只读的:

type Person = {
  name?: string,
  age: number
}

type MyReadOnlyProperties = Readonly<Person> // Person 上的所有属性前边会加上 readonly 表示只读属性

image.png

所有属性前边都加上了 readonly 描述符,表示属性是只读的。

Pick

Pick 可以从类型中筛选出某个属性:

type Person = {
  name: string,
  age: number
}

type MyPickProperties = Pick<Person, "name"> // 仅筛选出 Person 中为 name 的那个属性

image.png

从泛型 Person 中取出了 name 属性作为新类型,如上图 MyPickProperties 只剩下 name 属性。

Omit

可以从对象类型中排除掉不需要的属性,支持传联合类型用于同时排除多个属性:

type Person = {
  name: string,
  age: number,
  sex: string
}

type MyOmitProperties1 = Omit<Person, "name"> // 排除掉 Person 类型中的 name 属性
type MyOmitProperties2 = Omit<Person, "name" | "age"> // 排除掉 Person 类型中的 name 和 age 属性

image.png

将泛型 Person 中的 name 属性排除掉了。

image.png

通过联合类型将泛型 Person 中的 nameage 属性一块排除掉。

Record

可以创建一个新的对象类型,这个新对象类型是由某个指定类型中的属性组成的,同时可以指定新类型上的属性类型:

type Example = 'name' | 'age' | 'sex'

type MyRecord = Record<Example, string> // 新类型中属性由 Example 中组成,同时新类型上属性类型指定为 string

image.png

Example 类型中的所有属性组成的新类型 MyRecord,并且指定新类型中属性的类型为 string

ReturnType

ReturnType 用于提取函数类型的返回值类型,而可以不用手动指定函数的返回值类型:

function testExample(a: number, b: number) {
  return a + b
}

type GetFuncReturnType = ReturnType<typeof testExample> // number

image.png

提取 testExample 函数的返回值类型于新类型 GetFuncReturnType 中。

Extract

Extract 用于从两个类型中取出相互兼容的部分,其实就是取交集:

type A = string | number
type B = boolean | number

type ExtractAB = Extract<A, B> // number(相交的部分就是 number)

image.png

类型 A 和类型 B 共有的部分就是 number,自然取到的交集就是 number,作为 ExtractAB 的新类型。

Exclude

Exclude 主要用于从联合类型中排除掉不需要的属性:

type Example = string | number | boolean
type ExcludeString = Exclude<Example, string> // ExcludeString 剩下 number | boolean

image.png

Example 类型中排除掉 string 类型,剩下的 numberboolean 就作为 ExcludeString 的类型。

type Example1 = "dog" | "cat"
type Example2 = "cat"
type ExcludeCat = Exclude<Example1, Example2>

image.png

上边这个例子可以看出,Exclude 会排除掉 Example1 中和 Example2 相同那一部分,Example1 中和 Example2 相同的那部分是 cat,故排除掉 Example1 中的 cat

我们可以发现,ExtractExclude 的操作刚好相反,Extract 是取两个泛型相交的部分,而 Exclude 是从第一个泛型中排除掉和第二个泛型相同(相交)的那部分。Extract 是取,而 Exclude 是排,两者都是取交集

NonNullable

NonNullable 用于排除类型中为 nullundefined 的部分,返回一个新类型:

type Example = number | undefined | null
type NotNullAndUndefined = NonNullable<Example> // 排除类型 C 中为 null 和 undefined 的部分

image.png

NonNullable 可以排除 Example 类型中的 nullundefined

条件类型

条件类型我们在开篇泛型那里就见过了,ts 中的条件类型类似于 js 中的三元运算符,一般配合 extends 一起使用,如:T extends U ? U : never。下面我们来看下分布式条件类型

分布式条件类型

当条件类型作用于泛型类型参数时,如果该类型是联合类型(注意是联合类型),则条件会分布到每一个联合成员上,分别计算,再将结果合并成一个新的联合类型,我们来举例看下:

type Animal = "dog" | "cat"
type AnimalOrFruit = "dog" | "apple" | "cat" | "banana"

type TogetherExample<T> = T extends Animal ? T : never

type OnlyAnimal = TogetherExample<AnimalOrFruit>

image.png

  • T extends AnimalT 能否赋值于 Animal 类型(Animal 类型中包含 dogcat,也就是 T 是否为 dogcat)。
  • T extends Animal ? T : never:接收的泛型 T 如果能赋值于 Animaldogcat),那么就取这个 T,反之就用 never 来忽略类型。
  • TogetherExample<AnimalOrFruit>AnimalOrFruit 类型中为 dogcat 的就正常收集到新类型 OnlyAnimal 中,其他的 applebanana 由于不能赋值于 Animal,被使用 never 类型忽略了。故最终新类型 OnlyAnimal 中仅包含 dogcat

从上边例子也能看出来,所谓分布式条件类型,就是当接收的泛型参数为联合类型时,会将条件作用于每个类型中。

infer

infer 关键字的作用是延时推导,它会在类型未推导时进行占位,等到真正推导出来后,它能返回准确的类型:

type Example<T> = T extends (...args: any) => infer R ? R : never

type ExampleFunc = (a: number, b: number) => number

type TestGetFuncReturnExample = Example<ExampleFunc> // number
  • T extends (...args: any) => infer R ? R : never:判断 T 是否为一个函数类型
  • (...args: any) => infer Rargs 为函数入参,infer R 为返回值类型的占位操作

整个意思就是,T 是函数类型的话,能推导出它的返回值类型 R;反之,就返回 never。代入上边例子,ExampleFunc 是一个函数类型,先使用 infer R 占位返回值类型,等到真正推导出函数的返回值类型为 number 时,它能准确返回类型。

映射类型

映射类型可以基于现有的类型来修改某个属性或通过排除属性来生成新的类型,修改属性包括把属性映射为只读可选、属性名添加前缀等操作。排除属性主要就是将不符合条件的属性映射为 never。我们逐个举例来看下:

  • 映射为只读属性
interface Person {
    name: string,
    age: number,
}

type MyReadOnlyProperties<T> = {
    readonly [P in keyof T]: T[P] // 通过 readonly 关键字将属性映射为只读
}

type ReadOnlyPerson = MyReadOnlyProperties<Person>

image.png

  • 映射为可选属性
interface Person {
    name: string,
    age: number,
}

type MyPartialProperties<T> = {
    [P in keyof T]?: T[P] // 通过 ? 符号将属性映射为可选
}

type PartialPerson = MyPartialProperties<Person>

image.png

  • 为属性添加前缀,可以结合 as 来完成:
interface Person {
    name: string,
    age: number,
}

type MyPrefixProperties<T> = {
  [P in keyof T as `prefix_${string & P}`]: T[P]
}

type PrefixProperties = MyPrefixProperties<Person>

image.png

1、首先使用 P in keyof T 将枚举出 T 类型中的每个属性

2、使用 as 来将每个属性重命名为 prefix_${string & P},即加上 prefix_ 这个前缀,如 name 就会变成 prefix_name,这里的 string & P 作用就是确保 P 类型是一个字符串

3、T[P]:属性的值就正常映射为 P 属性在 T 上的原有类型

  • 仅映射特定类型来生成新的类型
interface Person { 
    name: string,
    age: number,
}

type GenerateNewTypeWithString<T> = {
    [P in keyof T as T[P] extends string ? P : never]: T[P] // 仅映射出类型为 string 的属性
}

type NewTypeString = GenerateNewTypeWithString<Person> // 仅包含 string 类型的属性,即 name

image.png

索引类型

索引类型可以通过属性名直接访问某个属性的具体类型,主要使用中括号 [],例如:

interface Person {
    name: string
    age: number
}
type PersonOfAge = Person["age"]

image.png

如上访问 Person 上的 age 属性,其类型就是 number

还可以获取类型中所有属性的联合类型,如下:

interface Person {
    name: string
    age: number
}

type PersonOfValue = Person[keyof Person]

image.png

使用 keyof 遍历出 Person 上所有的属性,相当于:

  • Person["name"] | Person["age"]
  • 那结果就是 string | age

类型守卫

类型守卫可以根据条件来细化变量的具体类型,从而使代码在运行时更加安全和可维护,主要通过几种方式来实现,包括 typeofinstanceofin自定义类型函数,我们逐个来看看。

type A = string | number | boolean

function logInfo(a: A): void {
    if(typeof a === 'string') {
        // 当 a 为 string 时执行某些操作...
        console.log(`variable ${a} is a string`)
    }
}
  • typeof

image.png

logInfo 函数接收形参 a,用类型 A 约束,类型 A 是个联合类型,也就是说形参 a 的类型可能是 stringnumberboolean 中的一个,在函数体内通过 typeof 来判断 astring 类型,那么在该条件分支内,ts 就能确定 a 的类型为 string 了,这也很好的避免由于类型不确定导致的意外操作。

  • instanceof
class Person {
    speak() {
        console.log('people can speak')
    }
}

class Animal {
    fly() {
        console.log('some animals can fly')
    }
}

const p1 = new Person()
const a1 = new Animal()

function personOrAnimal(a: Person | Animal) {
    if(a instanceof Person) {
        a.speak()
    } else if(a instanceof Animal) {
        a.fly()
    }
}

personOrAnimal(a1) // people can speak

使用 instanceof 能够检查变量是否属于某个类的实例,这样在对应条件分支内 ts 编译器就能确定该实例所属类,从而能给予我们该实例上能调用的方法和属性的提示,这也很好保证了运行时的准确性。

  • in
interface Person {
    write(): void
}
interface Animal {
    eat(): void
    fly(): void
}

class A implements Animal {
    eat() {
        console.log('animal can eat')
    }
    fly() {
        console.log('animal can fly')
    }
}

function getInfo(a: Person | Animal) {
    if('write' in a) {
        a.write()
    } else if('fly' in a) {
        a.fly()
        a.eat()
    }
}

上边 getInfo 函数接收一个实例,通过 in 来判断属性是否存在于实例上,如果存在就能直接使用,而且该属性所属类上的其他属性也能直接访问,比如判断 a 上如果存在 fly 属性。那么 fly 属性所属类上的 eat 也能访问了。

  • 自定义类型
function isString(str: any) {
    return typeof str === 'string'
}

function getInfo(a: any) {
    if(isString(a)) {
        console.log('the operation of string a')
    }
}

通过使用自定义的 isString 函数来判断某个变量是否为特定的类型,满足就能在条件分支内对该类型变量进行一些操作。

类是面向对象的核心概念,它主要封装了对象的状态和行为,也就对应着属性和方法,ts 为类提供了类型检查功能。为此我们可以在类中为属性或方法定义类型,如下例子:

class Animal {
    name: string
    constructor(name: string) {
        this.name = name
    }
    say(): void {
        console.log('动物发出声音')
    }
}

继承

在 ts 中实现类的继承和 js 中是一致的,如下示例:

class Dog extends Animal {
    constructor(name: string) {
        super(name) // 调用父类构造函数初始化
    }
    say(): void { // 重写父类方法
        console.log("wang wang~")
    }
}

const d1 = new Dog("哈士奇")
console.log(d1.name) // 哈士奇

super

super 主要用于调用父类的构造函数,将子类构造函数接收的参数传给父类构造函数,由父类构造函数来做初始化,这样也省去了在子类构造函数中重复声明初始化的操作。

class Dog extends Animal {
    constructor(name: string) {
        super(name) // 调用父类构造函数初始化
        // this.name = name // 相当于
        // this.xxx = xxx // 更多参数
    }
    say(): void { // 重写父类方法
        console.log("wang wang~")
    }
}

修饰符

  • public:可在任何地方访问
  • private:仅可在类内部访问,子类也不允许访问
  • protected:仅可在类内部、子类中访问

下边我们举例来理解这三个修饰符:

  • 实例访问:仅能访问公有属性(public)
class Person {
    public name: string // 公共属性
    private age: number // 私有属性
    protected sex: string // 保护属性

    constructor(name: string, age: number, sex: string) {
        this.name = name
        this.age = age
        this.sex = sex
    }
    getPersonAge(): number { 
        return this.age // 私有属性,仅在当前类能访问
    }
}

const p1 = new Person("man", 11, 'male')
console.log(p1.name) // man 
console.log(p1.age) // 编译报错,age 是类私有属性
console.log(p1.sex) // 编译报错,sex 是类的保护属性,仅可子类中访问
  • 子类访问:可以访问公有属性(public)和保护属性(protected)
class Student extends Person {
    constructor(name: string, age: number, sex: string) {
        super(name, age, sex)
    }

    getStudentName(): string {
        return this.name // 正常
    }

    getStudentSex(): string {
        return this.sex // 正常
    }

    getStudentAge(): number {
        return this.age // 编译报错,age 是私有属性,仅能声明类自身访问
    }
}

抽象类与接口

抽象类就是使用 abstract 修饰的类,抽象类中可以定义抽象、也可以定义具体方法,继承抽象类的子类必须实现抽象类中定义的抽象方法,同时也可以重写抽象类中的具体方法,我们看下边例子就知道了:

abstract class Person {
    abstract myHobby(): void // 抽象方法

    walk(): void {
        console.log('people walking')
    }
}

class Student extends Person {
    myHobby(): void { // 子类必须实现父类的抽象方法
        console.log('music')
    }
    walk(): void { // 重写父类的方法
        console.log('student walking')
    }
}

const s1 = new Student()
s1.myHobby() // music
s1.walk() // student walking

再来看看接口: 定义一组规范,不提供具体的实现,仅包含函数的签名

interface Play {
    games(): void // 函数签名
}

由实现类来完成函数体:

class Student extends Person implements Play {
    // 实现接口中的函数签名
    games() {
        console.log('lol')
    }

    myHobby(): void {
        console.log('music')
    }
    walk(): void {
        console.log('student walking')
    }
}
const s1 = new Student()
s1.games() // lol

为此,我们可以得出抽象类接口的区别:

相同点:都用于定义行为规范,抽象类的抽象方法和接口中的函数签名都必须在子类中实现。

不同点:抽象类中可以包含具体方法的实现,而接口仅含函数签名或属性签名,不包含具体方法的实现。抽象类不能直接实例化,只能作为基类通过子类来继承;而接口可以被类实现,一个类可以同时实现多个接口,使用 implements 关键字。

总结

本篇文章主要围绕 TypeScript 中的高级类型展开介绍,从泛型开始,扩展来看它的一些场景,包括泛型约束、ts 中的内置工具类型。然后就是常用的条件类型、映射类型、索引类型、类型守卫,以及我们常用的类,最后对比了抽象类与接口的区别。

我是 luckyCover,我正在持续更新 TypeScript 学习系列的文章,欢迎大家一起讨论学习呀~

Elpis NPM 发布:把框架从业务中剥离出来

这是 Elpis 框架系列的最后一篇。前四篇把框架从服务端内核、Webpack 构建、DSL 配置、表单组件一路讲到了完整的 CRUD 闭环。但到这一步为止,框架代码和业务代码还混在同一个仓库里。这一篇要做的事情是:把 Elpis 变成一个 npm 包,业务项目通过 require("@nickmjiang/elpis") 引入,框架和业务彻底分离。


一、为什么要分离

之前的项目结构是这样的:框架代码(elpis-core、webpack 配置、通用组件)和业务代码(controller、router、model 配置、自定义页面)全部放在一个仓库里。

这带来几个问题:

  • 框架升级要改业务仓库,业务开发也可能误改框架代码
  • 多个业务项目想用同一套框架,只能复制粘贴
  • 框架的版本没法管理,出了问题不知道该回退到哪个版本

分离之后,框架是一个独立的 npm 包,业务项目只需要 npm install @nickmjiang/elpis,框架升级就是改个版本号的事。


二、分离的思路

核心问题是:哪些东西属于框架,哪些东西属于业务?

graph TD
    Z["Elpis 分离"] --> A["框架 npm 包"]
    Z --> K["业务项目"]

    A --> A1["elpis-core<br/>Koa 服务端内核"]
    A --> A2["webpack 配置<br/>构建体系"]
    A --> A3["通用页面<br/>dashboard / schema-view"]
    A --> A4["通用组件<br/>schema-table / schema-form"]
    A --> A5["基类<br/>BaseController / BaseService"]

    K --> K1["model 配置<br/>DSL 定义"]
    K --> K2["controller / service<br/>业务 API"]
    K --> K3["router / router-schema<br/>路由和校验"]
    K --> K4["自定义页面<br/>custom 组件"]
    K --> K5["扩展控件<br/>自定义表单 / 搜索控件"]

    style Z fill:#f5f5f5,stroke:#9e9e9e
    style A fill:#e3f2fd,stroke:#1565c0
    style K fill:#fff3e0,stroke:#f57c00
    style A1 fill:#e3f2fd,stroke:#1565c0
    style A2 fill:#e3f2fd,stroke:#1565c0
    style A3 fill:#e3f2fd,stroke:#1565c0
    style A4 fill:#e3f2fd,stroke:#1565c0
    style A5 fill:#e3f2fd,stroke:#1565c0
    style K1 fill:#fff3e0,stroke:#f57c00
    style K2 fill:#fff3e0,stroke:#f57c00
    style K3 fill:#fff3e0,stroke:#f57c00
    style K4 fill:#fff3e0,stroke:#f57c00
    style K5 fill:#fff3e0,stroke:#f57c00

简单说:不变的归框架,变化的归业务


三、框架导出了什么

分离后,Elpis 的 index.js 变成了一个 SDK 入口,对外暴露三个能力:

// index.js — npm 包入口
module.exports = {
  // 服务端基类,业务项目继承它写 Controller 和 Service
  Controller: {
    Base: require("./app/controller/base.js"),
  },
  Service: {
    Base: require("./app/service/base.js"),
  },

  // 前端构建,根据环境变量选择 dev 还是 prod
  frontendBuild(env) {
    if (env === "local") FEBuildDev();
    if (env === "production") FEBuildProd();
  },

  // 启动 Koa 服务
  serverStart(options = {}) {
    return ElpisCore.start(options);
  },
};

业务项目的使用方式:

// 业务项目 — 启动服务
const { serverStart } = require("@nickmjiang/elpis");
serverStart({ name: "我的电商后台", homePage: "/view/project-list" });
// 业务项目 — 构建前端
const { frontendBuild } = require("@nickmjiang/elpis");
frontendBuild(process.env._ENV);
// 业务项目 — 写 Controller
const { Controller } = require("@nickmjiang/elpis");
module.exports = (app) => {
  const BaseController = Controller.Base(app);
  return class ProductController extends BaseController {
    async getList(ctx) {
      /* 业务逻辑 */
    }
  };
};

框架提供骨架和基类,业务项目填充具体逻辑。


四、前端扩展点:业务怎么注入自定义内容

框架把 dashboard、schema-view、schema-form 这些通用页面和组件都打包进了 npm 包。但业务项目需要扩展——加自定义路由、加自定义表单控件、加自定义动态组件。

问题是:npm 包里的代码是固定的,业务项目怎么往里面"注入"自己的东西?

答案是 Webpack alias + 空模块降级

4.1 扩展点设计

框架定义了四个扩展点,每个扩展点对应业务项目中的一个约定文件:

扩展点 业务项目约定路径 作用
路由扩展 app/pages/dashboard/router.js 注入自定义路由(custom 页面)
动态组件扩展 app/pages/dashboard/.../component-config.js 注入自定义动态组件
表单控件扩展 app/pages/weights/schema-form/form-item-config.js 注入自定义表单控件
搜索控件扩展 app/pages/weights/schema-search-bar/search-item-config.js 注入自定义搜索控件

4.2 实现原理

Webpack 构建时,框架在 resolve.alias 中为每个扩展点定义一个别名。如果业务项目中存在对应的文件,alias 指向业务文件;如果不存在,alias 指向一个空模块。

// webpack.base.js — alias 动态生成
const blankModulePath = path.resolve(__dirname, "../libs/blank.js");

// 检查业务项目是否有路由扩展文件
const businessDashboardRouterConfig = path.resolve(
  process.cwd(),
  "./app/pages/dashboard/router.js",
);
aliasMap["$businessDashboardRouterConfig"] = fs.existsSync(
  businessDashboardRouterConfig,
)
  ? businessDashboardRouterConfig // 存在 → 指向业务文件
  : blankModulePath; // 不存在 → 指向空模块

空模块就一行代码:

// libs/blank.js
module.exports = {};

框架内部的代码通过 alias 引入业务扩展,然后用展开运算符合并:

// component-config.js(框架内部)
import BusinessComponentConfig from "$businessComponentConfig";

const ComponentConfig = {
  createForm: { component: createForm },
  editForm: { component: editForm },
  detailPanel: { component: DetailPanel },
};

export default {
  ...ComponentConfig, // 框架内置的组件
  ...BusinessComponentConfig, // 业务扩展的组件(没有就是空对象)
};

搜索控件和表单控件的扩展方式完全一样:

// form-item-config.js(框架内部)
import BusinessFormItemConfig from "$businessFormItemConfig";
export default { ...FormItemConfig, ...BusinessFormItemConfig };
// search-item-config.js(框架内部)
import BusinessSearchItemConfig from "$businessSearchItemConfig";
export default { ...SearchItemConfig, ...BusinessSearchItemConfig };

4.3 路由扩展

路由扩展稍微不同。业务项目导出一个函数,框架调用它并传入 routessiderRouters 数组,业务代码往里面 push 自定义路由:

// entry.dashboard.js(框架内部)
import businessDashboardRouterConfig from "$businessDashboardRouterConfig";

// 业务扩展路由
if (typeof businessDashboardRouterConfig === "function") {
  businessDashboardRouterConfig({ routes, siderRouters });
}

业务项目的路由扩展文件:

// 业务项目 app/pages/dashboard/router.js
export default ({ routes, siderRouters }) => {
  routes.push({
    path: "/view/dashboard/my-custom-page",
    component: () => import("./my-custom-page/my-custom-page.vue"),
  });
  siderRouters.push({
    path: "my-sider-page",
    component: () => import("./my-sider-page/my-sider-page.vue"),
  });
};

这样框架的路由是固定的(schema、iframe、sider),业务的路由是动态注入的,互不干扰。


五、package.json 的变化

发布为 npm 包后,package.json 有几个关键变化:

1. 包名改为 scoped 包

{ "name": "@nickmjiang/elpis" }

2. 构建相关的依赖从 devDependencies 移到 dependencies

之前 webpack、babel-loader、vue-loader 这些都在 devDependencies 里,因为它们只在开发时用。但现在 Elpis 是一个 npm 包,业务项目 npm install @nickmjiang/elpis 时不会安装 devDependencies。而业务项目需要用 Elpis 提供的 frontendBuild() 来构建前端,所以这些构建工具必须放到 dependencies 里,确保业务项目安装后能正常使用。

{
  "dependencies": {
    "webpack": "^5.88.1",
    "webpack-merge": "^4.2.1",
    "vue-loader": "^17.2.2",
    "babel-loader": "^8.0.4",
    "css-loader": "^0.23.1",
    "less-loader": "^11.1.3",
    "mini-css-extract-plugin": "^2.7.6",
    "terser-webpack-plugin": "^5.4.0",
    "thread-loader": "^4.0.4"
    // ... 所有构建相关的包
  },
  "devDependencies": {
    // 只剩下 eslint、mocha 等纯开发工具
    "eslint": "^7.32.0",
    "mocha": "^6.1.4",
    "supertest": "^4.0.2"
  }
}

3. 移除业务相关的 scripts

{
  "scripts": {
    "lint": "eslint --quiet --ext js,vue .",
    "test": "_ENV='local' mocha 'test/**/*.js'"
    // dev、beta、prod、build:dev、build:prod 都移除了
    // 这些命令由业务项目自己定义
  }
}

六、业务代码的剥离

框架仓库中删除了所有业务代码:

  • app/controller/business.js → 删除(业务项目自己写 Controller)
  • app/router/business.js → 删除(业务项目自己定义路由)
  • app/router-schema/business.js → 删除(业务项目自己写校验规则)
  • app/pages/dashboard/todo/todo.vue → 删除(业务项目自己写自定义页面)
  • docs/dashboard.model.js → 删除(文档移到 README)

框架仓库只保留通用的、不随业务变化的代码。


七、业务项目的目录结构

分离后,业务项目的结构变成这样:

my-business-project/
├── index.js                    # 启动入口
├── build.js                    # 构建入口
├── package.json
│
├── model/                      # DSL 配置
│   ├── business/
│   │   ├── model.js            # 基础模型
│   │   └── project/
│   │       ├── taobao.js       # 淘宝项目配置
│   │       └── pdd.js          # 拼多多项目配置
│   └── index.js
│
├── config/                     # 环境配置
│   ├── config.default.js
│   └── config.local.js
│
├── app/
│   ├── controller/             # 业务 Controller
│   ├── service/                # 业务 Service
│   ├── router/                 # 业务路由
│   ├── router-schema/          # 参数校验
│   ├── middleware/              # 自定义中间件
│   ├── extend/                 # 扩展
│   └── pages/                  # 前端页面(可选扩展)
│       ├── dashboard/
│       │   └── router.js       # 路由扩展(可选)
│       └── weights/
│           └── schema-form/
│               └── form-item-config.js  # 表单控件扩展(可选)

启动入口只需要两行:

// index.js
const { serverStart } = require("@nickmjiang/elpis");
serverStart({ name: "我的电商后台", homePage: "/view/project-list" });

构建入口也只需要两行:

// build.js
const { frontendBuild } = require("@nickmjiang/elpis");
frontendBuild(process.env._ENV);

elpis-core 的 Loader 机制会自动扫描业务项目的 app/ 目录,加载 controller、service、router 等。Webpack 的 alias 机制会自动检测扩展文件是否存在。业务项目不需要做任何"注册"操作,放对目录就行。


八、扩展点总结

graph LR
    E["业务 router.js"] -->|alias + merge| A["内置路由<br/>schema / iframe / sider"]
    F["业务 component-config.js"] -->|alias + merge| B["内置动态组件<br/>createForm / editForm / detailPanel"]
    G["业务 form-item-config.js"] -->|alias + merge| C["内置表单控件<br/>input / inputNumber / select"]
    H["业务 search-item-config.js"] -->|alias + merge| D["内置搜索控件<br/>input / select / dynamicSelect / dateRange"]

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#e3f2fd,stroke:#1565c0
    style E fill:#fff3e0,stroke:#f57c00
    style F fill:#fff3e0,stroke:#f57c00
    style G fill:#fff3e0,stroke:#f57c00
    style H fill:#fff3e0,stroke:#f57c00

四个扩展点都遵循同一个模式:

  1. 框架通过 Webpack alias 引入业务文件
  2. 如果业务文件不存在,alias 降级到空模块 {}
  3. 框架用 { ...内置配置, ...业务配置 } 合并
  4. 业务配置可以新增,也可以覆盖同名的内置配置

这个模式让框架开箱即用(不写任何扩展文件也能正常运行),同时保留了完整的扩展能力。


九、从项目到框架

回顾整个系列,Elpis 经历了这样一个演进过程:

graph LR
    A["① elpis-core<br/>服务端内核"] --> B["② Webpack<br/>构建体系"]
    B --> C["③ DSL 配置<br/>菜单/路由/渲染"]
    C --> D["④ Schema 表单<br/>CRUD 闭环"]
    D --> E["⑤ NPM 发布<br/>框架与业务分离"]

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#f3e5f5,stroke:#6a1b9a
    style E fill:#fce4ec,stroke:#c62828

从一个具体的业务项目,逐步抽象出通用的框架能力,最后发布为独立的 npm 包。这个过程本身就是框架设计的典型路径:先在业务中验证,再抽象,最后分离。

手撕 V8:我是如何用 2.67ms 的心跳活捉 700ms 冻结幽灵的

手撕 V8:我是如何用 2.67ms 的心跳活捉 700ms 冻结幽灵的

最近在搞一个高性能 Web 应用,被一个"幽灵"困扰了很久:页面总会无征兆地出现瞬间掉帧。

大家都知道这是 V8 的 Stop-The-World (STW) 在搞鬼,但在浏览器里,监控 STW 是个悖论——如果主线程被冻结了,你用来监控的主线程代码(比如 rAF 或 performance.now)本身也是冻结的。

你没法在自己心脏停跳的时候记录停跳时长。

为了抓到这个幽灵,我折腾出了一个叫 stw-sentinel 的小工具,思路挺"偏门"的,发出来给各位老哥 Review 下。

核心思路:找一个"编外"保镖

既然主线程不可信,我就把目光投向了 AudioWorklet

  • 优先级极高:它跑在音频回调线程上,受操作系统音频驱动调度,优先级甚至高于浏览器的渲染线程。
  • 物理隔离:哪怕 V8 的主线程正在进行大规模的垃圾回收(Major GC),只要 CPU 还没爆表,AudioWorklet 依然能稳定跳动。

技术栈:无锁通信

监控者(AudioWorklet)和被监控者(Main Thread)之间必须保持绝对的高效:

  • SharedArrayBuffer (SAB) :两边共享一块物理内存。
  • Atomics:主线程每隔一段时间去 SAB 里打个卡,AudioWorklet 负责高频检查。如果打卡中断,幽灵就现身了。

踩过的深坑

中间最想吐槽的是底层寻址。在处理 SAB 偏移量时,我被一个 16 bytes ÷ 4 = 4 elements 的寻址偏移搞掉了半个通宵。

在 SharedArrayBuffer 中,内存是连续的字节流。当你用 Int32Array 操作时,索引是按 4 字节步进的:

Index = ByteOffset / 4

所以 16 字节的 Header 对应的索引就是 4。这个 16 → 4 的转换,就是高级语言开发者和内存地址之间的"最后一公里"。JS 层的索引步长和 C 层的字节偏移在这里撞车了,这种底层 Bug 真的只能靠硬啃。

战果

在我的测试 Lab 里,我成功捕获到了一次长达 684.5ms 的 V8 冻结,而此时我的 Sentinel 心跳依然稳定在 2.67ms(48kHz/128 frames)。

这种"降维打击"的观测感非常爽。

如果你在本地跑不起来,先别急着提 Issue。检查下你的 Response Headers。在这个 Spectre 漏洞后的时代,没有 Cross-Origin-Opener-Policy: same-origin,你连 SharedArrayBuffer 的边都摸不到。这是属于硬核开发者的"入场券"。


  • 源码 & 文档: GitHub - stw-sentinel

  • 在线演示(Lab): diffserv.xyz/lab

  • 一行命令体验: npx stw-sentinel

    如果你也对 V8 性能、线程隔离或者 SharedArrayBuffer 感兴趣,欢迎来 GitHub 提个 Issue 或者点个 Star。

Elpis Schema 动态组件与表单:配置驱动的完整 CRUD 闭环

这是 Elpis 框架系列的第四篇。第三篇讲了 DSL 配置如何驱动菜单、路由、搜索栏和表格。但一个完整的 CRUD 页面还缺三块:新增表单、编辑表单、详情面板。这一篇补上最后的拼图——如何用同一套 Schema 配置,驱动表单渲染、数据回填、字段校验和 API 提交。


一、要补什么

上一篇结束时,schema-view 已经能渲染搜索栏和表格了。但表格里的"新增"、"修改"、"查看详情"按钮点了之后什么都不会发生。

这一篇要做的事情:

  1. 实现 schema-form 通用表单组件,根据配置动态渲染表单控件
  2. 实现三个动态组件:create-form(新增)、edit-form(编辑)、detail-panel(详情)
  3. 用 AJV(JSON Schema 校验库)做表单字段校验
  4. 打通按钮点击 → 弹出表单 → 填写/校验 → 提交 API → 刷新表格的完整链路

二、动态组件是怎么挂上去的

上一篇讲过,表格按钮通过 eventKey: "showComponent" 声明"点击后要展示一个组件",eventOption.comName 指定展示哪个组件。

// model 配置
tableConfig: {
  headerButtons: [
    { label: "新增商品", eventKey: "showComponent", eventOption: { comName: "createForm" } },
  ],
  rowButtons: [
    { label: "查看详情", eventKey: "showComponent", eventOption: { comName: "detailPanel" } },
    { label: "修改", eventKey: "showComponent", eventOption: { comName: "editForm" } },
  ],
}

comName 对应的 Vue 组件在 component-config.js 中注册:

// component-config.js
import createForm from "./create-form/create-form.vue";
import editForm from "./edit-form/edit-form.vue";
import DetailPanel from "./detail-panel/detail-panel.vue";

const ComponentConfig = {
  createForm: { component: createForm },
  editForm: { component: editForm },
  detailPanel: { component: DetailPanel },
};

schema-view 根据配置中的 componentConfig 动态渲染这些组件:

<!-- schema-view.vue -->
<component
  v-for="(item, key) in components"
  :key="key"
  :is="ComponentConfig[key]?.component"
  ref="comListRef"
  @command="onComponentCommand"
/>

点击按钮时,schema-view 通过 ref 找到对应的组件实例,调用它的 show() 方法:

// schema-view.vue
function showComponent({ buttonConfig, rowData }) {
  const { comName } = buttonConfig.eventOption;
  const comRef = comListRef.value.find((item) => item.name === comName);
  comRef.show(rowData); // rowData 是当前行数据,新增时为 undefined
}

每个动态组件都通过 defineExpose 暴露 nameshow 两个属性。name 用于匹配,show 用于触发展示。

这套机制的关键是:配置只声明"要展示哪个组件",不关心组件内部怎么实现。新增一种动态组件只需要两步:写一个 Vue 组件,在 component-config.js 里注册。


三、componentConfig:动态组件的配置来源

每个动态组件需要知道自己的标题、按钮文案、主键字段等信息。这些信息在 model 配置的 componentConfig 中定义:

// model/business/model.js
componentConfig: {
  createForm: {
    title: "新增商品",
    saveBtnText: "新增商品",
  },
  editForm: {
    mainKey: "product_id",     // 主键字段,用于查询和提交
    title: "修改商品",
    saveBtnText: "修改商品",
  },
  detailPanel: {
    mainKey: "product_id",
    title: "商品详情",
  },
}

useSchema Hook 在解析配置时,会为每个 componentConfig 中的 key 构建对应的 schema:

// hook/schema.js
const { componentConfig } = mItem;
if (componentConfig && Object.keys(componentConfig).length > 0) {
  const dtoComponents = {};
  for (const comName in componentConfig) {
    dtoComponents[comName] = {
      schema: buildDtoSchema(configSchema, comName), // 提取 createFormOption / editFormOption 等
      config: componentConfig[comName], // 标题、按钮文案等
    };
  }
  components.value = dtoComponents;
}

buildDtoSchema(schema, "createForm") 会从字段定义中提取所有带 createFormOption 的字段,组装成表单需要的 schema。同一个 buildDtoSchema 方法,传不同的 comName,就能提取不同方向的投影。


四、schema-form:通用表单组件

schema-form 是表单的核心渲染器,和搜索栏的 schema-search-bar 思路一样:遍历 schema 的 properties,根据 comType 动态渲染对应的表单控件。

graph TD
    A["schema-form 接收 schema + model"] --> B["遍历 schema.properties"]
    B --> C{"comType"}
    C -->|input| D["Input 组件"]
    C -->|inputNumber| E["InputNumber 组件"]
    C -->|select| F["Select 组件"]

    D --> G["统一接口<br/>validate() + getValue()"]
    E --> G
    F --> G

    style A fill:#fff3e0,stroke:#f57c00
    style D fill:#e3f2fd,stroke:#1565c0
    style E fill:#e3f2fd,stroke:#1565c0
    style F fill:#e3f2fd,stroke:#1565c0
    style G fill:#e8f5e9,stroke:#2e7d32
<!-- schema-form.vue -->
<template v-for="(itemSchema, key) in schema.properties">
  <component
    ref="formComList"
    v-show="itemSchema.option.visible !== false"
    :is="FormItemConfig[itemSchema.option?.comType]?.component"
    :schemaKey="key"
    :schema="itemSchema"
    :model="model ? model[key] : undefined"
  />
</template>

控件类型映射表:

// form-item-config.js
const FormItemConfig = {
  input: { component: Input },
  inputNumber: { component: InputNumber },
  select: { component: Select },
};

和搜索栏的区别在于:

  1. 表单控件多了 model 属性——编辑表单需要回填已有数据
  2. 表单控件多了 validate() 方法——提交前需要校验
  3. 表单控件支持 visibledisabled 配置——有些字段只读(如编辑时的 ID 字段)

schema-form 对外暴露两个方法:

// schema-form.vue
const validate = () => {
  return formComList.value.every((component) => component.validate());
};

const getValue = () => {
  return formComList.value.reduce((dtoObj, component) => {
    return { ...dtoObj, ...component.getValue() };
  }, {});
};

validate() 遍历所有控件,全部通过才返回 truegetValue() 收集所有控件的值,合并成一个对象。


五、表单控件与 AJV 校验

每个表单控件内部都集成了 AJV 校验。AJV 是一个 JSON Schema 校验库,它能根据 JSON Schema 的规则(type、minLength、maxLength、minimum、maximum、pattern、enum 等)自动校验数据。

schema-form 在初始化时创建 AJV 实例,通过 provide 注入给所有子控件:

// schema-form.vue
const Ajv = require("ajv");
const ajv = new Ajv();
provide("ajv", ajv);

以 Input 控件为例,校验流程:

graph TD
    A["用户输入 / 失焦触发校验"] --> B{"required 且为空?"}
    B -->|是| C["提示:不能为空"]
    B -->|否| D["ajv.compile(schema)"]
    D --> E{"校验通过?"}
    E -->|是| F["清除错误提示"]
    E -->|否| G{"错误类型"}
    G -->|type| H["提示:类型必须为 string"]
    G -->|maxLength| I["提示:最大长度应为 N"]
    G -->|minLength| J["提示:最小长度应为 N"]
    G -->|pattern| K["提示:格式不正确"]

    style C fill:#fce4ec,stroke:#c62828
    style H fill:#fce4ec,stroke:#c62828
    style I fill:#fce4ec,stroke:#c62828
    style J fill:#fce4ec,stroke:#c62828
    style K fill:#fce4ec,stroke:#c62828
    style F fill:#e8f5e9,stroke:#2e7d32
// input.vue
const validate = () => {
  validTips.value = null;

  // 1. 必填校验
  if (schema.option?.required && !dtoValue.value) {
    validTips.value = "不能为空";
    return false;
  }

  // 2. AJV Schema 校验
  if (dtoValue.value) {
    const validate = ajv.compile(schema);
    const valid = validate(dtoValue.value);
    if (!valid && validate.errors?.[0]) {
      const { keyword, params } = validate.errors[0];
      if (keyword === "type") validTips.value = `类型必须为 ${schema.type}`;
      if (keyword === "maxLength")
        validTips.value = `最大长度应为 ${params.limit}`;
      if (keyword === "minLength")
        validTips.value = `最小长度应为 ${params.limit}`;
      if (keyword === "pattern") validTips.value = `格式不正确`;
      return false;
    }
  }
  return true;
};

AJV 的 compile 方法接收一个 JSON Schema 对象,返回一个校验函数。调用校验函数传入数据,返回 true/false,失败时 validate.errors 包含详细的错误信息。

这意味着校验规则直接写在字段的 Schema 定义中(typeminLengthmaxLengthpattern 等),不需要额外写校验逻辑。JSON Schema 本身就是校验规则的声明。

InputNumber 控件的校验类似,但处理的是 minimummaximum

// input-number.vue
if (keyword === "minimum") validTips.value = `最小值应为 ${params.limit}`;
if (keyword === "maximum") validTips.value = `最大值应为 ${params.limit}`;

Select 控件校验枚举范围:

// select.vue
let dtoEnum = schema.option?.enumList?.map((item) => item.value) ?? [];
const validate = ajv.compile({ ...schema, enum: dtoEnum });
// 如果选中的值不在枚举列表中 → "取值超出枚举范围"

每个控件都在失焦(blur)或值变化(change)时触发校验,实时反馈错误信息。提交时 schema-form 再做一次全量校验。


六、placeholder 自动生成

表单控件会根据 Schema 中的校验规则自动生成 placeholder 提示:

// input.vue
const { minLength, maxLength, pattern } = schema;
const ruleList = [];
if (schema.option?.placeholder) ruleList.push(schema.option.placeholder);
if (minLength) ruleList.push(`最小长度: ${minLength}`);
if (maxLength) ruleList.push(`最大长度: ${maxLength}`);
if (pattern) ruleList.push(`格式: ${pattern}`);
placeholder.value = ruleList.join("|");

如果一个字段定义了 minLength: 2, maxLength: 50,输入框的 placeholder 会自动显示 最小长度: 2|最大长度: 50。用户不需要看文档就知道输入要求。


七、三个动态组件的实现

7.1 create-form:新增表单

sequenceDiagram
    participant 用户
    participant CreateForm as create-form
    participant SchemaForm as schema-form
    participant API as Koa API

    用户->>CreateForm: 点击"新增商品"按钮
    CreateForm->>CreateForm: show() → 打开 Drawer
    CreateForm->>SchemaForm: 传入 createForm 的 schema
    SchemaForm->>SchemaForm: 动态渲染表单控件
    用户->>SchemaForm: 填写表单
    用户->>CreateForm: 点击"保存"
    CreateForm->>SchemaForm: validate() 校验
    SchemaForm-->>CreateForm: 校验通过
    CreateForm->>SchemaForm: getValue() 获取表单值
    CreateForm->>API: POST /api/proj/product
    API-->>CreateForm: { success: true }
    CreateForm->>CreateForm: 关闭 Drawer
    CreateForm->>CreateForm: emit("command", { event: "loadTableData" })

核心代码:

// create-form.vue
const { api, components } = inject("schemaViewData");

const show = () => {
  const { config } = components.value[name.value];
  title.value = config.title;
  saveBtnText.value = config.saveBtnText;
  isShow.value = true;
};

const save = async () => {
  if (!schemaFormRef.value.validate()) return; // 校验不通过就不提交

  const res = await $curl({
    method: "post",
    url: api.value,
    data: { ...schemaFormRef.value.getValue() },
  });

  if (res?.success) {
    ElNotification({ title: "创建成功", type: "success" });
    close();
    emit("command", { event: "loadTableData" }); // 通知表格刷新
  }
};

7.2 edit-form:编辑表单

编辑表单比新增多两个步骤:根据主键查询已有数据,回填到表单中。

sequenceDiagram
    participant 用户
    participant EditForm as edit-form
    participant SchemaForm as schema-form
    participant API as Koa API

    用户->>EditForm: 点击行"修改"按钮
    EditForm->>EditForm: show(rowData) → 提取主键值
    EditForm->>API: GET /api/proj/product?product_id=1
    API-->>EditForm: 返回商品详情
    EditForm->>SchemaForm: 传入 schema + model(已有数据)
    SchemaForm->>SchemaForm: 渲染控件并回填数据
    用户->>SchemaForm: 修改字段
    用户->>EditForm: 点击"保存"
    EditForm->>SchemaForm: validate() + getValue()
    EditForm->>API: PUT /api/proj/product
    API-->>EditForm: { success: true }
    EditForm->>EditForm: 关闭 + 通知表格刷新

和新增的区别:

// edit-form.vue
const show = (rowData) => {
  const { config } = components.value[name.value];
  mainKey.value = config.mainKey; // "product_id"
  mainValue.value = rowData[config.mainKey]; // 从行数据中取主键值
  isShow.value = true;
  fetchFormData(); // 根据主键查询详情
};

const fetchFormData = async () => {
  const res = await $curl({
    method: "get",
    url: api.value,
    query: { [mainKey.value]: mainValue.value }, // GET /api/proj/product?product_id=1
  });
  dtoModel.value = res.data; // 回填到 schema-form
};

const save = async () => {
  if (!schemaFormRef.value.validate()) return;
  const res = await $curl({
    method: "put", // 用 PUT 而不是 POST
    url: api.value,
    data: {
      [mainKey.value]: mainValue.value, // 提交时带上主键
      ...schemaFormRef.value.getValue(),
    },
  });
  // ...
};

schema-form 接收 model 属性后,每个控件会用 model[key] 作为初始值:

// input.vue
const initData = () => {
  dtoValue.value = model.value ?? schema.option?.default;
};

model.value 有值就用已有数据,没有就用配置中的默认值。

编辑表单中有些字段需要只读(比如商品 ID 不能改),通过 disabled: true 控制:

// model 配置
product_id: {
  editFormOption: {
    comType: "input",
    disabled: true,    // 编辑时不可修改
  },
}

v-bind="schema.option" 会把 disabled 透传给 ElementPlus 的 el-input,输入框自动变为禁用状态。

7.3 detail-panel:详情面板

详情面板不需要表单控件,直接遍历 schema 展示 label + value:

<!-- detail-panel.vue -->
<el-row
  v-for="(item, key) in components[name]?.schema?.properties"
  :key="key"
  class="row-item"
>
  <el-row class="item-label">{{ item.label }}:</el-row>
  <el-row class="item-value">{{ dtoModel[key] }}</el-row>
</el-row>

打开时根据主键查询详情数据,和 edit-form 的 fetchFormData 逻辑一样。区别是详情面板只展示不编辑,不需要 schema-form


八、完整 CRUD 事件流

把所有操作串起来,一个 schema 模块的完整 CRUD 事件流:

graph TD
    A["页面加载"] --> B["schema-view 解析配置"]
    B --> C["渲染搜索栏 + 表格 + 动态组件"]
    C --> D["请求 GET /list 填充表格"]

    D --> E{"用户操作"}
    E -->|搜索| F["收集搜索参数 → 重新请求 /list"]
    E -->|点击新增| G["打开 create-form"]
    E -->|点击修改| H["打开 edit-form"]
    E -->|点击详情| I["打开 detail-panel"]
    E -->|点击删除| J["请求 DELETE"]

    G --> K["填写 → 校验 → POST → 刷新表格"]
    H --> L["查询回填 → 修改 → 校验 → PUT → 刷新表格"]
    I --> M["查询 → 展示详情"]
    J --> N["确认 → DELETE → 刷新表格"]

    K --> D
    L --> D
    N --> D

    style A fill:#e8f5e9,stroke:#2e7d32
    style G fill:#e3f2fd,stroke:#1565c0
    style H fill:#fff3e0,stroke:#f57c00
    style I fill:#f3e5f5,stroke:#6a1b9a
    style J fill:#fce4ec,stroke:#c62828

动态组件通过 emit("command", { event: "loadTableData" }) 通知 schema-view 刷新表格:

// schema-view.vue
const onComponentCommand = (data) => {
  if (data.event === "loadTableData") {
    tablePanelRef.value.loadTableData();
  }
};

这是一个松耦合的通信方式——动态组件不直接操作表格,只发出一个事件,schema-view 作为协调者决定怎么响应。


九、服务端 CRUD API

配置中的 api: "/api/proj/product" 是一个基础路径,框架按照 RESTful 约定拼接完整的 API:

操作 HTTP 方法 URL 说明
列表 GET /api/proj/product/list 搜索栏参数作为 query
详情 GET /api/proj/product 主键作为 query
新增 POST /api/proj/product 表单数据作为 body
修改 PUT /api/proj/product 主键 + 表单数据作为 body
删除 DELETE /api/proj/product 主键作为 body

服务端对应的 Controller:

// app/controller/business.js
async create(ctx) {
  const { product_name, price, inventory } = ctx.request.body;
  this.success(ctx, { product_id: Date.now(), product_name, price, inventory });
}

async update(ctx) {
  const { product_id, product_name, price, inventory } = ctx.request.body;
  this.success(ctx, { product_id, product_name, price, inventory });
}

async get(ctx) {
  const { product_id } = ctx.request.query;
  const productItem = this.getProductList(ctx).find(
    (item) => item.product_id === product_id,
  );
  this.success(ctx, productItem);
}

每个 API 都有对应的 Router Schema 做参数校验:

// app/router-schema/business.js
"/api/proj/product": {
  post: {
    body: {
      type: "object",
      properties: {
        product_name: { type: "string" },
        price: { type: "number" },
        inventory: { type: "number" },
      },
      required: ["product_name"],
    },
  },
  put: {
    body: {
      type: "object",
      properties: {
        product_id: { type: "string" },
        product_name: { type: "string" },
        price: { type: "number" },
        inventory: { type: "number" },
      },
      required: ["product_name", "product_id"],
    },
  },
}

前端用 AJV + JSON Schema 校验,后端也用 AJV + JSON Schema 校验。同一套 Schema 标准,前后端双重保障。


十、从配置到完整 CRUD 页面

回到最开始的问题:一个完整的 CRUD 页面需要多少配置?

{
  key: "product",
  name: "商品管理",
  menuType: "module",
  moduleType: "schema",
  schemaConfig: {
    api: "/api/proj/product",
    schema: {
      type: "object",
      properties: {
        product_name: {
          type: "string", label: "商品名称",
          tableOption: { width: 200 },
          searchOption: { comType: "dynamicSelect", api: "/api/proj/product_enum/list" },
          createFormOption: { comType: "input" },
          editFormOption: { comType: "input" },
          detailPanelOption: {},
        },
        price: {
          type: "number", label: "价格",
          tableOption: { width: 200 },
          searchOption: { comType: "select", enumList: [...] },
          createFormOption: { comType: "inputNumber" },
          editFormOption: { comType: "inputNumber" },
          detailPanelOption: {},
        },
        // ... 其他字段
      },
      required: ["product_name"],
    },
  },
  tableConfig: {
    headerButtons: [{ label: "新增商品", eventKey: "showComponent", eventOption: { comName: "createForm" } }],
    rowButtons: [
      { label: "查看详情", eventKey: "showComponent", eventOption: { comName: "detailPanel" } },
      { label: "修改", eventKey: "showComponent", eventOption: { comName: "editForm" } },
      { label: "删除", eventKey: "remove", eventOption: { params: { product_id: "schema::product_id" } } },
    ],
  },
  componentConfig: {
    createForm:  { title: "新增商品", saveBtnText: "新增商品" },
    editForm:    { mainKey: "product_id", title: "修改商品", saveBtnText: "修改商品" },
    detailPanel: { mainKey: "product_id", title: "商品详情" },
  },
}

这大约 50 行配置,产出的是:一个带搜索栏(支持输入框、下拉框、动态下拉框、日期范围)、带分页表格、带新增/编辑表单(含字段校验)、带详情面板、带删除确认的完整 CRUD 页面。传统写法大概需要 300 行以上的 Vue 代码 + 路由配置 + API 对接代码。

而且这份配置是可继承的——写在 Model 里,所有 Project 自动拥有,Project 只需要写差异部分。

来聊聊 Codex 高效编程的正确姿势

HFJYHg2bYAAZYIf.jpeg

前言

继 Cursor 拉跨,Claude Code 渠道匮乏之后,Codex 成为了我的新宠。最近纯 AI 编程了一个前端工程项目后,对他的“驯服”,深有领悟,今日与众道友分享一番。

项目简介

先说下我这次项目的基本情况哈,以便后续让大家了解 Codex 帮我完成了哪些工作。

面向 C 端用户的一个 H5 短剧播放网站,内容涵盖搜索,充值,视频播放等功能。技术栈为 Nuxt4、Ts、Tailwindcss、Vant-ui、Scss 等。

规范约束

对于一个前端项目而言,Codex 做起来还是相对简单的,我们只需要约束好工程规范,剩下的就是需求喂给他就行。

Codex 在读取项目目录的时候,首先都会先去找 AGENTS.md 文件,这是项目里的入口约定,告诉 Codex 进入这个仓库要遵守什么、先读哪个 skill。例如去约定哪些文件不能动,创建文件时的命名规则等。

适合放在 AGENTS.md 里的内容:

  • 这个仓库做代码任务时必须先读哪个 skill
  • 项目通用原则,比如“不要过度抽象”“不要擅自改翻译文件”
  • 特殊目录说明,比如 app/、api/、stores/ 怎么组织
  • 提交、测试、构建的默认要求
  • 哪些文件不能随便动
  • 多人协作规则,比如不要回滚用户改动

至于 skill,就是具体执行细则,也就是真正的工作流和编码规范。

适合放在 SKILL.md 里的内容:

  • 具体编码风格,比如优先简单直接、逻辑直白、少做兜底处理
  • 注释规则,比如超过五行的业务逻辑方法加中文注释
  • API 使用规则,比如按接口字段类型直接用,不兼容旧结构
  • 页面初始化规则,比如首屏逻辑放后面,用显式 initXxxPage()
  • UI 规则,比如 Tailwind 优先用 mt-[16px] 这种像素写法,不要写 mt-6 这种 spacing scale 写法
  • i18n 规则,比如不直接使用文案,采用 t('xxx') 的写法,旁边备注好中文
  • 验证规则,比如普通改动跑 pnpm lint,路由/构建相关再跑 pnpm build,校验时走 playwright 打开页面自测功能
  • 提交规则,比如 commit 前缀、提交后是否推送

可以说 AGENTS.md 和 SKILL.md 文件就是我们对 AI 的完全约束,不让他天马行空的去帮我们完成新需求。我碰到过多次 AI,做接口联调时,不信任接口数据,每一次都要做判断兜底,生成了很多无用的代码,然后让他完全信任接口数据和返回格式,并生成对应 skill 后,代码清爽了很多。

包括很多 UI 规范,如果搞一些自己的项目,没有 UI 设计,可以直接用现成的 design.md 做规范喂给 AI,做出来的界面也会更舒服一些。

所以,对 AI 的驯服,主要就是在于 skill 文档的描述,写的越详细,他的代码就越目标化。

MCP 和技能的使用

现在很多第三方的服务平台,都有自己的 MCP 服务 或 Plugin 暴漏给 AI 提供服务,使它能更好的为我们完成工作。对前端而言,有几个很棒的工具。

Apifox MCP

前后端联调很重要的一点就是接口文档了,我们项目文档集成在 Apifox 上,只要接口文档上对字段描述清晰,传参的类型变量定义完整,就可以直接复制文档的 id,让 AI 根据项目文档完成功能联调。

尤其在做一些管理后台的增删改查需求时,把接口 id 和原型图发给他,再加上一些需求描述,就可以直接帮你完成项目功能。

使用限制:需开通编辑者权限,每个项目都要配一个 MCP 服务。

详见文档

Figma MCP

Figma MCP 可以直接帮我们绘制好界面上的 UI 图,但是一些切图处理的不好,有些静态图是 UI 做了多层图层后生成的,需要我们自己切下来,告诉他引用位置,之后他会对照这个图把其他元素渲染好。使用过程中,我发现 AI 很爱做 reactive 定位处理,这个也要在 skill 里对他做限制,尽量使用 flex 布局。

使用限制:需开通编辑者权限,企业版的账号开一个编辑者,需要每个月大几百的支出,建议大家可以自己搞个教育版账号,将图复制过来一份开发调用。

详见文档

Playwright

可以调用浏览器的服务,非常适合做一些回归测试。可以搭配 Figma MCP 对 UI稿完成校验,也适合做项目报错时的走查。

我目前只用到这三个 MCP,当把他们组合起来之后,你就会发现真的是解放双手啊。

例如:对于列表页,你可以说

根据 UI 地址:xxx。完成页面绘制,注意顶部标题栏要做吸顶处理。列表数据调接口取值,参考接口 id:xxx。空数据时,引用空图文件 xxx.png。整体页面完成后,调用 playwright 打开浏览器的 H5 模式,对照一遍 UI 完成校验

结尾

以上就是我近期的一些使用感悟,大家一定要多拥抱 AI 去做开发,可能很多朋友用起来会觉得:

「嗯,是很不错,但感觉他也就只能帮我做个小模块而已,做多了就瞎改起来了」

但,请相信 AI 的强大,丰富好你的 skill 文件,每一次对他的”调教“,都会让它下一次的编程,离你想象中更进一步。

20210324105052601.gif

Elpis Webpack 工程化实战:Vue 多页应用的构建体系搭建

这是 Elpis 框架系列的第二篇。上一篇拆解了 elpis-core 的服务端框架内核,这一篇聚焦前端——如何用 Webpack 为一个 Koa 全栈项目搭建 Vue 的完整构建体系。


一、整体架构

先看全貌,整个构建体系由三层组成:

graph TD
    subgraph 配置层
        A["webpack.base.js<br/>入口 / Loader / 插件 / 代码分割"]
        B["webpack.dev.js<br/>HMR / Source Map"]
        C["webpack.prod.js<br/>多线程 / 压缩 / CSS 抽离"]
    end

    subgraph 执行层
        D["dev.js<br/>Express DevServer"]
        E["prod.js<br/>构建脚本"]
    end

    subgraph 插件层
        F["MultiThreadPlugin<br/>多线程打包插件"]
    end

    A -->|merge| B
    A -->|merge| C
    B --> D
    C --> E
    C --> F

    style A fill:#fff3e0,stroke:#f57c00
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#fce4ec,stroke:#c62828
    style D fill:#e3f2fd,stroke:#1565c0
    style E fill:#fce4ec,stroke:#c62828
    style F fill:#f3e5f5,stroke:#6a1b9a

配置采用 Base + Dev + Prod 三层分离。webpack.base.js 放所有环境共用的配置,webpack.dev.jswebpack.prod.js 各自叠加环境专属的部分,通过 webpack-merge 合并。

这样做的好处是:通用配置只写一份,环境差异一目了然,改一个环境不会影响另一个。


二、入口(Entry):自动扫描,约定优于配置

Webpack 需要知道从哪些文件开始打包,这就是 Entry。传统做法是手动在配置里写死每个入口,每新增一个页面就要改配置。

这里用了另一种方式:用 glob 自动扫描目录。

graph LR
    A["glob 扫描<br/>app/pages/**/entry.*.js"] --> B["提取文件名<br/>entry.page1"]
    B --> C["生成 Entry 对象<br/>{ entry.page1: '文件路径' }"]
    B --> D["生成 HtmlWebpackPlugin<br/>每个入口 → 一个 .tpl 模板"]

    style A fill:#e8f5e9,stroke:#2e7d32
    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#e3f2fd,stroke:#1565c0
// webpack.base.js
const pageEntries = {};
const htmlWebpackPluginList = [];

glob
  .sync(path.resolve(process.cwd(), "./app/pages/**/entry.*.js"))
  .forEach((file) => {
    const entryName = path.basename(file, ".js");
    pageEntries[entryName] = file;
    htmlWebpackPluginList.push(
      new HtmlWebpackPlugin({
        filename: path.resolve(
          process.cwd(),
          `./app/public/dist/${entryName}.tpl`,
        ),
        template: path.resolve(process.cwd(), "./app/view/entry.tpl"),
        chunks: [entryName],
      }),
    );
  });

约定规则:在 app/pages/ 下任意目录,只要文件名符合 entry.{pageName}.js 的格式,就会被自动识别为入口。

每个入口同时会生成一个 .tpl 模板文件。这个模板是给 Koa 服务端用的——用户访问 /view/page1 时,Koa 通过 Nunjucks 渲染 entry.page1.tpl,Webpack 已经把打包后的 JS/CSS 注入到了这个模板里。

// app/controller/view.js — Koa 服务端渲染页面
async renderPage(ctx) {
  await ctx.render(`dist/entry.${ctx.params.page}`, {
    name: app.options?.name,
    env: app.env.get(),
  });
}

所以整个链路是:新建 entry.xxx.js → Webpack 自动识别 → 生成 .tpl → Koa 自动渲染。不需要改任何配置。


三、Loader:告诉 Webpack 怎么处理不同类型的文件

Webpack 本身只认识 JS。要处理 .vue.css.less、图片等文件,需要配置对应的 Loader。

graph LR
    S["源文件"] --> A[".vue"]
    S --> C[".js"]
    S --> E[".css"]
    S --> G[".less"]
    S --> I["图片"]
    S --> K["字体"]

    A -->|vue-loader| B["解析 SFC 单文件组件<br/>template / script / style 拆分"]
    C -->|babel-loader| D["ES6+ 转译为 ES5"]
    E -->|css-loader + style-loader| F["解析 CSS → 注入 DOM"]
    G -->|less-loader + css-loader + style-loader| H["Less 编译 → 解析 → 注入"]
    I -->|url-loader| J["小于 300B 转 Base64<br/>否则输出文件"]
    K -->|file-loader| L["直接输出文件"]

    style S fill:#f5f5f5,stroke:#9e9e9e
    style A fill:#e8f5e9,stroke:#2e7d32
    style C fill:#fff3e0,stroke:#f57c00
    style E fill:#e3f2fd,stroke:#1565c0
    style G fill:#f3e5f5,stroke:#6a1b9a
    style I fill:#fff8e1,stroke:#f9a825
    style K fill:#efebe9,stroke:#4e342e

几个关键点:

vue-loader 的作用不只是处理 .vue 文件。它会把 .vue 中的 <script> 交给 babel-loader 处理,<style> 交给 css-loader 处理。它需要配合 VueLoaderPlugin 使用,这个插件的职责就是把你定义的其他 Loader 规则"复制"到 .vue 文件的各个块中。

babel-loader 通过 include 限定只处理 app/pages 目录:

{
  test: /\.js$/,
  include: [path.resolve(process.cwd(), "./app/pages")],
  use: { loader: "babel-loader" },
}

不加 include 的话,Webpack 会对 node_modules 里的 JS 也跑 Babel 转译,几千个包全过一遍,构建会非常慢。node_modules 里的包通常已经是编译好的,不需要再转。

url-loader 设置了 300 字节的阈值。小于这个值的图片会被转成 Base64 内联到 JS 中,减少一次 HTTP 请求;大于的则输出为独立文件。


四、Resolve:模块解析规则

当代码里写 import xxx from '$common/curl' 时,Webpack 需要知道 $common 指向哪个目录。这就是 resolve.alias 的作用。

resolve: {
  extensions: [".js", ".vue", ".less", ".css"],
  alias: {
    $pages:   path.resolve(process.cwd(), "./app/pages"),
    $common:  path.resolve(process.cwd(), "./app/pages/common"),
    $weights: path.resolve(process.cwd(), "./app/pages/weights"),
    $store:   path.resolve(process.cwd(), "./app/pages/store"),
  },
}

extensions 的作用是:import boot from '$pages/boot' 不需要写 .js 后缀,Webpack 会按照数组顺序依次尝试 .js.vue.less.css


五、代码分割(splitChunks):按变更频率拆包

如果不做代码分割,所有代码会打成一个巨大的 JS 文件。任何一行代码改动,用户都要重新下载整个文件,浏览器缓存完全失效。

代码分割的核心思路是:把变更频率不同的代码拆到不同的文件里

graph TD
    A["所有代码"] --> B{"splitChunks 分析"}
    B -->|来自 node_modules| C["vendor.js<br/>第三方库<br/>Vue / ElementPlus / Lodash<br/>版本不升级就不变"]
    B -->|被 ≥ 2 个入口引用| D["common.js<br/>公共业务模块<br/>偶尔变动"]
    B -->|页面独有| E["entry.page1.js<br/>页面业务代码<br/>频繁变动"]
    A --> F["runtime.js<br/>Webpack 模块加载运行时"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#f57c00
    style E fill:#e3f2fd,stroke:#1565c0
    style F fill:#f3e5f5,stroke:#6a1b9a
optimization: {
  splitChunks: {
    chunks: "all",
    cacheGroups: {
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name: "vendor",
        priority: 20,
        enforce: true,
        reuseExistingChunk: true,
      },
      common: {
        name: "common",
        minChunks: 2,
        minSize: 1,
        priority: 10,
        reuseExistingChunk: true,
      },
    },
  },
  runtimeChunk: true,
}

逐个解释:

  • chunks: "all":对同步和异步引入的模块都做分割
  • vendor:匹配 node_modules 下的所有包,打成一个文件。priority: 20 表示优先级最高,一个模块同时满足 vendor 和 common 条件时,归入 vendor
  • common:被 2 个以上入口引用的模块提取出来。minSize: 1 表示哪怕只有 1 字节也提取
  • reuseExistingChunk: true:如果一个模块已经被提取到某个 chunk 中,不会重复提取
  • runtimeChunk: true:把 Webpack 自身的模块加载代码(__webpack_require__ 等)单独打包。这段代码每次构建都可能变,独立出来避免污染业务 chunk 的 hash

这样用户第一次访问时加载所有文件,之后只要第三方库没升级,vendor.js 就一直走浏览器缓存。日常开发改的业务代码只影响 entry.xxx.js,用户只需重新下载这一个小文件。


六、插件(Plugins):在构建流程中注入额外能力

Loader 处理单个文件,Plugin 则作用于整个构建流程。

plugins: [
  // 1. 必须:让 vue-loader 工作
  new VueLoaderPlugin(),

  // 2. 全局注入:业务代码中不需要 import 就能用 Vue、axios、lodash
  new webpack.ProvidePlugin({
    Vue: "vue",
    axios: "axios",
    _: "lodash",
  }),

  // 3. 定义编译时常量:Vue 3 的特性标志
  new webpack.DefinePlugin({
    __VUE_OPTIONS_API__: JSON.stringify(true),
    __VUE_PROD_DEVTOOLS__: JSON.stringify(false),
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(false),
  }),

  // 4. 每个入口生成对应的 HTML 模板
  ...htmlWebpackPluginList,
];

ProvidePlugin 的原理是:当 Webpack 在代码中遇到 axios 这个自由变量时,自动在文件顶部插入 import axios from 'axios'。所以前端代码里可以直接写 axios.request(...) 而不需要手动 import。

DefinePlugin 是编译时替换,不是运行时。__VUE_OPTIONS_API__: true 表示保留 Options API 支持;设为 false 的话 Vue 会在打包时 Tree Shake 掉 Options API 相关代码,减小体积。__VUE_PROD_DEVTOOLS__: false 关闭生产环境的 Vue DevTools 支持。


七、HMR 热模块替换:修改代码不刷新页面

HMR(Hot Module Replacement)解决的问题是:开发时每次改代码都要手动刷新页面,页面状态(表单输入、滚动位置、组件状态)全部丢失。HMR 让修改后的模块在不刷新页面的情况下直接替换,保留应用状态。

7.1 HMR 需要什么

HMR 需要三个东西配合:

  1. 一个能监控文件变更并重新编译的服务(webpack-dev-middleware
  2. 一个能把"有更新"这个消息推送给浏览器的通道(webpack-hot-middleware,基于 SSE)
  3. 一个运行在浏览器里的客户端,接收消息后拉取新模块并替换(HMR Client)
sequenceDiagram
    participant 编辑器
    participant DevMiddleware as webpack-dev-middleware<br/>编译 + 监控
    participant HotMiddleware as webpack-hot-middleware<br/>SSE 推送
    participant 浏览器 as 浏览器 HMR Client

    编辑器->>DevMiddleware: 保存文件,触发文件变更
    DevMiddleware->>DevMiddleware: 检测到变更,增量重编译
    DevMiddleware->>HotMiddleware: 编译完成,通知有更新
    HotMiddleware->>浏览器: 通过 SSE 推送更新通知
    浏览器->>DevMiddleware: 根据通知请求更新的模块(hot-update.js)
    DevMiddleware-->>浏览器: 返回新模块代码
    浏览器->>浏览器: 用新模块替换旧模块,页面不刷新

7.2 SSE 是什么

SSE(Server-Sent Events)是一种服务器向浏览器单向推送消息的技术。和 WebSocket 不同,SSE 是单向的(只能服务器推给浏览器),基于 HTTP,实现更简单。

webpack-hot-middleware/__webpack_hmr 路径上开了一个 SSE 端点。浏览器端的 HMR Client 连上这个端点后,服务器每次编译完成都会推送一条消息,告诉浏览器"有新的模块可以更新了"。

7.3 具体实现

第一步:入口注入 HMR Client

// webpack.dev.js
Object.keys(baseConfig.entry).forEach((v) => {
  if (v !== "vendor") {
    baseConfig.entry[v] = [
      baseConfig.entry[v],
      `webpack-hot-middleware/client?path=http://127.0.0.1:9002/__webpack_hmr&timeout=20000&reload=true`,
    ];
  }
});

把 HMR Client 脚本追加到每个业务入口中。这样打包后的 JS 里就包含了 HMR Client 代码,它会在浏览器中运行,负责和服务器建立 SSE 连接。

vendor 被排除了——第三方库不需要热更新,排除它减少 HMR 的处理范围。

参数说明:

  • path:SSE 端点地址
  • timeout=20000:20 秒没收到消息就重连
  • reload=true:如果 HMR 失败(某些模块不支持热替换),降级为整页刷新

第二步:启用 HotModuleReplacementPlugin

// webpack.dev.js
plugins: [
  new webpack.HotModuleReplacementPlugin({
    multiStep: false,
  }),
];

这个插件让 Webpack 在编译时生成 HMR 需要的额外代码(模块更新清单、更新后的模块代码)。multiStep: false 表示不分步编译,每次变更一次性编译完成。

第三步:启动 DevServer

// dev.js
const app = express();
const compiler = webpack(webpackConfig);

// 编译中间件:监控文件变更,增量编译,产物存在内存中
app.use(
  devMiddleware(compiler, {
    writeToDisk: (filePath) => filePath.endsWith(".tpl"),
    publicPath: webpackConfig.output.publicPath,
    headers: { "Access-Control-Allow-Origin": "*" },
  }),
);

// 热更新中间件:SSE 推送
app.use(
  hotMiddleware(compiler, {
    path: "/__webpack_hmr",
  }),
);

app.listen(9002);

这里单独用 Express 起了一个 DevServer(端口 9002),和 Koa 业务服务器(端口 8080)分开。

为什么要分开?因为职责不同:

  • Koa 负责页面路由和 API
  • Express DevServer 负责 Webpack 编译产物的分发和 HMR 推送

devMiddleware 把编译产物存在内存中,不写磁盘,读写速度更快。但 .tpl 模板文件例外——writeToDisk: (filePath) => filePath.endsWith(".tpl") 让模板文件落盘,因为 Koa 的 Nunjucks 引擎需要从文件系统读取模板。

headers 里设置了 CORS,因为页面从 Koa(:8080)加载,JS/CSS 资源从 DevServer(:9002)加载,属于跨域请求。

7.4 双服务器协作

graph LR
    A["浏览器"] -->|"页面 + API<br/>localhost:8080"| B["Koa 服务器 :8080"]
    A -->|"JS / CSS 资源<br/>127.0.0.1:9002"| C["Express DevServer :9002"]
    A <-->|"SSE 热更新<br/>/__webpack_hmr"| C

    B -->|读取| D[".tpl 模板文件"]
    C -->|落盘| D
    C -->|内存中| E["JS / CSS 产物"]

    style B fill:#e8f5e9,stroke:#2e7d32
    style C fill:#e3f2fd,stroke:#1565c0

开发时的 publicPath 设置为 DevServer 的完整地址:

publicPath: `http://127.0.0.1:9002/public/dist/dev/`;

这样 .tpl 模板中注入的 <script> 标签的 src 会指向 DevServer,浏览器从 DevServer 拉取 JS/CSS。

7.5 CSS 的热更新

CSS 的热更新不需要额外配置。开发环境用的 style-loader 天然支持 HMR——它把 CSS 通过 <style> 标签注入到 DOM 中,更新时直接替换 <style> 标签的内容,不需要刷新页面。

这也是为什么开发环境用 style-loader,而不是 MiniCssExtractPlugin——后者把 CSS 抽成独立文件,无法做到热替换。


八、Source Map:开发时的调试支持

// webpack.dev.js
devtool: "eval-cheap-module-source-map",

Webpack 打包后的代码和源码差别很大,报错时看到的行号对不上。Source Map 建立了打包产物和源码之间的映射关系,让浏览器 DevTools 能显示原始源码。

eval-cheap-module-source-map 是一个折中选择:

  • eval:每个模块用 eval() 包裹,重编译速度快
  • cheap:只映射到行,不映射到列,生成速度更快
  • module:能映射到 Loader 处理前的源码(比如 .vue 文件的原始代码)

生产环境不配置 Source Map,避免暴露源码。


九、生产环境:多线程编译与压缩

9.1 MultiThreadPlugin:多线程打包

JS 的编译(Babel 转译)和 CSS 的处理是 CPU 密集型任务。默认情况下 Webpack 是单线程的,只用一个 CPU 核心。多线程方案把这些任务分发到多个 Worker 进程并行处理。

项目中把多线程方案封装成了一个 Webpack 插件,支持三种模式切换:

graph TD
    A["MultiThreadPlugin"] --> B{"mode 参数"}
    B -->|'thread-loader'| C["thread-loader<br/>Webpack 官方维护<br/>在 Loader 前插入"]
    B -->|'happypack'| D["HappyPack<br/>社区方案<br/>替换 Loader 为 happypack/loader"]
    B -->|'none'| E["不启用多线程<br/>用于排查问题"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#f57c00
    style E fill:#efebe9,stroke:#4e342e

使用方式:

// webpack.prod.js
new MultiThreadPlugin({ mode: "thread-loader" });

插件内部通过 Webpack 的 apply(compiler) 钩子,在编译开始前动态往 compiler.options.module.rules 里注入对应的 Loader 配置:

// thread-loader 模式下注入的规则
{
  test: /\.js$/,
  include: [path.resolve(process.cwd(), "./app/pages")],
  use: [
    {
      loader: "thread-loader",
      options: {
        workers: os.cpus().length - 1,  // 留一个核给主线程
        workerParallelJobs: 50,
        poolTimeout: 2000,  // 构建完成 2 秒后回收 Worker
      },
    },
    {
      loader: "babel-loader",
      options: {
        presets: ["@babel/preset-env"],
        plugins: ["@babel/plugin-transform-runtime"],
        cacheDirectory: true,  // 缓存转译结果
      },
    },
  ],
}

thread-loader 的原理:它放在其他 Loader 前面,把后面的 Loader 放到 Worker 池中运行。每个 Worker 是一个独立的 Node.js 进程,通过进程间通信传递数据。

workers: os.cpus().length - 1:Worker 数量设为 CPU 核数减 1,留一个核给 Webpack 主线程做模块依赖分析。

poolTimeout: 2000:生产构建完成后 2 秒回收 Worker 进程,释放系统资源。

9.2 JS 压缩

optimization: {
  minimize: true,
  minimizer: [
    new TerserPlugin({
      cache: true,
      parallel: true,
      terserOptions: {
        compress: {
          drop_console: true,
        },
      },
    }),
  ],
}
  • cache: true:缓存压缩结果,没有变更的模块不重复压缩
  • parallel: true:多进程并行压缩
  • drop_console: true:删除所有 console.log,减小体积,避免生产环境泄露调试信息

9.3 CSS 抽离与压缩

生产环境的 CSS 处理和开发环境完全不同:

graph LR
    subgraph 开发环境
        A1[".css"] --> B1["css-loader"] --> C1["style-loader<br/>注入 DOM 的 style 标签<br/>支持 HMR"]
    end

    subgraph 生产环境
        A2[".css"] --> B2["css-loader"] --> C2["MiniCssExtractPlugin.loader<br/>抽离为独立 .css 文件"]
        C2 --> D2["CSSMinimizerPlugin<br/>压缩"]
    end

    style C1 fill:#e3f2fd,stroke:#1565c0
    style C2 fill:#fce4ec,stroke:#c62828
    style D2 fill:#fce4ec,stroke:#c62828

为什么生产环境要抽离 CSS?

  • 独立的 CSS 文件可以被浏览器并行加载,不阻塞 JS 执行
  • CSS 文件使用 contenthash,内容不变 hash 不变,缓存更精准
  • 可以单独压缩优化
new MiniCssExtractPlugin({
  chunkFilename: "css/[name]_[contenthash:8].css",
}),
new CSSMinimizerPlugin(),

9.4 构建前清理

new CleanWebpackPlugin(["public/dist"], {
  root: path.resolve(process.cwd(), "./app/"),
});

每次生产构建前清空 dist 目录,避免旧文件残留。因为文件名带 hash,不清理的话旧文件会一直堆积。


十、Hash 策略:让浏览器缓存生效

文件名中的 hash 是缓存的关键。Webpack 提供三种 hash:

graph LR
    A["hash<br/>整个构建共享<br/>任何文件变 → 全部变"] ~~~ B["chunkhash<br/>按 chunk 计算<br/>chunk 内容变才变"]
    B ~~~ C["contenthash<br/>按文件内容计算<br/>文件内容变才变"]

    style A fill:#ffcdd2,stroke:#c62828
    style B fill:#fff9c4,stroke:#f9a825
    style C fill:#e8f5e9,stroke:#2e7d32

项目中的使用:

资源 Hash 类型 示例 为什么
JS chunkhash page1_a1b2c3d4.bundle.js 同一 chunk 内容不变则 hash 不变
CSS contenthash common_e5f6g7h8.css CSS 和 JS 独立计算,改 JS 不影响 CSS 的 hash

如果 CSS 也用 chunkhash,那改了 JS 代码,CSS 的 hash 也会变(因为它们在同一个 chunk 里),导致 CSS 缓存失效。用 contenthash 就不会有这个问题。


十一、前端应用启动器:boot.js

每个页面的入口文件只需要两行:

// app/pages/page1/entry.page1.js
import page1 from "./page1.vue";
import boot from "$pages/boot";
boot(page1, {});

boot.js 统一处理 Vue 应用的初始化:

// app/pages/boot.js
export default async (pageComponent, { routes = [], libs }) => {
  const app = createApp(pageComponent);
  app.use(ElementUI);
  app.use(pinia);

  if (libs?.length) {
    for (let i = 0; i < libs.length; ++i) {
      app.use(libs[i]);
    }
  }

  const router = createRouter({
    history: createWebHashHistory(),
    routes,
  });
  app.use(router);
  await router.isReady();

  app.mount("#root");
};

Vue、ElementPlus、Pinia、Router 的初始化全部收口在这里。每个页面只需要关心"用哪个组件"和"传什么路由",不需要重复写初始化代码。


十二、前后端签名通信

前端封装了统一的请求方法 curl.js,和后端的签名校验中间件配合:

sequenceDiagram
    participant 前端 as curl.js
    participant 后端 as Koa 中间件

    前端->>前端: 取当前时间戳 st
    前端->>前端: md5(signKey + "_" + st) 生成签名
    前端->>后端: headers 携带 s_t 和 s_sign
    后端->>后端: apiSignVerify:用同样的算法算签名,比对
    后端->>后端: 检查时间戳是否在 10 分钟内
    后端->>后端: apiParamsVerify:JSON Schema 校验参数
    后端->>后端: Controller → Service 处理业务
    后端-->>前端: { success, data, metadata }

前端和后端使用相同的密钥和算法生成签名。后端额外检查时间戳,超过 10 分钟的请求会被拒绝,防止请求被截获后重放。


十三、完整数据流

从写代码到用户看到页面,完整链路:

graph TD
    A["开发者编写<br/>app/pages/page1/page1.vue"] --> B["Webpack 编译<br/>vue-loader → babel-loader → 打包"]
    B --> C["产物<br/>entry.page1.tpl + JS + CSS"]
    D["用户访问 /view/page1"] --> E["Koa Router 匹配"]
    E --> F["ViewController 渲染模板"]
    F --> G["Nunjucks 输出 HTML<br/>(已注入 JS/CSS 引用)"]
    G --> H["浏览器加载 JS"]
    H --> I["boot.js 初始化 Vue 应用"]
    I --> J["页面渲染完成"]

    style A fill:#e3f2fd,stroke:#1565c0
    style D fill:#e8f5e9,stroke:#2e7d32
    style J fill:#e8f5e9,stroke:#2e7d32

十四、开发环境与生产环境配置对比

维度 开发环境 生产环境
mode development production
Source Map eval-cheap-module-source-map 不生成
CSS 处理 style-loader 注入 DOM MiniCssExtract 抽离文件 + 压缩
JS 压缩 不压缩 TerserPlugin 压缩 + 去 console
多线程 不启用 MultiThreadPlugin
HMR 开启 不需要
产物存储 内存(DevServer) 磁盘
清理旧产物 不需要 CleanWebpackPlugin
publicPath DevServer 完整 URL 相对路径 /dist/prod

Elpis-Core 技术解析:从零构建一个基于 Koa 的企业级 Node.js 框架内核

本文会拆解 elpis-core 的设计哲学、架构实现与工程实践。如果你正在思考"如何设计一个 Node.js 框架",这篇文章会给你一个完整的答案。


一、为什么要做这个事情

作为程序员的我们如果只是单纯守着自己所谓的技术壁垒,去熟练的使用一门工具,或者一直做着重复性的增删改查工作,会导致我们的竞争力逐步减小,想要成为AI浪潮下的复合型人才,就需要逐步提升自身的竞争力,不再成为使用工具的奴隶,而是逐步扩充自身的全栈开发能力。本人作为前端开发从学习路径上会先选择拥抱Node.js 生态,但核心还是要构建自己的知识体系,Egg, Koa, Express 切记都是只是工具。本文围绕着如何设计一个可运行的服务端框架开展,也是我们掌握全栈开发能力的万里长征第一步。

在 Node.js 生态中,Express 和 Koa 提供了极简的 HTTP 抽象,但"极简"也意味着每个团队都要自己回答一系列问题:

  • 项目目录怎么组织?
  • 路由、控制器、服务层怎么拆分?
  • 中间件加载顺序谁来保证?
  • 多环境配置怎么管理?

Elpis-Core 的定位很明确:在 Koa 之上,用最小的代码量实现一套"约定优于配置"的自动化加载框架,让开发者只需要关注业务代码本身。


二、整体架构一览

graph TD
    A["index.js 启动入口<br/>ElpisCore.start(options)"] --> B["elpis-core/index.js 框架内核<br/>new Koa() → 设置路径/环境 → 执行 Loader<br/>→ 注册中间件 → 挂载路由 → app.listen()"]
    B --> C["Loaders"]
    B --> D["全局中间件<br/>middleware.js"]
    B --> E["koa-router<br/>路由注册"]

    style A fill:#e1f5fe,stroke:#0288d1
    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e8f5e9,stroke:#388e3c
    style D fill:#e8f5e9,stroke:#388e3c
    style E fill:#e8f5e9,stroke:#388e3c

整个框架的核心就是 Loader 机制—— 扫描约定目录下的文件,按照统一规则解析文件名、转换命名风格,然后挂载到 app 实例上。业务代码只需要放在正确的目录里,框架会自动完成发现和注册。


三、启动流程

项目的入口:

// index.js
const ElpisCore = require("./elpis-core");
ElpisCore.start({ name: "Eplis", homePage: "/" });

start() 内部的执行序列如下:

// elpis-core/index.js(简化)
start(options = {}) {
  const app = new Koa();
  app.options = options;
  app.baseDir = process.cwd();
  app.businessPath = path.resolve(app.baseDir, `./app`);
  app.env = env();

  // 严格按序加载
  configLoader(app);       // 1. 配置
  serviceLoader(app);      // 2. 服务层
  middlewareLoader(app);   // 3. 中间件
  controllerLoader(app);   // 4. 控制器
  routerSchemaLoader(app); // 5. 路由校验规则
  extendLoader(app);       // 6. 扩展

  // 7. 注册全局中间件(业务层编排)
  require(`${app.businessPath}/middleware.js`)(app);

  // 8. 路由(最后加载,依赖前面所有组件)
  routerLoader(app);

  app.listen(port, host);
}

加载顺序的设计考量

这个顺序不是随意的,每一步都有依赖关系:

graph LR
    A["① Config<br/>最基础"] --> B["② Service<br/>业务逻辑层"]
    B --> C["③ Middleware<br/>中间件函数"]
    C --> D["④ Controller<br/>依赖 Service"]
    D --> E["⑤ RouterSchema<br/>校验规则"]
    E --> F["⑥ Extend<br/>扩展工具"]
    F --> G["⑦ 全局中间件<br/>编排执行顺序"]
    G --> H["⑧ Router<br/>依赖所有前置组件"]

    style A fill:#ffecb3,stroke:#ff8f00
    style B fill:#c8e6c9,stroke:#2e7d32
    style C fill:#bbdefb,stroke:#1565c0
    style D fill:#c8e6c9,stroke:#2e7d32
    style E fill:#d1c4e9,stroke:#4527a0
    style F fill:#ffe0b2,stroke:#e65100
    style G fill:#bbdefb,stroke:#1565c0
    style H fill:#ffcdd2,stroke:#c62828
顺序 模块 原因
1 Config 最基础,所有模块都可能读取配置
2 Service 业务逻辑层,Controller 会调用它
3 Middleware 中间件函数加载到内存,供后续注册
4 Controller 请求处理器,依赖 Service
5 RouterSchema 路由参数校验规则,供中间件使用
6 Extend 扩展工具,可能被任何模块使用
7 全局中间件 编排中间件执行顺序,依赖已加载的中间件实例
8 Router 最后注册,依赖 Controller + Middleware

四、Loader 机制深度解析

Loader 是 Elpis-Core 的灵魂。7 个 Loader 共享同一套设计模式,但各有细节差异。

4.1 通用模式:扫描 → 解析 → 转换 → 挂载

graph LR
    A["glob 扫描<br/>递归查找 JS 文件"] --> B["路径解析<br/>提取相对路径"]
    B --> C["命名转换<br/>kebab-case → camelCase"]
    C --> D["嵌套挂载<br/>按目录层级构建对象树"]
    D --> E["app.controller.*<br/>app.service.*<br/>app.middlewares.*"]

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#e8eaf6,stroke:#283593
    style C fill:#f3e5f5,stroke:#6a1b9a
    style D fill:#fff3e0,stroke:#e65100
    style E fill:#e8f5e9,stroke:#2e7d32

以 Controller Loader 为例,核心流程:

// elpis-core/loader/controller.js
module.exports = (app) => {
  // 1. 扫描:用 glob 递归查找所有 JS 文件
  const controllerPath = path.resolve(app.businessPath, `./controller`);
  const fileList = glob.sync(path.resolve(controllerPath, `./**/*.js`));

  const controller = {};
  fileList.forEach((file) => {
    // 2. 解析:提取相对路径
    let name = file.substring(
      file.lastIndexOf(`controller/`) + `controller/`.length,
      file.lastIndexOf("."),
    );

    // 3. 转换:kebab-case → camelCase
    name = name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());

    // 4. 挂载:按目录层级构建嵌套对象
    let temp = controller;
    const names = name.split(sep);
    for (let i = 0; i < names.length; i++) {
      if (i === names.length - 1) {
        const Module = require(file)(app);
        temp[names[i]] = new Module(); // 实例化
      } else {
        temp[names[i]] = temp[names[i]] || {};
        temp = temp[names[i]];
      }
    }
  });

  app.controller = controller;
};

命名转换示例:

graph LR
    A["app/controller/<br/>project.js"] -->|转换| B["app.controller.project"]
    C["app/controller/<br/>custom-module/user.js"] -->|转换| D["app.controller.customModule.user"]
    E["app/controller/<br/>my_api/data-list.js"] -->|转换| F["app.controller.myApi.dataList"]

    style A fill:#fff3e0,stroke:#e65100
    style B fill:#e8f5e9,stroke:#2e7d32
    style C fill:#fff3e0,stroke:#e65100
    style D fill:#e8f5e9,stroke:#2e7d32
    style E fill:#fff3e0,stroke:#e65100
    style F fill:#e8f5e9,stroke:#2e7d32

正则 /[_-][a-z]/gi 同时处理了短横线和下划线两种风格,统一转为驼峰。

4.2 各 Loader 的差异对比

Loader 目录 挂载位置 是否实例化 特殊处理
Config config/ app.config default + env 合并覆盖
Service app/service/ app.service.* 是(new 支持多级目录嵌套
Middleware app/middleware/ app.middlewares.* 否(函数) 返回 Koa 中间件函数
Controller app/controller/ app.controller.* 是(new 支持多级目录嵌套
RouterSchema app/router-schema/ app.routerSchema 扁平合并,key 为 API 路径
Extend app/extend/ app.*(直接挂载) 冲突检测,防止覆盖已有属性
Router app/router/ Koa 中间件栈 兜底路由 302 重定向

关键区别在于:

  • Controller 和 Service 会 new 出实例,因为它们是有状态的类
  • Middleware 返回的是函数,直接作为 Koa 中间件使用
  • Extend 直接挂载到 app 顶层,并做了冲突检测
  • RouterSchema 采用扁平合并,以 API 路径为 key

4.3 跨平台兼容

所有 Loader 都使用 path.sep 处理路径分隔符:

const { sep } = path;
// Windows: sep = '\\'
// macOS/Linux: sep = '/'

这保证了框架在不同操作系统上的行为一致性。


五、环境管理

5.1 三环境模型

// elpis-core/env.js
module.exports = () => ({
  isLocal() {
    return process.env._ENV === "local";
  },
  isBeta() {
    return process.env._ENV === "beta";
  },
  isProduction() {
    return process.env._ENV === "production";
  },
  get() {
    return process.env._ENV ?? "local";
  },
});

通过 process.env._ENV 控制,默认回退到 local。启动脚本中注入:

{
  "dev": "_ENV='local' nodemon ./index.js",
  "beta": "_ENV='beta' nodemon ./index.js",
  "prod": "_ENV='production' nodemon ./index.js"
}

5.2 配置合并策略

// elpis-core/loader/config.js
// 1. 加载 config.default.js 作为基础配置
// 2. 根据当前环境加载 config.{env}.js
// 3. Object.assign 合并,环境配置覆盖默认配置
app.config = Object.assign({}, defaultConfig, envConfig);
graph LR
    A["config.default.js<br/>基础配置"] -->|Object.assign 合并| C["app.config"]
    B["config.{env}.js<br/>环境配置"] -->|覆盖同名字段| C

    subgraph 环境配置文件
        D["config.local.js"]
        E["config.beta.js"]
        F["config.prod.js"]
    end

    D -.->|_ENV=local| B
    E -.->|_ENV=beta| B
    F -.->|_ENV=production| B

    style A fill:#e8f5e9,stroke:#388e3c
    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e1f5fe,stroke:#0288d1

这是一种简洁有效的配置管理方式:公共配置写一次,差异化配置按环境覆盖。


六、分层架构实战

Elpis 采用经典的 MVC 分层,请求的完整生命周期如下:

graph TD
    A(("HTTP Request")) --> B["全局中间件链<br/>koa-static → nunjucks → bodyparser<br/>→ errorHandler → apiSignVerify → apiParamsVerify"]
    B --> C["koa-router 路由匹配<br/>/api/project/list → ProjectController<br/>/view/:page → ViewController"]
    C --> D["Controller 层<br/>解析参数 → 调用 Service → 组装响应"]
    D --> E["Service 层<br/>封装业务逻辑 / 数据库操作 / 外部 API 调用"]
    E --> F(("HTTP Response"))

    style A fill:#ffcdd2,stroke:#c62828
    style B fill:#e1f5fe,stroke:#0288d1
    style C fill:#fff3e0,stroke:#f57c00
    style D fill:#e8f5e9,stroke:#388e3c
    style E fill:#f3e5f5,stroke:#7b1fa2
    style F fill:#ffcdd2,stroke:#c62828

6.1 Controller:请求的入口

Controller 负责接收请求、调用 Service、返回响应。项目中通过基类统一了响应格式:

// app/controller/base.js
module.exports = (app) =>
  class BaseController {
    constructor() {
      this.app = app;
      this.config = app.config;
    }
    success(ctx, data = {}, metadata = {}) {
      ctx.status = 200;
      ctx.body = { success: true, data, metadata };
    }
    fail(ctx, message, code) {
      ctx.body = { success: false, message, code };
    }
  };

业务 Controller 继承基类:

// app/controller/project.js
module.exports = (app) => {
  const BaseController = require("./base")(app);
  return class ProjectController extends BaseController {
    async getList(ctx) {
      const { proj_key: projKey } = ctx.request.body;
      const projectList = await app.service.project.getList();
      this.success(ctx, projectList);
    }
  };
};

统一的 success/fail 方法确保所有 API 返回结构一致,前端无需猜测响应格式。

6.2 Service:业务逻辑的归宿

Service 层通过基类封装了公共能力(配置访问、HTTP 客户端):

// app/service/base.js
const superagent = require("superagent");
module.exports = (app) => {
  return class BaseService {
    constructor() {
      this.app = app;
      this.config = app.config;
      this.curl = superagent; // HTTP 客户端,用于调用外部 API
    }
  };
};

业务 Service 继承基类,专注于逻辑实现:

// app/service/project.js
module.exports = (app) => {
  const BaseService = require("./base")(app);
  return class ProjectService extends BaseService {
    async getList() {
      // 实际项目中这里会查询数据库或调用外部接口
      return [{ a: "1" }];
    }
  };
};

6.3 路由:连接 URL 与 Controller

// app/router/project.js
module.exports = (app, router) => {
  const { project: ProjectController } = app.controller;
  router.get(
    "/api/project/list",
    ProjectController.getList.bind(ProjectController),
  );
};

注意 .bind(ProjectController) 的使用——因为 koa-router 调用处理函数时会改变 this 指向,bind 确保 Controller 方法内部的 this 始终指向正确的实例。

路由加载器还提供了兜底机制:

// elpis-core/loader/router.js
router.get("*", async (ctx) => {
  ctx.status = 302;
  ctx.redirect(app?.options?.homepage ?? "/");
});

未匹配的路径会被 302 重定向到首页,避免用户看到空白页或错误页。


七、中间件体系

7.1 全局中间件编排

app/middleware.js 是中间件的编排中心,决定了中间件的执行顺序:

// app/middleware.js
module.exports = (app) => {
  // 静态资源服务
  app.use(koaStatic(path.resolve(process.cwd(), "./app/public")));

  // 模板渲染引擎(Nunjucks)
  app.use(koaNunjucks({ ext: "tpl", path: "./app/public" }));

  // 请求体解析
  app.use(
    bodyParser({ formLimit: "1000mb", enableTypes: ["json", "form", "text"] }),
  );

  // 异常兜底(最外层 try-catch)
  app.use(app.middlewares.errorHandler);

  // API 签名校验
  app.use(app.middlewares.apiSignVerify);

  // API 参数校验
  app.use(app.middlewares.apiParamsVerify);
};

Koa 的中间件模型是洋葱模型,请求从外到内穿透,响应从内到外返回:

graph LR
    subgraph 洋葱模型 - 请求方向 →
        A["koa-static"] --> B["nunjucks"]
        B --> C["bodyparser"]
        C --> D["errorHandler"]
        D --> E["apiSignVerify"]
        E --> F["apiParamsVerify"]
        F --> G["Router + Controller"]
    end

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#fff8e1,stroke:#f9a825
    style E fill:#fce4ec,stroke:#c62828
    style F fill:#fce4ec,stroke:#c62828
    style G fill:#e8f5e9,stroke:#2e7d32

这里的顺序意味着:

  1. 请求先经过静态资源检查
  2. 然后是模板引擎注入
  3. body 解析
  4. 错误处理包裹后续所有逻辑
  5. 签名校验
  6. 参数校验
  7. 最后到达路由和 Controller

7.2 错误处理中间件

// app/middleware/error-handler.js
module.exports = (app) => {
  return async (ctx, next) => {
    try {
      await next();
    } catch (error) {
      app.logger.error("[-- exception --]: ", error);

      // 模板未找到 → 重定向首页
      if (message?.indexOf("template not found") > -1) {
        ctx.status = 302;
        ctx.redirect(app.options?.homePage);
        return;
      }

      // 其他异常 → 统一错误响应
      ctx.status = 200;
      ctx.body = {
        success: false,
        code: 50000,
        message: "网络异常,请稍候重试",
      };
    }
  };
};

这个中间件放在业务逻辑之前,利用 Koa 的洋葱模型,try-catch 可以捕获后续所有中间件和路由处理中抛出的异常。

7.3 API 签名校验

// app/middleware/api-sign-verify.js
module.exports = (app) => {
  return async (ctx, next) => {
    if (ctx.path.indexOf("/api") < 0) return await next(); // 非 API 请求跳过

    const { s_sign, s_t } = ctx.request.headers;
    const signature = md5(`${signKey}_${s_t}`);

    // 校验签名 + 时间窗口(10 分钟)
    if (
      !s_sign ||
      !s_t ||
      signature !== s_sign.toLowerCase() ||
      Date.now() - s_t > 600000
    ) {
      ctx.body = {
        success: false,
        message: "signature not correct or api timeout!",
        code: 445,
      };
      return;
    }
    await next();
  };
};

签名算法:md5(signKey + "_" + timestamp),同时校验时间戳防止重放攻击(10 分钟窗口)。

7.4 参数校验中间件(JSON Schema + AJV)

// app/middleware/api-params-verify.js
module.exports = (app) => {
  return async (ctx, next) => {
    if (ctx.path.indexOf("/api") < 0) return await next();

    const schema = app.routerSchema[ctx.path]?.[ctx.method.toLowerCase()];
    if (!schema) return await next();

    // 依次校验 headers → body → query → params
    // 使用 AJV 编译 JSON Schema 并验证
    // 校验失败返回 442 错误码
  };
};

配合 app/router-schema/ 下的 Schema 定义:

// app/router-schema/project.js
module.exports = {
  "/api/project/list": {
    get: {
      query: {
        type: "object",
        properties: {
          proj_key: { type: "string" },
        },
        required: ["proj_key"],
      },
    },
  },
};

这种声明式的参数校验方式,让接口约束一目了然,且与业务逻辑完全解耦。


八、扩展机制

8.1 Extend Loader

app/extend/ 目录下的模块会被直接挂载到 app 实例上:

// elpis-core/loader/extend.js
// 冲突检测:如果 app 上已存在同名属性,跳过并警告
for (const key in app) {
  if (key === name) {
    console.log(`[extend load error] name: ${name} is already in app`);
    return;
  }
}
app[name] = require(file)(app);

8.2 实际案例:日志扩展

// app/extend/logger.js
module.exports = (app) => {
  if (app.env.isLocal()) {
    return console; // 本地开发直接用 console
  }

  // 非本地环境:log4js 日志落盘
  log4js.configure({
    appenders: {
      console: { type: "console" },
      dateFile: {
        type: "dateFile",
        filename: "./logs/application.log",
        pattern: ".yyyy-MM-dd", // 按天切分
      },
    },
    categories: {
      default: { appenders: ["console", "dateFile"], level: "trace" },
    },
  });

  return log4js.getLogger();
};

加载后通过 app.logger 访问。本地环境用 console 减少噪音,线上环境用 log4js 实现日志落盘和按天切分。


九、项目目录全景

elpis/
├── index.js                        # 启动入口(2 行代码)
├── package.json
│
├── elpis-core/                     # 框架内核
│   ├── index.js                    # 核心启动逻辑
│   ├── env.js                      # 环境管理
│   └── loader/                     # 7 个自动加载器
│       ├── config.js
│       ├── service.js
│       ├── middleware.js
│       ├── controller.js
│       ├── router-schema.js
│       ├── extend.js
│       └── router.js
│
├── config/                         # 多环境配置
│   ├── config.default.js
│   ├── config.local.js
│   ├── config.beta.js
│   └── config.prod.js
│
├── app/                            # 业务代码
│   ├── middleware.js                # 全局中间件编排
│   ├── controller/                 # 控制器层
│   │   ├── base.js                 # 控制器基类
│   │   ├── project.js
│   │   └── view.js
│   ├── service/                    # 服务层
│   │   ├── base.js                 # 服务基类
│   │   └── project.js
│   ├── middleware/                  # 中间件
│   │   ├── error-handler.js
│   │   ├── api-sign-verify.js
│   │   └── api-params-verify.js
│   ├── router/                     # 路由定义
│   │   ├── project.js
│   │   └── view.js
│   ├── router-schema/              # 路由参数校验规则
│   │   └── project.js
│   ├── extend/                     # 扩展
│   │   └── logger.js
│   └── public/                     # 静态资源
│       ├── static/
│       └── output/
│
└── logs/                           # 日志输出
    └── application.log

十、设计亮点与工程思考

10.1 约定优于配置的实践

整个框架没有一个 YAML/JSON 配置文件来声明"哪些文件是 Controller"。你只需要把文件放到 app/controller/ 目录下,框架就知道它是 Controller。这种约定带来的好处是:

  • 新成员看到目录结构就能理解项目组织
  • 不需要维护额外的注册/配置文件
  • 减少了"配置漂移"的风险

10.2 工厂模式 + 依赖注入

所有业务模块都导出一个接收 app 的工厂函数:

module.exports = (app) => {
  return class SomeController {
    // 通过闭包访问 app
  };
};

这种模式的优势:

  • 每个模块都能访问完整的应用上下文
  • 不需要全局变量或 import 循环
  • 测试时可以轻松 mock app 对象

10.3 容错设计

框架在关键位置都加了 try-catch

// 配置加载失败不阻塞启动
try {
  defaultConfig = require(path.resolve(configPath, `./config.default.js`));
} catch (error) {
  console.error("[exception] there is no config.default file");
}

// 全局中间件文件不存在也不崩溃
try {
  require(`${app.businessPath}/middleware.js`)(app);
} catch (error) {
  console.log("[exception] there is no global middleware file.");
}

这保证了框架的健壮性——即使某个非关键模块加载失败,应用仍然可以启动。

10.4 统一的命名转换

一个正则搞定文件名到代码标识符的映射:

name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());

api-params-verify.jsapiParamsVerifymy_service.jsmyService。文件系统用 kebab-case 保持可读性,代码中用 camelCase 符合 JavaScript 惯例。


十一、总结

Elpis-Core 实现了一个功能完备的框架内核:

  • 7 个 Loader 覆盖了 Web 应用的所有核心组件
  • 严格的加载顺序保证了依赖关系的正确性
  • 约定式目录结构让项目组织零成本
  • 工厂模式 + 闭包实现了优雅的依赖注入
  • 多环境配置、参数校验、签名验证、错误兜底一应俱全

理解了 Elpis-Core,你就理解了大多数 Node.js 框架的底层思路。

本文基于 Elips-Core 框架源码分析撰写,适用于了解 Node.js Web 框架设计原理的开发者。

❌