跳到主要内容

泛域名 HTTPS 解析实践:一张通配证书,Nginx 按 host 分发所有子站点

· 阅读需 7 分钟

一台服务器上跑着博客、网盘、GitLab、漫画站……每个都想有自己的子域名(blog.git.nas.),每个子域名都想走 HTTPS。最省事的做法是:一张泛域名证书 + 一个泛域名 server 块,接收所有子域名,再按 host 分发到各自的服务


1. 场景:一个域名,N 个子服务

需求是这样的:

两种实现对比:

方案证书数量server 块新增子域名
每域名单独签N 张N 个要重新签证书 + 加配置
泛域名证书1 张1 个泛块 + 需要时加精确块DNS 加记录 + map 加一行

泛域名方案的核心收益:证书只管一次,新子域名零证书成本

React 19:把属性 diff 从渲染阶段挪进 commit 阶段 —— prepareUpdate/updatePayload 的终结

· 阅读需 8 分钟

很多 React 教程(尤其是写 React 16–18 的书)里会有这样一段:"HostComponent 在 completeWork 的更新流程中,属性发生变化时,会把 diff 的结果以 ['title', 1, 'style', {'color': '#333'}] 这样的扁平数组保存在 updateQueue 里。"

这段描述是真的,但只对 React 16–18 成立。React 19 干了一件大事:把 prepareUpdate 整个删掉,属性 diff 从"渲染阶段预计算"挪到了"commit 阶段边 diff 边应用"。这个改动同时回答了一个经典困惑——updateQueue 到底是"环形链表"还是"key-value 数组"?答案是:它俩都对,因为 updateQueue 是"多态"的,而这个改动恰好把其中一个形态清掉了。

本文扒开这个改动的全部细节:前后机制、diffProperties 怎么生成数组、为什么要删(PR #26583)。


1. React 16–18:渲染时算好 diff,commit 照单执行

React 18(packages/react-reconciler/src/ReactFiberCompleteWork.new.js)里,completeWork 处理已有 HostComponent 的更新分支长这样:

const updatePayload = prepareUpdate(
instance,
type,
oldProps,
newProps,
rootContainerInstance,
currentHostContext,
);
// 把算好的 diff 结果存进 updateQueue
workInProgress.updateQueue = (updatePayload: any);
// 有变化才打 Update 标记
if (updatePayload) {
markUpdate(workInProgress);
}

prepareUpdate(DOM 实现里叫 diffProperties)做的事情是:逐属性比较 oldPropsnewProps,把"要变更的属性"压进一个扁平数组

['title', 1, 'style', {'color': '#333'}]
^^^^^^ ^ ^^^^^ ^^^^^^^^^^^^^^
key val key val

规则包括:

  • 新增/修改的属性push(propKey, propValue)
  • 被删除的属性push(propKey, '')(空字符串,应用时清掉)
  • style 特殊处理 → 先把旧 style 里的属性收集成 styleUpdates = {color: ''}(清掉消失的样式),再把新 style 差异合并进去
  • children / dangerouslySetInnerHTML / 合成事件等 → 直接跳过,不走这个数组

然后 commit 的 mutation 阶段:

commitUpdate(instance, updatePayload, type, oldProps, newProps)
updateProperties(domElement, updatePayload, type, oldProps, newProps)
两两一组 (i, i+1) 应用到 DOM

好处:diff 只在渲染阶段算一次,commit 阶段只做"照单执行"的机械活;updatePayload === null 时连 Update 标记都不打——这是一个"深度 bailout":只要属性没变,commit 阶段整条链都跳过这个节点。


2. React 19:删掉 prepareUpdate,commit 现场边 diff 边应用

React 19(v19.2.7)里,同一个更新分支变成了:

// ReactFiberCompleteWork.js
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
if (supportsMutation) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
return; // 引用相等 → 直接 bailout
}
markUpdate(workInProgress); // 只打一个 Update 标记,不算 diff、不存数组
}
}

commit 阶段:

// ReactFiberConfigDOM.js
commitUpdate(domElement, type, oldProps, newProps)
updateProperties(domElement, type, oldProps, newProps) // 边 diff 边应用

updateProperties 把 v18 的 diffProperties(生成数组)和"应用数组"合并成了单次遍历:对每个属性直接比较 lastProps vs nextProps,变了就立刻 setProp。没有中间数组,updateQueue 也不再承担 HostComponent 的属性 diff 载体。

对比:

React 16–18React 19
diff 发生在哪渲染阶段(completeWork)commit 阶段(mutation)
产物扁平数组 [k,v,k,v,…]无(边 diff 边改 DOM)
存哪workInProgress.updateQueue不存
提交阶段照单执行现场计算 + 应用
bailoutpayload===null 深度跳过只能靠 oldProps===newProps 引用相等

3. 为什么要删?(PR #26583,作者 Sebastian Markbåge)

这个改动的提交信息非常坦诚,直接摆出了代价与收益

Diff properties in the commit phase instead of generating an update payload (#26583) "This removes the concept of prepareUpdate(), behind a flag."

收益(为什么这么做)

  1. 省内存分配:不再每次渲染为每个有变化的元素分配一个数组;diffProperties 里数组是 [] 起步、可能扩容,元素多时可能产生多个临时对象。commit 阶段全部省掉。
  2. 总工作量更少:diff 只在一处做一遍,不再"渲染算一遍 + commit 应用一遍"。
  3. 为未来优化铺路:既然在 commit 阶段做单次循环,就可以"在一个循环里把需要的所有属性读出来",避免对 props 的多态(polymorphic)读取——这是后续重构的基础。
  4. 统一 host config:React Native(生成 payload 再应用)和 React Fabric(渲染阶段做)各有一套,这个改动把它们统一成"单一宿主配置",更一致。

代价(作者列出的 downsides)

  1. children-only 也会触发 commit 更新:如果只有 children 变了,v18 里 diffProperties 会跳过 children、返回 null → 不 markUpdate;v19 只能靠引用比较,children 属性对象变了就会排一个 commit 更新(虽然遍历本来就会经过它,额外开销不大)。
  2. commit 阶段更重:对一棵"大部分没变"的大树,commit 停留时间变长。
  3. 失去深度 bailout:v18 的 payload === null 是一种"精确知道啥也没变"的信号;v19 没了这层,特殊场景要做的活变多。
  4. 代码重复:每个特殊 case(input/select/textarea…)都要自己复制一遍"清理旧属性"的循环。

落地过程:先是 flag,后 Ship

  1. 2023-04 PR #26583ca41adb8c1 把这个行为放到 diffInCommitPhase 开关后面;
  2. 随后 PR #274097f6201889e "Ship diffInCommitPhase"——Meta 内部性能测试结果中性,于是默认开启;
  3. React 19 正式版:开关被彻底删除,成为唯一路径。

"Meta 测试中性"这个结论很重要——它不是一次"大幅性能优化",而是一次架构净收益:性能不变,但代码更少、分配更少、宿主配置更统一。

更深一层的动机:渲染阶段应当是"纯的"

React 19 的核心方向是默认并发(concurrent by default)。并发渲染意味着渲染随时可能被 shouldYield 打断、整棵 workInProgress 树被丢弃重来。在渲染阶段预计算 updatePayload 属于"宿主相关的副作用",如果这次渲染被放弃,那数组就是白算的。挪到 commit 阶段后:

  • diff 只会在最终提交的那棵树上计算一次;
  • 渲染阶段保持纯净(只做 JS 层面的树构建),宿主细节全部收敛到 commit。

4. React 19 里 HostComponent 的 updateQueue:现在是 null

既然 React 19 删掉了数组,那 HostComponent 的 updateQueue 现在是什么形态、干什么用?

答案是:null,彻底闲置。 Fiber 构造函数里 updateQueue 初始就是 nullReactFiber.js:161),而 v19 里没有任何代码会去给 HostComponent 设置或读取它——completeWork 的 HostComponent 分支(ReactFiberCompleteWork.js:1337)只做一件事:updateHostComponentmarkUpdate(workInProgress) 打一个 Update flag。属性 diff 要的 old/new props 直接来自 current.memoizedPropsworkInProgress.memoizedProps,完全不走 updateQueue

所以"19 里 HostComponent 的 updateQueue 干什么用"的答案是:什么都不干。它保留在 Fiber 结构里,纯粹是因为 Fiber 是通用数据结构——updateQueue 字段对别的 fiber 类型还有用:

fiber 类型updateQueue 的形态与用途(React 19)
HostRoot状态更新链表(render() 的元素入队)
类组件状态更新链表(base + pending)
hook(useState/useReducer)hook 自己的 queue(不是 fiber.updateQueue)
Suspense / Offscreenretry 队列(offscreenQueue.retryQueueReactFiberCompleteWork.js:1964
HostComponentnull,无用途

换句话说,React 19 把 HostComponent 的 updateQueue 从"重载字段"降级成了"空字段"——属性 diff 的载体被彻底移除,"需要更新"的信号由 Update flag 单独承担,old/new props 在 commit 时现取。


5. 那"updateQueue 是环形链表还是 key-value 数组"到底谁对?

两种说法都对,因为 updateQueue多态的——同一个字段在不同 fiber 类型上装的东西完全不同:

fiber 类型updateQueue 装什么结构
HostRoot / hook / 类组件状态更新(Update 节点)链表pending 环形链 / base+pending 单向链)
HostComponent(16–18)属性 diff 结果扁平 key-value 数组 ['title',1,'style',{…}]
HostComponent(19+)空(见上一节)

React 19 这次改动,顺带把 HostComponent 这个"数组形态"清掉了updateQueue 的多态程度下降,基于 19 的教材不会再出现"updateQueue 是 key-value 数组"的说法——而"环形链表"的形象反而更纯粹,因为它只属于状态/副作用队列。


6. 简化后的实现示意

把 v19 的机制抽象成伪代码,就是三步:渲染阶段只打标记,commit 阶段现场 diff

// 渲染阶段 completeWork(更新分支)
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
return; // 引用相等 → bailout
}
markUpdate(workInProgress); // 只打 Update flag,不算 diff、不存数组
}

// commit 阶段 mutation
commitUpdate(domElement, type, oldProps, newProps)
updateProperties(domElement, type, oldProps, newProps) // 边 diff 边应用

reconciler 与宿主彻底解耦:reconciler 只负责"这个节点需要更新"(打 flag),"怎么更新"完全交给宿主在 commit 时决定。整个 reconcile 层再也看不到 prepareUpdate / updatePayload——它们已经属于历史。


7. 一句话总结

React 19 把 HostComponent 的属性 diff 从**渲染阶段的预计算(扁平数组存进 updateQueue)**挪到了 commit 阶段的现场 diff + 应用,并删掉了 prepareUpdate。收益是省掉临时分配、渲染阶段保持纯净、宿主逻辑统一;代价是失去深度 bailout、commit 变重。updateQueue 在 HostComponent 上从此是 null,环形链表的形象也更纯粹。


参考

  • React PR #26583 ca41adb8c1 — Diff properties in the commit phase instead of generating an update payload
  • React PR #27409 7f6201889e — Ship diffInCommitPhase
  • React 18.2.0 ReactFiberCompleteWork.new.js / ReactDOMComponent.js diffProperties
  • React 19.2.7 ReactFiberCompleteWork.js / ReactFiberConfigDOM.js updateProperties

React 19 弃用 forwardRef:ref 传参方式的前后对比

· 阅读需 5 分钟

在 React 里,「把一个 DOM 节点的引用从父组件传给子组件」——这么简单一件事,React 18 及之前却得包一层 forwardRef

import { forwardRef, useRef } from 'react';

const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} {...props} />;
});

function App() {
const inputRef = useRef<HTMLInputElement>(null);
return <FancyInput ref={inputRef} />;
}

为什么不能直接写 function FancyInput(props) 然后取 props.ref?因为在 React 19 之前,ref 不是普通 prop——它是 React 内部的特殊属性:类组件通过它拿到实例引用,而函数组件默认什么都收不到,只能靠 forwardRef 显式接收再转发。

React 19 把这件事彻底改掉了:ref 变成了普通 prop,forwardRef 被官方标记为 deprecated(弃用,但兼容可用)。新代码不再需要它。

大厂自建沙箱实现微前端:从 Proxy 沙箱到 iframe 沙箱的完整拆解

· 阅读需 13 分钟

微前端把多个团队的应用塞进同一个页面,但浏览器只给了一个 window、一个 document、一条渲染管道。两个子应用如果都往全局上写东西、都动全局样式、都监听全局事件,必然互相踩脚。所以微前端框架的核心难点从来不是"怎么把代码加载进来",而是怎么给每个子应用一个互不干扰的"沙箱"

本文拆解各大厂自建沙箱的实现:先讲沙箱的本质(JS 全局隔离有哪几条路),再逐家拆 qiankun(蚂蚁)、micro-app(京东)、wujie(腾讯)、Garfish(字节)的方案与权衡,最后给出一套自建沙箱的工程要点。


1. 沙箱要解决什么问题

微前端里,沙箱至少承担四件事:

要隔离的风险沙箱手段
JS 全局变量子应用 A 写的 window.foo 污染 B伪造 window / iframe 原生隔离
全局方法劫持addEventListenersetTimeout 无处卸载代理 + 应用级缓存清理
样式子应用 CSS 互相影响、影响主应用Shadow DOM / 样式前缀
生命周期卸载后定时器、事件、DOM 残留统一挂载/卸载钩子 + 清理

一句话:沙箱 = 给每个子应用一个"看起来是全局、其实是私有"的运行环境,应用挂载时激活、卸载时清理。


2. 沙箱的本质:JS 全局隔离只有几条路

不管哪家大厂,全局隔离的实现手段就这几条,其余全是组合和优化:

手段隔离级别优点缺点
iframe原生 window 级隔离最彻底、浏览器保证资源重、事件/路由/登录态割裂
Proxy 伪造 window变量级多应用共存、性能好依赖 ES6 Proxy,不兼容旧浏览器
with + eval变量级灵活作用域链性能损耗
Shadow DOM样式/DOM 级原生样式隔离事件冒泡/焦点行为改变(React 事件代理失效)

大厂方案本质上是在"iframe 的原生隔离"和"Proxy 的灵活隔离"之间做选择

  • qiankun / micro-appProxy 路线(多应用灵活共存);
  • wujieiframe + WebComponent 路线(牺牲一部分灵活性,换最强的原生隔离);
  • micro-app 的 vite/esm 场景 也兜底回 iframe。

3. 方案一:Proxy 沙箱(qiankun 的 ProxySandbox)

qiankun 在 single-spa 之上提供了三代沙箱,演进史本身就是"性能与隔离度"的权衡史:

沙箱原理多应用是否污染真实 window性能
SnapshotSandbox(早期)激活时快照整个 window,失活时 diff 还原❌ 单例短暂污染(靠还原)差(每次遍历整个 window)
LegacySandbox(单应用)Proxy 记录 diff,用三个 Map 存"新增/修改原值/当前值"❌ 单例部分污染(仍读写真实 window)
ProxySandbox(主流)每个应用一个 fakeWindow,Proxy 拦截读写✅ 多应用并存不污染

ProxySandbox 的核心就十几行——关键在 set 落到 fakeWindowget 沙箱内优先、沙箱内没有才读真实 window:

// qiankun ProxySandbox 的简化核心
function createProxySandbox() {
const fakeWindow = Object.create(null); // 每个子应用独立的"私有 window"
const running = true;

const proxy = new Proxy(fakeWindow, {
set(target, key, value) {
if (running) target[key] = value; // ① 写:只写 fakeWindow,不碰真实 window
return true;
},
get(target, key) {
if (key in target) return target[key]; // ② 读:沙箱内有的用沙箱的
return window[key]; // ③ 沙箱内没有的,才去真实 window 读
},
has(target, key) {
return key in target || key in window; // 让 in / hasOwnProperty 也按此规则
},
});
return proxy;
}

子应用的 JS 怎么"用"这个 fakeWindow? 把子应用代码包进一个立即执行函数,把 window 指向 proxy:

// qiankun 执行子应用 JS(简化):window/self/globalThis 全部换成 proxy
new Function('window', 'self', 'globalThis', `
${childCode} // 子应用打包产物
`)
.bind(windowProxy)(windowProxy, windowProxy, windowProxy);

于是子应用里写 window.xxx = 1,实际写进的是它自己的 fakeWindow;两个子应用各写各的,互不污染。失活时不用做任何还原——因为真实 window 从头到尾就没被动过,只需要把 running 置 false 停止拦截。


4. 方案二:proxy + with(micro-app 及其性能优化)

micro-app(京东零售)把微前端封装成"类 WebComponent 组件"(<micro-app> 标签),JS 沙箱同样是 proxy + with,但它重点解决了两个 Proxy 沙箱没解决的工程问题:

① 变量前置:别在 proxy 的 get 里干重活

频繁的 window.xxx 读会反复走进 proxy 的 get。micro-app 的做法是用 Object.defineProperty 预先把高频全局变量定义到 fakeWindow 上,让常规读写走原生的属性访问而不是 proxy 的拦截逻辑:

// micro-app 变量前置(简化)
for (const key of HIGH_FREQ_GLOBAL_KEYS) {
Object.defineProperty(fakeWindow, key, {
get() { return getRealGlobal(key); }, // 读真实全局,但不再走 proxy.get
set(value) { setRealGlobal(key, value); },
});
}

② 异步防抖:避免并行 promise 的渲染风暴

子应用运行时常有大量 promise 回调,micro-app 给 promise 打标记、保证"上一个 promise 执行完成后再进下一个",避免并发触发导致主线程被瞬间塞满。

但 with 有代价with 改变了作用域链,每次变量访问都可能触发多级查找,性能比裸全局访问差。所以 micro-app 一方面用"变量前置"把热点变量从 with 作用域里摘出来,一方面承认它的 JS 沙箱仍是代理式方案的性能上限所在

vite / esm 的兜底:iframe 沙箱

with 环境跑不了 ESM(import 无法在 with 块里执行)。所以 micro-app 给 vite 项目准备了 iframe 沙箱:把 esm 产物放进 iframe 执行,再通过重写子应用的原型链实现对 JS 和 DOM 的拦截——"with 沙箱灵活、iframe 沙箱隔离更严",按需二选一


5. 方案三:iframe 沙箱(wujie 的 JS + Shadow DOM 组合)

5.1 为什么"纯 iframe"不行

iframe 是浏览器原生的最强隔离,但它有一长串坑,这也是微前端界对 iframe 又爱又恨的原因:

iframe 的问题后果
资源消耗大每次都要全新文档环境,内存/计算翻倍
事件冒泡不穿透子应用里的按键、快捷键主应用统一劫持不了
路由不同步子应用内跳转,刷新后主应用不知道在哪一页
登录态不共享子应用要重新登录,三方 cookie 禁用时更糟
加载失败无感知子应用崩了主应用不知道
不能预加载缓存也无法共享基础库

5.2 wujie:用 iframe 做 JS 沙箱,用 WebComponent 补样式和 DOM

wujie(腾讯)的思路是各取所长

  • JS 沙箱 = 同域 iframe:把子应用 JS 注入主应用同域的 iframe 里执行——iframe 内部是完整的原生 window 隔离,隔离度拉满,还自带 history / location,路由天然独立;
  • 样式与 DOM = WebComponent(Shadow DOM):在主应用里创建一个 wujie 自定义元素,子应用的完整 DOM 渲染在它的 Shadow DOM 内,CSS 原生隔离(Shadow DOM 边界外的样式进不去、里面的样式出不来);
  • 三套代理打通"iframe 里的 JS"和"主应用里的 DOM"
  • Window Proxy:拦截子应用对 window 的访问,路由类事件(popstate/hashchange)路由到 iframe,其他事件委派给主应用;
  • Document Proxy:子应用的 document.createElement 等操作落到 Shadow DOM 容器;<script> 注入 iframe 执行、<style> 经处理后加入 Shadow DOM;
  • Location Proxy:维护"主应用路径"和"子应用路径"两个上下文并自动互译,实现双向路由同步。

性能与代价:wujie 的通信延迟实测 <50ms(对比 qiankun 100~300ms),DOM 代理可避免 iframe 整体重渲染,内存占用降约 60%;但强制 Shadow DOM 会让 React 的事件代理失效(React 16 兼容差),这是它"隔离强"换来的代价。


6. 样式隔离:Shadow DOM 与前缀之争

JS 沙箱解决"全局变量",样式隔离解决"CSS 互相污染"。三条路:

  1. Shadow DOM(wujie):浏览器原生边界,隔离最彻底——但会切断事件冒泡、改变焦点行为,对依赖事件委托的库(React 16 时代)不友好;
  2. 样式前缀 / 作用域化(micro-app、qiankun 也有):把子应用所有 CSS 选择器加一个前缀(如 micro-app[data-xxx] .app),不改变 DOM 结构,兼容最好;
  3. CSS Modules / BEM:工程层面约定,最轻但靠自觉。

没有银弹:要原生隔离选 Shadow DOM,要兼容性选前缀。这也是为什么大厂框架普遍"JS 隔离手段不同、但都会提供样式前缀方案作为降级"。


7. 大厂方案总对比与 2026 现状

框架所属JS 沙箱样式隔离多应用特点
qiankun蚂蚁ProxySandbox(快照→Legacy→Proxy 三代)前缀为主生态最成熟,但维护已放缓
micro-app京东proxy + with(变量前置 + 异步防抖);esm 走 iframe前缀 / Shadow DOM组件化思路,接入最简单
wujie腾讯同域 iframeShadow DOM隔离最强、保活/嵌套,React16 兼容差
Garfish字节路由 + 通信桥为主前缀一体化路由、@garfish/bridge 通信规范
Module FederationWebpack 生态不提供沙箱(靠打包隔离)共享依赖,不解决运行时污染

2026 年值得注意的现状

  • qiankun 维护放缓:Proxy 沙箱仍是"多应用灵活共存"的标杆实现,但蚂蚁已基本停止大版本演进,存量项目向"vite + 模块联邦"或"iframe 兜底"迁移;
  • iframe 路线抬头:wujie 持续迭代,强隔离、安全敏感(金融/政务)场景偏爱"原生隔离";对 React 16 兼容问题的社区修补也在跟进;
  • "还需要沙箱吗"的讨论:Module Federation 时代,不少人质疑运行时沙箱的必要性——但 MF 只解决"构建期共享与加载",运行时全局污染问题它不解决。沙箱是否必要,取决于你的子应用是否"不可信/不可控":内部团队 + 可控代码,可以靠工程约束(模块化 + 命名规范)替代;集成第三方老系统,沙箱就是刚需。

8. 自建沙箱的工程要点

如果要在团队内部自建一个最小可用沙箱,把上面所有方案浓缩成一张 checklist:

JS 隔离(二选一)

  • 轻量:fakeWindow + Proxy(写进私有对象、读时回退真实 window),多应用并存;
  • 强隔离:同域 iframe + Document/Window 代理。

执行与恢复

  • 子应用代码包进 new Function('window', 'self', 'globalThis', code)bind 上 proxy 再执行;
  • 挂载时激活沙箱、卸载时清理——定时器、事件监听、全局变量、DOM 引用一个都不能漏。

一个极简可用的 Proxy 沙箱骨架(约 30 行):

function createMicroSandbox() {
const fakeWindow = Object.create(null);
const active = { value: true };
const proxy = new Proxy(fakeWindow, {
set: (t, k, v) => (active.value ? ((t[k] = v), true) : true),
get: (t, k) => (k in t ? t[k] : window[k]),
});
return {
proxy,
run(code) {
// 把 window 指向 proxy 执行子应用代码
return new Function('window', 'self', 'globalThis', code)
.bind(proxy)(proxy, proxy, proxy);
},
dispose() {
active.value = false; // 停止写入 fakeWindow
// 清掉子应用注册的定时器 / 事件 / DOM 引用(略)
},
};
}

样式隔离

  • 首选前缀方案data-app-xxx 属性 + 选择器前缀),兼容性最好;
  • 需要原生隔离再上 Shadow DOM,但要评估对事件委托的影响。

别忘了沙箱之外:真正的微前端是"加载 + 沙箱 + 路由 + 通信 + 生命周期"五件套,沙箱只是其中一环——加载(html entry / 模块联邦)、路由互译、父子通信,每一件都是独立的大工程。


9. 一句话总结

沙箱是微前端的核心难点,本质是"给每个子应用一个看起来全局、实际私有的运行环境",实现手段只有几条路:Proxy 伪造 window(qiankun 的 ProxySandbox:写进 fakeWindow、读时回退真实 window,多应用共存不污染)、proxy + with(micro-app:变量前置 + 异步防抖优化,esm 场景兜底 iframe)、iframe 原生隔离(wujie:JS 进同域 iframe、DOM/样式进 Shadow DOM,用三套代理打通,隔离最强但牺牲兼容)。样式隔离在"Shadow DOM 原生边界"和"前缀兼容"之间取舍。自建沙箱时,把"执行 → 激活 → 清理"做成闭环,并记住:沙箱解决全局污染,但解决不了加载、路由和通信——它们是微前端里同样重要、却常常被"沙箱"这个名字掩盖的另外三座山。


参考

useEffectEvent:把"非响应式逻辑"从 Effect 里抽出来

· 阅读需 20 分钟

在 React 里,你迟早会遇到这么个两头堵的问题:Effect 里要读某个最新的 props / state,但又不想它一变就让 Effect 重跑。加进依赖数组吧,副作用会过度执行(反复重连、反复重置定时器);不加吧,闭包读到的永远是"过期的旧值",而且 ESLint 还会红字报警。

React 19.2(2025 年 10 月转正)带来了专门解决这个问题的 useEffectEvent。但要真正理解它为什么长这样、限制为什么那么多,得从 React 最底层的心智模型——不可变(Immutable)快照——说起。


1. 从 Immutable 说起:每次渲染都是一份"照片"

React 的 props 和 state 是不可变的。这句话的意思是:更新 state 不是"把旧值改掉",而是丢弃旧快照、生成一个新快照、然后带着新快照重新渲染一次

这个模型带来一个极其关键的推论:组件体里的每一个闭包,捕获的都是"这一次渲染"的快照值。

function Counter() {
const [count, setCount] = useState(0);

useEffect(() => {
const id = setTimeout(() => {
console.log(count); // 打印的是"创建 effect 时"的 count,不是最新的
}, 1000);
return () => clearTimeout(id);
}, []);

return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

上面这个例子里,不管点多少次按钮,setTimeout 回调里打印的 count 都是第一次渲染时的值(0)。因为 useEffect 的回调是在"渲染 #1"里创建的,它捕获的 count 就是渲染 #1 的快照——之后 state 变了,组件重新渲染,但旧闭包不会"自动看到"新值。

对比 Vue 的响应式模型会更容易看清这个差异:Vue 的 ref / reactive可变的代理,effect 里读 .value 永远是"运行时的当前值"(依赖收集 + 追踪);而 React 的值在渲染那一刻就是定死的,不存在"活的引用"这种东西。

所以 React 里"在 effect 里读最新值"天然是个难题——这就是 useEffectEvent 存在的全部理由。

useTransition 用法与原理:从"非紧急更新"到 React 的 lane 调度机制

· 阅读需 22 分钟

如果一个页面有 1 万个列表项,用户在搜索框每敲一个字符,列表就整体重新过滤、重新渲染一次——输入会卡得没法用。useTransition 就是为了解决这类问题:把"不紧急的渲染"和"用户正在操作的渲染"分开,让输入永远先响应,重的活放到后台慢慢干,还可以随时被新输入打断。

本文先讲用法和简单示例(React 19 的写法),再深入 useTransition 的底层机制——lane 优先级模型、startTransition 内部做了什么、渲染为什么能被中断。


1. 一个场景:为什么需要"非紧急"更新

先看问题。一个受控输入框 + 昂贵过滤:

function SearchList() {
const [query, setQuery] = useState('');

const results = useMemo(() => filterProducts(query), [query]); // 1 万条数据的过滤+渲染

return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>{results.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</div>
);
}

用户每敲一个字符:onChangesetQuery → 整棵列表重渲染。如果这次渲染要花 200ms,输入框就会滞后 200ms 才回显——因为输入框和列表共用同一个 state、同一次渲染,谁都跑不掉。

问题本质:"输入框回显"(紧急)和"过滤并渲染列表"(不紧急)被绑死在一次更新里了。

合成监控(Synthetic Monitoring)调研与实操:CI 门禁、大厂方案与自研方向

· 阅读需 12 分钟

监控前端性能有两条路线:**真实用户监控(RUM)**采集真实用户的实际体验,合成监控(Synthetic)则用脚本/无头浏览器"模拟访问"页面,在受控环境里测性能。RUM 是"事后诸葛",合成监控是"事前预防"——它能在发布之前、上线之后、甚至上线之前就发现性能退化。

本文调研合成监控的完整玩法:它和 RUM 怎么分工、第三方工具有哪些、怎么接进 CI 当"性能门禁"、大厂自研/开放的产品长什么样、以及如果你想自己搭一套,核心模块和最小实现是什么。


1. 合成监控是什么:和 RUM 怎么分工

维度合成监控(Synthetic)真实用户监控(RUM)
数据来源脚本 / 无头浏览器模拟访问真实用户的实际访问
环境受控(固定设备/网络/机型)不可控(五花八门)
时机发布前、定时、可主动触发发布后,被动采集
覆盖覆盖不到真实长尾,但可复现覆盖全量用户,但难复现
用途CI 门禁、回归比对、竞品对标、可用性拨测线上体验度量、报警、归因
成本每次跑都要花钱(无头浏览器)随流量走,边际成本低

两者的正确关系:合成监控是"最低门槛"——通过它才能发布,但它不代表生产环境满分;生产现实要用 RUM 来衡量。而且两者要相互校准

  • 合成指标漂移上升、RUM 平稳 → 怀疑实验室 profile(设备/限速配置)和现实偏离了;
  • RUM 漂移上升、合成平稳 → 存在合成没覆盖的用户群体(设备层级、地域、登录态),要补探测路径。

大厂实践(如汽车之家)印证了这套组合:合成监控进 CI 和发布流程,RUM 看线上真实体验。


2. 第三方工具全景

2.1 Lighthouse / Lighthouse CI(Google)

合成监控的事实标准。Google 维护,基于 Chrome DevTools Protocol,内置 94 条性能规则 + 16 条最佳实践,输出评分(Performance / Accessibility / SEO / Best Practices)和明细指标(LCP 元素、长任务、转移字节数)。核心价值是 Lighthouse CI——每次 commit / PR 自动跑,配上性能预算断言,超预算就阻断合并

2.2 WebPageTest(Catchpoint)

擅长真实网络模拟:多地点、多设备、3G/LTE/Cable 等网络条件,产出 Waterfall 图、HAR、视频帧回放。能录制真实用户脚本(Selenium 风格),适合发布前跨地域压测和竞品对标。可托管私有实例。

2.3 sitespeed.io(Browsertime)

基于 CDP 的高性能采集引擎,深度集成 Lighthouse 审计,侧重 CI/CD 流水线内的快速反馈与回归比对,支持注入自定义指标。社区常把它和 WebPageTest 组合:一个快、一个准。

2.4 k6(Grafana)

虽然更多用于后端,但作为负载测试和定时拨测(HTTP/WebSocket)也能补前端合成监控的"接口层"短板,输出 RPS、p50/p95/p99 延迟、错误率。

2.5 商业 SaaS

产品形态特点
SpeedCurve商业 SaaSLighthouse/WPT 的一站式托管 + 预算 + 趋势
Datadog Synthetics商业 SaaS全球探测点 + 浏览器/API 拨测 + 告警
New Relic Synthetics商业 SaaS与 APM/RUM 打通
腾讯云拨测云产品20 万+ 探测点、覆盖 2000+ 城市,可用性/网络质量/CDN 选型
阿里云 ARMS云产品RUM + APM + 智能洞察一体

2.6 一套开源工具链怎么协同

社区常见的"全栈性能测试框架"长这样(sitespeed + WebPageTest + k6 + Grafana):


3. 实操一:Lighthouse CI 接入"性能门禁"

3.1 定义性能预算(双层:按指标 + 按体积)

.lighthouserc.json 里声明断言,达标才放行

{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-byte-weight": ["warn", { "maxNumericValue": 512000 }]
}
}
}
}

更工程化的做法是独立的 budget 文件budget.json),可以按路径配不同的包:

[
{
"path": "/*",
"timings": {
"largest-contentful-paint": { "error": 2500 },
"total-blocking-time": { "error": 200 },
"cumulative-layout-shift": { "error": 0.1 }
},
"resourceSizes": {
"script": { "error": 512000 },
"image": { "warn": 512000 }
}
}
]

3.2 接进 CI:commit / MR 时自动跑,超预算阻断

以 GitLab CI 为例,一个极简 job:

lighthouse-ci:
stage: test
image: cypress/browsers:latest # 自带 Chrome
script:
- npm i -g @lhci/cli
- lhci autorun --config=./lighthouserc.json # 内置跑 Lighthouse + 断言 + 上报
only:
- merge_requests

流程:

效果案例(Lighthouse CI 官方实践):把"每次引入新库"都纳入预算检查后,客户的 TBT 平均值从 2190ms 降到 200ms。核心原则是:"系统应测量、系统应失败,人类只负责调查原因"——靠 code review 人工盯性能无法规模化,必须让 CI 自动拦截。

3.3 定时合成拨测:性能回归 + 可用性

CI 只在"有代码变更"时跑。线上还要定时拨测(每 6/12/24h),检测性能漂移和可用性:

  • 基准环境:统一容器/硬件/网速(如汽车之家 4 核 + 4G + M 端统一 10M),保证结果可比;
  • 设备与网络模拟:低端机(CPU 降速 4x)+ 弱网(3G)profile,暴露"实验室快、低端机卡"的问题;
  • 漂移告警(业界最佳实践):滚动基线(取最近 N 次夜间运行的中位数)+ 幅度阈值 + 持续性(连续 K 晚)——单次超阈值是噪声,持续超才是真回归。例如 P75 LCP 用"超基线 10% 且连续 3 晚",CLS 数值小用绝对下限(基线 + 0.02)。

4. 大厂自研工具 / 开放产品调研

4.1 阿里云 ARMS:RUM + APM + AI 洞察一体

ARMS(应用实时监控服务)是阿里的一站式观测平台,前端相关能力包括:

  • 用户体验监控(RUM):Web/H5、小程序、移动端一键接入,页面/资源/接口/JS 错误分析,会话追踪;
  • APM 打通:应用拓扑、调用链、持续剖析(Profiling);
  • 智能洞察:基于 LLM 的根因分析和优化建议、告警收敛;
  • 2025 年演进:3 月发布业务链路分析、7 月新增页面秒开率指标、兼容 OpenTelemetry/Prometheus/Grafana Tempo。

4.2 腾讯云前端性能监控(RUM)+ 云拨测

腾讯的路径是"亿级流量验证过的 SDK 开放成云产品":

  • 前端性能监控 RUM:宣称日上报量 4000 亿级、上百亿 PV,一行代码接入,覆盖 Web/小程序/React Native/Hippy/Flutter;
  • 2025 年 2 月 RUM + APM 全链路:RUM SDK 通过 injectTraceHeader 透传 TraceId(支持 Traceparent / sw8 / b3 / Sentry-Trace),前端日志和 APM 调用链无感打通,前后端都用 OpenTelemetry 上报即可;
  • 云拨测(合成监控 SaaS)20 万+ 探测点、覆盖全球 2000+ 城市运营商,PC/手机端拨测,常用于网络质量评估、CDN 选型、域名劫持监测。

4.3 汽车之家:自研 SYN 服务的实战细节

汽车之家的合成监控是自研 + 工程化的代表,几个关键设计:

  • Web 版 SYN 服务部署在容器里,用队列策略保证单容器同时只跑一个任务,统一硬件(4核+4G)和网速(M 端 10M)——基准统一,结果才公平;
  • 计划任务调度:按 6/12/24 小时间隔跑,统计多次结果的 AVG/TP 排除异常值;
  • 加权评分体系:给各指标设基线和权重,加权求和应用得分,再按 PV 数向上聚合成团队 → 部门 → 公司层级得分,管理层一眼看懂性能状况;
  • 进 CI/QA 套件:作为上线前页面性能测试和竞品对比工具。

4.4 大厂做法的规律

把各家放一起看,规律其实非常一致:先自建"埋点 SDK + 上报链路 + 大盘告警"三件套服务内部,验证成熟后开放成云产品(ARMS、腾讯云 RUM/拨测)。自研和云产品的关系不是二选一,而是"内部用自研、对外卖云"——你如果在大厂内部,往往直接用自家这套;外部团队则可以借云产品快速起步。


5. 自研方向:自己搭一套合成监控

如果你不想用整套商业产品,从零自建的最小闭环只需要五个模块:

  • 调度器:定时(cron/队列)触发探测,也可被 CI 调用;
  • 探测执行器:无头浏览器跑页面——Puppeteer / Playwright(采集层)/ Lighthouse(审计层);
  • 采集:Navigation Timing、Resource Timing、PerformanceObserver(CLS/长任务),最后断言预算
  • 存储 + 大盘:InfluxDB/Prometheus + Grafana;
  • 告警:阈值 + 滚动基线漂移。

一个最小实现(Node + Puppeteer + Lighthouse Node API,约 30 行,可直接进 CI):

const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');

const BUDGET = {
'largest-contentful-paint': 2500, // ms
'total-blocking-time': 200, // ms
'cumulative-layout-shift': 0.1,
};

(async () => {
const browser = await puppeteer.launch({ headless: 'new' });
const { lhr } = await lighthouse('https://your.site', {
port: new URL(browser.wsEndpoint()).port,
output: 'json',
throttling: { cpuSlowdownMultiplier: 4 }, // 模拟低端机 CPU
screenEmulation: { mobile: true, width: 375, height: 667 }, // 移动端视口
});

const failed = Object.entries(BUDGET).filter(([id, max]) => {
const v = lhr.audits[id]?.numericValue ?? Infinity;
console.log(`${id}: ${v.toFixed(v < 1 ? 3 : 0)} (预算 ${max})`);
return v > max;
});

console.log(failed.length ? `❌ 超预算: ${failed.map(([id]) => id).join(', ')}` : '✅ 达标');
await browser.close();
process.exit(failed.length ? 1 : 0); // 超预算 → 非零退出 → CI 失败
})();

自研的进阶方向(按投入递增):

  1. 多环境/多地域:staging + prod、多个探测点;
  2. 多 profile:低端机 + 3G 的"最差情况"探测,和"最好情况"分开看;
  3. 回归比对:每次结果和上次/基线 diff,输出 Lighthouse 差异报告;
  4. 与 RUM 校准:把合成指标和线上 RUM 的 P75 放同一个大盘,发现偏差及时调 profile;
  5. 开放上报:自研的探测结果也可对接到现有 RUM/APM 体系,形成"合成预警 + 真实验证"闭环。

什么时候别自研:如果你只需要"几个关键页面 + 达标即发布",Lighthouse CI 就够了;需要多地域多设备拨测,直接用商业 SaaS / 云拨测(探测点成本自建根本没法比)。自研的价值在于:和自家 CI/发布链路深度耦合、指标口径完全可控、可以插进内部平台。


6. 一句话总结

合成监控是"受控环境里的模拟访问测性能",和 RUM 分工明确:合成管"发布前/回归/竞品对标",RUM 管"线上真实体验",两者必须相互校准。第三方工具选型:CI 快速反馈用 Lighthouse CI(性能预算断言,超预算阻断合并),真实网络模拟用 WebPageTest,采集引擎用 sitespeed.io,接口层补 k6;大厂层面,阿里 ARMS、腾讯云 RUM + 云拨测 把内部验证过的"SDK + 上报 + 大盘 + 告警"开放成云产品,汽车之家则展示了自研 SYN 的工程细节(统一基准、定时调度、加权评分聚合)。自研方向只有五个模块:调度器 → 探测执行器 → 采集断言 → 存储大盘 → 基线告警,一个 Lighthouse Node API 的 30 行脚本就能进 CI 当门禁。原则就一条:让系统测量、让系统失败,人类只负责调查原因。


参考

为什么 React 调度器用小顶堆做任务队列:数据结构、排序与时间复杂度的取舍

· 阅读需 14 分钟

React 的 Scheduler 本质是一个「任务队列 + 时间切片器」:任意时刻都可能有人提交任务,调度器要不断回答同一个问题——下一个执行谁? 它的答案是按「过期时间」排序,谁先过期谁最紧急,先执行谁。

实现这个队列用的数据结构,不是数组加排序,也不是链表,而是一个几十行的小顶堆(Min-Heap)。这篇文章从队列的需求出发,对比各种方案的时间复杂度,讲清楚为什么堆是最优解。

前端 RUM:真实用户性能监测

· 阅读需 7 分钟

「网站加载快吗?」这个问题,实验室(Lighthouse、压测)回答不了真实答案——用户可能在弱网、地铁、安卓千元机在后台还挂着几十个 App。性能是分用户、分设备、分网络、分地区的,只有从真实用户浏览器里采集到的数据,才反映真实体验。

这就是 RUM(Real User Monitoring,真实用户监控)——从真实用户的页面里采集性能与错误数据,用「真实发生」代替「假设场景」。

前端面试高频算法与数据结构:从复杂度到手写题一网打尽

· 阅读需 18 分钟

前端面试考算法,问的不是"你数学好不好",而是两件事:你有没有数据结构意识(看到问题能想到该用栈、哈希表还是树),你有没有复杂度意识(知道这段代码是 O(n) 还是 O(n²))。因为前端日常处理的 DOM、事件、状态、缓存,底层全是数据结构——只是平时被框架藏起来了。

这篇文章按"数据结构 → 算法 → 手写题"三层把最常考的内容串一遍:每个结构讲清特性、给一两个高频题、再落到前端场景。不贪多,但每一块都能直接拿去应对面试。


1. 复杂度:一切讨论的前提

面试里"这个解法复杂度多少"是必问的。复杂度用大 O 表示法描述数据量变大时,运行时间/空间增长的量级

复杂度含义典型场景
O(1)恒定数组按下标访问、哈希表查找
O(log n)每次砍一半二分查找、平衡树查找
O(n)线性数组遍历、单层 for 循环
O(n log n)分治快排、归并
O(n²)双重循环冒泡排序、两两比较

前端的复杂度直觉:一个 10 万条的列表,O(n) 是 10 万次操作,O(n²) 是 100 亿次——后者必然卡死页面。所以 React 的 diff、虚拟列表、防抖节流,本质上都是在"把复杂度压下来"。这也是为什么"写代码前先估一下复杂度"是面试官最看重的基本功。


2. 数组:随机访问之王

特性:按下标访问 O(1);但在中间插入/删除要挪动后面的元素,O(n)

高频操作三个:

// ① 去重 —— O(n)
const unique = [...new Set([1, 1, 2, 3, 3])]; // [1, 2, 3]

// ② 扁平化 —— O(n)(含所有元素)
const flat = [1, [2, [3, [4]]]].flat(Infinity); // [1, 2, 3, 4]
// 手写版(递归,面试爱考)
function flatten(arr) {
return arr.reduce(
(acc, cur) => Array.isArray(cur) ? acc.concat(flatten(cur)) : acc.concat(cur),
[],
);
}

// ③ 翻转 —— O(n)
const rev = [1, 2, 3].reverse(); // [3, 2, 1]

前端场景:列表渲染 map、去重后的筛选条件、flatMap 拍平树形数据再渲染——都是数组基本功。


3. 栈 Stack:后进先出(LIFO)

栈只有两个动作:压栈 push(放栈顶)、弹栈 pop(取栈顶),看栈顶 peek。特性一句话:最后放进去的,最先被拿出来

经典题:有效的括号

function isValid(s) {
const stack = [];
const map = { ')': '(', ']': '[', '}': '{' };
for (const ch of s) {
if (ch in map) {
if (stack.pop() !== map[ch]) return false; // 遇到右括号,必须匹配栈顶
} else {
stack.push(ch); // 左括号入栈
}
}
return stack.length === 0;
}

前端场景:栈无处不在——

  • 函数调用栈:每次函数调用压栈,返回时弹栈——递归爆栈就是栈被压满了;
  • 错误堆栈console.trace() / 报错时的调用链,就是栈的直观展示;
  • Undo / Redo:撤销栈 + 重做栈;
  • 括号/标签匹配:HTML 解析、模板引擎都在用。

4. 队列 Queue:先进先出(FIFO)

队列只有两个动作:入队(放队尾)、出队(取队头)。特性:先来的先处理

JS 里的队列shift() 出队是 O(n)(要挪动),追求性能可用两个栈模拟或用环形数组。但前端高频考的不是实现,而是事件循环

规则就一句:每次事件循环取一个宏任务执行 → 然后把微任务队列清空 → 再取下一个宏任务。所以:

console.log(1); // 同步
Promise.resolve().then(() => console.log(2)); // 微任务
setTimeout(() => console.log(3)); // 宏任务
// 输出:1 → 2 → 3

前端场景:BFS 也是用队列——见第 6 节树的层序遍历。React 的并发渲染、消息队列、任务调度全是队列思想。


5. 链表 Linked List:插入删除快,随机访问慢

链表和数组正好互补:已知节点时插入/删除 O(1)(改指针即可),但按下标访问要逐个走,O(n)

经典题 ①:反转链表(三指针)

function reverseList(head) {
let prev = null, cur = head;
while (cur) {
const next = cur.next; // 先记住下一个
cur.next = prev; // 反转指针
prev = cur;
cur = next;
}
return prev;
}

经典题 ②:环形链表(快慢指针——最常用的技巧之一)

function hasCycle(head) {
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next; // 走一步
fast = fast.next.next; // 走两步
if (slow === fast) return true; // 有环必相遇
}
return false;
}

前端场景:React Fiber 的 workInProgress 树底层就是链表结构;浏览器的 DOMnextSibling / parentNode 也是指针式遍历;LRU 缓存(见第 7 节)更是链表 + 哈希表的经典组合。


6. 哈希表(Map / Set / Object):O(1) 查找

哈希表的核心思想:用"散列函数"把键映射成数组下标,直接落到对应的"桶"里。所以查找 / 插入 / 删除平均都是 O(1)——不靠遍历,靠"算一下就定位"。

6.1 先分清 JS 里的三个容器:Map / Set / Object

面试最爱问"MapObject 选谁"。先说结论:需要"键值对 + 保持插入顺序 + 任意类型的键"时用 Map;只是普通的"名值结构 / 要 JSON 序列化"时用 Object

特性MapObject
键的类型任意(对象、函数都能当键)只能是字符串 / Symbol(数字键会被转成字符串)
顺序保持插入顺序for...of / forEach 遍历稳定)整数键会自动按升序重排,不保证插入序
元素个数map.size 直接拿Object.keys(o).length 额外数一遍
遍历原生可迭代,for...of 直接用要借 Object.keys / Object.entries
频繁增删性能稳定删属性可能触发枚举重排,历史上更慢
JSON不能直接 JSON.stringify✅ 原生序列化
原型链纯"数据容器",无原型干扰继承 Object.prototype,键可能和原型方法撞名

Set 是"只有键、不重复"的集合:new Set([1, 1, 2]){1, 2},同样保持插入顺序,常用于去重判存在

6.2 经典题:两数之和

用哈希表把"已经见过的值"记下来,一次遍历就能找到另一半:

function twoSum(nums, target) {
const map = new Map();
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i];
if (map.has(need)) return [map.get(need), i]; // 找到了之前存的另一半
map.set(nums[i], i); // 记下"当前值 → 下标"
}
return [];
}

不用哈希表的话,双循环要 O(n²);用哈希表把"查找另一半"从 O(n) 降到 O(1),整体 O(n)。这就是"用空间换时间"的典型。

6.3 进阶:LRU 缓存(最常考的设计题)

先弄懂它是干嘛的。缓存 = 把"算得贵 / 读得慢"的结果暂时存起来,下次直接取,省得再算。但缓存容量有限(内存就那么大),满了就得淘汰旧数据。淘汰谁?——最久没被用过的那个。这就是 LRU(Least Recently Used,最近最少使用)

为什么偏偏淘汰"最久未使用"? 因为真实数据有时间局部性(temporal locality):刚被访问过的数据,短时间内大概率还会再被访问;反之,很久没碰的最可能以后也用不上了。所以"先丢最久没用的"。浏览器 HTTP 缓存、Redis 内存淘汰、操作系统的页置换,全是这个思路。

Map 版本(能写出来就是加分项)——秘密全在 §6.1 说的"Map 保持插入顺序":

class LRUCache {
constructor(capacity) { this.capacity = capacity; this.cache = new Map(); }
get(key) {
if (!this.cache.has(key)) return -1;
const value = this.cache.get(key);
this.cache.delete(key); // ① 先删掉旧的
this.cache.set(key, value); // ② 再插回去 → 排到"最新"位置
return value;
}
put(key, value) {
if (this.cache.has(key)) this.cache.delete(key); // 更新也先删旧的
this.cache.set(key, value);
if (this.cache.size > this.capacity) {
this.cache.delete(this.cache.keys().next().value); // 淘汰队头
}
}
}

每步在干什么,拆开看:

  • get 命中deleteset,等于把这条记录从旧位置挪到队尾——队尾永远是最新用过的;
  • put 更新:同理,先删后插;
  • 淘汰keys().next().value 是迭代器的第一个元素 = 插入最早 = 最久未使用,超容量就删它。

(整套思路一句话:访问一次就把记录"顶"到最新,淘汰时永远踢队头。

教科书版:双向链表 + 哈希表——为什么还要会这个版本?因为 Map 版本虽然能写,但面试官想确认你理解 O(1) 是怎么保证的。标准答案是双向链表管"顺序",哈希表管"O(1) 定位"

class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.map = new Map(); // key → 链表节点
this.head = this.tail = null; // 双向链表头尾
}
_detach(node) { // O(1) 从链表摘掉 node
if (node.prev) node.prev.next = node.next; else this.head = node.next;
if (node.next) node.next.prev = node.prev; else this.tail = node.prev;
}
_pushToTail(node) { // O(1) 把 node 放到队尾
node.prev = this.tail; node.next = null;
if (this.tail) this.tail.next = node; else this.head = node;
this.tail = node;
}
get(key) {
const node = this.map.get(key);
if (!node) return -1;
this._detach(node);
this._pushToTail(node); // 顶到最新
return node.value;
}
put(key, value) {
if (this.map.has(key)) this._detach(this.map.get(key));
const node = { key, value, prev: null, next: null };
this.map.set(key, node);
this._pushToTail(node);
if (this.map.size > this.capacity) {
this.map.delete(this.head.key); // 淘汰队头
this._detach(this.head);
}
}
}

为什么是"哈希表 + 双向链表"这套组合:

  • 哈希表负责"给个 key 直接 O(1) 找到节点"——没有它,找节点要遍历链表 O(n);
  • 双向链表负责"O(1) 移动 / 删除节点"——单向链表删除时不知道前驱,还得遍历找;双向链表有 prev 指针,摘除即 O(1)。

两个数据结构各管一件事,合起来才是完整的 O(1) LRU。这也是面试设计题的标准答题骨架:先想清楚"哪个结构负责哪种能力",再动手写。

真实场景:浏览器 HTTP 缓存、Redis 的 maxmemory-policy=allkeys-lru、Vue 的 <keep-alive> 组件缓存、React Query / SWR 的请求缓存、小程序本地缓存——凡是"内存有限 + 读多 + 想把热的留着"的地方,都是 LRU。

前端场景:对象属性查找 O(1)、Set 去重、函数 memo 缓存(useMemo 依赖比对底层)、依赖收集(Vue 的响应式)、虚拟 DOM 的属性 diff,全是哈希表。


7. 树:二叉树遍历与 DFS / BFS

树是最"前端"的数据结构——DOM 树、组件树、AST 都是树。核心操作是遍历,两种方式:

DFS(深度优先):用递归或栈。二叉树的前/中/后序都是 DFS:

// 前序:根 → 左 → 右(递归版,最好写)
function preorder(root, res = []) {
if (!root) return res;
res.push(root.val);
preorder(root.left, res);
preorder(root.right, res);
return res;
}

// 前序(迭代版,用栈)——面试官常让你把递归改成迭代
function preorderIter(root) {
const res = [], stack = [root];
while (stack.length) {
const node = stack.pop();
if (!node) continue;
res.push(node.val);
stack.push(node.right, node.left); // 先压右,后压左 → 弹出来先左
}
return res;
}

BFS(广度优先):用队列,一层一层扫。层序遍历必考:

function levelOrder(root) {
const res = [];
if (!root) return res;
const queue = [root];
while (queue.length) {
const size = queue.length; // 先记下这一层有几个节点
const level = [];
for (let i = 0; i < size; i++) {
const node = queue.shift();
level.push(node.val);
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
res.push(level);
}
return res;
}

DOM 树的 DFS vs BFS(以一颗小 DOM 树为例):

  • DFS(前序)html → head → body → div → span → p(一条道走到黑再回头);
  • BFShtml → head, body → div, p → span(一层层扫)。

document.querySelectorAll 等选择器匹配、ReactDOM 的递归渲染,本质都是树的 DFS。


8. 排序:快排必须能手写

经典题:手写快排(分治思想:选基准 → 分区 → 递归)

function quickSort(arr) {
if (arr.length <= 1) return arr;
const pivot = arr[0];
const left = [], right = [];
for (let i = 1; i < arr.length; i++) {
(arr[i] < pivot ? left : right).push(arr[i]);
}
return [...quickSort(left), pivot, ...quickSort(right)];
}

(这是最容易手写、最不会出错的版本,代价是空间 O(n)。进阶可以聊原地分区的 in-place 版本,以及"最坏 O(n²) 怎么避免"——随机选基准 / 三数取中。)

排序复杂度与稳定性

算法平均最坏空间稳定
快排O(n log n)O(n²)O(log n)
归并O(n log n)O(n log n)O(n)
冒泡O(n²)O(n²)O(1)
选择O(n²)O(n²)O(1)
插入O(n²)O(n²)O(1)

稳定的意思是"相等的元素排序后保持原来的相对顺序"——需要稳定时(比如按日期排完再按状态排)选归并。

前端场景Array.prototype.sort 底层——现代 V8 用 TimSort(稳定),小数组会走插入排序;但要注意 sort() 默认按字符串排序,排数字必须传比较函数:

[10, 9, 100].sort(); // [10, 100, 9] ← 按字符串排的坑
[10, 9, 100].sort((a, b) => a - b); // [9, 10, 100] ✅

9. 二分查找:有序数组的 O(log n)

经典题:标准二分(注意边界 lo <= hi

function binarySearch(arr, target) {
let lo = 0, hi = arr.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1; // 取中位
if (arr[mid] === target) return mid;
if (arr[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}

面试升级问法:找左边界(第一个 >= target 的位置):

function lowerBound(arr, target) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo;
}

前端场景:数据库索引就是 B+Tree 上的二分(见本博客 MySQL 索引);前端里的二分常用于"有序列表找插入位置"、降级策略、CDN 最优节点等。


10. 递归与手写深拷贝

递归的核心是"把大问题拆成同样的子问题,直到最小规模"。手写深拷贝是前端最常考的手写题:

function deepClone(obj, seen = new Map()) {
if (obj === null || typeof obj !== 'object') return obj; // 基础类型直接返回
if (seen.has(obj)) return seen.get(obj); // 处理循环引用!
const res = Array.isArray(obj) ? [] : {};
seen.set(obj, res);
for (const key of Object.keys(obj)) {
res[key] = deepClone(obj[key], seen);
}
return res;
}

三个考点,一个都不能少:

  1. 基础类型直接返回(否则 typeof 会误判);
  2. seen 记录已克隆对象,处理循环引用 obj.self = obj——没有它直接爆栈;
  3. 数组要单独判断(用 Array.isArray),否则克隆出来是对象。

递归的坑:层级太深会爆栈(RangeError: Maximum call stack size exceeded)。能改迭代就改迭代,或至少知道"大列表别用深递归"。


11. 动态规划入门:爬楼梯

动态规划(DP)记住三句话:① 大问题能拆成子问题;② 子问题会被反复用到(重叠子问题);③ 边界和递推式。

经典题:爬楼梯(一次爬 1 或 2 阶,到第 n 阶有几种爬法)——答案就是斐波那契:f(n) = f(n-1) + f(n-2)

从"会写"到"不超时"有三个层次:

// 层次 1:纯递归 —— O(2^n),指数爆炸,n 稍大就卡死 ❌
function climb1(n) {
if (n <= 2) return n;
return climb1(n - 1) + climb1(n - 2);
}

// 层次 2:记忆化(自顶向下)—— O(n) ✅
function climb2(n, memo = {}) {
if (n <= 2) return n;
if (memo[n] !== undefined) return memo[n];
return (memo[n] = climb2(n - 1, memo) + climb2(n - 2, memo));
}

// 层次 3:自底向上滚动数组(真正的 DP)—— O(n) 时间 O(1) 空间 ✅
function climb3(n) {
if (n <= 2) return n;
let a = 1, b = 2;
for (let i = 3; i <= n; i++) [a, b] = [b, a + b];
return b;
}

前端场景:diff 算法的 LCS(最长公共子序列)就是二维 DP(见 React diff 系列博客);菜单高亮、路径统计这类"分步决策"问题都是 DP 的形态。


12. 双指针 / 滑动窗口

双指针是解字符串、数组题的高频套路。两个经典:

① 滑动窗口:最长无重复子串

function lengthOfLongestSubstring(s) {
const seen = new Set();
let left = 0, max = 0;
for (let right = 0; right < s.length; right++) {
while (seen.has(s[right])) { // 窗口里有重复 → 右移 left 直到没有
seen.delete(s[left]);
left++;
}
seen.add(s[right]);
max = Math.max(max, right - left + 1);
}
return max;
}

② 双指针:回文判断(字符串预处理后左右夹逼)

function isPalindrome(s) {
s = s.toLowerCase().replace(/[^a-z0-9]/g, '');
let l = 0, r = s.length - 1;
while (l < r) {
if (s[l] !== s[r]) return false;
l++; r--;
}
return true;
}

前端场景:防抖节流里的时间窗口、输入联想、评论分页加载,本质都是"维护一个窗口,只在窗口内做事"。


13. 前端手写题清单(速查表)

面试前最后冲刺,对着这张表过一遍。带链接的已有专题文章,不重复展开:

手写题关键点复杂度位置
数组去重Set / filter + indexOfO(n)本文 §2
数组扁平化递归 + reduceO(n)本文 §2
有效括号O(n)本文 §3
两数之和哈希表O(n)本文 §6
LRU 缓存Map 保持插入顺序 / 双向链表 + 哈希表均摊 O(1)本文 §6.3
反转链表三指针O(n)本文 §5
环形链表快慢指针O(n)本文 §5
二叉树遍历递归 ↔ 迭代O(n)本文 §7
快排分治 + 递归O(n log n)本文 §8
二分查找边界 lo <= hiO(log n)本文 §9
深拷贝递归 + 循环引用 MapO(n)本文 §10
爬楼梯滚动数组 DPO(n)本文 §11
最长无重复子串滑动窗口O(n)本文 §12
防抖 / 节流闭包 + 定时器防抖节流专题
call / apply / bind / Promise状态机 / 链式调用手写 JS 系列

14. 一句话总结

前端面试考算法,考的是数据结构意识 + 复杂度意识:看到"要快速查找"想哈希表、看到"要后进先出"想栈、看到"要先进先出 / 一层层"想队列、看到"递归结构 / 树形"想 DFS/BFS、看到"有序数组"想二分、看到"要排序"想快排、看到"分步决策可拆分"想 DP。而这一切在前端都有真实的落点——调用栈、事件循环、DOM 树、diff、LRU、响应式依赖收集,数据结构从来不是面试的孤岛,它就是前端的日常。


参考