阅读视图

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

面试官:JS数组的常用方法有哪些?这篇总结让你面试稳了!

面试官往往会这么问:“JS 数组的常用方法有哪些?”然后追问:“哪些会改变原数组?哪些不会?”或“能举一个实际使用场景吗?”因此回答不仅要列出方法,还要讲清楚分类、返回值、是否改变原数组、典型用法与坑。

方法分类与速查

操作方法:增、删、改、查

排序方法:reverse、sort

转换方法:join

迭代方法:forEach、map、filter、some、every、find(包含 find、findIndex 等)

一、操作方法(增删改查)

  • push():末尾追加,返回新长度;改原数组
  • unshift():开头插入,返回新长度;改原数组
  • splice(start, 0, ...items):指定位置插入,返回空数组;改原数组
  • concat(...items):合并并返回新数组,不改原数组
let colors = ["red", "green"];
colors.push("blue"); // 3; colors => ["red","green","blue"]
colors.unshift("yellow"); // 4; colors => ["yellow","red","green","blue"]
colors.splice(1, 0, "purple"); // [] => 原数组被修改
let colors2 = colors.concat("black", ["white"]); // 新数组

  • pop():末尾删除,返回被删项;改原数组
  • shift():首项删除,返回被删项;改原数组
  • splice(start, deleteCount):删除指定位置项,返回被删数组;改原数组
  • slice(start, end):拷贝子数组,返回新数组;不改原数组
let colors = ["red", "green", "blue"];
let last = colors.pop(); // "blue"; colors => ["red","green"]
let first = colors.shift(); // "red"; colors => ["green"]
let removed = colors.splice(0, 1); // ["green"]; colors => []
let sub = colors.slice(1, 3); // 新数组,不改原数组

  • splice(start, deleteCount, ...items):删除并插入,返回被删数组;改原数组
let colors = ["red", "green", "blue"];
colors.splice(1, 1, "purple"); // ["green"]; colors => ["red", "purple", "blue"]

  • indexOf(item):返回索引,不存在返回 -1
  • includes(item):返回 boolean
  • find(callback):返回第一个满足条件的元素
  • findIndex(callback):返回第一个满足条件的索引
let arr = [1, 2, 3, 4];
arr.indexOf(3); // 2
arr.includes(5); // false
let found = arr.find(x => x > 2); // 3
let foundIdx = arr.findIndex(x => x > 2); // 2

二、排序方法

reverse():反转数组,改原数组,返回引用 sort(compareFn):排序,改原数组,返回引用

let nums = [3, 1, 4, 1, 5];
nums.reverse(); // [5,1,4,1,3]; 改原数组
nums.sort((a,b)=>a-b); // [1,1,3,4,5]; 改原数组

注意:不传 compareFn 时,按 UTF-16 代码单元排序,对数字排序可能不符合预期,务必传比较函数。

三、转换方法

join(separator):用指定分隔符拼接成字符串,不改原数组

let colors = ["red", "green", "blue"];
colors.join(","); // "red,green,blue"
colors.join("||"); // "red||green||blue"

四、迭代方法(不改原数组)

  • forEach(callback):遍历,无返回值
  • map(callback):映射,返回新数组
  • filter(callback):过滤,返回新数组
  • some(callback):任一满足则 true
  • every(callback):全部满足则 true
  • find(callback):返回第一个满足元素
  • findIndex(callback):返回第一个满足索引
  • reduce/reduceRight:归约,常用于累加、组合
let nums = [1, 2, 3, 4];
let doubled = nums.map(x => x * 2); // [2,4,6,8]
let evens = nums.filter(x => x % 2 === 0); // [2,4]
let has = nums.some(x => x > 3); // true
let all = nums.every(x => x > 0); // true
let first = nums.find(x => x > 2); // 3
let sum = nums.reduce((a,b)=>a+b,0); // 10

五、是否改变原数组一览

改变原数组:push、pop、shift、unshift、splice、sort、reverse

不改变原数组:concat、slice、join、forEach、map、filter、some、every、find、findIndex、reduce、reduceRight、flatMap、flat、indexOf、includes

六、典型面试追问与场景举例

问:如何在不改变原数组的前提下在末尾追加一项?

答:使用 concat 或展开运算符 [...arr, item]。 问:如何移除数组中所有 falsy 值?

答:arr.filter(Boolean)。 问:如何按某属性排序对象数组?

答:arr.sort((a,b)=>a.key.localeCompare(b.key))。 问:forEach 与 map 的区别?

答:forEach 无返回值,仅遍历;map 返回新数组,常用于转换。 问:splice 与 slice 的区别?

答:splice 会改变原数组并支持插入/删除;slice 不会改变原数组,仅拷贝子集。

七、常见坑与避坑建议

  • 直接用 sort() 对数字排序可能出错,务必传比较函数。
  • splice 的参数易混淆,牢记参数顺序与返回值。
  • 在需要保留原数组的场景,避免误用会改变原数组的方法。
  • 注意 map 等迭代方法不会提前终止,如需提前中断请用 some/every 或传统 for。

八、总结

JS 数组方法多且常用,记住“是否改变原数组”是高频考点。建议按“操作、排序、转换、迭代”四个维度掌握,并多在实际项目中用这些方法替代手动循环,代码会更简洁、易读。


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

React 底层原理 & 新特性

React 底层原理 & 新特性

本文深入探讨 React 的底层架构演进、核心原理以及最新版本带来的突破性特性。


原文地址

墨渊书肆/React 底层原理 & 新特性


React 版本变动历史

React 自发布以来经历了多个版本的更新,每个主要版本的变动都带来了新的特性和改进,同时也对旧有的API进行了调整或废弃。以下是React几个重要版本的主要变动概述:

React 15 (2016年)

  • 引入Fiber架构:在 React 15后期版本中引入了 Fiber, 提供了更灵活的渲染调度和更换的错误恢复机制。
  • 改进了服务器端渲染:提升了SSR(Server-Side Rendering)的性能 and 稳定性。
  • SVG和MathML的支持增强:更好地支持SVG和MathML元素,使其渲染更加一致和准确。

React 16 (2017年)

  • 全面实施Fiber:Fiber成为了React核心的更新算法,提供了更细粒度的任务调度和更强大的并发模式,使得React应用的性能和响应性有了显著提升。
  • Error Boundaries:引入了错误边界的概念,允许组件捕获其子组件树中的JavaScript错误,并优雅地降级,而不是让整个应用崩溃。
  • Portals:允许将子节点渲染到DOM树的其他位置,为模态框、弹出层等场景提供了更好的解决方案。
  • 支持返回数组的render方法:可以直接从组件的render方法返回多个元素,而不需要额外的包装元素。

React 17 (2020年)

  • 自动批处理更新:默认开启了自动批处理更新,即使开发者没有手动使用 React.startTransitionunstable_batchedUpdates,React也会尝试批处理状态更新,以减少渲染次数。
  • 事件委托改进:改变了事件处理的方式,将事件监听器绑定到 document 上,减少了委托层级,简化了第三方库的继成。
  • 更严格的 JSX 类型检查:增强了对JSX类型的检查,帮助开发者提前发现潜在的类型错误。
  • 无-breaking-change 版本:React 17被设计为一个过渡版本,尽量减少对现有代码的破坏,为未来更大的更新铺路。

React 18 (2022年)

  • 并发模式:进一步深化了Fiber架构的并发特性,通过新的 SuspenseUseTransition API,允许开发者更好地控制组件的加载和更新策略。
  • 自动 hydration:React 18引入了新的渲染模式,包括 Server ComponentsAutomatic Hydration,旨在减少初次加载时间和提高用户体验。
  • 改进的错误处理:增强了错误边界和错误报告的能力,使得调试和问题定位更加容易。
  • StartTransition API:允许开发者标记某些状态更新为低优先级,从而优化UI的响应性和流畅性。

React 19 新特性深度解析 (2024年)

React 19 是一个重大的里程碑,它将许多在 React 18 中处于 Canary/Experimental 阶段的特性正式稳定化,并引入了全新的开发范式。

1. Actions 与异步状态管理

React 19 引入了 Actions 的概念,用于简化异步操作(如表单提交)及其状态管理。

  • useActionState: 自动处理异步函数的 pending 状态和结果。

    function UpdateName({ name, updateName }) {
      const [error, submitAction, isPending] = useActionState(
        async (previousState, formData) => {
          const error = await updateName(formData.get("name"));
          if (error) return error;
          return null;
        },
        null
      );
    
      return (
        <form action={submitAction}>
          <input type="text" name="name" disabled={isPending} />
          <button type="submit" disabled={isPending}>Update</button>
          {error && <p>{error}</p>}
        </form>
      );
    }
    
  • useFormStatus: 子组件无需通过 Props 即可感知父表单的提交状态。

  • useOptimistic: 极致的乐观更新体验。在请求发出时立即更新 UI,请求失败后自动回滚。

2. Server Actions:打通前后端的“虫洞”

Server Actions 允许你在客户端直接调用服务器上的异步函数,是 React 19 的核心特性之一。

  • 指令: 使用 'use server' 标记函数或整个文件。
  • 全链路流程:
    1. 定义: 在服务端定义异步函数。
    2. 序列化: React 自动处理参数的序列化(支持复杂对象、FormData)。
    3. 传输: 客户端调用时,React 发起一个特殊的 POST 请求,将参数序列化后传输。
    4. 执行: 服务器接收请求,反序列化参数,执行逻辑(如操作数据库)。
    5. 响应: 服务器返回执行结果,React 自动刷新相关的客户端数据(通过 Revalidation 机制)。
  • 核心优势:
    • 安全性: 自动包含 CSRF 防护,防止跨站请求伪造。
    • 简化代码: 无需手动编写 API 路由、处理 fetch 和状态更新逻辑。
    • 渐进增强: 在 JS 未加载完成时,表单提交依然可以通过原生的 form action 工作。

3. use API:统一的资源读取

use 是一个全新的运行时 API,可以在渲染时读取 Promises 或 Context。

  • 条件调用: 不同于普通的 Hooks,use 可以在 iffor 循环中调用。
  • 自动 Suspense: 当 use(promise) 还在等待时,React 会自动挂起当前组件并显示最近的 Suspense 占位符。

4. Hook 进阶:useEffectEvent (React 19.2+)

为了解决 useEffect 依赖项过多的问题,React 19.2 引入了 useEffectEvent

  • 设计初衷: 在 useEffect 中,有些逻辑需要读取最新的 propsstate,但不希望这些值的变化触发 Effect 重新运行。

  • 示例:

    function ChatRoom({ roomId, theme }) {
      // 将“纯逻辑事件”抽离
      const onConnected = useEffectEvent(() => {
        showNotification('已连接!', theme); // 始终能拿到最新的 theme
      });
    
      useEffect(() => {
        const connection = createConnection(roomId);
        connection.on('connected', () => {
          onConnected(); // 调用事件
        });
        connection.connect();
        return () => connection.disconnect();
      }, [roomId]); // ✅ theme 变化不再导致重新连接
    }
    
  • 核心逻辑: useEffectEvent 定义的函数具有“反应性”,但它不是“依赖项”。它能捕获最新的闭包值,却不会触发渲染。


5. React Server Components (RSC) 进阶

RSC 不仅仅是服务端渲染,它是一种新的组件架构。

  • 零包体积: 服务端组件的代码不会下载到浏览器,减少了 JS Bundle 大小。
  • 直接访问数据: 可以直接在组件内写 sql 查询或读取文件系统。
  • 混合模式: 通过 'use client' 指令,开发者可以精确定义客户端交互边界。

6. Web Components 原生支持

React 19 终于完美支持了 Web Components,解决了长期以来的“痛点”。

  • 属性与特性的智能映射:
    • 以前: React 总是将属性作为 Attribute 处理,导致无法传递对象或布尔值给 Web Components。
    • 现在: React 会自动检测自定义元素。如果该元素上定义了对应的 Property(属性),React 会优先使用属性赋值;否则使用 setAttribute
  • 原生事件支持:
    • 以前: 开发者需要通过 ref 手动调用 addEventListener
    • 现在: 可以像原生 DOM 一样直接使用 onMyEvent={handleEvent},React 会自动处理事件委托和解绑。
  • 跨团队协作: 这意味着大型企业可以在同一个页面中混合使用 React 组件和基于 Lit、Stencil 开发的 Web Components,而不会产生任何兼容性壁垒。

7. 开发者体验 (DX) 的全面进化

React 19 移除了许多历史包袱,让 API 变得更加直观。

  • 简化 ref 传递:

    • 以前: 必须使用 forwardRef 才能将 ref 传递给子组件。
    • 现在: ref 现在作为一个普通的 prop 传递。你可以直接在函数组件的参数中解构它:
    function MyInput({ placeholder, ref }) {
      return <input placeholder={placeholder} ref={ref} />;
    }
    
  • 文档元数据 (Metadata) 支持:

    • 开发者现在可以直接在组件中渲染 <title>, <meta>, <link>。React 会自动将它们提升(Hoist)到文档的 <head> 部分,并处理去重。
  • 静态资源加载优化:

    • React 19 引入了资源预加载 API,如 preload, preinit
    • 样式表与脚本: 支持在组件中直接声明样式表,React 会确保在组件渲染前样式已加载完成,避免闪烁(FOUC)。

底层原理深度解析

React 的底层设计旨在解决大规模应用中的 UI 响应速度和开发效率问题。其核心逻辑遵循从 “数据描述 (JSX) -> 内存模型 (Fiber) -> 任务调度 (Scheduler) -> 真实渲染 (Commit)” 的流水线。

1. JSX 的本质:声明式描述 UI

JSX(JavaScript XML)是 JavaScript 的语法扩展,本质是 React.createElement 的语法糖。

  • 源码转换JSX 通过 Babel 编译为 _jsx 调用,生成描述 UI 的普通对象(React Element)。
  • 设计初衷
    • 声明式编程:开发者只需关注 UI 的“最终状态”,而非如何操作 DOM。
    • 跨平台一致性React Element 是纯 JSON 对象,不仅能渲染为 DOM,还能渲染为原生应用(React Native)或 Canvas。

2. Fiber 架构:最小工作单元与增量渲染

Fiber 是 React 16 的核心重构,它将渲染过程从不可中断的“递归”变为了可控制的“迭代”。

  • Fiber 节点源码结构

    function FiberNode(tag, pendingProps, key) {
      // 1. 实例属性
      this.tag = tag;                 // 组件类型(Function, Class, Host...)
      this.stateNode = null;          // 对应真实 DOM 或组件实例
      
      // 2. 树结构属性 (单向链表)
      this.return = null;             // 指向父节点
      this.child = null;              // 指向第一个子节点
      this.sibling = null;            // 指向右侧兄弟节点
      
      // 3. 状态属性
      this.memoizedState = null;      // 存储 Hooks 链表
      this.updateQueue = null;        // 存储更新任务 (UpdateQueue)
      
      // 4. 并发与优先级
      this.alternate = null;          // 双缓存指向 (WIP vs Current)
      this.lanes = NoLanes;           // 当前任务优先级
      this.childLanes = NoLanes;      // 子树优先级
    }
    
  • UpdateQueue 内部结构: 每一个 Fiber 节点都有一个 updateQueue,用于存放状态更新。

    const updateQueue = {
      baseState: fiber.memoizedState,
      firstBaseUpdate: null,          // 基础更新链表头
      lastBaseUpdate: null,           // 基础更新链表尾
      shared: {
        pending: null,                // 待处理的循环链表
      },
      effects: null,                  // 存放副作用的数组
    };
    
  • Effect 链表 (副作用清理)

    Commit 阶段,React 会遍历 Effect 链表来执行 DOM 操作、生命周期或 Hooks 的 cleanup

    const effect = {
      tag: tag,                       // Hook 类型 (HookHasEffect | HookPassive)
      create: create,                 // useEffect 的第一个参数
      destroy: destroy,               // useEffect 的返回值 (cleanup)
      deps: deps,                     // 依赖项
      next: null,                     // 指向下一个 Effect
    };
    
  • 核心优势

    • 可中断性:将巨大的更新拆分为细小的 Fiber 任务,主线程可以在任务间隔处理更高优先级的用户输入。
    • 状态持久化:由于 Fiber 节点存储在内存中,即使渲染中断,之前的状态也能被保留,下次继续。

3. Fiber 树的遍历逻辑:深度优先遍历

React 采用“深度优先遍历”算法来处理 Fiber 树,这是一个典型的“递归”转“迭代”的过程。

  • beginWork 阶段:从上往下。

    • 核心逻辑:根据 React Element 的变化,决定是复用现有 Fiber 还是新建。
    • 任务:计算新的 props、计算新的 state、调用生命周期或 Hooks、打上副作用标记(Flags)。
  • completeWork 阶段:从下往上。

    • 核心逻辑
    function completeWork(current, workInProgress, renderLanes) {
      const newProps = workInProgress.pendingProps;
      switch (workInProgress.tag) {
        case HostComponent: // 真实 DOM 节点
          if (current !== null && workInProgress.stateNode != null) {
            // 更新模式:对比 props,记录差异
            updateHostComponent(current, workInProgress, tag, newProps);
          } else {
            // 创建模式:生成真实 DOM,并插入子节点
            const instance = createInstance(type, newProps, ...);
            appendAllChildren(instance, workInProgress);
            workInProgress.stateNode = instance;
          }
          break;
        // ... 其他类型处理
      }
    }
    
    • 任务
      • 构建离屏 DOM 树:在内存中完成 DOM 节点的创建和属性绑定。
      • 副作用冒泡 (Bubble up):将子树的所有 Flags 收集到父节点,这样 Commit 阶段只需遍历根节点的 Flags 链表。
  • 带来的性能体验: 这种双向遍历确保了 React 可以在中途暂停,并在恢复时准确知道当前处理到的位置。通过“副作用冒泡”,Commit 阶段的执行速度得到了极大的提升。

4. 双缓存 (Double Buffering) 机制

React 在内存中维护两棵 Fiber 树:current 树(屏幕显示)和 workInProgress 树(正在构建)。

  • 设计初衷
    • 避免 UI 破碎:如果直接在 current 树上修改,用户可能会看到渲染到一半的页面。
    • 极致性能:构建完成后,只需切换 FiberRoot 指针即可完成整棵树的更新,这种“内存交换”比逐个修改 DOM 节点快得多。

5. Scheduler 与时间切片

Scheduler 是 React 的心脏,负责任务的全局调度。

  • 时间切片 (Time Slicing):React 默认每 5ms 会让出一次主线程。它通过 MessageChannel 模拟宏任务。
  • 设计初衷:即使在执行极其复杂的渲染任务(如万级列表),页面依然能响应用户的点击和输入,彻底解决了 JavaScript 阻塞主线程导致的“卡死”感。

6. Lanes 优先级模型

React 17 引入了基于 31 位位掩码的 Lanes 模型。

  • 设计优势
    • 多任务并行:相比旧的 ExpirationTimeLanes 可以表示“一组”任务优先级。
    • 任务插队:React 可以准确识别出最高优先级任务,优先处理它,并将正在进行的低优先级任务挂起或废弃。

7. 合成事件 (Synthetic Events) 与批处理 (Batching)

React 并不直接使用浏览器的原生事件,而是实现了一套全平台的合成事件机制。

  • 合成事件原理:
    • 事件委派: React 17+ 将事件绑定在 root 容器上,而不是 document
    • 对象池化: (注:React 17 之后已移除池化,改为直接传递)。
    • 跨平台映射: 将不同浏览器的差异(如 transitionend, animationend)封装为统一的 API。
  • 自动批处理 (Automatic Batching):
    • 原理: React 会将多个状态更新合并为一次渲染。
    • React 18/19 的突破: 以前只有在 React 事件处理函数中才有批处理。现在,无论是在 PromisesetTimeout 还是原生事件中,所有的更新都是自动批处理的。
    • 底层实现: 通过 ExecutionContext(执行上下文)标记。当 React 发现处于“更新流程”中时,它不会立即触发渲染,而是将更新放入 UpdateQueue,等待主任务结束后一次性处理。

8. 协调 (Reconciliation) 过程深度拆解

协调是 React 区分“计算”与“渲染”的核心。

  • 阶段拆分:
    1. Render 阶段 (异步/可中断): 生成 Fiber 树,计算差异。
    2. Commit 阶段 (同步/不可中断):
      • BeforeMutation: 处理 DOM 渲染前的逻辑(如 getSnapshotBeforeUpdate)。
      • Mutation: 真正操作 DOM(增删改)。
      • Layout: 渲染后的逻辑(如 useLayoutEffect)。
  • 事务机制 (Transaction):
    • 虽然 React 源码中没有直接命名为 Transaction 的类,但其更新流程遵循典型的事务模式:performSyncWorkOnRoot 开启事务 -> 执行更新 -> commitRoot 结束事务并清理环境。

并发渲染 (Concurrent Rendering) 深度解析

并发渲染是 React 18+ 的核心能力,它改变了 React 处理更新的基础方式。

1. 传统渲染 vs 并发渲染

  • 传统渲染 (Stack Reconciler):渲染过程是同步且不可中断的。如果一个组件树很大,浏览器会一直忙于计算,无法响应用户操作。
  • 并发渲染:React 可以在渲染过程中暂停。如果用户点击了按钮,React 会暂停当前的渲染,处理点击事件,然后再恢复之前的渲染。

2. 并发特性的核心:Transitions

通过 startTransition,开发者可以告诉 React 哪些更新是“不紧急”的。

  • 应用场景:输入框打字是紧急的,下方的搜索结果列表更新是不紧急的。
  • 底层实现startTransition 会将更新标记为低优先级的 Lane,使得紧急更新(输入)可以打断它。

流式 SSR 与 Suspense 架构

React 18+ 彻底重塑了服务端渲染 (SSR) 的工作流程。

1. 传统的 SSR 瓶颈

在 React 18 之前,SSR 必须经历:

  1. 服务器拉取所有数据
  2. 生成整个 HTML
  3. 客户端下载整个 JS
  4. 整个页面进行 Hydration

任何一个环节慢了,用户都会看到白屏或无法交互。

2. 流式 SSR (Streaming SSR)

React 现在支持通过 renderToPipeableStream 将 HTML 分块发送给浏览器。

  • 结合 Suspense: 页面可以先显示外壳,耗时较长的组件(如评论列表)在服务器端准备好后再“流”向客户端,并自动插入到正确位置。
  • 选择性注水 (Selective Hydration): 用户点击了还没注水的组件时,React 会优先为该组件进行注水,提升了交互的实时性。

隐藏的宝藏:Offscreen (Activity) 模式

React 19 引入了 <Activity> 组件(实验性名称为 Offscreen API),开启了“智能预渲染”的大门。

  • 核心原理: 允许 React 在后台渲染组件树,而不将其挂载到真实的 DOM 上。
  • 运行模式:
    • hidden 模式:
      • DOM 隐藏: 组件的 DOM 节点被隐藏或不创建。
      • Effect 卸载: 所有的 useEffect 会执行 cleanup,避免后台任务占用过多资源。
      • 状态保留: 组件内部的 useStateuseReducer 状态会被完整保留。
      • 低优先级更新: 当 React 处理完所有可见任务后,会利用空闲时间悄悄更新 hidden 的树。
    • visible 模式: 组件瞬间恢复可见,useEffect 重新挂载,UI 立即同步到最新状态。
  • 优势与场景:
    • 瞬间回退 (Back Navigation): 用户点击“返回”按钮时,之前的页面可以瞬间重现,无需重新加载数据。
    • 标签页切换 (Tabs): 预先渲染非活跃的 Tab 页面,切换时零延迟。
    • 列表预加载: 当用户滚动列表时,提前渲染屏幕下方的几个节点。

性能优化进阶:Transition Tracing & Profiler

为了帮助开发者量身定制性能方案,React 19 提供了更强大的追踪工具和底层的调度观察能力。

1. Transition Tracing (过渡追踪)

允许开发者监听特定的“过渡任务”的生命周期。

  • onTransitionStart / onTransitionProgress: 可以精准监控 startTransition 开启的任务。

    import { unstable_useTransitionTracing } from 'react';
    
    function SearchPage() {
      unstable_useTransitionTracing('search-results', {
        onTransitionStart: (startTime) => {
          console.log('搜索开始', startTime);
        },
        onTransitionComplete: (endTime) => {
          console.log('搜索渲染完成', endTime);
        }
      });
      // ...
    }
    
  • 核心价值: 帮助开发者识别哪些复杂的渲染导致了 UI 的延迟,从而决定是否需要拆分组件或优化数据结构。

2. DevTools Profiler 增强

现在的 Profiler 可以清晰展示每个任务所属的 Lane(优先级级别)。

  • 优先级可视化: 开发者可以看到哪些任务是 User Blocking(用户阻塞,高优先级),哪些是 Transition(过渡,低优先级)。
  • 任务插队分析: Profiler 会标注出哪些任务是因为被更高优先级的任务“插队”而暂停的,这对于调试复杂的并发逻辑至关重要。

协调过程与 Diff 算法深度解析

协调是计算“变了什么”的过程,而 Diff 是其中的核心算法。

1. 核心策略:O(n) 复杂度

React 的 Diff 算法基于三个预设限制:

  • 同层比较:只比较同级节点,跨层级移动会被视为删除和重新创建。
  • 类型判断:如果节点类型变了,直接销毁旧树,创建新树。
  • Key 标识:通过 key 属性,开发者可以告知 React 哪些元素在不同渲染中是稳定的。

2. 多节点 Diff 的“两次遍历”

  1. 第一轮遍历:从左往右对比新旧节点。如果 keytype 都匹配,复用;否则跳出。
  2. 第二轮遍历:将剩余的旧节点放入 Map。遍历新节点时,尝试从 Map 中通过 key 寻找可复用的节点,从而高效处理位移。

Hooks 底层:基于链表的状态管理

Hooks 的状态存储在 Fiber 节点的 memoizedState 单向链表中。

1. Hook 对象结构

const hook = {
  memoizedState: null, // 存储 useState 的值、useEffect 的 effect 等
  baseState: null,
  baseQueue: null,
  queue: null,         // 状态更新队列
  next: null,          // 下一个 Hook
};

2. 闭包陷阱的本质

当 Hook 在渲染过程中被调用时,它会读取当前 Fiber 的状态。如果异步操作引用了旧的变量,而组件已经重新渲染,就会产生闭包陷阱。这正是 useEffect 依赖项数组存在的原因。


未来展望:React Compiler (React Forget)

为了进一步提升性能,Meta 正在开发 React Compiler

  • 自动记忆化: 目前开发者需要手动使用 useMemouseCallback。编译器将通过静态分析,自动插入这些优化代码。
  • 性能体验: 彻底告别手动性能优化,让 React 应用在默认情况下就拥有极致的运行效率。

总结:Meta 为什么这样设计?

Meta(原 Facebook)之所以设计这套极其复杂的源码结构,其核心目标只有一个:在保证开发者体验(DX)的同时,提供极致的用户体验(UX)。

  1. 响应性优先:通过 FiberScheduler,确保用户操作永远拥有最高优先级。
  2. 内存换速度:通过“虚拟 DOM”和“双缓存”,用内存中的对象运算来换取昂贵的真实 DOM 操作。
  3. 架构的生命力LanesConcurrent Mode 的引入,让 React 从一个简单的 UI 库进化成了一个能处理复杂调度任务的“前端操作系统”。
  4. 全栈融合:React 19 的 Server ActionsRSC 标志着 React 正在从“UI 库”向“全栈框架”迈进,试图统一前后端的开发模型

React 基础理论 & API 使用

React 基础理论 & API 使用

本文主要记录一些关于 React 的基础理论、核心概念以及常用 API 的使用方法,供查漏补缺。


原文地址

墨渊书肆/React 基础理论 & API 使用


React 简介

React 是一个由 Facebook(现在称为 Meta)开发的开源 JavaScript 库,主要用于构建用户界面,特别是单页应用程序(SPA)的开发。React 不仅限于 Web 开发,通过React Native,开发者还可以使用几乎相同的组件化开发方式来构建原生移动应用程序,实现了跨平台的代码复用。由于其灵活性和高效性,React 已成为现代 Web 开发中最受欢迎的前端库之一。

核心特点

  • 组件化编程:React 将页面和功能分解为可复用的组件,每个组件可以管理自己的状态 and 渲染逻辑,大大提高了代码的可维护性和可重用性。
  • Virtual DOM:引入虚拟 DOM 的概念,它是一个树形数据结构,用来表示真实 DOM 的抽象。当状态发生改变时,在Render阶段会先计算VDOM的最小更新,然后在Commit阶段生成真实 DOM,减少了浏览器的重排和重绘,提高了性能。
  • 声明式编程:React 使用声明式的方式定义页面的 UI 和状态逻辑,让代码更容易理解。
  • JSX:允许开发者在 JS 中混写 HTML-like 的语法,这种语法糖被称为JSX,可以更直观和简洁的描述组件的结构。
  • 单向数据流:React 应用遵循单向数据流的原则,父组件向子组件传递状态(props)和回调函数,子组件通过调用这些回调来通知父组件的状态变更,这有助于保持数据流的清晰和可预测性。这点有别于 Vue 的双向绑定。

安装

在搭建 React 框架时,我们现在通常使用目前更主流、构建速度更快的 Vite,它是现代前端开发的优选脚手架。

# 使用 Vite 创建项目
npm create vite@latest my-react-app -- --template react

如果你选择其他框架或工具链,也有对应的安装方式:

  • Next.jsnpx create-next-app@latest
  • UmiJS:使用 create-umi

组件通讯方式

在 React 中,组件间的通信主要有以下几种方式:

  1. 通过 props 向子组件传递数据:父组件通过属性将数据传递给子组件。
  2. 通过回调函数向父组件传递数据:父组件向子组件传递一个函数,子组件调用该函数并传入数据。
  3. 使用 Refs 调用子组件暴露的方法:通过 forwardRefuseImperativeHandle 钩子,父组件可以访问子组件内部定义的方法。
  4. 通过 Context 进行跨组件通信:使用 createContextuseContext 实现跨层级的状态共享。
  5. 使用状态管理库:如 ReduxMobXZustand 等进行全局状态管理。

生命周期

经典生命周期

在React 16.3之后的生命周期可以分为三个阶段:

挂载阶段(Mounting):

  • constructor: 组件实例化时调用,初始化 state 和绑定 this
  • getDerivedStateFromProps: (React 16.3新增)在组件实例被创建后续更新时被调用,用于根据 props 来计算 state
  • render: 根据 state 和 props 渲染UI到虚拟 DOM。
  • componentDidMount: 组件已经被渲染到 DOM 后调用,常用于发起网络请求、设置定时器等。

更新阶段(Updating):

  • getDerivedStateFromProps: 同挂载阶段。
  • shouldComponentUpdate: 判断是否需要更新 DOM,返回 true/false。
  • render: 状态或props改变时再次渲染 UI。
  • getSnapshotBeforeUpdate: (React 16.3新增)在 DOM 更新前调用,可以获取一些信息用于在 componentDidUpdate 中使用。
  • componentDidUpdate: 组件更新后立即调用,可以进行 DOM 操作或网络请求。

卸载阶段(Unmounting):

  • componentWillUnmount: 组件将要卸载时调用,清理工作如取消网络请求、清除定时器等。

从React 16.3开始,componentWillMount, componentWillReceiveProps, 和 componentWillUpdate 被标记为不安全的,并最终在React 17中被废弃。React推荐使用getDerivedStateFromProps和useState、useEffect等Hooks来替代。

Hooks 生命周期模拟

对于 React 函数组件,现在的实践更倾向于使用如下的 Hooks 生命周期:

  • useState: 用于组件内部状态管理。

  • useEffect: 用于处理副作用,可模拟以下生命周期:

    • 模拟挂载阶段 (componentDidMount): 依赖数组传空 []
    useEffect(() => { /* 只在挂载后执行 */ }, []);
    
    • 模拟更新阶段 (componentDidUpdate): 不传依赖数组或传入特定依赖。
    useEffect(() => { /* 每次渲染后都执行 */ });
    useEffect(() => { /* count 变化后执行 */ }, [count]);
    
    • 模拟卸载阶段 (componentWillUnmount): 在 useEffect 中返回一个清理函数。
    useEffect(() => {
      return () => { /* 组件卸载前执行 */ };
    }, []);
    
  • useContext: 用于从上下文中消费值。

  • useRef: 用于持久化一个可变的引用对象,不会引起组件重新渲染。

  • useReducer: 用于有复杂状态逻辑的组件,替代某些 useState 的使用场景。

  • useCallbackuseMemo: 用于优化性能,避免不必要的函数或计算的重新创建。

父子组件生命周期调用顺序

在函数组件中,挂载阶段的执行顺序如下:

  1. 父组件执行函数体(首次渲染)。
  2. 子组件执行函数体(首次渲染)。
  3. 子组件执行 useEffect(挂载完成)。
  4. 父组件执行 useEffect(挂载完成)。

更新阶段:

  1. 父组件重新渲染。
  2. 子组件重新渲染。
  3. 子组件 useEffect 清理函数执行。
  4. 父组件 useEffect 清理函数执行。
  5. 子组件 useEffect 执行。
  6. 父组件 useEffect 执行。

组件类 API

PureComponent

PureComponentComponent 的子类,是基于 shouldComponentUpdate 的一种优化方式。使用 PureComponent 的主要优点在于它自动执行了浅比较来检查 props 和 state 是否有变化,没有变化的时候不会重新渲染,从而提高了性能,减少了不必要的计算和 DOM 操作。

import React, { PureComponent } from 'react';

class MyComponent extends PureComponent {
  render() {
    return (
      <div>
        {this.props.text}
      </div>
    );
  }
}

export default MyComponent;

memo

React.memo 是 React 中用于函数组件的性能优化手段,它是一个高阶函数,用来包装一个函数组件,并利用引用地址比较(浅比较)来决定是否重新渲染该组件。当组件的 props 没有发生变化时(基于浅比较),则跳过重新渲染,从而提高性能。

import React, { memo } from 'react';

const MyComponent = memo((props) => {
  // 组件逻辑...
  return <div>{props.text}</div>;
});

// 自定义比较函数:可以通过传递第二个参数给memo来自定义比较逻辑,这允许你实现深度比较或其他定制化的比较策略。
const MyComponent = memo((props) => {...}, (prevProps, nextProps) => {
  // 自定义比较逻辑
  // 返回true如果 props 没有变化,无需重新渲染
  // 返回false如果 props 有变化,需要重新渲染
  return prevProps.text === nextProps.text;
});

createRef

createRef 是 React 中管理 DOM 元素或组件实例引用的一个现代、灵活的方法,有助于处理表单、动画交互、原生DOM操作等场景。

class MyComponent extends React.Component {
  myInputRef = React.createRef();

  componentDidMount() {
    // 在组件挂载后访问DOM元素
    this.myInputRef?.current?.focus();
  }

  render() {
    return <input type="text" ref={this.myInputRef} />;
  }
}

forwardRef

forwardRef 是 React 中的一个高阶组件(HOC),它允许我们将 React 的 refs 转发到被包裹的组件中,即使这个组件是一个函数组件。这在需要访问子组件的 DOM 节点或者想要从父组件传递一些引用到子组件的场景下非常有用。极大地增强了函数组件的能力,使得它们在处理需要直接操作DOM或传递引用的场景下更加灵活和强大。

import React, { forwardRef } from 'react';

// 第一个参数是React.forwardRef接收的render函数,它接收两个参数:props和ref
const MyForwardedComponent = forwardRef((props, ref) => {
  // 现在你可以在这个函数组件内部使用ref了
  return <input type="text" ref={ref} {...props} />;
});

// 使用forwardRef的组件时,可以像普通组件那样使用ref
class ParentComponent extends React.Component {
  myInputRef = React.createRef();

  focusInput = () => {
    this.myInputRef?.current?.focus();
  };

  render() {
    return (
      <>
        <MyForwardedComponent ref={this.myInputRef} />
        <button onClick={this.focusInput}>Focus Input</button>
      </>
    );
  }
}

createContext

createContext 是 React 中的一个API,用于创建一个“context”对象。Context 提供了一种在组件树中传递数据的方式,而不必显式地通过每一个层级手动传递 props。这使得在不同层级的组件中共享数据变得简单且高效,特别适合管理如主题语言设置认证信息等全局状态。

基本用法:

  • 创建 Context: createContext(defaultValue)
import React from 'react';
// 创建一个context
const MyContext = React.createContext('light');
  • Provider组件: 注入值上下文
class App extends React.Component {
  state = {
    theme: 'light',
  };

  render() {
    return (
      // 通过Provider组件向上下文中注入值
      <MyContext.Provider value={this.state.theme}>
        <ComponentThatNeedsTheContext />
      </MyContext.Provider>
    );
  }
}
  • Consumer组件: 读取上下文的方法
function ComponentThatNeedsTheContext() {
  return (
    <MyContext.Consumer>
      {theme => /* 使用theme值 */}
    </MyContext.Consumer>
  );
}

或者使用 useContext Hook:

import React, { useContext } from 'react';

function ComponentThatNeedsTheContext() {
  const theme = useContext(MyContext);
  // 现在可以使用theme值
  return <div>{theme}</div>;
}

注意事项:

Context 会随着组件树的遍历而传递,无论组件是否使用了这个 Context。因此,应当谨慎使用,避免创建过多的 Context,尤其是嵌套使用时。

createElement

createElement 在我们的平时使用中较少,它用于创建 React 元素,是构成用户界面的基本单位,我们常写的 JSX 就是 createElement 的语法糖,所以还是很有必要了解这个 API 的。

基本语法:

React.createElement(
  type, // 通常是一个字符串(对应HTML标签名)或一个React组件(函数组件或类组件的构造函数)。
  [props], // 一个对象,用于传递给组件的属性。它可以包含事件处理器、样式等。
  [...children] // 代表组件的子元素,可以是一个React元素、字符串或数字,也可以是这些类型的数组。
)

示例:

const element = React.createElement(
  'div',
  { id: 'example', className: 'box' },
  'Hello, world!'
);

这段代码等同于下面的JSX写法:

<div id="example" className="box">
  Hello, world!
</div>

cloneElement

cloneElement 是 React 提供的一个方法,用于克隆并返回一个新的 React 元素,同时可以修改传入元素的 props,甚至可以添加或替换子元素。这个方法常用于在高阶组件中,或者任何需要基于现有元素创建一个具有额外 props 或不同子元素的新元素的场景。

基本语法:

React.cloneElement(
  element, // 要克隆的React元素
  [props], // 一个对象,包含了要添加或覆盖到原始元素props上的新属性
  [...children] // 可选的,用于替换或追加子元素到克隆后的元素中
)

自定义 HOC

高阶组件(Higher-Order Components, HOC)是 React 中用于重用组件逻辑的一种高级技术。HOC 本身不是 React API 的一部分,而是一种从函数式编程原则中借来的模式。一个 HOC 是一个接受组件作为参数并返回一个新的增强组件的函数。

function withEnhancement(WrappedComponent) {
  return function EnhancedComponent(props) {
    // 添加额外的props或逻辑
    const newProps = { ...props, enhancedProp: "Enhanced Value" };
    
    // 渲染被包装的组件,并传递新的props
    return <WrappedComponent {...newProps} />;
  };
}

注意事项:

  • 不要修改传入组件的props: 最好是通过组合新的 props 而不是修改原有的 props来保持纯净性。
  • 命名约定: 通常 HOC 函数名以 with 开头,以表明它是一个 HOC。
  • 文档和测试: 编写清晰的文档说明 HOC 的功能和用法,并确保充分测试,以防止引入bug。

Hooks

React Hooks 是React 16.8版本引入的一个新特性,在不编写类的情况下使用 React 的状态和其他生命周期特性。Hooks 使函数组件的功能更加丰富,使得函数组件逻辑更易于理解和重用。

useState

允许在函数组件中添加状态(state)。它返回一个状态变量和一个用来更新这个状态的函数。

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

useEffect

useEffect 是 React Hooks 系统中的一个重要成员,它主要用于执行副作用操作,比如数据获取、订阅或者手动修改 DOM 等。此 Hook 允许你同步副作用与 React 组件的生命周期,替代了类组件中的一些生命周期方法,如 componentDidMountcomponentDidUpdatecomponentWillUnmount

// useEffect 接收两个参数:一个包含副作用操作的函数,和一个依赖项数组
useEffect(() => {
  // 副作用操作:订阅或数据获取等
  document.title = `You clicked ${count} times`;

  // 可选的清理函数,用于在下次effect执行前或组件卸载时清理副作用
  return () => {
    // 清理操作,例如取消网络请求或移除事件监听器
  };
}, [count]); // 依赖项数组,当这些值变化时触发effect重新执行

useContext

useContext 是 React Hooks 系统中的一个 API,它使你能够在组件树中无需通过 props 逐层传递,就能访问到全局状态或其他组件上下文中的值。这对于管理如主题、语言、认证信息等跨多个组件共享的数据尤为有用。

import React, { useContext } from 'react';

function ComponentThatNeedsTheContext() {
  const theme = useContext(MyContext);
  // 现在可以使用theme值
  return <div>{theme}</div>;
}

useRef

useRef 是 React Hooks 系统中的一个API,它用于创建一个可变的引用对象(ref),这个对象的.current属性被初始化为传递的参数(initialValue)。useRef的主要用途是在渲染之间持久化一个可变的值,并且可以用来直接访问 DOM 元素或在函数组件之间保持一些状态。

import React, { useRef } from 'react';

function TextInputWithFocusButton() {
  // 初始化一个ref,用来存放input元素的引用
  const inputEl = useRef(null);

  const onButtonClick = () => {
    // 当按钮被点击时,让input元素获取焦点
    inputEl?.current?.focus();
  };

  return (
    <>
      {/* 将input元素的引用赋给useRef返回的对象 */}
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useReducer

useReducer 是React中的一个Hook,它用于管理组件中的状态,特别适用于状态更新逻辑较复杂的场景。

基本用法:

import React, { useReducer } from 'react';

// 定义reducer函数
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
};

// 初始化状态
const initialState = { count: 0 };

function Counter() {
  // 使用useReducer,传入reducer函数和初始状态
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </div>
  );
}

注意事项:

  • 确保reducer函数是纯函数,即给定相同输入始终产生相同输出,不产生副作用。
  • 选择合适的状态管理方式,对于简单的状态管理,useState可能更直观易用。
  • 利用useCallback来记忆化dispatch函数,避免在每个渲染周期都创建新的函数引用,进而减少不必要的子组件重渲染。

useMemo

useMemo 是React中的一个Hook,用于优化性能,避免在每次渲染时都进行复杂的计算。它让你能够 memoize(记忆化)一个值,这个值是基于某些依赖项计算出来的,只有当这些依赖项改变时,才会重新计算这个值。

基本用法:

import React, { useMemo } from 'react';

function MyComponent({ list }) {
  // 使用useMemo进行性能优化
  const sortedList = useMemo(() => {
    console.log('Sorting list');
    return list.sort((a, b) => a - b);
  }, [list]); // 依赖项数组,当list变化时才重新计算sortedList

  return (
    <div>
      {sortedList.map(item => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
}

注意事项:

  • 不要过度使用: 虽然useMemo可以帮助优化性能,但是不必要的使用反而可能导致额外的性能开销,特别是在计算简单或频繁变化的值时。
  • 理解其限制: useMemo不会阻止其依赖项内的对象或数组的内部变化触发重渲染。只有当依赖项的引用本身发生变化时,才会触发重计算。
  • 与React.memo区别: React.memo是一个高阶组件,用于记忆化整个组件,防止不必要的渲染,而useMemo是记忆化组件内部的某个值或计算结果。

useCallback

useCallback 是 React中 的另一个性能优化 Hook,它用于记忆化函数。与 useMemo 相似,useCallback 也用于避免在每次渲染时都进行新的函数引用,但它的主要应用场景是当这些函数作为 props 传递给子组件时,帮助子组件避免不必要的重新渲染。

import React, { useCallback, useState } from 'react';

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

  // 使用useCallback记忆化increment函数
  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count, setCount]); // 依赖项数组,当这些值变化时,才会生成新的increment函数

  return <ChildComponent onClick={increment} />;
}

function ChildComponent({ onClick }) {
  // ...
}

注意事项:

  • 与useMemo的区别: useMemo 适用于记忆化计算值或对象,而 useCallback 专门用于记忆化函数。
  • 避免闭包陷阱: 在使用 useCallback 时,需要注意函数内部引用的外部变量也应包含在依赖项数组中,以确保正确的重渲染逻辑。

ts随笔:面向对象与高级类型

ts随笔:面向对象与高级类型

本篇主要聚焦在类、模块、高级类型以及在常见前端框架中的实践,同时结合生态中新出现的一些特性,如何自然地用上这些新能力。


原文地址

墨渊书肆/ts随笔:面向对象与高级类型


类(Class)

类是面向对象编程的基础,用于创建具有属性(数据成员)和方法(成员函数)的对象的蓝图。TypeScript 中的类支持 继承封装多态 等面向对象特性。

基本语法

class Person {
  name: string;
  age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  greet() {
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
  }
}

const person = new Person("Alice", 30);
person.greet();

在较新的 TypeScript 版本中,你也可以结合 ECMAScript 的 私有字段 语法(以 # 开头),在保持类型安全的同时实现更彻底的封装:

class Counter {
  #value = 0;

  increment() {
    this.#value++;
  }

  get value(): number {
    return this.#value;
  }
}

继承

class Student extends Person {
  studentId: string;

  constructor(name: string, age: number, studentId: string) {
    super(name, age);
    this.studentId = studentId;
  }

  study() {
    console.log(`${this.name} is studying.`);
  }
}

const student = new Student("Bob", 20, "S123");
student.greet();
student.study();

借助 TypeScript 的严格类型系统,继承关系中的属性和方法都会得到完整的类型检查支持,在重写方法时也能获得参数和返回值的约束。

模块(Module)

模块是用于组织代码的容器,它允许你将相关联的类、接口、函数等封装在一个单独的文件中,并可以控制它们的可见性(导出/导入)。模块有助于避免命名冲突和促进代码的复用。

导入与导出

// moduleA.ts
export class MyClass {
  // ...
}

// 在其他文件中使用导出的元素
import { MyClass } from "./moduleA";

const myInstance = new MyClass();

默认导出命名导出 可以混合使用,但在一个模块中只能有一个默认导出;命名导出则可以有多个。

命名空间与模块的异同

在早期版本的 TypeScript 中,命名空间(Namespace)是另一种组织代码的方式,它类似于 C# 或 Java 中的包,提供了一种分层次的方式来组织代码。虽然模块现在是推荐的做法,但命名空间仍然可用,特别是在需要合并多个文件定义的命名空间时。

面向未来的模块特性:JSON 模块import defer

从 ES2025 开始,JSON 模块 等特性有望在主流环境中稳定可用,你可以直接以模块的方式导入 JSON 文件,并配合 TypeScript 的类型系统进行约束:

// config.json
// {
//   "apiBaseUrl": "https://api.example.com",
//   "featureFlags": {
//     "newUI": true
//   }
// }

interface FeatureFlags {
  newUI: boolean;
}

interface AppConfig {
  apiBaseUrl: string;
  featureFlags: FeatureFlags;
}

// 在支持 JSON 模块的环境下
import configJson from "./config.json" with { type: "json" };

const config = configJson as AppConfig;

在 ES2026 及之后,import defer 等语法提案逐步成熟时,可以在保持语义清晰的前提下延迟加载非关键模块,而 TypeScript 依然会对导入的符号进行完整的类型检查:

// 伪代码示意:具体语法以最终标准为准
// import defer "./heavy-analytics.js";

// TypeScript 关注的是导出的类型本身,只要声明文件同步更新,
// 即使底层加载时机发生变化,类型系统仍然保持稳定。

和上一篇中提到的声明文件一样,这些新的模块特性最终都会通过 .d.ts 的方式落地到 TypeScript 生态中。

高级类型探索

泛型 Generics

泛型(Generics)是 TypeScript 中一个强大的特性,它允许你在定义函数、接口或类的时候不预先指定具体的类型,而是将类型作为参数传递。

基本概念

泛型的核心在于使用类型变量(通常用大写字母表示,如 T、U 等)来代表一些未知的类型。当使用这个组件时,你再指定这些类型变量的具体类型。

泛型函数
function identity<T>(arg: T): T {
  return arg;
}

let output = identity<string>("hello");
console.log(output);

let numberOutput = identity<number>(123);
console.log(numberOutput);
泛型接口
interface Pair<T> {
  first: T;
  second: T;
}

let pairStr: Pair<string> = { first: "hello", second: "world" };
let pairNum: Pair<number> = { first: 1, second: 2 };
泛型类
class Box<T> {
  private containedValue: T;

  set(value: T) {
    this.containedValue = value;
  }

  get(): T {
    return this.containedValue;
  }
}

let boxStr = new Box<string>();
boxStr.set("hello");
console.log(boxStr.get());

let boxNum = new Box<number>();
boxNum.set(123);
console.log(boxNum.get());
泛型约束

有时候,你可能需要限制可以作为类型参数的具体类型,这时候可以使用泛型约束。泛型约束通过接口来定义,要求传入的类型必须满足该接口定义的条件。

interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

loggingIdentity({ length: 10, value: "test" });
// loggingIdentity(123); // 错误:number 没有 length 属性

联合类型 Union Types

联合类型 允许一个变量可能是多种类型之一。例如,你可以定义一个变量既可能是字符串也可能是数字:

let myValue: string | number;
myValue = "Hello";
myValue = 42;

类型守卫 Type Guards

当你在操作联合类型的变量时,TypeScript 可能无法确定变量的具体类型,这会影响到你能够调用的方法或访问的属性。类型守卫 就是用来缩小类型范围,确保在运行时变量属于某种特定类型。

typeof 类型守卫
if (typeof myValue === "string") {
  console.log(myValue.toUpperCase());
} else {
  console.log(myValue.toFixed(2));
}
instanceof 类型守卫
class Animal {}

class Dog extends Animal {
  bark() {
    console.log("Woof!");
  }
}

function isDog(animal: Animal): animal is Dog {
  return animal instanceof Dog;
}

let pet = new Dog();
if (isDog(pet)) {
  pet.bark();
}
in 操作符
interface Cat {
  meow: () => void;
}

function makeSound(animal: Animal | Cat) {
  if ("meow" in animal) {
    animal.meow();
  } else {
    console.log(animal.toString());
  }
}

Iterator Helpers 与 Set 扩展下的类型推断

在 ES2025、ES2026 相关提案中,Iterator HelpersSet 扩展 是非常值得关注的一类特性:它们让各种可迭代对象(包括数组、Set、Map 的键值迭代器等)拥有类似链式操作的能力。

当对应的类型定义进入 TypeScript 之后,可以配合泛型和类型守卫写出既简洁又安全的代码。例如,以 Set 扩展为例:

// 假设运行时与 TypeScript lib 均已支持 Set 的扩展方法
const ids = new Set([1, 2, 3, 4, 5]);

// filter 返回的仍然是 Set<number>,类型信息由泛型推断而来
// const evenIds = ids.filter((id) => id % 2 === 0);

// map 等其他 Iterator Helpers 也同理可以得到明确的类型
// const idStrings = ids.map((id) => `id-${id}`);

虽然上面的代码在当前某些环境中还处于“提案阶段”,但可以预期的是,未来在 TypeScript 中使用这些 API 时,你同样能获得完整的泛型推断和类型守卫支持。

日期时间与本地化:TemporalIntl.Locale

时间与本地化一直是前端开发中的老大难问题。Temporal 和 Intl.Locale 等提案正是为了解决 Date 语义不清、Intl 配置复杂等问题。

Temporal 定稿并进入主流运行时时,你可以在 TypeScript 中这样书写代码:

// 假设 lib 已经包含 Temporal 与最新的 Intl 声明
// const now: Temporal.ZonedDateTime = Temporal.Now.zonedDateTimeISO();
// const locale = new Intl.Locale("zh-CN", { calendar: "gregory" });

// console.log(now.toLocaleString(locale.toString()));

这些 API 本身是 JavaScript 语言层面的特性,但它们的类型声明会第一时间进入 TypeScript 官方声明文件,从而让我们在使用它们时也能享受完整的类型推断、自动补全和错误检查。

ts 在 React 中的使用

新项目使用 create-react-app 接入

npx create-react-app my-app --template typescript

React 老项目接入

首先安装 @types/react@types/react-dom 这些 React 的类型定义文件:

npm install --save-dev @types/react @types/react-dom

然后将 .js 文件逐步转换为 .tsx(TypeScript 支持 JSX 的文件扩展名)并添加类型注释。

React 代码编写

import React, { useState } from "react";

interface Props {
  name: string;
}

const Hello: React.FC<Props> = ({ name }) => {
  const [message, setMessage] = useState<string>("Hello");

  return (
    <div>
      <h1>{`${message}, ${name}!`}</h1>
      <button onClick={() => setMessage("Welcome")}>Change Message</button>
    </div>
  );
};

export default Hello;

在较新的 TypeScript 与 React 生态中,配合前面提到的 JSON 模块Iterator HelpersTemporal 等能力,你可以更放心地在组件中使用这些新特性——只要升级依赖并确保声明文件同步更新,编辑器就会用类型系统帮你“兜住”大部分错误。

ts 在 Vue 3 中的使用

新项目使用 Vue CLI 接入

vue create my-vue3-project --preset typescript

Vue 老项目接入

vue add typescript

Vue 代码编写

<script lang="ts">
import { defineComponent, ref, reactive } from "vue";

interface Props {
  msg: string;
}

export default defineComponent({
  props: {
    msg: String,
  },
  setup(props: Props) {
    const count = ref(0);
    const state = reactive({ status: "active" });

    // 在这里同样可以安心地使用前文提到的高级类型、
    // Iterator Helpers 或 Temporal 等能力,TypeScript
    // 会在编译阶段帮你把控类型安全。

    return {
      count,
      state,
    };
  },
});
</script>

无论是 React 还是 Vue,TypeScript 都会继续扮演“粘合剂”的角色:只要按需升级依赖、合理配置 tsconfig,就能够在习惯的写法下自然享受到这些新特性带来的收益。

从 Vue2 到 Vue3:语法差异与迁移时最容易懵的点

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、为什么要写这篇文章?

Vue3 已经是官方默认推荐版本,但很多团队的存量项目仍然在 Vue2 上跑。即便你已经开始用 Vue3 了,也很可能是"Options API 的写法 + <script setup> 的壳"——形式换了,思维没换。

这篇文章不讲玄学的底层原理,只讲一个核心问题

日常写代码到底该怎么选、为什么这么选、踩坑会踩在哪?

我们会把 Vue2 的 data / props / computed / methods / watch / 生命周期 和 Vue3 的 Composition API 做一次逐项对照,每一项都给出完整的代码示例和踩坑说明。

二、先建立一个全局视角:Options API vs Composition API

在动手对比之前,先花 30 秒看一张对照表,心里有个全貌:

关注点 Vue2(Options API) Vue3(Composition API / <script setup>
响应式数据 data() ref() / reactive()
接收外部参数 props 选项 defineProps()
计算属性 computed 选项 computed() 函数
方法 methods 选项 普通函数声明
侦听器 watch 选项 watch() / watchEffect()
生命周期 created / mounted … onMounted / onUnmounted …
模板访问 this.xxx 直接用变量名(<script setup> 自动暴露)

一句话总结:Vue2 按"选项类型"组织代码(数据放一块、方法放一块);Vue3 按"逻辑关注点"组织代码(一个功能的数据+方法+侦听可以放在一起)。

三、逐项对比 + 完整示例 + 踩坑点

3.1 响应式数据:data()ref() / reactive()

Vue2 写法

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ user.name }} - {{ user.age }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
      user: {
        name: '张三',
        age: 25
      }
    }
  },
  methods: {
    add() {
      this.count++
      this.user.age++
    }
  }
}
</script>

Vue2 里一切都挂在 this 上,data() 返回的对象会被 Vue 内部用 Object.defineProperty 做递归劫持,所以你只要 this.count++,视图就会更新。简单粗暴,上手友好。

Vue3 写法(<script setup>

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ user.name }} - {{ user.age }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

// 基本类型 → 用 ref
const count = ref(0)

// 对象类型 → 用 reactive
const user = reactive({
  name: '张三',
  age: 25
})

function add() {
  count.value++   // ← 注意:ref 在 JS 里要 .value
  user.age++      // ← reactive 对象不需要 .value
}
</script>

踩坑重灾区

坑 1:ref.value 到底什么时候要加?

这是从 Vue2 转过来最高频的困惑,记住一个口诀:

模板里不加,JS 里要加。

<template>
  <!-- 模板中直接用,Vue 会自动解包 -->
  <p>{{ count }}</p>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)

// JS 中必须 .value
console.log(count.value) // 0
count.value++
</script>

为什么模板里不用加?因为 Vue 的模板编译器遇到 ref 时会自动帮你插入 .value,这是编译期的语法糖。但在 <script> 里你是在写原生 JS,Vue 管不到,所以必须手动 .value

坑 2:refreactive 到底选哪个?

这是社区吵了很久的问题。我的实战建议(也是 Vue 官方文档推荐的倾向):

场景 推荐 原因
基本类型(number / string / boolean) ref() reactive() 不支持基本类型
对象/数组,且不会被整体替换 reactive() 不用到处写 .value,更清爽
对象/数组,但可能被整体替换 ref() reactive() 整体替换会丢失响应性
拿不准的时候 ref() 全部用 ref 不会出错,reactive 有限制

坑 3:reactive 的解构陷阱 —— 这个真的会坑到你

<script setup>
import { reactive } from 'vue'

const user = reactive({ name: '张三', age: 25 })

// ❌ 错误:解构后变量失去响应性!
let { name, age } = user
age++  // 视图不会更新,因为 age 现在只是一个普通的数字 25

// ✅ 正确做法1:不解构,直接用
user.age++

// ✅ 正确做法2:用 toRefs 解构
import { toRefs } from 'vue'
const { name: nameRef, age: ageRef } = toRefs(user)
ageRef.value++  // 视图会更新(注意变成了 ref,需要 .value)
</script>

为什么会这样?因为 reactive 的响应性是挂在对象的属性访问上的(基于 Proxy),一旦你把属性值解构出来赋给一个新变量,那个新变量只是一个普通的 JS 值,和原来的 Proxy 对象已经没有关系了。

坑 4:reactive 整体替换会丢失响应性

<script setup>
import { reactive, ref } from 'vue'

let state = reactive({ list: [1, 2, 3] })

// ❌ 错误:整体替换,模板拿到的还是旧的那个对象
state = reactive({ list: [4, 5, 6] })  
// 此时模板绑定的引用还指向旧对象,视图不会更新

// ✅ 正确做法1:修改属性而不是替换对象
state.list = [4, 5, 6]  // 这样是OK的

// ✅ 正确做法2:需要整体替换的场景,改用 ref
const state2 = ref({ list: [1, 2, 3] })
state2.value = { list: [4, 5, 6] }  // 没问题,视图正常更新
</script>

这也是我建议"拿不准就用 ref"的原因——ref 不存在这个问题,因为你永远是通过 .value 赋值,Vue 能追踪到。


3.2 Props:props 选项defineProps()

Vue2 写法

<!-- 子组件 UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p>是否VIP:{{ isVip ? '是' : '否' }}</p>
  </div>
</template>

<script>
export default {
  props: {
    name: {
      type: String,
      required: true
    },
    age: {
      type: Number,
      default: 18
    },
    isVip: {
      type: Boolean,
      default: false
    }
  },
  mounted() {
    // 通过 this 访问
    console.log(this.name, this.age)
  }
}
</script>
<!-- 父组件中使用 -->
<UserCard name="李四" :age="30" is-vip />

Vue3 写法(<script setup>

<!-- 子组件 UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p>是否VIP:{{ isVip ? '是' : '否' }}</p>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'

// defineProps 是编译器宏,不需要 import
const props = defineProps({
  name: {
    type: String,
    required: true
  },
  age: {
    type: Number,
    default: 18
  },
  isVip: {
    type: Boolean,
    default: false
  }
})

onMounted(() => {
  // 不再有 this,直接用 props 对象
  console.log(props.name, props.age)
})
</script>

如果你用 TypeScript,还可以用纯类型声明的写法,更加简洁:

<script setup lang="ts">
interface Props {
  name: string
  age?: number
  isVip?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  age: 18,
  isVip: false
})
</script>

踩坑重灾区

坑 1:defineProps 不需要 import,但 IDE 可能会报红

definePropsdefineEmitsdefineExpose 这些都是编译器宏(compiler macro),在编译阶段就被处理掉了,运行时并不存在。所以不需要 import

如果你的 ESLint 报 'defineProps' is not defined,那是 ESLint 配置问题,需要在 .eslintrc 里配置:

// .eslintrc.js
module.exports = {
  env: {
    'vue/setup-compiler-macros': true
  }
}

或者升级到较新版本的 eslint-plugin-vue(v9+),它默认已经支持了。

坑 2:Props 解构也会丢失响应性(Vue 3.2 及以前)

<script setup>
const props = defineProps({ count: Number })

// ❌ Vue 3.2及以前:解构会丢失响应性
const { count } = props  // count 变成普通值,父组件更新后这里不会变

// ✅ 保持响应性的做法
import { toRefs } from 'vue'
const { count: countRef } = toRefs(props)
// 或者直接用 props.count
</script>

好消息:Vue 3.5+ 引入了响应式 Props 解构(Reactive Props Destructure),如果你的项目版本够新,可以直接解构:

<script setup>
// Vue 3.5+ 可以直接解构,自动保持响应性
const { count = 0 } = defineProps({ count: Number })
// count 是响应式的,可以直接在模板中用
</script>

但如果你的项目还在 3.4 或更早版本上,老老实实用 props.counttoRefs 是最稳的。


3.3 Computed:computed 选项computed() 函数

Vue2 写法

<template>
  <div>
    <p>原价:{{ price }} 元</p>
    <p>折后价:{{ discountedPrice }} 元</p>
    <input v-model="fullName" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      price: 100,
      discount: 0.8,
      firstName: '张',
      lastName: '三'
    }
  },
  computed: {
    // 只读计算属性
    discountedPrice() {
      return (this.price * this.discount).toFixed(2)
    },
    // 可读可写计算属性
    fullName: {
      get() {
        return this.firstName + this.lastName
      },
      set(val) {
        // 假设第一个字是姓,后面是名
        this.firstName = val.charAt(0)
        this.lastName = val.slice(1)
      }
    }
  }
}
</script>

Vue3 写法

<template>
  <div>
    <p>原价:{{ price }} 元</p>
    <p>折后价:{{ discountedPrice }} 元</p>
    <input v-model="fullName" />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const price = ref(100)
const discount = ref(0.8)
const firstName = ref('张')
const lastName = ref('三')

// 只读计算属性 —— 传一个 getter 函数
const discountedPrice = computed(() => {
  return (price.value * discount.value).toFixed(2)
})

// 可读可写计算属性 —— 传一个对象
const fullName = computed({
  get: () => firstName.value + lastName.value,
  set: (val) => {
    firstName.value = val.charAt(0)
    lastName.value = val.slice(1)
  }
})
</script>

踩坑重灾区

坑 1:computed 里千万别做"副作用"操作

这条 Vue2 和 Vue3 都一样,但很多人还是会犯:

// ❌ 错误示范:在 computed 里修改别的状态、发请求、操作 DOM
const total = computed(() => {
  otherState.value = 'changed'  // 副作用!
  fetch('/api/log')             // 副作用!
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

// ✅ computed 应该是纯函数,只根据依赖算出一个值
const total = computed(() => {
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

computed 的设计初衷就是"根据已有状态派生出新状态",它有缓存机制——只有依赖变了才重新计算。如果你往里面塞副作用,会导致不可预测的执行时机和执行次数。

坑 2:别把 computed 和 methods 搞混了

Vue2 老手可能觉得"computed 和 method 返回的值不是一样吗",但核心区别是缓存

<script setup>
import { ref, computed } from 'vue'

const list = ref([1, 2, 3, 4, 5])

// computed:有缓存,list 不变就不会重新执行
const total = computed(() => {
  console.log('computed 执行了')
  return list.value.reduce((a, b) => a + b, 0)
})

// 普通函数:每次模板渲染都会重新执行
function getTotal() {
  console.log('function 执行了')
  return list.value.reduce((a, b) => a + b, 0)
}
</script>

<template>
  <!-- 假设模板里用了3次 -->
  <p>{{ total }} {{ total }} {{ total }}</p>
  <!-- computed 只会打印1次 log,函数会打印3次 -->
  <p>{{ getTotal() }} {{ getTotal() }} {{ getTotal() }}</p>
</template>

结论:需要缓存、依赖响应式数据派生值的用 computed;需要执行某个动作(点击事件等)的用普通函数。


3.4 Methods:methods 选项 → 普通函数

Vue2 写法

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="incrementBy(5)">+5</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    },
    incrementBy(n) {
      this.count += n
    },
    reset() {
      this.count = 0
      this.logAction('reset')  // 方法之间互相调用
    },
    logAction(action) {
      console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
    }
  }
}
</script>

Vue2 的 methods 是一个选项对象,所有方法平铺在里面,互相调用要通过 this

Vue3 写法

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="incrementBy(5)">+5</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

function incrementBy(n) {
  count.value += n
}

function logAction(action) {
  console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
}

function reset() {
  count.value = 0
  logAction('reset')  // 直接调用,不需要 this
}
</script>

关键差异说明

Vue3 里没有 methods 这个概念了——就是普通的 JavaScript 函数。在 <script setup> 中声明的函数会自动暴露给模板,不需要额外 return。

这带来几个实质性的好处:

  1. 不再需要 this:函数直接闭包引用变量,没有 this 指向问题
  2. 可以用箭头函数:Vue2 的 methods 里不建议用箭头函数(会导致 this 指向错误),Vue3 随意用
  3. 方法可以和相关数据放在一起:不用再在 datamethods 之间跳来跳去
<script setup>
import { ref } from 'vue'

// ———— 计数器相关逻辑 ————
const count = ref(0)
const increment = () => count.value++  // 箭头函数完全OK
const reset = () => (count.value = 0)

// ———— 用户信息相关逻辑 ————
const username = ref('')
const updateUsername = (name) => (username.value = name)
</script>

看到没?数据和操作数据的方法紧挨在一起,按"功能"而不是按"类型"组织。这就是 Composition API 的核心思想——当组件逻辑复杂的时候,不用在 datacomputedmethodswatch 之间反复横跳。


3.5 Watch:watch 选项watch() / watchEffect()

Vue2 写法

<script>
export default {
  data() {
    return {
      keyword: '',
      user: { name: '张三', age: 25 }
    }
  },
  watch: {
    // 基础用法
    keyword(newVal, oldVal) {
      console.log(`搜索词变了:${oldVal} → ${newVal}`)
      this.doSearch(newVal)
    },
    // 深度侦听
    user: {
      handler(newVal) {
        console.log('user 变了', newVal)
      },
      deep: true,
      immediate: true  // 创建时立即执行一次
    }
  },
  methods: {
    doSearch(kw) { /* ... */ }
  }
}
</script>

Vue3 写法

<script setup>
import { ref, reactive, watch, watchEffect } from 'vue'

const keyword = ref('')
const user = reactive({ name: '张三', age: 25 })

// ——— watch:和 Vue2 类似,显式指定侦听源 ———

// 侦听 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`搜索词变了:${oldVal} → ${newVal}`)
  doSearch(newVal)
})

// 侦听 reactive 对象的某个属性(注意:要用 getter 函数)
watch(
  () => user.age,
  (newAge, oldAge) => {
    console.log(`年龄变了:${oldAge} → ${newAge}`)
  }
)

// 侦听整个 reactive 对象(自动深度侦听)
watch(user, (newVal) => {
  console.log('user 变了', newVal)
})

// 加选项:立即执行
watch(keyword, (newVal) => {
  doSearch(newVal)
}, { immediate: true })

// ——— watchEffect:自动收集依赖,不用指定侦听源 ———
watchEffect(() => {
  // 回调里用到了哪些响应式数据,就自动侦听哪些
  console.log(`当前搜索词:${keyword.value},用户:${user.name}`)
})

function doSearch(kw) { /* ... */ }
</script>

watch vs watchEffect 怎么选?

特性 watch watchEffect
需要指定侦听源 否(自动收集依赖)
能拿到 oldValue 不能
默认是否立即执行 否(可设 immediate: true 是(创建时立即执行一次)
适合场景 需要精确控制"侦听谁"、需要新旧值对比 "用到啥就侦听啥",简化写法

我的实战建议:大多数场景用 watch,因为它意图更明确——看代码就知道你在侦听什么。watchEffect 适合那种"把几个数据凑一起做点事、不关心谁变了"的简单场景。

踩坑重灾区

坑 1:侦听 reactive 对象的属性,必须用 getter 函数

const user = reactive({ name: '张三', age: 25 })

// ❌ 错误:直接写 user.age,这只是传了个数字 25 进去
watch(user.age, (val) => { /* 永远不会触发 */ })

// ✅ 正确:传一个 getter 函数
watch(() => user.age, (val) => { console.log(val) })

原因很简单:user.age 在传参时就已经求值了,得到数字 25——一个普通的数字不是响应式的,Vue 没法侦听它。用 () => user.age 则是传了一个函数,Vue 每次执行这个函数时都会触发 Proxy 的 get 拦截,从而建立依赖追踪。

坑 2:watch 的清理——组件卸载后还在跑?

// 在 <script setup> 顶层调用的 watch 会自动与组件绑定
// 组件卸载时自动停止,不用手动处理
watch(keyword, (val) => { /* ... */ })

// 但如果你在异步回调或条件语句里创建 watch,就需要手动停止
let stop
setTimeout(() => {
  stop = watch(keyword, (val) => { /* ... */ })
}, 1000)

// 需要停止时调用
// stop()
</script>

3.6 生命周期:选项式 → 组合式

对照表

Vue2(Options API) Vue3(Composition API) 说明
beforeCreate 不需要(setup 本身就是) <script setup> 的代码就运行在这个时机
created 不需要(setup 本身就是) 同上
beforeMount onBeforeMount() DOM 挂载前
mounted onMounted() DOM 挂载后
beforeUpdate onBeforeUpdate() 数据变了、DOM 更新前
updated onUpdated() DOM 更新后
beforeDestroy onBeforeUnmount() 卸载前(注意改名了!)
destroyed onUnmounted() 卸载后(注意改名了!)

完整示例

<!-- Vue2 -->
<script>
export default {
  data() {
    return { timer: null }
  },
  created() {
    console.log('created: 可以访问数据了')
    this.fetchData()
  },
  mounted() {
    console.log('mounted: DOM 准备好了')
    this.timer = setInterval(() => {
      console.log('tick')
    }, 1000)
  },
  beforeDestroy() {
    clearInterval(this.timer)
    console.log('beforeDestroy: 清理定时器')
  }
}
</script>
<!-- Vue3 -->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const timer = ref(null)

// <script setup> 中的顶层代码 ≈ created
console.log('setup: 可以访问数据了')
fetchData()

onMounted(() => {
  console.log('onMounted: DOM 准备好了')
  timer.value = setInterval(() => {
    console.log('tick')
  }, 1000)
})

onBeforeUnmount(() => {
  clearInterval(timer.value)
  console.log('onBeforeUnmount: 清理定时器')
})

async function fetchData() { /* ... */ }
</script>

踩坑重灾区

坑 1:beforeDestroyonBeforeUnmount,名字改了!

Vue3 把 destroy 相关的钩子全部改名为 unmount

  • beforeDestroyonBeforeUnmount
  • destroyedonUnmounted

如果你用 Options API 写 Vue3 组件(是的,Vue3 也支持 Options API),那对应的选项名也变了:beforeUnmountunmounted

坑 2:不要在 setup 顶层做 DOM 操作

<script setup>
// ❌ 这里 DOM 还没挂载!
document.querySelector('.my-el')  // null

// ✅ DOM 操作要放到 onMounted 里
import { onMounted } from 'vue'
onMounted(() => {
  document.querySelector('.my-el')  // OK
})
</script>

<script setup> 的顶层代码执行时机等同于 beforeCreate + created,这时候 DOM 还不存在。


3.7 Emits:this.$emit()defineEmits()

Vue2 写法

<!-- 子组件 -->
<script>
export default {
  methods: {
    handleClick() {
      this.$emit('update', { id: 1, name: '新名称' })
      this.$emit('close')
    }
  }
}
</script>

<!-- 父组件 -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 写法

<!-- 子组件 -->
<script setup>
const emit = defineEmits(['update', 'close'])

// 或者带类型校验(TypeScript)
// const emit = defineEmits<{
//   (e: 'update', payload: { id: number; name: string }): void
//   (e: 'close'): void
// }>()

function handleClick() {
  emit('update', { id: 1, name: '新名称' })
  emit('close')
}
</script>

<!-- 父组件(用法不变) -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 要求显式声明组件会触发哪些事件。这不仅仅是规范,还有一个实际好处:Vue3 会把未声明的事件名当作原生 DOM 事件处理。如果你不声明 emits,给组件绑定 @click,这个 click 会直接穿透到子组件的根元素上。

四、一个完整的实战对比:Todo List

最后,用一个麻雀虽小五脏俱全的 Todo List,把上面所有知识点串起来。

Vue2 版本

<template>
  <div class="todo-app">
    <h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
    <div class="input-bar">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入待办事项..."
      />
      <button @click="addTodo" :disabled="!canAdd">添加</button>
    </div>
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>
    <div class="filters">
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      newTodo: '',
      nextId: 1,
      filter: 'all',
      todos: []
    }
  },
  computed: {
    canAdd() {
      return this.newTodo.trim().length > 0
    },
    activeCount() {
      return this.todos.filter(t => !t.done).length
    },
    filteredTodos() {
      if (this.filter === 'active') return this.todos.filter(t => !t.done)
      if (this.filter === 'completed') return this.todos.filter(t => t.done)
      return this.todos
    }
  },
  watch: {
    todos: {
      handler(newTodos) {
        localStorage.setItem('todos', JSON.stringify(newTodos))
      },
      deep: true
    }
  },
  created() {
    const saved = localStorage.getItem('todos')
    if (saved) {
      this.todos = JSON.parse(saved)
      this.nextId = this.todos.length
        ? Math.max(...this.todos.map(t => t.id)) + 1
        : 1
    }
  },
  methods: {
    addTodo() {
      if (!this.canAdd) return
      this.todos.push({
        id: this.nextId++,
        text: this.newTodo.trim(),
        done: false
      })
      this.newTodo = ''
    },
    removeTodo(id) {
      this.todos = this.todos.filter(t => t.id !== id)
    }
  }
}
</script>

Vue3 版本

<template>
  <div class="todo-app">
    <h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
    <div class="input-bar">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入待办事项..."
      />
      <button @click="addTodo" :disabled="!canAdd">添加</button>
    </div>
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>
    <div class="filters">
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'

// ———— 状态 ————
const newTodo = ref('')
const filter = ref('all')
const todos = ref([])
let nextId = 1

// ———— 初始化(等同于 created) ————
const saved = localStorage.getItem('todos')
if (saved) {
  todos.value = JSON.parse(saved)
  nextId = todos.value.length
    ? Math.max(...todos.value.map(t => t.id)) + 1
    : 1
}

// ———— 计算属性 ————
const canAdd = computed(() => newTodo.value.trim().length > 0)

const activeCount = computed(() => {
  return todos.value.filter(t => !t.done).length
})

const filteredTodos = computed(() => {
  if (filter.value === 'active') return todos.value.filter(t => !t.done)
  if (filter.value === 'completed') return todos.value.filter(t => t.done)
  return todos.value
})

// ———— 侦听器 ————
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

// ———— 方法 ————
function addTodo() {
  if (!canAdd.value) return
  todos.value.push({
    id: nextId++,
    text: newTodo.value.trim(),
    done: false
  })
  newTodo.value = ''
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}
</script>

对比两个版本你会发现:模板部分完全一样,变化全在 <script> 里。这也是 Vue3 设计的一个巧妙之处——模板语法几乎没有 breaking change,迁移成本主要在 JS 逻辑层。

五、迁移时的高频"懵圈"清单

最后汇总一下,从 Vue2 迁到 Vue3,最容易懵的点:

序号 懵圈点 一句话解惑
1 ref.value 什么时候加? 模板里不加,JS 里加
2 ref 还是 reactive 拿不准就全用 ref,不会出错
3 reactive 解构丢失响应性 toRefs() 解构,或者不解构
4 this 去哪了? 没有了,<script setup> 里直接用变量和函数
5 defineProps / defineEmits 要 import 吗? 不用,它们是编译器宏
6 beforeDestroy 不生效了? 改名了,叫 onBeforeUnmount
7 created 里的逻辑放哪? 直接写在 <script setup> 顶层
8 watch 侦听 reactive 属性无效? 要用 getter 函数 () => obj.prop
9 watchwatchEffect 选哪个? 大多数场景用 watch,意图更清晰
10 组件暴露方法给父组件怎么办? defineExpose({ methodName })

六、结语

Vue3 的 Composition API 不是为了"炫技"而存在的,它解决的是一个非常现实的问题:当组件逻辑变复杂后,Options API 的代码会像面条一样——数据在上面,方法在下面,watch 在中间,改一个功能要上下反复跳。

Composition API 让你可以按逻辑关注点把代码组织在一起,甚至抽成可复用的 composables(组合式函数),这才是它真正的威力所在。

但说实话,不需要一步到位。Vue3 完全兼容 Options API,你可以:

  1. 新组件用 <script setup> + Composition API
  2. 老组件维护时逐步迁移
  3. 复杂逻辑才抽 composables,简单组件怎么顺手怎么来

技术服务于业务,够用、好维护,就是最好的选择。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

FE视角下的Referrer全面解析

一、核心概念解析

1.1 什么是Referrer?

  • Referrer(引荐来源)是 HTTP 协议中的一个标准头部字段,用于标识当前请求的来源页面 URL。当用户从页面 A 跳转到页面 B 时,浏览器会在请求页面 B 的 HTTP 头部自动携带 Referer: [A的URL]。

  • 技术特性:

    • 遵循同源策略,跨域时可能被过滤
    • 包含完整URL结构(协议+域名+路径+参数)
    • 前端可通过document.referrer读取

// 获取来源页面示例

console.log('Referrer来源:', document.referrer);

1.2 浏览器差异性

  • Chrome:默认发送完整Referrer
  • Safari:智能跟踪防护可能截断
  • Firefox:支持最新Referrer Policy规范

二、核心应用场景

  • 安全防护:服务器可以根据 Referer 头验证请求来源合法性,防止跨站请求伪造(CSRF)攻击;根据关键操作日志记录进行敏感操作溯源。
  • 日志分析与流量追踪:网站可以通过 Referer 分析流量来源,了解哪些外部页面或广告带来了流量。
  • 内容定向与个性化:根据 Referer 字段判断用户是否通过某个推广链接、广告或推荐页面访问,进而定向展示不同的内容,也可以进行合作伙伴流量区分。

三、策略配置指南

3.1 多层级控制机制

优先级矩阵:

设置方式 优先级 作用范围
标签 当前文档
请求响应头 整个域名
元素级属性a标签 单个元素

3.2 配置示例

HTML全局设置:


<meta name="referrer" content="strict-origin-when-cross-origin">

元素级控制:


<a href="https://external.com" rel="noreferrer">安全跳转</a>

HTTP响应头设置:

add_header Referrer-Policy "no-referrer";

3.3 Fetch API策略


// 禁用Referrer示例

fetch('/api', {

referrer: "",

referrerPolicy: "no-referrer"

});

3.4 referrerPolicy

Referrer Policy是W3C官方提出的一个候选策略,主要用来规范Referrer

配置对照表

同源 跨源 HTTPS→HTTP
"no-referrer" - - -
"no-referrer-when-downgrade"或 ""(默认) 完整的 url 完整的 url -
"origin" 仅域 仅域 仅域
"origin-when-cross-origin" 完整的 url 仅域 仅域
"same-origin" 完整的 url - -
"strict-origin" 仅域 仅域 -
"strict-origin-when-cross-origin" 完整的 url 仅域 -
"unsafe-url" 完整的 url 完整的 url 完整的 url

四、安全风险与应对方案

4.1 典型风险场景

风险类型 案例场景 解决方案
URL参数泄露 密码重置链接token暴露 动态策略调整
管理路径暴露 后台地址出现在第三方日志 Nginx强制策略
GDPR合规风险 用户访问路径记录包含个人数据 数据匿名化处理

4.2 敏感页面保护方案

<script>

// 动态调整策略

if (location.pathname.includes('/admin')) {

const meta = document.createElement('meta');

meta.name = 'referrer';

meta.content = 'no-referrer';

document.head.appendChild(meta);

}

</script>

4.3 数据匿名化处理


function sanitizeReferrer(url) {

const u = new URL(url);

return `${u.origin}${u.pathname}`.replace(/\/user\/\d+/g, '/user/{id}');

}

五、跨浏览器兼容策略

5.2 兼容性处理方案

  • 特性检测:if ('referrerPolicy' in document.createElement('a'))

  • 渐进增强:优先使用标签设置全局策略

  • 服务端兜底:日志系统进行Referrer清洗

// 浏览器特性检测与降级处理

function applyReferrerPolicy() {

const policies = ['strict-origin-when-cross-origin', 'no-referrer-when-downgrade'];

  


if ('document' in globalThis && document.createElement('meta').hasAttribute('referrerpolicy')) {

// 支持新式策略

document.querySelector('meta[name="referrer"]').content = policies[0];

} else {

// 传统浏览器降级处理

window.onclick = (e) => {

if (e.target.tagName === 'A' && isExternalLink(e.target.href)) {

e.target.rel += ' noreferrer';

}

};

}

}

六、最佳实践总结

  1. 最小化原则:采用最严格的策略等级

  2. 动态调整:根据页面敏感程度切换策略

  3. 双重验证:客户端+服务端联合校验


参考文献:www.w3cschool.cn/qoyhx/qoyhx…

扩展阅读:www.w3cschool.cn/qoyhx/qoyhx…

JS 异步编程实战 | 从回调地狱到 Promise/Async/Await(附代码 + 面试题)

一、为什么需要异步编程?

JavaScript 是单线程语言,同一时间只能做一件事。如果有耗时操作(如网络请求、文件读取、定时任务),就会阻塞后续代码执行。

// 同步阻塞示例 
console.log('开始')
for(let i = 0; i < 1000000000; i++) {}
// 耗时操作 console.log('结束') 
// 必须等待循环结束才执行

为了解决这个问题,JavaScript 提供了异步编程解决方案。

二、回调函数(Callback)—— 最基础的异步方案

2.1 基本概念

回调函数是将函数作为参数传递给另一个函数,在异步操作完成后调用。

// 模拟异步请求
function fetchData(callback) {
  setTimeout(() => {
    callback('数据加载完成')
  }, 1000)
}

console.log('开始请求')
fetchData((data) => {
  console.log(data) // 1秒后输出:数据加载完成
})
console.log('继续执行其他操作')
// 输出顺序:开始请求 → 继续执行其他操作 → 数据加载完成

2.2 回调地狱的产生

当有多个依赖的异步操作时,回调嵌套会形成"回调地狱":

// 回调地狱示例
getUserInfo(function(user) {
  getOrderList(user.id, function(orders) {
    getOrderDetail(orders[0].id, function(detail) {
      getProductInfo(detail.productId, function(product) {
        console.log('最终数据:', product)
      }, function(error) {
        console.error('获取商品失败', error)
      })
    }, function(error) {
      console.error('获取订单详情失败', error)
    })
  }, function(error) {
    console.error('获取订单列表失败', error)
  })
}, function(error) {
  console.error('获取用户失败', error)
})

回调地狱的问题:

  • 代码难以阅读和维护
  • 错误处理分散
  • 难以复用和调试

三、Promise —— 优雅的异步解决方案

3.1 Promise 基本用法

Promise 是 ES6 引入的异步编程解决方案,它代表一个异步操作的最终完成或失败。

// 创建 Promise
const promise = new Promise((resolve, reject) => {
  // 执行异步操作
  setTimeout(() => {
    const success = true
    if (success) {
      resolve('操作成功') // 成功时调用
    } else {
      reject('操作失败') // 失败时调用
    }
  }, 1000)
})

// 使用 Promise
promise
  .then(result => {
    console.log(result) // 成功:操作成功
  })
  .catch(error => {
    console.error(error) // 失败:操作失败
  })
  .finally(() => {
    console.log('无论成功失败都会执行')
  })

3.2 解决回调地狱

使用 Promise 重构上面的例子:

// 将每个异步操作封装成 Promise
function getUserInfo() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 1, name: '张三' })
    }, 1000)
  })
}

function getOrderList(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve([{ id: 101, name: '订单1' }, { id: 102, name: '订单2' }])
    }, 1000)
  })
}

function getOrderDetail(orderId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: orderId, productId: 1001, price: 299 })
    }, 1000)
  })
}

function getProductInfo(productId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: productId, name: '商品名称', price: 299 })
    }, 1000)
  })
}

// 链式调用,告别回调地狱
getUserInfo()
  .then(user => {
    console.log('用户:', user)
    return getOrderList(user.id)
  })
  .then(orders => {
    console.log('订单列表:', orders)
    return getOrderDetail(orders[0].id)
  })
  .then(detail => {
    console.log('订单详情:', detail)
    return getProductInfo(detail.productId)
  })
  .then(product => {
    console.log('商品信息:', product)
  })
  .catch(error => {
    console.error('发生错误:', error)
  })

3.3 Promise 静态方法

// Promise.all - 等待所有 Promise 完成
const p1 = Promise.resolve(3)
const p2 = 42
const p3 = new Promise((resolve) => setTimeout(resolve, 100, 'foo'))

Promise.all([p1, p2, p3]).then(values => {
  console.log(values) // [3, 42, "foo"]
})

// Promise.race - 返回最先完成的 Promise
const promise1 = new Promise(resolve => setTimeout(resolve, 500, 'one'))
const promise2 = new Promise(resolve => setTimeout(resolve, 100, 'two'))

Promise.race([promise1, promise2]).then(value => {
  console.log(value) // "two" (因为 promise2 更快)
})

// Promise.allSettled - 等待所有 Promise 完成(无论成功失败)
const promises = [
  Promise.resolve('成功1'),
  Promise.reject('失败2'),
  Promise.resolve('成功3')
]

Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

// Promise.any - 返回第一个成功的 Promise
const pErr = new Promise((resolve, reject) => reject('总是失败'))
const pSlow = new Promise(resolve => setTimeout(resolve, 500, '最终完成'))
const pFast = new Promise(resolve => setTimeout(resolve, 100, '很快完成'))

Promise.any([pErr, pSlow, pFast]).then(value => {
  console.log(value) // "很快完成"
})

四、Async/Await —— 同步方式的异步编程

4.1 基本语法

Async/Await 是 ES2017 引入的语法糖,让异步代码看起来像同步代码。

// async 函数返回一个 Promise
async function getData() {
  return '数据'
}

getData().then(result => console.log(result)) // 数据

// 使用 await 等待 Promise 完成
async function fetchUserData() {
  try {
    const user = await getUserInfo()
    console.log('用户:', user)
    
    const orders = await getOrderList(user.id)
    console.log('订单:', orders)
    
    const detail = await getOrderDetail(orders[0].id)
    console.log('详情:', detail)
    
    const product = await getProductInfo(detail.productId)
    console.log('商品:', product)
    
    return product
  } catch (error) {
    console.error('出错了:', error)
  }
}

// 调用 async 函数
fetchUserData().then(result => {
  console.log('最终结果:', result)
})

4.2 实战示例:模拟数据请求

// 模拟 API 请求函数
const mockAPI = (url, delay = 1000) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.1) { // 90% 成功率
        resolve({
          status: 200,
          data: { url, timestamp: Date.now() }
        })
      } else {
        reject(new Error(`请求 ${url} 失败`))
      }
    }, delay)
  })
}

// 使用 async/await 实现并发请求
async function fetchMultipleData() {
  try {
    // 并发请求
    const [userData, productData, orderData] = await Promise.all([
      mockAPI('/api/user', 800),
      mockAPI('/api/product', 1200),
      mockAPI('/api/order', 600)
    ])
    
    console.log('所有数据加载完成:')
    console.log('用户数据:', userData.data)
    console.log('商品数据:', productData.data)
    console.log('订单数据:', orderData.data)
    
    return { userData, productData, orderData }
  } catch (error) {
    console.error('数据加载失败:', error.message)
  }
}

// 串行请求(依赖关系)
async function fetchDependentData() {
  console.time('串行请求耗时')
  
  const user = await mockAPI('/api/user', 1000)
  console.log('第一步完成:', user.data)
  
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  console.log('第二步完成:', orders.data)
  
  const details = await mockAPI(`/api/orders/${orders.data.url}/details`, 1000)
  console.log('第三步完成:', details.data)
  
  console.timeEnd('串行请求耗时')
  // 总耗时约 3000ms
}

// 优化:并行处理不依赖的数据
async function fetchOptimizedData() {
  console.time('优化后耗时')
  
  // 同时发起两个独立请求
  const [user, products] = await Promise.all([
    mockAPI('/api/user', 1000),
    mockAPI('/api/products', 1000)
  ])
  
  console.log('用户和商品数据已获取')
  
  // 依赖用户数据的请求
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  
  // 可以并行处理的请求
  const [detail1, detail2] = await Promise.all([
    mockAPI(`/api/orders/${orders.data.url}/detail1`, 500),
    mockAPI(`/api/orders/${orders.data.url}/detail2`, 500)
  ])
  
  console.timeEnd('优化后耗时')
  // 总耗时约 2500ms
}

4.3 错误处理最佳实践

// 统一的错误处理函数
const handleAsyncError = (asyncFn) => {
  return async (...args) => {
    try {
      return [await asyncFn(...args), null]
    } catch (error) {
      return [null, error]
    }
  }
}

// 使用错误处理包装器
const safeFetchUser = handleAsyncError(fetchUserData)

async function main() {
  const [user, error] = await safeFetchUser()
  
  if (error) {
    console.error('操作失败:', error.message)
    return
  }
  
  console.log('操作成功:', user)
}

// 带超时的 Promise
function withTimeout(promise, timeout = 5000) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('请求超时')), timeout)
  })
  
  return Promise.race([promise, timeoutPromise])
}

async function fetchWithTimeout() {
  try {
    const result = await withTimeout(mockAPI('/api/data', 3000), 2000)
    console.log('数据:', result)
  } catch (error) {
    console.error('超时或失败:', error.message)
  }
}

五、手写实现(面试高频)

5.1 手写 Promise

class MyPromise {
  constructor(executor) {
    this.state = 'pending'
    this.value = undefined
    this.reason = undefined
    this.onFulfilledCallbacks = []
    this.onRejectedCallbacks = []

    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled'
        this.value = value
        this.onFulfilledCallbacks.forEach(fn => fn())
      }
    }

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected'
        this.reason = reason
        this.onRejectedCallbacks.forEach(fn => fn())
      }
    }

    try {
      executor(resolve, reject)
    } catch (error) {
      reject(error)
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value
    onRejected = typeof onRejected === 'function' ? onRejected : error => { throw error }

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.state === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })

        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })
      }
    })

    return promise2
  }

  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      reject(new TypeError('Chaining cycle detected'))
    }

    if (x && (typeof x === 'object' || typeof x === 'function')) {
      let called = false
      try {
        const then = x.then
        if (typeof then === 'function') {
          then.call(
            x,
            y => {
              if (called) return
              called = true
              this.resolvePromise(promise2, y, resolve, reject)
            },
            error => {
              if (called) return
              called = true
              reject(error)
            }
          )
        } else {
          resolve(x)
        }
      } catch (error) {
        if (called) return
        called = true
        reject(error)
      }
    } else {
      resolve(x)
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected)
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value
    return new MyPromise(resolve => resolve(value))
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason))
  }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const result = []
      let count = 0
      
      for (let i = 0; i < promises.length; i++) {
        MyPromise.resolve(promises[i]).then(
          value => {
            result[i] = value
            count++
            if (count === promises.length) resolve(result)
          },
          reject
        )
      }
    })
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      for (const promise of promises) {
        MyPromise.resolve(promise).then(resolve, reject)
      }
    })
  }
}

5.2 手写 async/await 的简单实现

// 使用 Generator 模拟 async/await
function asyncToGenerator(generatorFn) {
  return function() {
    const gen = generatorFn.apply(this, arguments)
    
    return new Promise((resolve, reject) => {
      function step(key, arg) {
        let result
        try {
          result = gen[key](arg)
        } catch (error) {
          reject(error)
          return
        }
        
        const { value, done } = result
        
        if (done) {
          resolve(value)
        } else {
          Promise.resolve(value).then(
            val => step('next', val),
            err => step('throw', err)
          )
        }
      }
      
      step('next')
    })
  }
}

// 使用示例
const fetchData = function() {
  return new Promise(resolve => {
    setTimeout(() => resolve('数据'), 1000)
  })
}

const getData = asyncToGenerator(function* () {
  const data1 = yield fetchData()
  console.log('data1:', data1)
  
  const data2 = yield fetchData()
  console.log('data2:', data2)
  
  return '完成'
})

getData().then(result => console.log(result))

六、面试高频题

6.1 输出顺序题

// 题目1
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')

// 输出:1, 4, 3, 2
// 解释:同步代码先执行,微任务(Promise)先于宏任务(setTimeout)

// 题目2
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})

console.log('script end')

// 输出顺序:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

6.2 错误处理题

// 题目:如何捕获 async/await 的错误?
async function getData() {
  try {
    const data = await Promise.reject('出错了')
    console.log(data)
  } catch (error) {
    console.log('捕获到:', error)
  }
}

// 或使用 .catch
async function getData2() {
  const data = await Promise.reject('出错了').catch(err => {
    console.log('处理错误:', err)
    return '默认值'
  })
  console.log(data) // 默认值
}

// 题目:Promise.all 的错误处理
const promises = [
  Promise.resolve(1),
  Promise.reject('错误'),
  Promise.resolve(3)
]

Promise.all(promises)
  .then(console.log)
  .catch(console.error) // 输出:错误

// 如何让 Promise.all 即使有错误也返回所有结果?
Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

6.3 并发控制题

// 题目:实现一个并发控制器,限制同时执行的 Promise 数量
class PromiseQueue {
  constructor(concurrency = 2) {
    this.concurrency = concurrency
    this.running = 0
    this.queue = []
  }
  
  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject })
      this.run()
    })
  }
  
  run() {
    while (this.running < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift()
      this.running++
      
      Promise.resolve(task())
        .then(resolve, reject)
        .finally(() => {
          this.running--
          this.run()
        })
    }
  }
}

// 使用示例
const queue = new PromiseQueue(2)

for (let i = 0; i < 5; i++) {
  queue.add(() => 
    new Promise(resolve => {
      setTimeout(() => {
        console.log(`任务${i}完成`)
        resolve(i)
      }, 1000)
    })
  )
}
// 每2个任务并行执行

6.4 重试机制题

// 题目:实现一个函数,请求失败时自动重试
async function retryRequest(fn, maxRetries = 3, delay = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      console.log(`第${i + 1}次尝试`)
      const result = await fn()
      console.log('请求成功')
      return result
    } catch (error) {
      console.log(`第${i + 1}次失败`)
      if (i === maxRetries - 1) {
        throw error
      }
      // 等待延迟时间后重试
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
}

// 使用示例
let attempt = 0
const request = () => {
  return new Promise((resolve, reject) => {
    attempt++
    if (attempt < 3) {
      reject('模拟失败')
    } else {
      resolve('成功')
    }
  })
}

retryRequest(request, 3, 1000)
  .then(console.log)
  .catch(console.error)

七、总结与建议

7.1 异步编程演进

  • 回调函数:基础但容易形成"回调地狱"
  • Promise:链式调用,错误统一处理
  • Async/Await:语法糖,代码更直观

7.2 使用建议

  1. 优先使用 async/await,代码更清晰
  2. 并发请求使用 Promise.all,提高性能
  3. 注意错误处理,不要吞掉错误
  4. 避免回调地狱,及时重构代码
  5. 理解事件循环,掌握执行顺序

7.3 面试准备

  • 掌握三种异步方案的原理和用法
  • 能够手写简单的 Promise
  • 理解宏任务和微任务的执行顺序
  • 熟悉常见的异步编程场景和解决方案
  • 能够处理并发控制和错误重试

异步编程是 JavaScript 的核心特性,掌握好这块内容不仅对面试有帮助,更能提升实际开发中的代码质量。

BroadcastChannel:浏览器原生跨标签页通信

在现代Web应用开发中,跨标签页通信是一个常见需求。无论是实现多标签页间的数据同步、构建协作工具,还是简单的消息广播,开发者都需要一个可靠的通信方案。虽然过去我们有 localStorage、postMessage 等方案,但 BroadcastChannel API 提供了一个更优雅、更专业的解决方案。

什么是 BroadcastChannel?

BroadcastChannel 是 HTML5 中引入的一个专门用于同源页面间通信的 API。它允许同一源下的不同浏览上下文(如标签页、iframe、Web Worker)之间进行消息广播。

核心特点

  • 同源限制:只能在相同协议、域名、端口的页面间通信

  • 一对多通信:一条消息可以同时被所有监听者接收

  • 双向通信:所有参与者既可以发送消息,也可以接收消息

  • 自动清理:页面关闭后自动断开连接

基础用法

1. 创建或加入频道

// 创建/加入名为 "chat_room" 的频道
const channel = new BroadcastChannel('chat_room');

// 查看频道名称
console.log(channel.name); // 输出: "chat_room"

2. 发送消息

// 发送字符串
channel.postMessage('Hello from Page 1');

// 发送对象
channel.postMessage({
  type: 'user_action',
  user: '张三',
  action: 'click',
  timestamp: Date.now()
});

// 支持大多数数据类型
channel.postMessage(['数组', '数据']);
channel.postMessage(new Blob(['文件内容']));
channel.postMessage(new Uint8Array([1, 2, 3]));

3. 接收消息

// 方式1:使用 onmessage
channel.onmessage = (event) => {
  console.log('收到消息:', event.data);
  console.log('消息来源:', event.origin);
  console.log('时间戳:', event.timeStamp);
};

// 方式2:使用 addEventListener
channel.addEventListener('message', (event) => {
  console.log('收到消息:', event.data);
});

// 错误处理
channel.onmessageerror = (error) => {
  console.error('消息处理错误:', error);
};

4. 关闭频道

// 关闭频道,不再接收消息
channel.close();

实际应用场景

场景1:主题同步

当用户在一个标签页切换主题时,所有其他标签页自动同步:

// theme-sync.js
class ThemeSync {
  constructor() {
    this.channel = new BroadcastChannel('theme_sync');
    this.setupListener();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      if (event.data.type === 'theme_change') {
        this.applyTheme(event.data.theme);
      }
    };
  }
  
  changeTheme(theme) {
    this.applyTheme(theme);
    this.channel.postMessage({
      type: 'theme_change',
      theme: theme,
      from: this.getTabId()
    });
  }
  
  applyTheme(theme) {
    document.body.className = `theme-${theme}`;
    localStorage.setItem('preferred_theme', theme);
  }
  
  getTabId() {
    return sessionStorage.getItem('tab_id') || 
           Math.random().toString(36).substring(7);
  }
}

// 使用
const themeSync = new ThemeSync();
themeSync.changeTheme('dark');

场景2:实时聊天室

创建一个简单的多标签页聊天室:

<!-- chat.html -->
<!DOCTYPE html>
<html>
<head>
    <title>BroadcastChannel 聊天室</title>
    <style>
        .chat-container { max-width: 600px; margin: 0 auto; padding: 20px; }
        .message-list { 
            height: 400px; 
            overflow-y: auto; 
            border: 1px solid #ccc; 
            padding: 10px;
            margin-bottom: 10px;
        }
        .message { margin: 5px 0; padding: 8px; background: #f0f0f0; border-radius: 5px; }
        .system { background: #e3f2fd; text-align: center; }
        .self { background: #e8f5e8; border-left: 3px solid #4caf50; }
        .input-area { display: flex; gap: 10px; }
        #messageInput { flex: 1; padding: 8px; }
        button { padding: 8px 15px; background: #4caf50; color: white; border: none; border-radius: 3px; cursor: pointer; }
    </style>
</head>
<body>
    <div class="chat-container">
        <h1>📱 跨标签页聊天室</h1>
        <div class="message-list" id="messageList"></div>
        <div class="input-area">
            <input type="text" id="messageInput" placeholder="输入消息..." onkeypress="if(event.key==='Enter') sendMessage()">
            <button onclick="sendMessage()">发送</button>
            <button onclick="changeNickname()">修改昵称</button>
        </div>
    </div>

    <script>
        // 聊天室逻辑
        const chatChannel = new BroadcastChannel('global_chat');
        const userId = Math.random().toString(36).substring(2, 10);
        let nickname = '用户_' + userId.substring(0, 4);
        
        // 监听消息
        chatChannel.onmessage = (event) => {
            const { type, data, from, userId: msgUserId } = event.data;
            
            switch(type) {
                case 'message':
                    displayMessage(from, data, msgUserId === userId);
                    break;
                case 'join':
                    displaySystemMessage(`${from} 加入了聊天室`);
                    break;
                case 'leave':
                    displaySystemMessage(`${from} 离开了聊天室`);
                    break;
                case 'nickname_change':
                    displaySystemMessage(`${from} 改名为 ${data}`);
                    break;
            }
        };
        
        // 广播加入消息
        chatChannel.postMessage({
            type: 'join',
            from: nickname,
            userId: userId,
            time: Date.now()
        });
        
        function sendMessage() {
            const input = document.getElementById('messageInput');
            const text = input.value.trim();
            
            if (text) {
                chatChannel.postMessage({
                    type: 'message',
                    from: nickname,
                    data: text,
                    userId: userId,
                    time: Date.now()
                });
                
                displayMessage(nickname, text, true);
                input.value = '';
            }
        }
        
        function changeNickname() {
            const newNickname = prompt('请输入新昵称:', nickname);
            if (newNickname && newNickname.trim() && newNickname !== nickname) {
                const oldNickname = nickname;
                nickname = newNickname.trim();
                
                chatChannel.postMessage({
                    type: 'nickname_change',
                    from: oldNickname,
                    data: nickname,
                    userId: userId,
                    time: Date.now()
                });
            }
        }
        
        function displayMessage(sender, text, isSelf = false) {
            const list = document.getElementById('messageList');
            const msgDiv = document.createElement('div');
            msgDiv.className = `message ${isSelf ? 'self' : ''}`;
            
            const time = new Date().toLocaleTimeString('zh-CN', { 
                hour: '2-digit', 
                minute: '2-digit' 
            });
            
            msgDiv.innerHTML = `<strong>${sender}${isSelf ? ' (我)' : ''}:</strong> ${escapeHtml(text)} <small>${time}</small>`;
            
            list.appendChild(msgDiv);
            list.scrollTop = list.scrollHeight;
        }
        
        function displaySystemMessage(text) {
            const list = document.getElementById('messageList');
            const msgDiv = document.createElement('div');
            msgDiv.className = 'message system';
            msgDiv.innerHTML = escapeHtml(text);
            list.appendChild(msgDiv);
            list.scrollTop = list.scrollHeight;
        }
        
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }
        
        // 页面关闭时通知
        window.addEventListener('beforeunload', () => {
            chatChannel.postMessage({
                type: 'leave',
                from: nickname,
                userId: userId
            });
            chatChannel.close();
        });
    </script>
</body>
</html>

场景3:数据同步

实现购物车在多标签页间的实时同步:

// cart-sync.js
class CartSync {
  constructor() {
    this.channel = new BroadcastChannel('cart_sync');
    this.items = this.loadFromStorage() || [];
    this.listeners = [];
    
    this.setupListener();
    this.syncWithOthers();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, data, from } = event.data;
      
      switch(type) {
        case 'cart_update':
          this.items = data.items;
          this.saveToStorage();
          this.notifyListeners('update', data);
          break;
          
        case 'cart_request':
          // 新标签页请求同步
          this.channel.postMessage({
            type: 'cart_response',
            data: { items: this.items },
            from: this.getTabId()
          });
          break;
          
        case 'cart_response':
          if (from !== this.getTabId() && this.items.length === 0) {
            this.items = data.items;
            this.saveToStorage();
            this.notifyListeners('sync', data);
          }
          break;
      }
    };
  }
  
  syncWithOthers() {
    // 请求其他标签页的数据
    this.channel.postMessage({
      type: 'cart_request',
      from: this.getTabId()
    });
  }
  
  addItem(item) {
    this.items.push({
      ...item,
      id: Date.now() + Math.random(),
      addedAt: new Date().toISOString()
    });
    
    this.broadcastUpdate();
  }
  
  removeItem(itemId) {
    this.items = this.items.filter(item => item.id !== itemId);
    this.broadcastUpdate();
  }
  
  updateQuantity(itemId, quantity) {
    const item = this.items.find(item => item.id === itemId);
    if (item) {
      item.quantity = Math.max(1, quantity);
      this.broadcastUpdate();
    }
  }
  
  broadcastUpdate() {
    this.saveToStorage();
    
    this.channel.postMessage({
      type: 'cart_update',
      data: { items: this.items },
      from: this.getTabId(),
      timestamp: Date.now()
    });
    
    this.notifyListeners('update', { items: this.items });
  }
  
  loadFromStorage() {
    const saved = localStorage.getItem('cart_items');
    return saved ? JSON.parse(saved) : null;
  }
  
  saveToStorage() {
    localStorage.setItem('cart_items', JSON.stringify(this.items));
  }
  
  getTabId() {
    let tabId = sessionStorage.getItem('tab_id');
    if (!tabId) {
      tabId = Math.random().toString(36).substring(2, 10);
      sessionStorage.setItem('tab_id', tabId);
    }
    return tabId;
  }
  
  subscribe(callback) {
    this.listeners.push(callback);
    return () => {
      this.listeners = this.listeners.filter(cb => cb !== callback);
    };
  }
  
  notifyListeners(event, data) {
    this.listeners.forEach(callback => callback(event, data));
  }
}

// 使用示例
const cart = new CartSync();

// 订阅更新
cart.subscribe((event, data) => {
  console.log(`购物车${event}:`, data);
  updateCartUI(data.items);
});

// 添加商品
cart.addItem({
  name: '商品名称',
  price: 99.9,
  quantity: 1
});

场景4:Web Worker 协作

// main.js
// 主线程
const workerChannel = new BroadcastChannel('worker_tasks');
const worker = new Worker('worker.js');

// 发送任务到所有worker
workerChannel.postMessage({
  type: 'new_task',
  taskId: 'task_001',
  data: [1, 2, 3, 4, 5]
});

// 接收worker结果
workerChannel.onmessage = (event) => {
  if (event.data.type === 'task_result') {
    console.log('任务完成:', event.data.result);
  }
};

// worker.js
// Web Worker
const channel = new BroadcastChannel('worker_tasks');
const workerId = Math.random().toString(36).substring(2, 6);

channel.onmessage = (event) => {
  const { type, taskId, data } = event.data;
  
  if (type === 'new_task') {
    console.log(`Worker ${workerId} 接收任务:`, taskId);
    
    // 模拟耗时计算
    const result = data.map(x => x * 2);
    
    // 广播结果
    channel.postMessage({
      type: 'task_result',
      taskId: taskId,
      result: result,
      workerId: workerId
    });
  }
};

与其他通信方案的比较

1. vs localStorage

// localStorage 方案
window.addEventListener('storage', (e) => {
  if (e.key === 'message') {
    console.log('收到消息:', e.newValue);
  }
});
localStorage.setItem('message', 'hello');

// BroadcastChannel 方案
const channel = new BroadcastChannel('messages');
channel.onmessage = (e) => console.log('收到消息:', e.data);
channel.postMessage('hello');

优势对比

  • BroadcastChannel:专门为通信设计,语义清晰,性能更好,支持复杂数据类型

  • localStorage:主要用于存储,通信只是附带功能,有大小限制(通常5MB)

2. vs postMessage

// postMessage 需要知道目标窗口
const otherWindow = window.open('other.html');
otherWindow.postMessage('hello', '*');

// BroadcastChannel 无需知道目标
const channel = new BroadcastChannel('messages');
channel.postMessage('hello');

优势对比

  • BroadcastChannel:一对多广播,无需维护窗口引用

  • postMessage:一对一通信,更灵活但需要管理目标

3. vs WebSocket

高级技巧

1. 频道管理器

class BroadcastChannelManager {
  constructor() {
    this.channels = new Map();
    this.globalListeners = new Set();
  }
  
  // 获取或创建频道
  getChannel(name) {
    if (!this.channels.has(name)) {
      const channel = new BroadcastChannel(name);
      
      channel.onmessage = (event) => {
        // 触发全局监听器
        this.globalListeners.forEach(listener => {
          listener(name, event.data, event);
        });
        
        // 触发频道特定监听器
        const channelListeners = this.channels.get(name)?.listeners || [];
        channelListeners.forEach(listener => {
          listener(event.data, event);
        });
      };
      
      this.channels.set(name, {
        channel,
        listeners: []
      });
    }
    
    return this.channels.get(name).channel;
  }
  
  // 订阅频道消息
  subscribe(channelName, listener) {
    this.getChannel(channelName); // 确保频道存在
    
    const channel = this.channels.get(channelName);
    channel.listeners.push(listener);
    
    return () => {
      channel.listeners = channel.listeners.filter(l => l !== listener);
    };
  }
  
  // 订阅所有频道消息
  subscribeAll(listener) {
    this.globalListeners.add(listener);
    return () => this.globalListeners.delete(listener);
  }
  
  // 发送消息到频道
  send(channelName, data) {
    const channel = this.getChannel(channelName);
    channel.postMessage(data);
  }
  
  // 关闭频道
  closeChannel(channelName) {
    if (this.channels.has(channelName)) {
      const { channel } = this.channels.get(channelName);
      channel.close();
      this.channels.delete(channelName);
    }
  }
  
  // 关闭所有频道
  closeAll() {
    this.channels.forEach(({ channel }) => channel.close());
    this.channels.clear();
    this.globalListeners.clear();
  }
}

// 使用示例
const manager = new BroadcastChannelManager();

// 订阅特定频道
const unsubscribe = manager.subscribe('chat', (data) => {
  console.log('聊天消息:', data);
});

// 订阅所有频道
const unsubscribeAll = manager.subscribeAll((channel, data) => {
  console.log(`[${channel}] 收到:`, data);
});

// 发送消息
manager.send('chat', { text: 'Hello' });

2. 消息确认机制

class ReliableBroadcastChannel {
  constructor(name) {
    this.channel = new BroadcastChannel(name);
    this.pendingMessages = new Map();
    this.messageId = 0;
    
    this.setupListener();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, id, data, from } = event.data;
      
      if (type === 'ack') {
        // 收到确认,移除待确认消息
        this.pendingMessages.delete(id);
      } else {
        // 处理消息
        this.handleMessage(data, from);
        
        // 发送确认
        this.channel.postMessage({
          type: 'ack',
          id: id,
          from: this.getSenderId()
        });
      }
    };
  }
  
  send(data, requireAck = true) {
    const id = ++this.messageId;
    
    this.channel.postMessage({
      type: 'message',
      id: id,
      data: data,
      from: this.getSenderId(),
      timestamp: Date.now()
    });
    
    if (requireAck) {
      // 存储待确认消息
      this.pendingMessages.set(id, {
        data,
        timestamp: Date.now(),
        retries: 0
      });
      
      // 启动重试机制
      this.startRetry(id);
    }
  }
  
  startRetry(id) {
    const maxRetries = 3;
    const timeout = 1000;
    
    const check = () => {
      const message = this.pendingMessages.get(id);
      
      if (message && message.retries < maxRetries) {
        message.retries++;
        console.log(`重发消息 ${id},第 ${message.retries} 次`);
        
        this.channel.postMessage({
          type: 'message',
          id: id,
          data: message.data,
          from: this.getSenderId(),
          retry: true
        });
        
        setTimeout(check, timeout * message.retries);
      } else if (message) {
        console.error(`消息 ${id} 发送失败`);
        this.pendingMessages.delete(id);
      }
    };
    
    setTimeout(check, timeout);
  }
  
  handleMessage(data, from) {
    console.log('可靠收到:', data, '来自:', from);
  }
  
  getSenderId() {
    return sessionStorage.getItem('sender_id') || 
           Math.random().toString(36).substring(2);
  }
}

3. 心跳检测和状态同步

class TabHeartbeat {
  constructor() {
    this.channel = new BroadcastChannel('heartbeat');
    this.tabId = Math.random().toString(36).substring(2, 10);
    this.tabs = new Map();
    
    this.setupListener();
    this.startHeartbeat();
    this.requestStatus();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, tabId, data } = event.data;
      
      switch(type) {
        case 'heartbeat':
          this.updateTab(tabId, data);
          break;
          
        case 'status_request':
          this.sendStatus();
          break;
          
        case 'status_response':
          this.updateTab(tabId, data);
          break;
      }
    };
  }
  
  startHeartbeat() {
    // 每秒发送心跳
    setInterval(() => {
      this.channel.postMessage({
        type: 'heartbeat',
        tabId: this.tabId,
        data: {
          url: window.location.href,
          title: document.title,
          lastActive: Date.now(),
          scrollY: window.scrollY
        }
      });
    }, 1000);
    
    // 每30秒清理离线标签
    setInterval(() => {
      this.cleanOfflineTabs();
    }, 30000);
  }
  
  requestStatus() {
    this.channel.postMessage({
      type: 'status_request',
      tabId: this.tabId
    });
  }
  
  sendStatus() {
    this.channel.postMessage({
      type: 'status_response',
      tabId: this.tabId,
      data: {
        url: window.location.href,
        title: document.title,
        lastActive: Date.now(),
        scrollY: window.scrollY
      }
    });
  }
  
  updateTab(tabId, data) {
    this.tabs.set(tabId, {
      ...data,
      lastSeen: Date.now()
    });
  }
  
  cleanOfflineTabs() {
    const now = Date.now();
    for (const [tabId, data] of this.tabs) {
      if (now - data.lastSeen > 5000) {
        this.tabs.delete(tabId);
      }
    }
  }
  
  getOnlineTabs() {
    return Array.from(this.tabs.values());
  }
}

降级方案

class CrossTabChannel {
  constructor(name) {
    this.name = name;
    this.listeners = [];
    
    if ('BroadcastChannel' in window) {
      // 使用 BroadcastChannel
      this.channel = new BroadcastChannel(name);
      this.channel.onmessage = (event) => {
        this.notifyListeners(event.data);
      };
    } else {
      // 降级到 localStorage
      this.setupLocalStorageFallback();
    }
  }
  
  setupLocalStorageFallback() {
    window.addEventListener('storage', (event) => {
      if (event.key === `channel_${this.name}` && event.newValue) {
        try {
          const data = JSON.parse(event.newValue);
          // 避免循环
          if (data.from !== this.getTabId()) {
            this.notifyListeners(data.payload);
          }
        } catch (e) {
          console.error('解析消息失败:', e);
        }
      }
    });
  }
  
  postMessage(data) {
    if (this.channel) {
      // 使用 BroadcastChannel
      this.channel.postMessage(data);
    } else {
      // 使用 localStorage
      localStorage.setItem(`channel_${this.name}`, JSON.stringify({
        from: this.getTabId(),
        payload: data,
        timestamp: Date.now()
      }));
      // 立即清除,避免积累
      setTimeout(() => {
        localStorage.removeItem(`channel_${this.name}`);
      }, 100);
    }
  }
  
  onMessage(callback) {
    this.listeners.push(callback);
  }
  
  notifyListeners(data) {
    this.listeners.forEach(callback => callback(data));
  }
  
  getTabId() {
    let tabId = sessionStorage.getItem('tab_id');
    if (!tabId) {
      tabId = Math.random().toString(36).substring(2, 10);
      sessionStorage.setItem('tab_id', tabId);
    }
    return tabId;
  }
  
  close() {
    if (this.channel) {
      this.channel.close();
    }
    this.listeners = [];
  }
}

最佳实践总结

1. 命名规范

// 使用清晰的命名空间
const channel = new BroadcastChannel('app_name:feature:room');
// 例如:'myapp:chat:room1', 'myapp:cart:sync'

2. 错误处理

channel.onmessageerror = (error) => {
  console.error('消息处理失败:', error);
  // 可以尝试重新发送或降级处理
};

3. 资源清理

// 组件卸载时关闭频道
useEffect(() => {
  const channel = new BroadcastChannel('my_channel');
  
  return () => {
    channel.close();
  };
}, []);

4. 消息格式标准化

// 统一的消息格式
const message = {
  type: 'MESSAGE_TYPE',     // 消息类型
  id: 'unique_id',          // 唯一标识
  from: 'sender_id',        // 发送者
  payload: {},              // 实际数据
  timestamp: Date.now(),    // 时间戳
  version: '1.0'            // 版本号
};

5. 避免消息风暴

// 使用防抖或节流
function debounceBroadcast(fn, delay = 100) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const debouncedSend = debounceBroadcast((data) => {
  channel.postMessage(data);
});

结语

BroadcastChannel API 为浏览器原生环境提供了一个简单而强大的跨页面通信解决方案。它不仅语法简洁、性能优秀,而且与现代Web开发范式完美契合。无论是构建实时协作应用、实现多标签页状态同步,还是简单的消息广播,BroadcastChannel 都能优雅地解决问题。

随着浏览器支持的不断完善,BroadcastChannel 必将成为Web开发中不可或缺的工具之一。希望本文能帮助您更好地理解和使用这个强大的API,在实际项目中发挥其最大价值。

🧠 空数组的迷惑行为:为什么 every 为真,some 为假?

一、前言

Hello~大家好。我是秋天的一阵风

在 JavaScript 开发中,everysome是我们日常处理数组时高频用到的两个数组方法,用法简单、逻辑直观,一直是前端处理数组判断的好帮手。但不少开发者在接触空数组的场景时,都会对一个现象感到困惑:

console.log([].every(item => item > 0)); // true 
console.log([].some(item => item > 0)); // false

同样是空数组,调用两个逻辑相近的方法,结果却截然相反。这并不是 JavaScript 的设计漏洞,而是背后遵循了严谨的数学逻辑。

与其只记着 “空数组 every 返回 true、some 返回 false” 这个结论就完事,不如跟着这篇内容,从数学逻辑到手写源码,把这个知识点掰扯透。

我之前还写过一篇《给我十分钟,手把手教你实现 Javascript 数组原型对象上的七个方法》,里面把 forEach、map、reduce 这些常用数组方法的实现思路拆得明明白白,和这篇讲的内容是一个思路,看完这篇再去翻那篇,能把 JS 数组的底层逻辑摸得更透。

二、解开疑惑

很多人第一次发现这个现象时,会觉得是 JavaScript 的特殊约定,其实不然,everysome的返回值逻辑,本质上是继承了数理逻辑中的量词规则,这也是这两个方法设计的底层依据。

1. every:对应全称量词的 “平凡真”

every的核心语义是 “数组中所有元素都满足某个条件”,对应数学中的全称量词(∀) 。在数理逻辑里有个 “平凡真” 的概念,简单说就是:如果一个集合是空集,那么 “这个集合里所有元素满足某条件” 这个说法,本身是成立的。

举个通俗的例子,我们说 “空盒子里的所有苹果都是红的”,因为盒子里根本没有苹果,也就不存在 “非红色的苹果” 来推翻这个说法,所以这个命题自然是真的。这也是[].every(...)会返回 true 的根本原因,是逻辑上的必然结果。

2. some:对应存在量词的 “平凡假”

some的核心语义是 “数组中至少有一个元素满足某个条件” ,对应数学中的存在量词(∃) 。同理,空集合里没有任何元素,自然不可能找到满足条件的那个元素,就像说 “空盒子里有一个红苹果”,显然是不成立的。所以[].some(...)返回 false,也是存在判断的必然结果。

光懂理论还不够,对于开发者来说,看得见的代码实现远比抽象的概念更易理解。接下来我们就用原生 JS 复刻这两个方法的核心实现,从代码层面看清楚背后的逻辑。

三、源码拆解:兜底值,是结果不同的关键

ECMAScript 规范中,对Array.prototype.everyArray.prototype.some的执行逻辑有明确定义,我们复刻的核心实现完全贴合原生逻辑,这也是理解原生方法最直接的方式 —— 亲手实现一遍,比看十遍文档更管用。

1. 复刻 Array.prototype.every

every的核心思路很简单:

  • 先给一个 “真” 的初始兜底值,遍历数组时只要遇到一个不满足条件的元素,就立刻把结果置为假并终止遍历;
  • 如果遍历完都没有反例,就保留初始的真。
Array.prototype.myEvery = function (callback, thisArg) {
  // 校验回调函数的合法性
  if (typeof callback !== 'function') {
    throw new TypeError(`${callback} is not a function`);
  }

  const arr = this;
  const len = arr.length;
  // 核心:初始兜底值设为true
  let result = true;

  // 空数组的len为0,会直接跳过这个循环
  for (let i = 0;< len; i++) {
    // 处理稀疏数组,跳过不存在的索引
    if (!arr.hasOwnProperty(i)) continue;
    // 执行回调并绑定this指向
    const isPass = callback.call(thisArg, arr[i], i, arr);
    // 有一个不满足,直接置假并终止遍历
    if (!isPass) {
      result = false;
      break;
    }
  }

  // 空数组直接返回初始的兜底值true
  return result;
};

// 测试,和原生方法结果完全一致
console.log([].myEvery(item => item > 0)); // true
console.log([1,2,3].myEvery(item => item > 0)); // true
console.log([1,-2,3].myEvery(item => item > 0)); // false

从代码里能清晰看到,空数组因为长度为 0,会直接跳过遍历循环,最终返回一开始设定的兜底值 true,这就是空数组调用 every 返回 true 的代码实锤。

2. 复刻 Array.prototype.some

some的实现思路和every呼应,只是初始兜底值做了调整:先给一个 “假” 的初始兜底值,遍历数组时只要遇到一个满足条件的元素,就立刻把结果置为真并终止遍历;如果遍历完都没有正例,就保留初始的假。

Array.prototype.mySome = function (callback, thisArg) {
  // 校验回调函数的合法性
  if (typeof callback !== 'function') {
    throw new TypeError(`${callback} is not a function`);
  }

  const arr = this;
  const len = arr.length;
  // 核心:初始兜底值设为false
  let result = false;

  // 空数组同样会直接跳过循环
  for (let i = 0; i< len; i++) {
    // 处理稀疏数组,跳过不存在的索引
    if (!arr.hasOwnProperty(i)) continue;
    // 执行回调并绑定this指向
    const isPass = callback.call(thisArg, arr[i], i, arr);
    // 有一个满足,直接置真并终止遍历
    if (isPass) {
      result = true;
      break;
    }
  }

  // 空数组直接返回初始的兜底值false
  return result;
};

// 测试,和原生方法结果完全一致
console.log([].mySome(item => item > 0)); // false
console.log([1,2,3].mySome(item => item > 5)); // false
console.log([1,6,3].mySome(item => item > 5)); // true

对比两个方法的实现代码,唯一的核心差异就是初始兜底值

  • every以 true 为兜底,没遇到反例就一直为真;
  • some以 false 为兜底,没遇到正例就一直为假。

空数组因为跳过了遍历,直接返回兜底值,这就是二者结果不同的根本原因。

四、开发中要注意的业务逻辑细节

理解了理论和源码,最终还是要落地到实际开发中。空数组的这个特性,在表单校验、列表筛选、数据判断等场景中,很容易因为忽略而引发小 bug,只要稍作处理就能避免。

典型场景:空列表的条件判断

举个电商开发的例子,我们需要校验购物车中的商品是否全部满足包邮条件(价格 > 100),满足的话就显示包邮按钮。如果直接写判断,就容易出问题:

// 考虑不周的写法:未判断数组是否为空
const cartList = []; // 用户还没加购任何商品
if (cartList.every(item => item.price > 100)) {
  showFreeShippingBtn(); // 会执行!因为空数组every返回true
}

显然,用户购物车为空时,不应该显示包邮按钮,这就是把逻辑上的 “真”,和业务上的 “合法” 搞混了。

正确解法:先校验数组非空,再做条件判断

无论使用every还是some,只要业务场景要求 “有数据的集合”,就先判断数组的长度,再执行后续的条件校验,这是最稳妥的方式。

// 严谨的写法:先判断数组非空,再执行判断
const cartList = [];
if (cartList.length > 0 && cartList.every(item => item.price > 100)) {
  showFreeShippingBtn();
} else if (cartList.length === 0) {
  showEmptyCartTip(); // 给用户展示空购物车提示,体验更好
}

再比如用some判断列表中是否有过期优惠券,虽然空数组返回 false 本身符合 “没有过期优惠券” 的逻辑,但如果需要区分 “空列表” 和 “有列表但无过期”,还是要单独判断:

const coupons = [];
if (coupons.some(item => item.isExpired)) {
  showExpiredTip();
} else if (coupons.length === 0) {
  showNoCouponTip(); // 空优惠券列表的专属提示
} else {
  showAllValidTip(); // 有优惠券且都未过期的提示
}

五、总结

其实空数组下every返真、some返假的现象,一点都不复杂,总结起来就是两层核心逻辑:

  1. 数学层面every是全称判断,空集合满足 “平凡真”;some是存在判断,空集合满足 “平凡假”,这是方法设计的底层依据;
  2. 代码层面every的初始兜底值为 true,some为 false,空数组会跳过遍历,直接返回兜底值。

希望看完这篇文章,你再遇到everysome的空数组场景时,能不再困惑,从容应对~

前端开发者的 AI 时代生存指南:大模型如何重塑岗位要求与技能

当 84% 的开发者用上 AI 编程助手,前端岗位正经历一场静悄悄的革命。
本文梳理了 AI 对前端的实际影响、大厂面试的新风向,并为你绘制了三份可落地的技能进化路线图。

7fbd8abc-7b71-4bc9-8216-a5b4534c5a7a.png

一、AI 已不是选择题,而是前端的默认环境

Stack Overflow 2024 年调查显示,84% 的开发者已在工作中使用 AI 编程助手。曾经作为“高级搜索”存在的 ChatGPT 和 GitHub Copilot,如今已成为前端工程师的代码导师、调试伙伴、创意合伙人

  • 效率跃升:有团队实测,借助 Copilot,页面组件开发速度提升约 70%,Bug 数量下降 60%。过去需要 2 天完成的页面搭建,现在通过自然语言提示半天即可交付。
  • 角色转变:前端不再只是“写页面”,而是 AI 训练师(设计高质量 Prompt)、架构师(设计 AI 功能的集成方式)、体验专家(让 AI 输出符合产品交互)。
  • 应用场景爆发:从自动生成组件/测试/文档,到内嵌智能聊天机器人、A/B 测试中的个性化内容生成(如 GPT-4 生成产品文案),再到图像/语音的前端处理,AI 正在渗透每个开发环节。

但工具越强大,对工程师的要求反而越高——你必须懂得如何驾驭它,而不是被它取代。

二、大厂前端面试:Prompt 设计与 RAG 成为新考点

阿里、字节、腾讯等一线公司已开始调整面试题库,传统“手写 Promise”之外,新增了 AI 落地能力的考察

1. 面试题风向标

  • Prompt 工程实战:“请为一个电商搜索框设计 Prompt,要求既能理解用户口语化输入(如‘便宜耐用的跑鞋’),又能返回结构化参数供前端渲染。”
  • AI 组件集成:“设计一个可复用的 React 组件,调用 OpenAI 的 Chat Completion API,并实现流式输出(SSE)与用户打断重试逻辑。”
  • RAG 原理与应用:“解释检索增强生成(RAG)的基本流程,如果要在前端实现一个文档问答助手,你会如何设计知识库的索引和检索逻辑?”

2. 技能要求升级

招聘 JD 中频繁出现:

  • 熟悉 TensorFlow.js / Brain.js 等前端推理库;
  • 掌握 LangChain / LlamaIndex 等 LLM 编排工具;
  • 具备 Agent(智能体) 开发思维,能设计多轮对话工作流;
  • 理解模型微调与 Prompt 优化的关系,能针对业务场景迭代 Prompt。

面试官不再满足于“你会用 Copilot”,而是考察你是否能在项目中引入 AI 能力,并保障其稳定性、性能和用户体验。

三、三条技能进化路线(附详细学习路径与资源)

deepseek_mermaid_20260225_f99800.png

面对新趋势,前端工程师需要选择适合自己的进阶方向。以下三条路线各有侧重,均包含核心技能、学习路径、实战建议

🚀 路线一:AI 工程化前端 —— 让模型在浏览器端“跑起来”

适合人群:对机器学习感兴趣,希望在前端直接集成 AI 能力(如图像识别、NLP 处理、实时推理)的工程师。

核心技能树

类别 技能点 学习资源推荐
前端基础 HTML5、CSS3、JavaScript/TypeScript、React/Vue/Angular MDN、Vue 官方文档、React 官方教程
前端 AI 库 TensorFlow.js、Brain.js、MediaPipe 《TensorFlow.js 实战》、Google Codelabs
LLM 原理 机器学习基础、深度学习概念、Transformer 架构、微调技术 李宏毅《机器学习》、吴恩达《Prompt Engineering for Developers》
工具链 LangChain、RAG 架构、Prompt 工程 LangChain 官方文档、DeepLearning.AI 课程
工程化 模块化设计、自动化测试 (Jest/Cypress)、性能优化、安全加固 《前端工程化:体系设计与实践》

实战建议

  1. 从玩具项目开始:用 TensorFlow.js 实现一个手写数字识别(MNIST)页面,理解模型加载与推理流程。
  2. 集成大模型 API:调用 OpenAI 或国内 API,做一个 AI 文案生成器,重点处理流式响应、错误重试、用户 Prompt 模板管理。
  3. 挑战 RAG 应用:基于 LangChain + 本地知识库,开发一个文档问答小助手(如公司内部 FAQ 机器人),实践向量检索与上下文注入。

职业前景

  • 智能客服、教育产品、创意工具(如 AI 海报生成)的前端核心开发;
  • 大模型应用公司的前端 AI 工程化岗位,起薪普遍高于普通前端 30%-50%。

🌐 路线二:全栈扩展 —— 从页面到云端,构建 AI 驱动的完整应用

适合人群:希望掌握后端、运维知识,能独立交付 AI 功能的全栈工程师。

核心技能树

类别 技能点 学习资源推荐
前端基础 HTML5、CSS3 (Flex/Grid)、JavaScript/TS、Vue/React/Angular、状态管理 《现代 JavaScript 教程》、React 官方文档
后端开发 Node.js/Express、Python/Django、Java/Spring 选其一;RESTful/GraphQL 设计 《Node.js 设计模式》、Django 官方教程
数据库 MySQL、PostgreSQL、MongoDB、Redis 《SQL 必知必会》、Redis 官方文档
运维与云 Docker、Kubernetes、CI/CD (Jenkins/GitLab CI)、云服务 (AWS/阿里云) 《Docker 实战》、阿里云 ACE 认证课程
AI 集成 大模型 API 集成、MLOps 流程、Kafka/Redis 数据处理、Flink 实时计算 《MLOps 实战》、Apache Kafka 官方文档

实战建议

  1. 构建一个 AI 应用后端:用 Python FastAPI 封装 OpenAI API,提供流式接口,前端用 React 消费。
  2. 容器化部署:将应用 Docker 化,使用 GitHub Actions 自动部署到云服务器(如阿里云 ECS)。
  3. 加入数据管道:用 Kafka 收集用户反馈,用 Flink 做实时统计,前端通过 WebSocket 展示实时看板。

职业前景

  • 中小型公司急需能独立交付 AI 产品的全栈工程师;
  • 可转型为 AI 应用架构师、技术负责人。

⚡ 路线三:传统交互与性能 —— 将体验打磨到极致

适合人群:热爱 UI/UX,追求页面流畅度、动画细节、跨端一致性的工程师。

核心技能树

类别 技能点 学习资源推荐
基础技术 HTML5、CSS3 (响应式)、JavaScript/TS、主流框架 《CSS 揭秘》、《You Don‘t Know JS》
性能优化 资源压缩 (Webpack/Vite)、懒加载/代码分割、SSR/CSR 切换、缓存策略、PWA 《Web 性能权威指南》、Chrome DevTools 文档
框架生态 Vue 全家桶、React 全家桶、微前端 (Qiankun)、跨端 (RN/Flutter) 各框架官方文档、umi 生态
用户体验与安全 可访问性 (a11y)、渐进增强、XSS/CSRF 防护、动画设计 (GSAP/Framer Motion) 《设计心理学》、MDN 安全指南

实战建议

  1. 性能优化专项:选择一个中等规模项目,用 Lighthouse 分析,实施图片优化、Bundle 分析、SSR 改造,记录优化前后数据。
  2. 微前端改造:将旧项目用 Qiankun 重构为微应用,实践独立开发与部署。
  3. 跨端体验:用 React Native 复刻一个已有 H5 页面,对比性能与交互差异,优化动画流畅度。

职业前景

  • 大厂体验技术部、基础架构组的核心岗位;
  • 随着 AI 生成内容增多,如何让 AI 内容以优雅方式呈现,成为新挑战,传统性能专家依然稀缺。

四、附:三条路线技能树(可保存为学习清单)

2e76ef16-c5eb-4812-bf25-ca977ddc99b0.png

AI 前端工程路线

维度 技能点
核心前端技术 HTML5, CSS3, JavaScript, TypeScript, React, Vue, Angular
AI 相关技能 机器学习基础, 深度学习概念, 大语言模型原理, Prompt 工程, 微调技术, RAG, LangChain, TensorFlow.js, Brain.js
开发工具与平台 GitHub Copilot, VSCode Copilot 插件, OpenAI/Baidu API, 智能测试与调试工具
工程化能力 模块化/组件化设计, 自动化测试 (Jest/Cypress), 性能优化, 安全加固

全栈扩展路线

维度 技能点
前端基础 HTML5, CSS3 (Flex/Grid), JavaScript/TypeScript, Vue/React/Angular, 状态管理 (Redux/Vuex)
后端技能 Node.js/Express/Koa, Python/Django, Java/Spring, 数据库 (MySQL/PostgreSQL/MongoDB/Redis), RESTful/GQL API 设计
运维与云 Docker, Kubernetes, CI/CD (Jenkins/GitLab CI), 云服务 (AWS/GCP/阿里云), 监控与日志
AI 集成 大模型 API 集成, MLOps 流程, 数据处理 (Kafka/Redis), 实时计算 (Flink)

传统交互与性能路线

维度 技能点
基础技术 HTML5, CSS3 (响应式布局), JavaScript/TypeScript, 前端框架 (Vue/React/Angular)
性能优化 资源压缩 (Webpack/Vite), 懒加载与代码分割, SSR/CSR 切换, 缓存策略, PWA
前端框架生态 Vue 全家桶, React 全家桶, 微前端框架 (Qiankun/Micro-Frontends), 跨端开发 (React Native/Flutter)
用户体验与安全 可访问性 (a11y), 渐进增强, 安全加固 (XSS/CSRF 防护), 动画与交互设计

五、写在最后:不要成为“被 AI 替代的人”,而要成为“驾驭 AI 的人”

AI 不会淘汰前端,但会用 AI 的前端会淘汰不用 AI 的前端。
在这个变革期,扎实的基础 + 对 AI 的深度理解是最大的护城河。无论你选择哪条路线,都建议:

  • 保持好奇心:每周花 2 小时尝试一个新 AI 工具或库;
  • 动手做项目:把 AI 集成到自己的小应用中,踩过坑才能真正理解;
  • 关注大厂动态:研究他们的 AI 产品前端实现,比如 Notion AI、钉钉 AI 助理的交互设计。

前端的世界变化很快,但也因此充满机会。希望这份指南能帮你找到自己的方向。

如果你正在准备面试,或者对某条路线有疑问,欢迎在评论区留言,我们一起探讨。

从异步探索者到现代信使:JavaScript数据请求的进化之旅


想象一下,你正在浏览一个网页,点击了一个按钮,页面的一部分内容瞬间刷新,而整个页面并没有重新加载。这背后,是一位名为Ajax的“异步探索者”在默默工作。今天,就让我们揭开这位探索者的面纱,并认识它的继任者——更加优雅的“现代信使”。

第一幕:古典的探索者——XMLHttpRequest

我们的故事始于一个名为XMLHttpRequest(简称XHR)的对象。文档中的代码向我们展示了这位古典探索者的标准工作流程:

  1. 整装待发(实例化) :探险的第一步是召唤这位探索者。const xhr = new XMLHttpRequest();这行代码就如同为他配备好了行囊。

  2. 规划路线(打开请求) :接着,探索者需要明确目的地和方式。xhr.open('GET', 'https://api.github.com/orgs/lemoncode/members', true)这行指令告诉他:“使用GET方法,前往这个API地址获取数据,并且以异步(async: true) 的方式前进。” 这里文档留下了一个悬念:truefalse的区别是什么?简单来说,true(异步)意味着探险家出发后,你不必原地傻等,可以继续处理其他事情;而false(同步)则会让你一直等到他归来才能做别的事,这通常会阻塞页面,导致糟糕的用户体验,因此现代开发中已极少使用。

  3. 正式启程(发送请求) :一声令下,xhr.send();,探索者踏上了征途。

  4. 监听消息(事件处理) :探索者不会不告而别。我们通过xhr.onreadystatechange事件来监听他的状态。文档清晰地列出了他旅程中的五个关键驿站(readyState):

    • 0 (UNSENT) :刚召唤出来,还没规划路线。
    • 1 (OPENED) :路线已规划好(open方法已被调用)。
    • 2 (HEADERS_RECEIVED) :已抵达目的地,收到了对方的初步回应(响应头)。
    • 3 (LOADING) :正在接收对方带来的具体货物(响应体)。
    • 4 (DONE) :任务彻底完成!所有货物(响应)已接收完毕。

只有当探索者抵达终点站(readyState === 4),并且对方表示任务成功(status === 200)时,我们才能安全地打开他带回的“包裹”——xhr.responseText。这份包裹通常是文本格式,我们需要用JSON.parse()将其解析成JavaScript能轻松处理的对象。最后,文档展示了如何将这些数据动态地更新到网页的列表(<ul id="members">)中,实现了页面的局部刷新。

这就是Ajax的核心魔法:异步的JavaScript与数据交换(如今主要是JSON,而非早期的XML) 。它让网页从静态文档变成了能与服务器动态对话的应用程序。

第二幕:优雅的现代信使——Fetch API与Promise

尽管XHR探索者功勋卓著,但他的工作方式略显繁琐,尤其是处理复杂的异步流程时,容易陷入“回调地狱”。于是,更现代的“信使”——fetch API携带着Promise这一强大的契约书登场了。

Promise:一份未来契约

Promise是一个对象,它代表一个异步操作的最终完成(或失败) 及其结果值。你可以把它想象成一份契约书:

  • 待定(Pending) :契约已签订,结果未知。
  • 已兑现(Fulfilled) :操作成功完成,契约兑现,带有结果值。
  • 已拒绝(Rejected) :操作失败,契约被拒,带有失败原因。

它允许你使用.then().catch().finally()这些清晰的方法来链式处理成功或失败,让异步代码的流程看起来更像同步代码,逻辑一目了然。

Fetch API:基于Promise的优雅请求

现在,让我们用fetch重写文档中的那个任务,感受一下现代信使的优雅:

// 使用fetch发起同样的请求
fetch('https://api.github.com/orgs/lemoncode/members')
  .then(response => {
    // 首先检查请求是否成功(类似于检查status===200)
    if (!response.ok) {
      throw new Error(`网络响应异常: ${response.status}`);
    }
    // 将响应体解析为JSON(这本身也返回一个Promise)
    return response.json();
  })
  .then(data => {
    // 在这里,data已经是解析好的JavaScript对象
    console.log(data);
    document.getElementById('members').innerHTML = data.map(item => `<li>${item.login}</li>`).join('');
  })
  .catch(error => {
    // 统一处理请求失败或JSON解析失败等所有错误
    console.error('请求过程中出现错误:', error);
  });

看,整个过程变得多么简洁流畅!fetch()函数直接返回一个Promise对象。我们通过.then()链式处理:第一个.then检查响应状态并开始解析JSON,第二个.then接收解析好的数据并更新DOM。任何环节出错,都会滑落到最后的.catch()中进行统一错误处理。

更进一步的优雅:Async/Await

Promise的基础上,ES7引入了async/await语法糖,让异步代码的书写和阅读几乎与同步代码无异:

async function fetchMembers() {
  try {
    const response = await fetch('https://api.github.com/orgs/lemoncode/members');
    if (!response.ok) throw new Error(`网络响应异常: ${response.status}`);
    const data = await response.json();
    document.getElementById('members').innerHTML = data.map(item => `<li>${item.login}</li>`).join('');
  } catch (error) {
    console.error('请求过程中出现错误:', error);
  }
}
fetchMembers();

async声明一个异步函数,await则“等待”一个Promise完成。代码自上而下执行,逻辑异常清晰。

总结

从手动管理状态码、监听状态变化的XMLHttpRequest,到基于契约(Promise)、写法简洁直观的Fetch API,再到使用async/await实现近乎同步的优雅语法,JavaScript数据请求的方式完成了一次华丽的进化。文档为我们夯实了古典Ajax的基石,而这条进化之路则指引我们走向更高效、更可维护的现代前端开发。理解XHR,让你知其然也知其所以然;掌握Fetch与Promise,则让你在开发中如鱼得水,挥洒自如。

React 核心揭秘:虚拟 DOM 原理与 Diff 算法深度解析

在前端工程化领域,React 的虚拟 DOM(Virtual DOM)机制经常被误解。许多开发者认为“虚拟 DOM 的引入是为了提升性能”,这一观点既不准确也不严谨。

本文将从源码架构视角,深入剖析 React 虚拟 DOM 的内存结构、安全性设计,以及 Reconciler(协调器)层核心的 Diff 算法实现。

一、引言:打破“虚拟 DOM 更快”的迷思

首先必须澄清一个技术事实:没有任何框架的运行时性能可以超越极致优化的原生 DOM 操作。

虚拟 DOM 本质上是 JavaScript 对象,React 在每一次更新时,都需要经过“创建对象 -> Diff 比对 -> 生成 Patch -> 更新真实 DOM”这一过程。相比直接操作 innerHTML 或 appendChild,它多出了繁重的 JS 计算层。

既然如此,为何 React 依然选择虚拟 DOM?其核心价值在于:

  1. 性能下限的保障:手动优化 DOM 操作极其依赖开发者水平。虚拟 DOM 结合批处理(Batch Update)机制,提供了一个“足够快”的性能下限,避免了低效 DOM 操作导致的页面卡顿。
  2. 跨平台能力:虚拟 DOM 是对 UI 的抽象描述(Abstract Syntax Tree of UI)。这一抽象层使得 React 可以通过不同的渲染器(Renderer)映射到不同平台:Web 端映射为 DOM,Native 端映射为原生视图(React Native),甚至映射为 PDF 或终端 UI。
  3. 声明式编程与开发效率:开发者只需关注状态(State)的变化,无需手动维护 DOM 状态,极大降低了应用复杂度。

二、核心结构:虚拟 DOM 在内存中的形态

React 的开发流程经历了 JSX -> Babel 编译 -> React.createElement -> ReactElement 对象的转化过程。

1. 内存结构与 React.createElement

JSX 仅仅是语法糖。在编译时,标签会被转换为 React.createElement 调用。该函数的主要职责是处理参数,构建并返回一个描述节点的 JavaScript 对象,即虚拟 DOM 节点(VNode)。

JavaScript

// 简化的 ReactElement 结构演示
const ReactElement = function(type, key, ref, props, owner) {
  const element = {
    // 核心安全标识
    $$typeof: REACT_ELEMENT_TYPE,

    // 元素的内置属性
    type: type,
    key: key,
    ref: ref,
    props: props,

    // 记录创建该元素的组件
    _owner: owner,
  };

  return element;
};

2. $$typeof 与 XSS 防御

在上述结构中,$$typeof 属性至关重要,它是 React 防止 XSS 攻击的一道防线。

攻击场景:假设服务器端存在漏洞,允许用户存储任意 JSON 对象,而前端直接将该对象作为组件渲染。黑客可以构造一个恶意的 JSON 对象来模拟 ReactElement。

防御机制
REACT_ELEMENT_TYPE 是一个 Symbol 类型的值:

JavaScript

const REACT_ELEMENT_TYPE = Symbol.for('react.element');

由于 JSON 不支持 Symbol 类型,当数据经过 JSON.stringify 序列化再传输时,Symbol 会丢失。React 在渲染时会严格校验 element.$$typeof === REACT_ELEMENT_TYPE。如果数据来自不受信任的服务端 JSON,该属性将缺失或无效,React 会拒绝渲染,从而拦截潜在的 XSS 攻击。

三、算法揭秘:Diff 算法的设计权衡

React 的核心是协调(Reconciliation),即通过 Diff 算法计算新旧虚拟 DOM 树差异的过程。

在计算机科学中,计算两棵树的最小编辑距离(Edit Distance)的标准算法复杂度为 

O(n3)O(n3)

。对于一个包含 1000 个节点的应用,这将导致 10 亿次计算,在浏览器端显然不可接受。

为了将复杂度降低至 

O(n)O(n)

,React 基于 Web UI 的特点,实施了大胆的启发式算法(Heuristic Algorithm) ,主要基于以下三大策略:

策略一:分层比较(Tree Diff)

Web UI 中,DOM 节点跨层级移动的操作极其罕见。React 选择忽略跨层级的节点移动

Diff 算法只对同一层级的节点进行比较。如果一个 DOM 节点在更新前后跨越了层级,React 不会尝试复用它,而是直接销毁旧节点,并在新位置重新创建新节点。

策略二:类型检查(Component Diff)

React 认为:不同类型的组件产生的树结构几乎完全不同。

  • 如果组件类型(type)发生变化(例如从 div 变为 p,或从 ComponentA 变为 ComponentB),React 会判定为“脏组件”,不再深入比较子树,直接销毁旧组件及其所有子节点,并创建新组件。
  • 如果组件类型相同,则认为结构相似,仅更新属性(Props),并递归比对子节点。

策略三:Key 标识(Element Diff)

对于同一层级的一组子节点,开发者可以通过 key 属性提供唯一标识。React 使用 key 来判断节点是否仅仅是发生了位置移动,从而复用现有 DOM 节点,避免不必要的销毁和重建。

四、源码级复盘:如何遍历与比对(Diff Flow)

React 的 Diff 过程本质上是一个**深度优先遍历(DFS)**的过程。从根节点开始,沿着深度向下比较,直到叶子节点,然后回溯。

以下通过简化的伪代码,展示 React 协调器的核心比对流程:

JavaScript

/**
 * 简化的 Diff 算法逻辑
 * @param {HTMLElement} parentNode 父真实DOM
 * @param {Object} oldVNode 旧虚拟DOM
 * @param {Object} newVNode 新虚拟DOM
 */
function diff(parentNode, oldVNode, newVNode) {
  // 1. 如果新节点不存在,说明被删除了
  if (!newVNode) {
    parentNode.removeChild(oldVNode.dom);
    return;
  }

  // 2. 如果旧节点不存在,说明是新增
  if (!oldVNode) {
    const newDOM = createDOM(newVNode);
    parentNode.appendChild(newDOM);
    return;
  }

  // 3. 节点类型变化或 Key 变化:暴力替换
  if (
    oldVNode.type !== newVNode.type ||
    oldVNode.key !== newVNode.key
  ) {
    const newDOM = createDOM(newVNode);
    parentNode.replaceChild(newDOM, oldVNode.dom);
    return;
  }

  // 4. 类型相同:复用 DOM,更新属性
  const el = (newVNode.dom = oldVNode.dom);
  updateProps(el, oldVNode.props, newVNode.props);

  // 5. 递归处理子节点 (Children Diff)
  diffChildren(el, oldVNode.children, newVNode.children);
}

/**
 * 子节点对比:利用 Map 进行 O(1) 查找
 */
function diffChildren(parentDOM, oldChildren, newChildren) {
  // 建立旧节点的 Map 索引:Key -> Node
  const keyMap = {};
  oldChildren.forEach((child, index) => {
    const key = child.key || index;
    keyMap[key] = child;
  });

  // 记录上一个不需要移动的节点索引
  let lastIndex = 0;

  newChildren.forEach((newChild, index) => {
    const key = newChild.key || index;
    const oldChild = keyMap[key];

    if (oldChild && oldChild.type === newChild.type) {
      // 命中缓存:复用节点
      diff(parentDOM, oldChild, newChild);
      
      // 判断是否需要移动
      if (oldChild.index < lastIndex) {
        // 如果当前旧节点的位置在 lastIndex 之前,说明它被“插队”了,需要移动真实 DOM
        // 伪代码:parentDOM.insertBefore(newChild.dom, refNode);
      } else {
        // 不需要移动,更新 lastIndex
        lastIndex = oldChild.index;
      }
    } else {
      // 未命中:创建新节点
      const newDOM = createDOM(newChild);
      // 插入逻辑...
    }
  });

  // 清理 keyMap 中未被复用的旧节点(删除操作)
  // ...
}

关键点解析

  1. DFS 遍历:React 会优先深入处理子节点。当父节点属性更新完毕后,立即进入 diffChildren。

  2. Key Map 优化:在 diffChildren 阶段,通过构建 keyMap,React 将查找复用节点的时间复杂度从 

    O(n2)O(n2)
    

     降低到了 

    O(n)O(n)
    

  3. LastIndex 移动判定:React 维护一个 lastIndex 游标。如果复用的节点在旧集合中的索引小于 lastIndex,说明该节点在新集合中被移到了后面,此时执行 DOM 移动操作;否则保持不动。这是一种基于顺序优化的策略。

五、总结

React 的虚拟 DOM 并非为了追求极致的单次渲染性能,而是为了提供可维护性、跨平台能力和性能安全感

Diff 算法通过放弃对跨层级移动的支持、假设不同类型产生不同树、以及利用 Key 进行同级复用这三大启发式策略,成功将复杂的 

O(n3)O(n3)

 树比对问题转化为线性的 

O(n)O(n)

 问题。理解这一机制,不仅有助于编写高性能的 React 组件,更是深入掌握现代前端框架设计哲学的必经之路。

一文读懂:CommonJS 和 ES Module 的本质区别

面试官:你能说说 CommonJS 和 ES Module 的区别吗?
我:……(脑子里只剩下 requireimport

说实话,这个问题你一定见过,而且99% 的前端都背过标准答案
但真要往深了问一句:

  1. 为什么 ESM 可以 Tree Shaking?CommonJS 不行
  2. 为什么 ESM 的 import 是“只读的”?

很多人,当场就开始“CPU 过载”。

于是我决定直接把底层逻辑捋清楚,以下就是我对 CommonJS 和 ES Module 一次系统性深挖的记录

一、什么是 CommonJS?它解决了什么问题?

1. CommonJS 的诞生背景

在早期 JavaScript 只有浏览器环境时,是没有模块系统的

  • 全局变量污染
  • 文件之间依赖混乱
  • 无法复用代码

于是 Node.js 社区提出了一套解决方案:CommonJS(CMJ)

👉 注意:CommonJS 是社区标准,不是官方语言层面的规范。

CommonJS 的核心特征

  • ✅ 社区标准
  • ✅ 使用函数实现(require
  • ✅ 仅 Node 环境支持
  • ✅ 动态依赖,同步执行

2. CommonJS 为什么叫“动态依赖”?

来看一段最典型的代码:

const moduleName = './a.js';
const a = require(moduleName);

这里的依赖路径,是不是运行时才能确定?这就是动态依赖;

CommonJS 的依赖关系,必须等代码执行时才能知道


3. require 到底做了什么?(核心原理)

你在 Node 中写的:

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

但如果我追问一句:require 加载的模块代码,是“直接执行”的吗? 模块里的 this、exports、module.exports 到底从哪来的?

答案其实藏在 Node.js 对模块的一层“函数包装”里:

function require(path) {
   const cache = {}
  // 1. 如果模块已经加载过,直接返回缓存
  if (cache[path]) {
    return cache[path].exports;
  }

  // 2. 创建模块对象
  const module = {
    id:path
    exports: {}
  };

  // 3. 执行模块代码(用函数包一层)
  function _run(exports, require, module, __filename, __dirname) {
    // 模块源码在这里执行
  }

  _run.call(
    module.exports,
    module.exports,
    require,
    module,
    __filename,
    __dirname
  );

  // 4. 缓存并返回结果
  cache[modulePath] = module;
  return module.exports;
}

假设你有一个文件 a.js,那么文件中的内容会放到上面的_run函数中执行

我们拆开来看:

名称 实际指向
this module.exports
exports module.exports
module.exports module.exports

在模块初始化阶段,这三个引用的是同一个对象。

所以以下判断永远成立:

console.log(arguments); // [exports, require ,module, __filename, __dirname]
console.log(this); // {}
console.log(this === exports); // true
console.log(exports === module.exports); // true

重点来了

  • require 是一个普通函数
  • module.exports 是一个普通对象
  • 模块执行是同步的
  • 导出的值是一次性的值拷贝

二、ES Module:语言层面的模块系统

如果说 CommonJS 是“工具方案”,那么 ES Module(ESM)就是 JavaScript 官方给出的答案

ES Module 的核心关键

  • ✅ 官方标准(ECMAScript)
  • ✅ 使用新语法(import / export
  • 所有环境支持(浏览器 / Node / Deno)
  • ✅ 同时支持静态依赖 & 动态依赖
  • 符号绑定

1. 什么是「静态依赖」?

import { a } from './a.js';

这行代码有两个关键点:

  1. import只能写在顶层
  2. 依赖路径在代码运行前就确定

👉 这意味着什么?

  • 构建工具在编译阶段就能分析依赖
  • 支持Tree Shaking
  • 可以做代码分割、预加载

这也是为什么 ESM 更适合前端工程化


2. ESM 也支持动态依赖,但它是异步的

import('./a.js').then(module => {
  console.log(module.a);
});

和 CommonJS 最大的不同点:

模块系统 动态依赖
CommonJS 同步
ES Module 异步

3. 符号绑定:ESM 最容易被忽略

这是 ESM 和 CommonJS 的本质区别

看一段代码

// a.js
export var a = 1;
export function changeA() {
  a = 2;
}
// index.js
import { a, changeA } from './a.js';
console.log(a); // 1
changeA();
console.log(a); // 2

这里为什么 a 会跟着变化?

真相就是 import 不是赋值,而是“引用同一个符号”

在 ESM 中: 导入的不是值,而是对导出符号的实时绑定

可以理解为:

  • a 在模块内部是一个变量
  • 所有 import 的地方,都指向同一个 a
  • 修改它,所有地方同步变化

这就是「符号绑定(Live Binding)」。


对比 CommonJS(非常关键)

// a.js
var n = 1;
function changeN() {
  n = 2;
}
module.exports = {
  n,
  changeN
}

// b.js
const { n, changeN } = require('./a.js');
console.log(n); // 1
changeN();
console.log(n); // 1

这里的 n

  • 是一次值拷贝
  • 后续模块内部怎么改,外面都不会同步

4. 再看下 下面几个问题

(1) export 和 export default 的区别

  • export:具名导出,可多个
  • export default:默认导出,只能一个
  • 默认导出本质是 { default: xxx }

(2) 下面代码导出了什么?

exports.a = 'a';
module.exports.b = 'b';
this.c = 'c';
module.exports = {
  d: 'd'
};

结果:

{ d: 'd' }

(3)下面代码导出了什么?

exports.a = 1;  
exports = { b: 2 };

结果:

{ a: 1 }

原因是:

  • exports 只是 module.exports 的一个引用
  • 当你重新给exports赋值时,只是断开了引用关系module.exports 并没有变
  • 等价于 let exports = module.exports; exports = {}; 只是改了局部变量

TS 基础扫盲:类型、接口、类型别名在业务代码里的最小集合

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、开篇:为什么要关心 TS 类型?

日常业务里经常会遇到:

  • 类型报错Object is possibly 'undefined'Type 'string' is not assignable to type 'number'
  • 不知道选什么anyunknowninterfacetype 什么时候用?
  • 写了很多 TS 却像在写 JS:到处用 any,类型形同虚设

TypeScript 的核心是「类型约束」,把很多问题在编译期暴露出来。但很多人要么写太多类型最后变玄学,要么只会 any,形同 JS。

这篇文章不讲特别深的底层原理,而是围绕:平时写业务时该怎么选、为什么这么选、容易踩哪些坑。从基础类型 → 接口 → 类型别名 → 实战选型 → 踩坑,一次性理清。

二、基础类型扫盲

先把日常最常用的 5 个类型搞清楚。

类型 含义 典型用途
string 字符串 文案、id、枚举值
number 数字(含整数、浮点、NaN) 数量、金额、分页
boolean 布尔 开关、状态
any 任意类型,不做检查 兼容老代码、临时兜底
unknown 任意类型,但必须先检查再用 比 any 更安全的兜底

2.1 string / number / boolean

这三个是基础原始类型,和 JS 里的用法一致,只是加了一层类型标注:

// 变量声明时标注类型
const name: string = '张三';
const age: number = 25;
const isActive: boolean = true;

// 函数参数和返回值
function greet(name: string): string {
  return `你好,${name}`;
}

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

业务里怎么用:接口返回值、表单字段、状态开关,优先用这三个而不是 any

2.2 any:最自由也最危险

any 表示「任意类型」,TS 不再做类型检查。

let data: any = 'hello';
data = 123;        // OK
data = { a: 1 };   // OK
data.toUpperCase(); // 编译通过,但运行时报错!data 实际是 number

问题any 会关闭类型检查,等于回到裸写 JS,很容易在运行时才发现错误。

适用场景

  • 临时接入老接口、第三方库,还没时间写类型
  • 快速迁移 JS 项目到 TS 时的过渡
  • 已经用 try-catch 等做了安全兜底

建议:能不用就不用,用的话尽量加注释说明原因。

2.3 unknown:比 any 更安全的兜底

unknown 也表示「任意类型」,但使用时必须先「收窄」类型,否则不能直接用。

let data: unknown = getFromApi(); // 不知道接口返回什么

// 直接调用会报错
// data.toString();  // Error: 'data' is of type 'unknown'

// 先判断类型再使用
if (typeof data === 'string') {
  console.log(data.toUpperCase()); // OK
} else if (typeof data === 'object' && data !== null && 'name' in data) {
  console.log((data as { name: string }).name); // 收窄后可安全使用
}

和 any 的对比

特性 any unknown
可直接调用方法 ❌ 必须先收窄
可赋给任意类型
类型安全 有(需检查后才用)

建议:拿不到确切类型时,用 unknown 代替 any,通过 typeofin、类型守卫等方式收窄后再用。

三、interface:描述对象形状

interface 用来描述「对象长什么样」:有哪些属性、什么类型、哪些可选。

3.1 基本用法

// 定义用户接口
interface User {
  id: number;
  name: string;
  age?: number;  // 可选属性
}

// 使用
const user: User = {
  id: 1,
  name: '张三'
  // age 可省略
};

3.2 可选属性、只读属性

interface Config {
  readonly apiUrl: string;  // 只读,不能改
  timeout?: number;         // 可选
}

const config: Config = { apiUrl: 'https://api.example.com' };
// config.apiUrl = 'xxx';  // Error: 只读

3.3 继承

interface BaseUser {
  id: number;
  name: string;
}

interface AdminUser extends BaseUser {
  role: 'admin';
  permissions: string[];
}

const admin: AdminUser = {
  id: 1,
  name: '管理员',
  role: 'admin',
  permissions: ['read', 'write']
};

3.4 索引签名(动态属性)

// 属性名是 string,值是 number
interface StringMap {
  [key: string]: number;
}

const map: StringMap = {
  a: 1,
  b: 2
};

业务场景:后端返回的用户、列表项、配置对象等,用 interface 描述结构最合适。

四、type 类型别名:给类型起个名字

type 用来给任意类型起别名,可以是基础类型、对象、联合类型、函数等。

4.1 基本用法

// 基础类型别名
type UserId = number;
type UserName = string;

// 对象类型
type User = {
  id: UserId;
  name: UserName;
};

// 联合类型(常见于业务)
type Status = 'pending' | 'success' | 'error';
type Theme = 'light' | 'dark';

4.2 联合类型、交叉类型

// 联合:A 或 B
type Result = { success: true; data: any } | { success: false; error: string };

// 交叉:A 且 B 的属性合并
type WithTimestamp = User & { createdAt: Date };

4.3 函数类型

type OnChange = (value: string) => void;
type FetchUser = (id: number) => Promise<User>;

业务场景:状态枚举、回调类型、联合类型等,用 type 更合适。

五、interface vs type:怎么选?

这是问得最多的一个问题,先看核心区别:

特性 interface type
声明合并 ✅ 同名可合并 ❌ 同名会报错
继承 extends & 交叉类型
适用对象 对象结构 任意类型
扩展对象 容易 容易
联合/交叉 不常用 常用

5.1 声明合并(interface 独有)

// interface 同名会合并
interface User {
  name: string;
}
interface User {
  age: number;
}
// 等价于 { name: string; age: number }

// type 同名会报错
type User = { name: string };
type User = { age: number };  // Error: 重复声明

业务含义:写插件、扩展第三方类型定义时,用 interface 可以多次补充属性;而 type 只能定义一次。

5.2 选型建议

用 interface

  • 描述对象结构(用户、配置、接口返回值等)
  • 有继承需求(如 extends BaseUser
  • 可能被第三方或插件扩展(依赖声明合并)

用 type

  • 联合类型:'pending' | 'success' | 'error'
  • 交叉类型:User & { role: string }
  • 函数类型:(id: number) => Promise<User>
  • 元组、复杂组合类型

实践中:对象结构优先 interface,其它复杂类型用 type。两者都能描述对象时,很多团队会统一用 interface,可读性更好。

六、实战场景:该怎么写

6.1 接口返回值

// 用 interface 描述
interface UserListItem {
  id: number;
  name: string;
  avatar?: string;
  status: 'active' | 'inactive';
}

interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

// 使用
async function fetchUserList(): Promise<ApiResponse<UserListItem[]>> {
  const res = await axios.get('/api/users');
  return res.data;
}

6.2 表单、状态枚举

// 用 type 做联合
type FormStatus = 'draft' | 'submitting' | 'success' | 'error';
type SortOrder = 'asc' | 'desc';

interface FilterState {
  status: FormStatus;
  sortBy: string;
  sortOrder: SortOrder;
}

6.3 事件回调

type OnSearch = (keyword: string) => void;
type OnPageChange = (page: number, size: number) => void;

interface TableProps {
  onSearch: OnSearch;
  onPageChange: OnPageChange;
}

6.4 拿不准类型时用 unknown

async function fetchData(url: string): Promise<unknown> {
  const res = await fetch(url);
  return res.json();
}

// 使用时必须收窄
const data = await fetchData('/api/config');
if (data && typeof data === 'object' && 'theme' in data) {
  const theme = (data as { theme: string }).theme;
  // 安全使用
}

七、踩坑指南

原因 建议
到处用 any,类型失效 any 关闭类型检查 尽量用 unknown,或用具体类型
Object is possibly 'undefined' 可能为 undefined 却直接访问 可选链 obj?.propif 判断、! 断言
interface 和 type 混用一团 团队没约定 对象用 interface,联合/函数用 type
对象字面量多了属性报错 多余属性检查 用变量接收再传入,或加索引签名
第三方库没有类型 老库、非 TS 编写 .d.ts@ts-ignore,标注原因

7.1 多余属性检查

interface User {
  id: number;
  name: string;
}

// 直接传字面量时,多了属性会报错
// createUser({ id: 1, name: '张三', age: 18 });  // Error

// 用变量接收再传则不会(会按结构兼容)
const user = { id: 1, name: '张三', age: 18 };
createUser(user);  // OK

7.2 类型断言要谨慎

// as 断言:你说它是什么,TS 就信
const data = getData() as User;  // 若实际不是 User,运行时可能崩

// 更安全的做法:用类型守卫
function isUser(obj: unknown): obj is User {
  return obj !== null && typeof obj === 'object' && 'id' in obj && 'name' in obj;
}

八、小结

概念 一句话 典型场景
string/number/boolean 基础类型,优先用 接口字段、函数参数、状态
any 任意类型,无检查 临时兜底、兼容老代码,少用
unknown 任意类型,用前须收窄 拿不准类型时的安全选择
interface 描述对象结构 用户、配置、接口返回值
type 类型别名,可联合/交叉 状态枚举、函数类型、复杂组合

记住三点:

  1. 能用具体类型就不用 any,拿不准就用 unknown 再收窄。
  2. 对象结构用 interface,联合/函数/复杂类型用 type
  3. 业务里够用就行,不必一开始就追求完美,先让类型系统帮你兜住大部分错误。

把类型选对,编译期就能发现很多问题,后面的重构和维护都会轻松很多。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

深入React19任务调度器Scheduler

Scheduler = 最小堆 + MessageChannel + 时间片检查

它的目标只有一个:推进任务,但永远别饿死浏览器。

调度任务

React 19 最新 Scheduler 源码**(packages/scheduler)** 中,普通任务(非 Immediate,同步优先级之外的任务)的完整调度链:

unstable_scheduleCallback
  ↓
requestHostCallback
  ↓
schedulePerformWorkUntilDeadline
  ↓
performWorkUntilDeadline
  ↓
flushWork
  ↓
workLoop
  ↓
shouldYieldToHost
  ↓
advanceTimers

Scheduler 本质是一个“带时间片控制的最小堆 + 宏任务驱动循环”调度器。

一、调度的起点:unstable_scheduleCallback

源码位置(React 19):

/packages/scheduler/src/forks/Scheduler.js
let getCurrentTime = () => performance.now();

// 为所有任务维护了连个最小堆(每次从队列里面取出来的都是优先级最高(时间即将过期))
// timerQueue     // 延时任务队列(task.startTime > now) —— 按 startTime 排序(延迟最小的先看)
// taskQueue      // 立即可执行的任务(task.startTime <= now)—— 按 expirationTime 排序(最紧急的先执行)

// Timeout 对应的值
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; // 1073741823

/**
 * 调度任务的入口
 * @param {*} priorityLevel 优先级等级
 * @param {*} callback 任务回调函数
 * @param {*} options { delay: number } 该对象有 delay 属性,表示要延迟的时间(决定 expirationTime)
 * @returns
 */
 function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 获取当前的时间
  var currentTime = getCurrentTime();

  var startTime;
  // 设置起始时间 startTime:如果有延时 delay,起始时间需要添加上这个延时,否则起始时间就是当前时间
  if (typeof options === "object" && options !== null) {
    var delay = options.delay;
    if (typeof delay === "number" && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }

  var timeout;
  // 根据传入的优先级等级来设置不同的 timeout
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT; // -1
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT; // 250
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT; // 1073741823
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT; // 10000
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT; // 5000
      break;
  }
  // 接下来就计算出过期时间
  // 只有 ImmediatePriority 任务 比当前时间要早,其他任务都会不同程度的延迟
  var expirationTime = startTime + timeout;

  // 创建一个新的任务
  var newTask = {
    id: taskIdCounter++, // 任务 id
    callback, // 执行任务回调函数 export type Callback = boolean => ?Callback;
    priorityLevel, // 任务的优先级
    startTime, // 任务开始时间
    expirationTime, // 任务的过期时间
    sortIndex: -1, // 用于小顶堆优先级排序,始终从任务队列中拿出最优先的任务
  };
  if (enableProfiling) {
    newTask.isQueued = false;
  }

  if (startTime > currentTime) {
    // 说明这是一个延时任务
    newTask.sortIndex = startTime;
    // 将该任务推入到 timerQueue 的任务队列中
    push(timerQueue, newTask);
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // 说明 taskQueue 里面的任务已经全部执行完毕,然后从 timerQueue 里面取出一个优先级最高的任务作为此时的 newTask
      if (isHostTimeoutScheduled) {
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // 如果是延时任务,调用 requestHostTimeout 进行延时任务的调度
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // 说明不是延时任务
    newTask.sortIndex = expirationTime; // 设置了 sortIndex 后,在任务队列里面进行一个排序
    push(taskQueue, newTask);
    if (enableProfiling) {
      markTaskStart(newTask, currentTime);
      newTask.isQueued = true;
    }
    // 最终调用 requestHostCallback 进行普通任务的调度
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    }
  }

  // 向外部返回任务
  return newTask;
}

它的职责是:

  1. 根据优先级计算 timeout
  2. 构造一个 Task 对象
  3. 放入任务到对应的最小堆
  4. 请求宿主开始调度(requestHostCallback 普通任务 / requestHostTimeout 延时任务)

任务执行时刻:

IMMEDIATE_PRIORITY_TIMEOUT -> currentTime -> USER_BLOCKING_PRIORITY_TIMEOUT -> IDLE_PRIORITY_TIMEOUT

二、普通任务调用 requestHostCallback

当普通任务进入 taskQueue 后,调用 requestHostCallback,然后调用 schedulePerformWorkUntilDeadline

/**
 * 
 * @param {*} callback 是在调用的时候传入的 flushWork
 * requestHostCallback 这个函数没有做什么事情,主要就是调用 schedulePerformWorkUntilDeadline
 */
 function requestHostCallback() {
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();// 实例化 MessageChannel 进行后面的调度
  }
}

let schedulePerformWorkUntilDeadline; // undefined
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // 大多数情况下,使用的是 MessageChannel
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // setTimeout 进行兜底
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

schedulePerformWorkUntilDeadline 根据不同的环境选择不同的生成宏任务的方式。大多数都是 MessageChannel :

  • 每一次调度推进
  • 都是一个新的宏任务
  • 浏览器中间有机会 paint

三、延时任务调用 requestHostTimeout & handleTimeout

React Scheduler 不直接使用 setTimeout,而是抽象成 HostConfig,可在不同环境实现。

requestHostTimeout(callback, ms) 这个函数会安排一个底层超时回调。

在浏览器环境下它一般等价于:

const timeoutID = setTimeout(callback, ms);

注意:这是 严格意义上的延时调度,用于把 timerQueue 的任务唤醒进 taskQueue。

requestHostTimeout 实际上就是调用 setTimoutout,然后在 setTimeout 中,调用传入的 handleTimeout。

注意:延时任务只需要“不会提前执行”,而不需要“精准执行”,所以这里使用了 setTimeout,setTimeout 只是负责“唤醒” Scheduler,不负责精度和优先级(expirationTime 控制)控制,鉴于负责延时和必须是宏任务的特性,这里使用 setTimeout 最合适。

React 的调度精度来自:MessageChannel + 时间片检查 + expirationTime 。

handleTimeout 是真正被 setTimeout 调用的函数:

function handleTimeout(currentTime) {
  // 遍历 timerQueue,将时间已经到了的延时任务放入到 taskQueue
  advanceTimers(currentTime);
  if (!isHostCallbackScheduled) {
    if (taskQueue.length > 0) {
      // 采用调度普通任务的方式进行调度
      requestHostCallback(flushWork);
    } else {
      // 如果任务仍然是延时,继续设置 HostTimeout
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        const nextDelay = firstTimer.startTime - currentTime;
        requestHostTimeout(handleTimeout, nextDelay);
      }
    }
  }
}

四、performWorkUntilDeadline

这是 Scheduler 的“驱动心跳”。

let startTime = -1;
const performWorkUntilDeadline = () => {
  if (enableRequestPaint) {
    needsPaint = false;
  }
  if (isMessageLoopRunning) {
    const currentTime = getCurrentTime();
    // 这里的 startTime 并非 unstable_scheduleCallback 方法里面的 startTime
    // 而是一个全局变量,默认值为 -1
    // 用来测量任务的执行时间,从而能够知道主线程被阻塞了多久
    startTime = currentTime;
    let hasMoreWork = true;
    try {
      // flushWork 为任务中转,本质上内部继续调用 workLoop 判断任务执行情况
      hasMoreWork = flushWork(currentTime);
    } finally {
      if (hasMoreWork) {
        // 上面刚刚讲过的方法,根据不同的环境选择不同的生成宏任务的方式(MessageChannel)
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
      }
    }
  }
};

它做三件事:

  1. 设置本次调度的时间起点
  2. 调用 flushWork
  3. 如果还有任务 → 再发一个 MessageChannel

五、flushWork 和 workLoop 执行任务循环

// flushWork 是个中转,它真正执行的是  workLoop
function flushWork(initialTime) {
  // ...
  // 各种开关、报错捕获...
  // 其实核心就是 workLoop
  return workLoop(initialTime);
}

// 不断从任务队列中取出任务执行
function workLoop(initialTime: number) {
  // initialTime 开始执行任务的时间
  let currentTime = initialTime;
  // advanceTimers 是用来遍历 timerQueue,判断是否有已经到期的任务
  // 如果有,将这个任务放入到 taskQueue
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (currentTask !== null) {
    if (!enableAlwaysYieldScheduler) {
      if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
        // 任务没有过期,并且需要中断任务,归还主线程
        break;
      }
    }
    // 返回任务本身,致使任务之后可以接着继续执行
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      if (enableProfiling) {
        markTaskRun(currentTask, currentTime);
      }
      // 执行任务,其他判断都是做“阶段过程资料收集”,无需关注
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;
        if (enableProfiling) {
          markTaskYield(currentTask, currentTime);
        }
        advanceTimers(currentTime);
        return true;
      } else {
        if (enableProfiling) {
          markTaskCompleted(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
        advanceTimers(currentTime);
      }
    } else {
      pop(taskQueue);
    }
    // 执行完,再从 taskQueue 中取出一个任务
    currentTask = peek(taskQueue);
    if (enableAlwaysYieldScheduler) {
      if (currentTask === null || currentTask.expirationTime > currentTime) {
        break;
      }
    }
  }
  // 如果任务不为空,那么还有更多的任务,hasMoreTask 为 true
  if (currentTask !== null) {
    return true;
  } else {
    // taskQueue 这个队列空了,那么我们就从 timerQueue 里面去看延时任务
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    // 没有进入上面的 if,说明 timerQueue 里面的任务也完了,返回 false,外部的 hasMoreWork 拿到的就也是 false
    return false;
  }
}

任务是否可以被打断?

shouldYieldToHost()

如果返回 true:

  • 当前宏任务结束
  • 重新发 MessageChannel
  • 浏览器可以渲染

六、shouldYieldToHost —— 时间片控制核心

源码核心:

function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;

  if (timeElapsed < frameInterval) {
    return false;
  }

  return true;
}

其中:

frameInterval = 5ms

注意:

React 不等 16ms 一整帧

而是:

每 5ms 检查一次

为什么?

  • 预留给浏览器 layout + paint
  • 预留给输入事件

七、advanceTimers —— 延迟任务晋升机制

每次 workLoop 结束后,会调用 advanceTimers(currentTime);

它会:

  • 检查 timerQueue
  • 把到时间的任务移动到 taskQueue
function advanceTimers(currentTime) {
  // 取出 startTime <= currentTime 的 timer
  let timer = peek(timerQueue);
  while (timer !== null && timer.startTime <= currentTime) {
    pop(timerQueue);
    timer.sortIndex = timer.expirationTime;
    push(taskQueue, timer);
    timer = peek(timerQueue);
  }
}

这保证:延迟任务不会饿死

八、Scheduler 与 Fiber 的关系

Scheduler 不知道 Fiber。

它只负责:“什么时候调用 callback”

而 callback 通常是:

performConcurrentWorkOnRoot

那里面才是:

  • beginWork
  • completeWork
  • commitRoot

九、任务拆分的思路来源

假设有这样一段源码:

unstable_scheduleCallback(NormalPriority, () => {
  heavyWork(); // 10ms
});

如果 heavyWork() 是同步执行 10ms:,Scheduler 无法中断它,因为 JS 是单线程的,函数执行过程中无法被抢占。

来看源码核心逻辑:

const continuation = callback();

if (typeof continuation === 'function') {
  currentTask.callback = continuation;
} else {
  pop(taskQueue);
}

如果 callback 返回一个函数,Scheduler 会把它当成“任务的下一段”。

举一个最小可理解示例:

// 假设这是一个“大”任务
function createBigTask(total) {
  let i = 0;

  function work() {
    while (i < total) {
      console.log(i++);
      
      if (shouldStopManually()) {
        return work; // 👈 返回自身,表示还有后续
      }
    }

    return null; // 完成
  }

  return work;
}

调度:

unstable_scheduleCallback(
  NormalPriority,
  createBigTask(1000)
);

执行:

workLoop()
  ↓
执行 work()
  ↓
执行到一部分
  ↓
返回 work (continuation)
  ↓
Scheduler 保存 callback = work
  ↓
shouldYieldToHost() 为 true
  ↓
break
  ↓
下一帧继续执行

在 React 里,被拆分的任务不是“普通函数”,而是:performConcurrentWorkOnRoot。

它内部会调用:workLoopConcurrent() 。

核心逻辑(简化版):

while (
  workInProgress !== null &&
  !shouldYield()
) {
  performUnitOfWork(workInProgress);
}

performUnitOfWork 只做一个 Fiber 节点:

function performUnitOfWork(unit) {
  const next = beginWork(unit);

  if (next === null) {
    completeUnitOfWork(unit);
  }

  return next;
}

也就是说:React 把整棵 Fiber 树拆成一个个“节点单位”/“任务切片”。

更形象的可以表示为:

一个组件树:

App
 ├─ Header
 ├─ Content
 │    ├─ List
 │    └─ Sidebar
 └─ Footer

会被拆成:

performUnitOfWork(App)
performUnitOfWork(Header)
performUnitOfWork(Content)
performUnitOfWork(List)
performUnitOfWork(Sidebar)
performUnitOfWork(Footer)

每个 Fiber 节点就是一个小任务。

回到 Scheduler 这段判断:

if (
  currentTask.expirationTime > currentTime &&
  shouldYieldToHost()
) {
  break;
}

当时间片用完时:break 。

此时:

  • workLoop 停止
  • 任务还没完成
  • currentTask.callback 仍然是 performConcurrentWorkOnRoot

下一帧:

MessageChannel
  ↓
performWorkUntilDeadline
  ↓
flushWork
  ↓
workLoop
  ↓
继续执行 performConcurrentWorkOnRoot

延时任务并不会立刻进入 taskQueue,而是先进入 timerQueue,等待被“唤醒”然后再调度。

十、完整调度流程

下面是一个完整的流程图:

unstable_scheduleCallback()
            ↓
            是否延时? (startTime > now)
           / \
        是     否
         ↓      ↓
  push(timerQueue) push(taskQueue)
         ↓      ↓
requestHostTimeout(requestHostCallback)
         ↓      ↓
       等待 Timer   requestHostCallback
         ↓              ↓
timeout 到时           MessageChannel
  ↓                   ↓
handleTimeout       performWorkUntilDeadline
  ↓                   ↓
advanceTimers         flushWork
  ↓                  ↓
timerQueue → taskQueue
                    ↓
                 workLoop
                    ↓
            shouldYieldToHost?
                    ↓
       是                      否
 notifyBrowserPaint          执行 Callback
                             ↓
                       callback 返回 continuation?
                             ↓
            是                               否
    更新 Task.callback                      pop(taskQueue)
                             ↓
                     可能再次调度

示例理解

示例 1:普通任务(正常进入队列)

unstable_scheduleCallback(
  NormalPriority,
  () => console.log("normal")
);

步骤:

  1. now <= startTime (0 delay)
  2. 放入 taskQueue
  3. requestHostCallback(flushWork)
  4. MessageChannel 触发 performWorkUntilDeadline
  5. flushWork → workLoop
  6. 执行 task
  7. taskQueue 空 → 调度结束

示例 2:延时任务(未来才执行)

unstable_scheduleCallback(
  NormalPriority,
  () => console.log("delayed"),
  { delay: 1000 }
);

步骤:

t=0
↓
startTime = now + 1000
push(timerQueue)
requestHostTimeout(handleTimeout, 1000)

1s 后:

handleTimeout 被 setTimeout 调用
↓
advanceTimers
  → 取出 timerQueue 里所有 startTime <= 1s 的任务
  → 推到 taskQueue
taskQueue 变非空
↓
requestHostCallback(flushWork)
↓
MessageChannel 回调
↓
flushWork → workLoop → task 执行

示例 3:延时任务到了中间又被打断

unstable_scheduleCallback(
  NormalPriority,
  step1,
  { delay: 1000 }
);

function step1() {
  console.log("step1");
  return step2;
}

function step2() {
  console.log("step2");
}

执行过程:

t=0 → timerQueue 入队
t=1000  → handleTimeout
  → advanceTimers → taskQueue
↓
MessageChannel
↓
workLoop 执行 step1 延时任务
↓
step1 返回 continuation step2 延时任务
↓
此时
  workLoop 检查 shouldYieldToHost
  如果 time片到 → 中断
  → callback (continuation) 留在 taskQueue
  → requestHostCallback 执行普通任务
下次继续执行 step2 延时任务

设计哲学

① 延时任务不能抢占浏览器渲染

如果立即调度:

setTimeout(() => {}), Promise.then(...)

React 可能会阻塞页面渲染

所以必须分开:

先 timerQueue 等待
再 taskQueue 才跑

② 延时任务按照优先级

即使 delay 到了:

expirationTime = startTime + timeout

如果更高优先级任务进入 taskQueue:

React 会先执行高优先级的。

delay 控制什么时候进入 taskQueue,而 expirationTime 控制进入 taskQueue 之后的优先级排序。

③ extendable 继续推进任务

一个任务返回 continuation 时:

currentTask.callback = continuation;

并且还可能继续 schedule。

过程即奖励|前端转后端经验分享

转岗动机

先简单介绍一下我的背景:通信专业,秋招前自学前端,21 年 7 月校招进入某教育公司做前端开发。刚毕业就赶上行业寒冬,那会儿“双减”政策落地,教育行业整体受挫,我们组的业务也大受影响,年底我就有了准备跳槽的念头。

22 年 5 月,我加入字节,做了两年的前端开发。24 年 3 月,我们团队有一轮调整,当时前端人力有点冗余,后端则比较稀缺。当时的 +1 找我聊,问我愿不愿意试试转岗做后端。我没有纠结太久,原因很简单:换一个岗位,相当于多了一种视角,我会接触到完全不一样的一套知识体系,就算未来不继续做后端,了解后端体系对前端工作也是加分项。

所以当时的我抱着非常明确的“学习型心态”,接受了 +1 的提议。

转岗阵痛期

和 +1 沟通确认之后,我就开始正式接触团队的后端项目了。团队统一用 Go,所以技术栈没什么选择余地。

我的入门路线是:

第一步:搞定环境配置。安装 Go 环境、配置 IDE;快速过一遍 Go 基础语法;把项目跑起来,能在本地看到服务正常启动。得益于公司完善的文档体系,这一步没什么太大难度。

第二步:熟悉项目代码。从入口开始顺藤摸瓜,找逻辑简单的接口,看一看处理链路。

第三步:开始上手需求,在干中学。我写的第一个后端功能是数据导出,在那个需求里,我一边写一边学到了 Go 协程的用法、操作系统和内存管理以及 MongoDB 的数据存储和处理。

为了不让自己“只停留在能写”的状态,周末我会给自己留一点“作业”:研究项目里用到的框架是怎么组织代码的;熟悉各种数据库的常见用法,学习该怎么选型;内网搜罗各种“后端扫盲手册”,一点点补课。大概一个月之后,回头看自己写的第一个功能,我已经能发现问题并且知道怎么去优化了。那一刻还蛮有成就感的:我在进步。

但没高兴几天,真正的考验来了。

24 年 5 月,带我 landing 的后端同事转岗走了。在只学了个大概、刚能磕磕绊绊写需求的情况下,我被迫成了那个模块的“后端负责人”。这意味着我需要自己去拆解需求、写方案,自己接 oncall、处理用户问题,还要扛线上问题。

那段时间是我转岗后最痛苦的时期:我还不太会处理线上数据,怕操作失误没法回滚,遇到问题的第一反应甚至是“打不过就跑”。但人在压力下的成长往往是加速的,我比我想象的要更抗压更坚韧。周末打黑工 review 技术方案,处理用户问题到凌晨 —— 就这样硬着头皮扛了两三个月,直到组里招到了新的资深后端,我才松了口气。

那一阵过去之后,我拿到了那个季度的 spot bonus,+1 也非常肯定那段时间我的撑场表现。那一刻的心态变化很微妙:原本我以为自己不行的事情,其实也能撑下来;后端这条路,好像还可以再走远一点。

渐入佳境

24 年下半年(7月 - 12月),我持续做后端需求,同时有计划地补课:从数据存储、服务搭建,到中间件的使用,再到操作系统、并发控制、公司各种基建。

如果按季度拆解,大概是这样一个过程:Q3 能 cover 日常需求,线上有报警能第一时间看日志、查监控定位问题;遇到复杂问题不再“完全没头绪”。Q4 可以独立负责一些模块, 能从 0 到 1 设计技术方案;开始考虑性能和扩展性,而不仅仅是“先实现再说”。

回头看 24 年,我的收获远远超出了我的预期:我不仅完成了从前端到后端的角色转换,更重要的是,我开始有能力独立负责一个模块从设计到上线的全流程。到了 25 年,工作状态逐渐变得“得心应手”:独立完成项目,主动做性能优化,日常工作能从容应对。

回看这段转岗之路,也是我慢慢读懂并实践毛选智慧的过程。《实践论》教会我“干中学”,边干边学习,边学习边完善,循环往复,螺旋上升;《矛盾论》教会我“抓重点”,找准当前阶段最关键的问题,集中精力解决它,其他的也会随之理顺。

经验总结

如果只用一句话来总结我的体会,那就是:后端不用关注那么琐碎的交互和 UI,真好。当然这是半开玩笑,但也是真实的感受。

做前端时,习惯看交互反馈、动画细节、兼容性,各种像素级的“抠”。做后端之后,关注点转移到了业务逻辑、数据存储、服务稳定性。后端的世界有一种“更纯粹”的感觉。但这并不是说前端不重要 —— 前端承载了用户最直观的体验和感受,后端更像是系统的“地基和管道”,问题不显眼,但影响很大。

回头再看这段经历,我想说:转岗是一条没那么难的路,只要你会写代码,你就可以转岗。甚至在这个 Vibe coding 的时代,会不会写代码都已经不是最重要的事了。

重要的是,你是否愿意从头开始学习一套新体系、接受短期内变回“新人”的落差感、在一段时间里承受不确定性和压力。

对我自己来说,支撑我走过这段路的几个关键词是:

  • 学习心态:把转岗当作一次进阶,不是“被动调岗”,而是“主动拓展边界”;

  • 不畏难:遇到不懂的东西,不急着给自己贴“我不行”的标签,而是拆解问题一个个啃;

  • 不给自己设限:有些事没做过,不代表做不了,试试又不会怎么样。

我的飞书签名一直是乔布斯的那句格言:过程即奖励。在这段经历里,我发现自己比想象中更能抗压,那些硬着头皮撑下来的日子,回过头看,恰恰是成长最快的时候。曾国藩说“吾生平长进,全在受挫受辱之时” —— 大概就是这个意思。

总而言之,如果你也有过类似的念头 —— 想换个方向,想看一看系统的另一面,或者单纯想跳出舒适区,那我真诚地献上一句来自“过来人”的鼓励:

你可以的。

只要你愿意试,愿意学,你肯定会有所收获。

以上,希望对你有点帮助:)

前端知识体系总结-前端工程化(Babel篇)

Babel

手写一个简易编译器

Babel本质上就是一个编译器。把一种代码变成另一种代码。
我们将要实现一个最简单的Babel核心功能:将ES6的箭头函数转换为ES5的普通函数
我们不要去背那些复杂的概念,编译器的工作流程在任何语言里都是一样的,只有三个阶段:

  1. 解析(Parse):把代码字符串变成树结构(AST)。
  2. 转换(Transform):在树上修修补补,把“箭头函数节点”改成“普通函数节点”。
  3. 生成(Generate):把改好的树重新变回代码字符串。

一、为什么需要将代码解析为 AST

我们先看一个简单的代码:

const add = (a, b) => a + b;

如果不生成AST,直接用正则替换,你可能会写出 code.replace('=>', 'function')。 但如果代码是这样的:

const str = "这个箭头 => 是字符串不是代码";
const func = () => { return "=>"; };

正则就不管用了。它分不清哪个是语法,哪个是字符串内容。
只有通过某种方式把代码拆解成 树状结构 去进行表示,我们才能精准地知道每行代码的实际含义,比如这是一个变量声明,那是一个函数表达式。
这里我们使用 @babel/parser 来生成AST(因为手写词法分析器和语法分析器通过大量switch-case处理字符,逻辑虽简单但代码量太大,这里我们聚焦于核心的转换逻辑)。

二、AST长什么样

我们先看看上面那句 const add = (a, b) => a + b; 解析出来是什么东西。

{
  "type": "VariableDeclaration", // 变量声明
  "kind": "const",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": { "type": "Identifier", "name": "add" },
      "init": {
        "type": "ArrowFunctionExpression", // 重点在这里:箭头函数表达式
        "params": [
          { "type": "Identifier", "name": "a" },
          { "type": "Identifier", "name": "b" }
        ],
        "body": {
          "type": "BinaryExpression", // 二进制表达式 (a + b)
          "left": { "type": "Identifier", "name": "a" },
          "operator": "+",
          "right": { "type": "Identifier", "name": "b" }
        }
      }
    }
  ]
}

转换的目标很明确:找到 ArrowFunctionExpression 类型的节点,把它替换成 FunctionExpression 类型的节点,同时处理一下函数体。

三、实现核心:遍历器(Traverser)

Babel最核心的部分不是解析,而是如何遍历这棵树。我们需要写一个函数,它能递归地访问树的每一个节点。当它遇到我们需要处理的节点时,调用我们提供的插件方法。 这是一个最基础的遍历器实现:

function traverse(ast, visitor) {
  // 遍历数组类型的属性(比如 body 里的多行代码)
  function traverseArray(array, parent) {
    array.forEach(child => traverseNode(child, parent));
  }

  // 遍历单个节点
  function traverseNode(node, parent) {
    if (!node || typeof node !== 'object') return;

    // 1. 如果visitor里定义了当前节点类型的处理函数,就执行它
    // 比如 visitor.ArrowFunctionExpression(node, parent)
    const method = visitor[node.type];
    if (method) {
      method(node, parent);
    }

    // 2. 递归遍历当前节点的所有属性
    // 比如遍历 body, params, left, right 等属性
    Object.keys(node).forEach(key => {
      const child = node[key];
      if (Array.isArray(child)) {
        traverseArray(child, node);
      } else {
        traverseNode(child, node);
      }
    });
  }

  traverseNode(ast, null);
}

这段代码的逻辑是:从根节点开始,先检查有没有对应的插件函数要执行,执行完后,继续递归找它的子节点。只要树没走完,就一直递归下去。

四、实现插件:转换箭头函数

现在我们有了遍历器,就可以写“插件”了。插件就是定义由于怎么修改节点。 我们要把箭头函数:

(a, b) => a + b

变成普通函数:

function(a, b) { return a + b; }

转换逻辑的具体步骤:

  1. 找到 ArrowFunctionExpression 节点。
  2. 保留它的 params (参数)。
  3. 处理 body。箭头函数如果直接返回表达式(没有花括号),变成普通函数时需要加 { return ... }
  4. 把节点类型改为 FunctionExpression
const transformer = {
  ArrowFunctionExpression(node) {
    // 1. 修改节点类型
    node.type = 'FunctionExpression';
    
    // 2. 处理函数体
    // 如果原体不是块语句(比如是 x => x + 1 这种直接返回的)
    // 我们需要把它包装成 { return x + 1; }
    if (node.body.type !== 'BlockStatement') {
      node.body = {
        type: 'BlockStatement',
        body: [{
          type: 'ReturnStatement',
          argument: node.body
        }]
      };
    }
    
    // 普通函数通常不需要 generator 或 async 属性,除非原样保留
    node.expression = false; 
  }
};

这里我们直接修改了 node 对象。因为AST本质上就是对象引用,直接修改树上的属性,整棵树的结构就变了。

五、代码生成(Generator)

树修改完了,最后一步是把树变回字符串。 这一步通常很繁琐,因为要处理缩进、括号、分号。为了演示核心逻辑,我们手写一个极简版的生成器,只处理我们涉及到的几种节点。

function generate(node) {
  switch (node.type) {
    case 'Program':
      return node.body.map(generate).join('\n');
      
    case 'VariableDeclaration':
      return `${node.kind} ${node.declarations.map(generate).join(', ')};`;
      
    case 'VariableDeclarator':
      return `${generate(node.id)} = ${generate(node.init)}`;
      
    case 'Identifier':
      return node.name;
      
    case 'FunctionExpression':
      // 组装函数字符串:function(参数) { 函数体 }
      const params = node.params.map(generate).join(', ');
      const body = generate(node.body);
      return `function(${params}) ${body}`;
      
    case 'BlockStatement':
      return `{\n${node.body.map(generate).join('\n')}\n}`;
      
    case 'ReturnStatement':
      return `return ${generate(node.argument)};`;
      
    case 'BinaryExpression':
      return `${generate(node.left)} ${node.operator} ${generate(node.right)}`;
      
    default:
      throw new Error(`Unknown node type: ${node.type}`);
  }
}

生成器逻辑:递归地拼接字符串。遇到 BinaryExpression 就拼左右两边,遇到 FunctionExpression 就拼关键字和参数。

六、串联整个流程(Compiler)

最后,我们把解析、转换、生成串起来,就是一个迷你版的 Babel。

const parser = require('@babel/parser'); // 借用parser,专注转换逻辑

function myBabelCompiler(code) {
  // 1. 解析 (Code -> AST)
  const ast = parser.parse(code);

  // 2. 转换 (AST -> New AST)
  // 传入我们的访问器对象
  traverse(ast, transformer);

  // 3. 生成 (New AST -> New Code)
  const output = generate(ast);

  return output;
}

// 测试
const sourceCode = "const add = (a, b) => a + b;";
const targetCode = myBabelCompiler(sourceCode);

console.log(targetCode);
// 输出结果:
// const add = function(a, b) {
// return a + b;
// };

总结

实现一个Babel,不要把问题想得太复杂,其实就是三个步骤:

  1. 对象化:代码是字符串,没法改,先变成对象(AST)。
  2. 递归:对象嵌套太深,必须用递归函数(Visitor)去一层层找。
  3. 还原:改完对象属性后,按照语法规则把字符串拼回去。 真正的Babel虽然庞大,因为它要处理几百种语法节点,还要处理作用域(Scope)和引用关系,但核心骨架就是上面这几十行代码。当你写Babel插件时,你其实就是在写那个 transformer 对象里的函数。

Babel工程化配置与使用

刚才我们手写了一个微型编译器,搞懂了原理。但在实际工作中,我们不可能自己去写AST遍历器和生成器。我们直接使用Babel官方提供的工具链。

这里有一个非常反直觉的事实:Babel本身什么都不做

如果你只安装 @babel/core 然后运行它,你把 ES6 代码丢进去,出来的还是 ES6 代码。它只是把代码解析成AST,然后又打印出来,中间没有任何修改。它不知道你要干什么。

要让它干活,必须明确告诉它:我要转换箭头函数,或者我要转换类(Class)。这些具体的转换功能,就是 Plugin(插件);而为了方便,把一堆常用的插件打包在一起,就是 Preset(预设)

一、基础配置:从零开始搭建

我们不讲虚的,直接看在一个空文件夹里怎么把 Babel 跑起来。

1. 初始化项目与安装核心库

你需要安装三个最基础的包:

  • @babel/core: 编译器核心,负责解析和生成。
  • @babel/cli: 命令行工具,让我们能在终端里运行 babel 命令。
  • @babel/preset-env: 这是一个智能预设,包含了所有现代 JS 语法的转换插件。
npm init -y
npm install --save-dev @babel/core @babel/cli @babel/preset-env

2. 编写配置文件

在项目根目录创建一个 babel.config.json 文件。这是控制 Babel 行为的大脑。最简单的配置只需要一行:告诉 Babel 使用 preset-env

{
  "presets": ["@babel/preset-env"]
}

3. 运行测试

创建一个 src/index.js,写点 ES6 代码:

const sayHello = () => console.log("Hello");

在终端运行编译命令:

npx babel src --out-dir dist

打开生成的 dist/index.js,你会发现箭头函数变成了 functionconst 变成了 var。这就是 preset-env 在起作用。它默认把所有新语法都转成了 ES5。

二、按需编译:Targets 的重要性

上面的默认配置有一个大问题:它太“笨”了。

它把所有代码都转成了 ES5,哪怕你只是跑在最新的 Chrome 浏览器上。现代浏览器原生支持 const 和箭头函数,强行转换只会让代码体积变大,运行变慢。

我们需要告诉 Babel 我们的代码要在什么环境下运行。

修改 babel.config.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "88",
          "ie": "11"
        }
      }
    ]
  ]
}

这里我们配置了 targets。 如果你把 ie: "11" 去掉,只保留 chrome: "88",再次编译,你会发现 const 和箭头函数被保留了,没有被转换。

这是因为 Babel 查表发现 Chrome 88 原生支持这些语法,所以它直接跳过了转换步骤。这是 Babel 配置中最核心的优化点:只转换目标环境不支持的语法

三、处理API:Polyfill (垫片)

这是新手最容易混淆的地方。Babel 有两类转换:

  1. 语法转换 (Syntax Transform):比如 => 转成 functionclass 转成 prototype。这是 preset-env 擅长的。
  2. API 添加 (Polyfill):比如 Array.fromnew Promise()Map

如果你在代码里写 new Promise(),Babel 默认是不处理的。因为从语法角度看,这就是创建了一个对象,语法没问题。但在 IE11 里运行会直接报错 Promise is not defined

我们需要引入 core-js 来实现这些缺少的 API。

不要全量引入,那样包会很大。我们要配置 Babel 自动按需引入。

首先安装 core-js:

npm install core-js

修改 babel.config.json,开启 useBuiltIns: "usage"

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58",
          "ie": "11"
        },
        "useBuiltIns": "usage", // 关键配置:按需引入
        "corejs": 3             // 指定 core-js 版本
      }
    ]
  ]
}

现在,如果在你的代码里写了 new Promise(),Babel 编译时会自动在文件头部加上一句: require("core-js/modules/es.promise.js")

如果你没用到 Promise,它就不加。这就是 usage 模式的威力。

四、在 Webpack 中集成

在实际开发中,我们很少直接运行 npx babel。通常是配合 Webpack 打包时自动转换。这需要用到 babel-loader

这是 Webpack 和 Babel 的连接桥梁。Webpack 负责读取文件,发现是 .js 后,交给 babel-loaderbabel-loader 调用 @babel/core 进行转换,转换完把代码还给 Webpack。

webpack.config.js 配置示例:

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/, // 极其重要:千万别编译 node_modules,慢且容易出错
        use: {
          loader: 'babel-loader',
          // options 可以在这里写,也可以直接读取 babel.config.json
          // 推荐使用独立配置文件,更清晰
        }
      }
    ]
  }
};

只要项目根目录下有 babel.config.jsonbabel-loader 会自动读取它,不需要重复配置。

总结 Babel 使用的核心逻辑

  1. Babel 核心只是空壳,必须通过配置文件告诉它用什么插件。
  2. Preset-env 是万能钥匙,它根据 targets 决定要转换哪些语法,避免过度编译。
  3. 语法 != API=> 是语法,Promise 是 API。处理 API 需要配置 core-jsuseBuiltIns: "usage"
  4. exclude node_modules。在使用 Webpack 时,永远记得排除 node_modules,第三方包通常已经是编译好的,重复编译纯属浪费时间。

手写 Babel 插件:复刻 babel-plugin-import

我们要写一个真的能用的、在生产环境中极其常见的插件。

很多 UI 组件库(比如 Ant Design)或者工具库(比如 Lodash),都有一个痛点:文件太大。

当你写下这行代码时:

import { Button, Alert } from 'antd';

在没有优化的情况下,Webpack 会把整个 antd 库(几百个组件)全打包进去,哪怕你只用了两个组件。我们要写的插件,就是要把上面那一行代码,在编译时自动转换成:

import Button from 'antd/lib/button';
import Alert from 'antd/lib/alert';

这样就能按需加载,体积瞬间变小。这个插件逻辑非常经典,涉及了节点查找节点替换多节点生成这几个 Babel 插件最核心的操作。

一、准备工作

写插件的第一步永远不是写代码,而是对比 AST。我们要搞清楚,处理前的 AST 长什么样,处理后长什么样。

处理前 (import { Button } from 'antd'): 它是一个 ImportDeclaration 节点。

  • source: 值是 'antd'
  • specifiers: 这是一个数组。里面有一个 ImportSpecifier,它的 imported 属性是 Button(引入的名字),local 属性也是 Button(本地使用的名字)。

处理后 (import Button from 'antd/lib/button'): 变成了两个(或多个)ImportDeclaration 节点。

  • 每个节点都是 ImportDefaultSpecifier(注意这里变成了默认导入,因为具体的组件文件通常是 export default)。
  • source: 值变成了 'antd/lib/button'

处理方案:

  1. 监听:专门盯着 ImportDeclaration 类型的节点。
  2. 检查:看它的来源库是不是我们要优化的库(比如 'antd')。
  3. 提取:如果是,就把里面的 ButtonAlert 这些名字取出来。
  4. 构造:用这些名字生成新的 import 语句。
  5. 替换:用新生成的数组,替换掉原来那一个老节点。

二、开始编写插件代码

创建一个 my-import-plugin.js 文件。

Babel 插件的标准写法是一个函数,它接受一个 babel 对象作为参数。我们需要从这个对象里拿出 types,这是 Babel 提供的节点构造工厂。你可以把它想象成乐高积木的模具,用来生成新的 AST 节点。

module.exports = function(babel) {
  const { types: t } = babel; // 这是我们的工厂

  return {
    visitor: {
      // 我们只关心 import 语句
      ImportDeclaration(path, state) {
        const { node } = path;

        // 1. 检查:如果引入的库不是 'antd',直接跳过,不做处理
        // state.opts 是我们在配置文件里传给插件的参数
        // 这样插件就不仅仅能处理 antd,也能处理 lodash 等其他库
        const libraryName = state.opts.libraryName || 'antd';
        if (node.source.value !== libraryName) {
          return;
        }

        // 2. 检查:如果是默认导入 (import Antd from 'antd'),不仅没法按需加载,还说明用户可能真想引入全量
        // 我们只处理 { Button } 这种命名导入 (ImportSpecifier)
        if (!t.isImportSpecifier(node.specifiers[0])) {
          return;
        }

        // 3. 核心逻辑:遍历原来的 specifiers,生成新的 import 节点数组
        const newImports = node.specifiers.map(specifier => {
          // specifier.imported.name 是 "Button"
          // specifier.local.name 是我们代码里用的变量名 (通常也是 "Button")
          const componentName = specifier.imported.name;
          const localName = specifier.local.name;

          // 构造新的路径: 'antd/lib/button'
          // 这里简单的转成小写,实际工程中可能需要驼峰转连字符
          const newPath = `${libraryName}/lib/${componentName.toLowerCase()}`;

          // 使用 Babel 的 types 工具创建新节点
          // 生成: import localName from 'newPath'
          return t.importDeclaration(
            [t.importDefaultSpecifier(t.identifier(localName))],
            t.stringLiteral(newPath)
          );
        });

        // 4. 替换:用新的节点数组替换原来的一个节点
        // replaceWithMultiple 专门用来把一个节点变成一堆节点
        path.replaceWithMultiple(newImports);
      }
    }
  };
};

这段代码虽然短,但它展示了 Babel 插件最核心的逻辑:Path(路径)操作path 对象非常强大,它不只是当前节点,还包含了父节点、兄弟节点的信息,以及最重要的操作方法(比如 replaceWithMultiple, remove, insertBefore)。

三、调试与运行

插件写好了,怎么用呢?我们不需要把它发布到 npm,直接在本地引用测试。

在项目根目录下创建一个 .babelrc 或者 babel.config.json,配置上我们刚写的插件:

{
  "presets": ["@babel/preset-env"],
  "plugins": [
    [
      "./my-import-plugin.js", 
      {
        "libraryName": "antd" 
      }
    ]
  ]
}

这里我们用了相对路径 ./my-import-plugin.js,并且传入了参数 libraryName: "antd"

验证效果

创建一个 test.js

import { Button, Modal } from 'antd';
console.log(Button, Modal);

然后运行 Babel 编译(假设你已经安装了 @babel/cli):

npx babel test.js

你的控制台输出应该会变成这样:

import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
console.log(Button, Modal);

四、进阶思考:为什么说这有难度?

刚才的代码是一个“乞丐版”实现。在真实场景中,情况会复杂得多,这也是为什么 babel-plugin-import 源码有几百行的原因。

1. 样式的处理 真正的按需加载,不仅仅是加载 JS,还要加载对应的 CSS。 你需要不仅生成 import Button from ...,还要顺便生成 import 'antd/lib/button/style/css'。这需要在 map 循环里多生成一个 importDeclaration 节点。

2. 作用域冲突 如果你在代码里已经定义了一个叫 Button 的变量,然后再 import { Button } from 'antd',Babel 插件如果不小心处理,可能会导致变量名冲突。虽然在这个场景下概率不大,但写通用插件时,通常需要用 path.scope.generateUidIdentifier 来生成唯一的变量名。

3. 路径转换规则 我们只用了简单的 .toLowerCase()。但有的组件叫 DatePicker,文件路径可能是 date-picker。这时候就需要引入更复杂的命名转换算法(Kebab Case)。

总结

写好一个 Babel 插件,其实就是三个步骤的循环:

  1. 看 AST:用 AST Explorer 这种在线工具,把你的源代码放进去,看它是怎么被解析的。
  2. 造节点:利用 babel.types (t) 构建你想要的新结构。
  3. 换节点:利用 path 提供的 API,把旧的换成新的。

当你掌握了 visitor 模式和 types 构建器,你就掌握了修改 JavaScript 语言本身的权力。

前端知识体系总结-前端工程化(Webpack篇)

Wepack实现

webpack打包功能实现

webpack打包与模块加载原理(从JS入口文件出发如何进行简单打包 -> __webpack_require__具体实现 -> 一个最基础的bundle.js至少具备的内容 -> 实现一个基本的webpack打包功能)

一、从JS文件打包说起

1.1 基本打包过程

当我们有以下文件结构时:

src/
  ├── a.js (入口文件)
  └── b.js (依赖文件)

a.js (入口文件):

import { getValue } from './b.js';
console.log(getValue());

b.js (依赖文件):

export function getValue() {
  return 'Hello from b.js';
}

1.2 打包后的结果(自测:请说出打包后的代码形式)

以a.js为入口进行打包后,生成的bundle.js会将每个模块包装成函数形式:

// 简化版的打包结果
{
  "./src/a.js": function(module, exports, __webpack_require__) {
    eval(`
      const { getValue } = __webpack_require__("./src/b.js");
      console.log(getValue());
    `);
  },
  "./src/b.js": function(module, exports, __webpack_require__) {
    eval(`
      function getValue() {
        return 'Hello from b.js';
      }
      exports.getValue = getValue;
    `);
  }
}

关键变化:

  • 原本的 import { getValue } from './b.js' 被转换为 __webpack_require__("./src/b.js")
  • 每个模块被包装在函数中,接收 module, exports, __webpack_require__ 参数

二、webpack_require 的实现原理(自测:说出核心代码实现)

2.1 函数签名与作用

function __webpack_require__(moduleId) {
  // 参数:moduleId - 模块的路径标识符(如 "./src/b.js")
  // 返回值:该模块的所有导出内容(exports对象)
}

2.2 完整实现过程

// 模块缓存对象
var __webpack_module_cache__ = {};

// 主要的模块加载函数
function __webpack_require__(moduleId) {
  // 1. 检查缓存,避免重复加载
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  
  // 2. 创建新的模块对象并缓存
  var module = __webpack_module_cache__[moduleId] = {
    exports: {}
  };
  
  // 3. 执行模块函数,填充exports
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  
  // 4. 返回模块的导出内容
  return module.exports;
}

2.3 模块执行机制

关键在于这一行:

__webpack_modules__[moduleId](module, module.exports, __webpack_require__);

执行过程:

  1. __webpack_modules__ 对象中获取对应的模块函数
  2. 传入三个参数:module(模块对象)、module.exports(导出对象)、__webpack_require__(加载函数)
  3. 模块函数内部通过修改 module.exports 来导出内容
  4. 执行完成后返回填充好的 module.exports

三、Bundle.js的基本结构(自测:说出结构是什么以及为什么)

一个完整的bundle.js至少需要包含以下内容:

3.1 核心组件

// 1. 模块存储对象 - 存放所有模块函数
var __webpack_modules__ = {
  "./src/a.js": function(module, exports, __webpack_require__) { /* ... */ },
  "./src/b.js": function(module, exports, __webpack_require__) { /* ... */ }
};

// 2. 模块缓存对象
var __webpack_module_cache__ = {};

// 3. 模块加载函数
function __webpack_require__(moduleId) { /* ... */ }

// 4. 启动应用程序
__webpack_require__("./src/a.js");

3.2 完整示例

(function() {
  "use strict";
  
  var __webpack_modules__ = {
    "./src/a.js": function(module, exports, __webpack_require__) {
      eval(`
        const { getValue } = __webpack_require__("./src/b.js");
        console.log(getValue());
      `);
    },
    "./src/b.js": function(module, exports, __webpack_require__) {
      eval(`
        function getValue() {
          return 'Hello from b.js';
        }
        exports.getValue = getValue;
      `);
    }
  };
  
  var __webpack_module_cache__ = {};
  
  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    
    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };
    
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    
    return module.exports;
  }
  
  // 启动入口模块
  __webpack_require__("./src/a.js");
})();

总结

Webpack的核心打包原理:

  1. 模块化处理:将每个文件包装成函数,统一模块接口,并存储在全局webpack——modules中。
  2. 依赖管理:通过__webpack_require__实现模块间的加载和缓存,获取文件导出内容,并且缓存导出结果下次复用。
  3. 代码整合:将所有模块函数和运行时代码组装成单一文件 bundle.js 用立即执行函数进行运行。

这种设计让浏览器能够执行原本不支持的ES6模块语法,同时实现了高效的模块缓存和按需加载机制。

实现Webpack依赖分析(自测:如何实现分析依赖,两种优缺点)

实现步骤

  1. 依赖分析:从入口文件开始,递归找到所有依赖的文件
  2. 代码转换:将每个文件转换为模块函数格式
  3. 生成bundle:将所有模块函数组装成最终的bundle.js

依赖分析的两种方法

方法一:正则表达式

function findDependenciesByRegex(code) {
  const importRegex = /import\s+.*?\s+from\s+['"](.*?)['"];?/g;
  const dependencies = [];
  let match;
  
  while ((match = importRegex.exec(code)) !== null) {
    dependencies.push(match[1]);
  }
  
  return dependencies;
}

优点:

  • 实现简单,代码量少
  • 执行速度快

缺点:

  • 容易误匹配字符串中的内容
  • 无法处理复杂的import语法
  • 不够准确和可靠

问题示例:

// 这种情况会被错误匹配
const code = `
  console.log("import something from 'fake-module'");
  import { real } from './real-module';
`;

方法二:抽象语法树(AST)

const babel = require('@babel/core');
const traverse = require('@babel/traverse').default;

function findDependenciesByAST(code) {
  const dependencies = [];
  
  // 将代码解析为AST
  const ast = babel.parse(code, {
    sourceType: 'module'
  });
  
  // 遍历AST节点找到import的路径
  traverse(ast, {
    ImportDeclaration(path) {
      dependencies.push(path.node.source.value);
    }
  });
  
  return dependencies;
}

优点:

  • 精确解析,不会误匹配字符串
  • 能处理各种复杂的import语法
  • 提供完整的语法信息

缺点:

  • 实现复杂度较高
  • 需要引入额外的解析库
  • 执行速度相对较慢

为什么AST更准确:

  • AST将代码解析为树形结构,每个import语句会生成专门的ImportDeclaration节点
  • 字符串内容不会被解析为import节点,从根本上避免了误匹配
  • 能够准确识别import语句的各个组成部分(导入内容、来源路径等)

手写实现抽象语法树与完整模块打包工具

一、获取JS文件依赖信息,获取依赖文件绝对路径:如何将代码解析为抽象语法树(AST)

1.1 使用@babel/parser解析代码

抽象语法树(Abstract Syntax Tree, AST)是源代码的抽象语法结构的树状表示。我们可以使用 @babel/parser(原名 Babylon)将 JavaScript 代码字符串解析为 AST 对象。

const parser = require('@babel/parser');

const code = `import React from 'react';`;
const ast = parser.parse(code, {
  sourceType: 'module' // 指定代码为模块化代码
});

console.log(ast);

解析后的 AST 本质上是一个 JavaScript 对象,其中包含描述代码结构的各种节点。当打印 AST 时,某些嵌套较深的属性会以其类型(如 Node、Position)代替显示,但直接转换为字符串可以看到完整结构。

1.2 手动遍历AST获取依赖

AST 的 program.body 属性是一个数组,包含了当前文件的所有顶级语句。我们可以遍历这个数组,找到所有类型为 ImportDeclaration 的节点,然后从中提取导入路径。

const dependencies = [];
ast.program.body.forEach(node => {
  if (node.type === 'ImportDeclaration') {
    dependencies.push(node.source.value);
  }
});

console.log(dependencies); // ['react']

这种方法虽然可行,但手动遍历 AST 结构繁琐且容易出错。

1.3 使用@babel/traverse简化遍历

@babel/traverse 提供了一个更便捷的方式来遍历 AST。我们可以使用它来查找特定类型的节点。

const traverse = require('@babel/traverse').default;

const dependencies = [];
traverse(ast, {
  ImportDeclaration(path) {
    dependencies.push(path.node.source.value);
  }
});

console.log(dependencies); // ['react']

这种方法更加简洁和可靠,我们只需要定义对特定节点类型的处理函数即可。

二、如何实现从入口文件开始自动化依赖分析所有依赖文件

2.1 单文件依赖分析

我们可以封装一个函数来分析单个文件的依赖:

const fs = require('fs');
const path = require('path');

function getDependencies(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = parser.parse(content, { sourceType: 'module' });

  const dependencies = [];
  traverse(ast, {
    ImportDeclaration(path) {
      const importPath = path.node.source.value;
      // 将相对路径转换为绝对路径
      const absolutePath = path.resolve(path.dirname(filename), importPath);
      dependencies.push(absolutePath);
    }
  });

  return {
    filename,
    dependencies
  };
}

2.2 广度优先搜索分析所有依赖

从入口文件开始,我们可以使用广度优先搜索(BFS)来分析整个项目的所有依赖:

function analyzeDependencies(entryFile) {
  const queue = [entryFile];
  const allDependencies = new Set();
  const dependencyGraph = new Map();

  while (queue.length > 0) {
    const currentFile = queue.shift();

    if (allDependencies.has(currentFile)) continue;
    allDependencies.add(currentFile);

    const { dependencies } = getDependencies(currentFile);
    dependencyGraph.set(currentFile, dependencies);

    dependencies.forEach(dep => {
      if (!allDependencies.has(dep)) {
        queue.push(dep);
      }
    });
  }

  return dependencyGraph;
}

这样我们就得到了一个包含所有模块及其依赖关系的映射表。

三、ES模块语法转换为CommonJS形式

为了使模块代码能在打包环境中运行,我们需要将 ES 模块语法转换为 CommonJS 形式。这包括处理 import 和 export 语句。

3.1 ImportDeclaration转换

对于不同类型的 import 语法,我们进行不同的转换:

const { transformFromAst } = require('@babel/core');
const t = require('@babel/types');

function transformImportDeclaration(ast, moduleIdMap) {
  traverse(ast, {
    ImportDeclaration(path) {
      const source = path.node.source.value;
      const absolutePath = path.resolve(path.dirname(path.hub.file.opts.filename), source);

      // 生成模块ID
      const moduleId = moduleIdMap.get(absolutePath) || generateModuleId(absolutePath);
      moduleIdMap.set(absolutePath, moduleId);

      const specifiers = path.node.specifiers;
      const imports = specifiers.map(spec => {
        if (t.isImportDefaultSpecifier(spec)) {
          // 默认导入:import foo from 'module' → const foo = webpack_require(moduleId)
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)])
          );
        } else if (t.isImportSpecifier(spec)) {
          // 命名导入:import { foo } from 'module' → const foo = webpack_require(moduleId).foo
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.memberExpression(
              t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)]),
              t.identifier(spec.imported.name)
            )
          );
        }
      }).filter(Boolean);

      // 替换 import 语句为 const 声明
      path.replaceWith(t.variableDeclaration('const', imports));
    }
  });
}

3.2 ExportDefaultDeclaration转换

将 export default 语句转换为 CommonJS 形式:

function transformExportDefaultDeclaration(ast) {
  traverse(ast, {
    ExportDefaultDeclaration(path) {
      // 替换 export default foo 为 module.exports = foo
      path.replaceWith(
        t.expressionStatement(
          t.assignmentExpression(
            '=',
            t.memberExpression(t.identifier('module'), t.identifier('exports')),
            path.node.declaration
          )
        )
      );
    }
  });
}

3.3 ExportNamedDeclaration转换

将命名导出语句转换为 CommonJS 形式:

function transformExportNamedDeclaration(ast) {
  traverse(ast, {
    ExportNamedDeclaration(path) {
      // 替换 export { foo } 为 module.exports.foo = foo
      if (path.node.specifiers.length) {
        const exports = path.node.specifiers.map(spec => {
          return t.expressionStatement(
            t.assignmentExpression(
              '=',
              t.memberExpression(
                t.memberExpression(t.identifier('module'), t.identifier('exports')),
                t.identifier(spec.exported.name)
              ),
              t.identifier(spec.local.name)
            )
          );
        });
        path.replaceWithMultiple(exports);
      }
    }
  });
}

3.4 模块ID生成

我们使用一个简单的自增 ID 来标识每个模块:

const moduleIdMap = new Map();
let nextModuleId = 0;

function generateModuleId(modulePath) {
  if (!moduleIdMap.has(modulePath)) {
    moduleIdMap.set(modulePath, nextModuleId++);
  }
  return moduleIdMap.get(modulePath);
}

实际 Webpack 会使用更复杂的哈希算法生成模块 ID,以实现更好的缓存效果。

四、打包产物(bundle.js)工作原理,核心概念和结构

4.1 模块打包的核心概念

打包工具的核心功能包括:

  • 模块作用域隔离:通过函数作用域将每个模块封装
  • 模块导入导出:实现模块间的引用关系
  • 模块缓存:避免重复执行模块代码
  • 入口执行:从入口文件开始执行整个应用

4.2 简化版打包产物结构

一个简化版的打包产物(bundle.js)通常包含以下部分:

(function(modules) {
  // 模块缓存
  const installedModules = {};

  // 模拟webpack_require函数
  function webpack_require(moduleId) {
    // 检查缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    // 创建新模块
    const module = installedModules[moduleId] = {
      exports: {}
    };

    // 执行模块函数
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      webpack_require
    );

    return module.exports;
  }

  // 执行入口模块
  return webpack_require('<%= entryModuleId %>');
})({
  <% modules.forEach((module) => { %>
    '<%= module.id %>': function(module, exports, webpack_require) {
        <%= module.code %>
     },
  <% }); %>
});

注意:这里使用的是 webpack_require 而不是 require,以避免与 Node.js 的原生 require 混淆,他们不是一个函数

五、使用EJS动态生成打包产物

5.1 EJS模板基础

EJS 是一个简单的模板引擎,可以让我们用 JavaScript 生成 HTML 或其他文本格式。基本语法:

  • <%= variable %>:输出变量值
  • <% code %>:执行 JavaScript 代码

5.2 创建打包模板

我们可以创建一个 EJS 模板来动态生成打包产物:

const ejs = require('ejs');

const template = `
(function(modules) {
  const installedModules = {};

  function webpack_require(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    const module = installedModules[moduleId] = {
      exports: {}
    };

    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      webpack_require
    );

    return module.exports;
  }

  return webpack_require(<%= entryModuleId %>);
})({
  <% modules.forEach((module) => { %>
    <%= module.id %>: function(module, exports, webpack_require) {
      <%= module.code %>
    },
  <% }); %>
});
`;

5.3 渲染打包产物

使用 EJS 渲染模板并生成最终的打包文件:

function generateBundle(modules, entryId) {
  const moduleList = Array.from(modules.values()).map(mod => ({
    id: mod.id,
    code: mod.code
  }));

  const bundleCode = ejs.render(template, {
    entryModuleId: entryId,
    modules: moduleList
  });

  return bundleCode;
}

六、完整的打包流程实现

整合所有步骤,实现完整的打包流程:

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

// 模块 ID 映射表
const moduleIdMap = new Map();
let nextModuleId = 0;

// 生成模块 ID
function generateModuleId(modulePath) {
  if (!moduleIdMap.has(modulePath)) {
    moduleIdMap.set(modulePath, nextModuleId++);
  }
  return moduleIdMap.get(modulePath);
}

// 解析模块内容,提取依赖并转换代码
function parseModule(modulePath) {
  const filename = path.resolve(modulePath);
  const content = fs.readFileSync(filename, 'utf-8');

  // 解析 AST
  const ast = parser.parse(content, {
    sourceType: 'module',
  });

  const dependencies = [];

  // 遍历 AST,提取 import 依赖并转换为 webpack_require
  traverse(ast, {
    ImportDeclaration(p) {
      const source = p.node.source.value;
      const absolutePath = path.resolve(path.dirname(filename), source);

      // 记录依赖
      dependencies.push(absolutePath);

      // 生成模块 ID
      const moduleId = generateModuleId(absolutePath);

      // 替换 import 语句为 webpack_require
      const specifiers = p.node.specifiers;
      const imports = specifiers.map(spec => {
        if (t.isImportDefaultSpecifier(spec)) {
          // 默认导入:import foo from 'module' → const foo = webpack_require(moduleId)
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)])
          );
        } else if (t.isImportSpecifier(spec)) {
          // 命名导入:import { foo } from 'module' → const foo = webpack_require(moduleId).foo
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.memberExpression(
              t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)]),
              t.identifier(spec.imported.name)
            )
          );
        }
      }).filter(Boolean);

      // 替换 import 语句为 const 声明
      p.replaceWith(t.variableDeclaration('const', imports));
    },

    ExportDefaultDeclaration(p) {
      // 替换 export default 为 module.exports
      p.replaceWith(
        t.expressionStatement(
          t.assignmentExpression(
            '=',
            t.memberExpression(t.identifier('module'), t.identifier('exports')),
            p.node.declaration
          )
        )
      );
    },

    ExportNamedDeclaration(p) {
      // 替换 export { foo } 为 module.exports.foo = foo
      if (p.node.specifiers.length) {
        const exports = p.node.specifiers.map(spec => {
          return t.expressionStatement(
            t.assignmentExpression(
              '=',
              t.memberExpression(
                t.memberExpression(t.identifier('module'), t.identifier('exports')),
                t.identifier(spec.exported.name)
              ),
              t.identifier(spec.local.name)
            )
          );
        });
        p.replaceWithMultiple(exports);
      }
    },
  });

  // 生成转换后的代码
  const { code } = generate(ast);

  return {
    id: generateModuleId(filename),
    filename,
    dependencies,
    code,
  };
}

// 递归分析所有依赖
function analyzeDependencies(entry) {
  const entryModule = parseModule(entry);
  const queue = [entryModule];
  const modules = new Map();

  modules.set(entryModule.id, entryModule);

  while (queue.length > 0) {
    const currentModule = queue.shift();

    currentModule.dependencies.forEach(depPath => {
      const depModule = parseModule(depPath);

      if (!modules.has(depModule.id)) {
        modules.set(depModule.id, depModule);
        queue.push(depModule);
      }
    });
  }

  return modules;
}

// 生成打包后的代码
function generateBundle(modules, entryId) {
  const moduleList = Array.from(modules.values()).map(mod => `
  ${mod.id}: function(module, exports, webpack_require) {
    ${mod.code}
  },
`).join('\n');

  return `
(function(modules) {
  const installedModules = {};

  function webpack_require(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    const module = installedModules[moduleId] = {
      exports: {}
    };

    modules[moduleId].call(
      module.exports,
      module,
      webpack_require
    );

    return module.exports;
  }

  return webpack_require(${entryId});
})({
${moduleList}
});
`;
}

// 主打包函数
function bundle(entryFile, outputFile) {
  const modules = analyzeDependencies(entryFile);
  const entryModule = Array.from(modules.values()).find(mod => mod.filename === path.resolve(entryFile));
  const bundleCode = generateBundle(modules, entryModule.id);

  fs.writeFileSync(outputFile, bundleCode);
  console.log(`✅ 打包完成: ${outputFile}`);
}

// 使用示例
bundle('./src/index.js', './dist/bundle.js');

七、总结

通过以上步骤,我们实现了一个简化版的模块打包工具,核心流程包括:

  1. 使用 @babel/parser 将代码解析为 AST
  2. 使用 @babel/traverse 遍历 AST 提取依赖关系
  3. 将 ES 模块语法转换为 CommonJS 形式
    • 处理默认导入:import foo from 'module'const foo = webpack_require(moduleId)
    • 处理命名导入:import { foo } from 'module'const foo = webpack_require(moduleId).foo
    • 处理默认导出:export default foomodule.exports = foo
    • 处理命名导出:export { foo }module.exports.foo = foo
  1. 通过广度优先搜索分析整个项目的依赖图
  2. 使用模块 ID 优化和代码转换完善打包产物
  3. 动态生成最终的打包代码

Webpack Loader实现

一、Loader的基本概念

1.1 什么是Loader

Loader是Webpack的核心功能之一,它的作用是将非JavaScript文件转换为JavaScript模块,使得Webpack能够处理除了JS之外的各种类型的文件。

1.2 为什么需要Loader

原生Webpack的局限性

  • Webpack原生只能理解JavaScriptJSON文件
  • 当遇到其他格式文件时,需要转换为JavaScript语法才能被解析为AST(进行依赖分析也就是寻找import的子文件路径)

问题示例

// 以下代码会导致解析失败
import './styles.css';        // CSS文件不符合JS语法
import data from './data.json'; // JSON需要特殊处理

解析失败的原因

  • CSS文件内容如 .button { color: red; } 不符合JavaScript语法规范
  • 直接解析会在AST生成阶段报错
  • 需要先转换为有效的JavaScript导出语句

1.3 Loader的工作原理

Loader本质上是一个转换函数,它接收源文件内容,返回转换后的JavaScript代码:

// Loader的基本结构
module.exports = function(source) {
  // source: 原始文件内容字符串
  // 返回: 转换后的JavaScript代码字符串
  return `export default ${JSON.stringify(source)}`;
};

二、Loader的配置与执行机制

2.1 Webpack配置中的Loader

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.json$/,           // 正则匹配文件类型
        use: ['json-loader']       // 使用的loader数组
      },
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'] // 多个loader的执行顺序
      }
    ]
  }
};

2.2 Loader的执行顺序

关键特性:Loader从右到左(从后到前)执行

use: ['style-loader', 'css-loader']
// 执行顺序:
// 1. css-loader 处理 .css 文件
// 2. style-loader 处理 css-loader 的输出结果

执行流程图

原始CSS文件 → css-loader → JavaScript字符串 → style-loader → 最终JavaScript模块

2.3 在打包器中集成Loader机制

修改文件分析逻辑

class SimpleWebpack {
  constructor(entry, output, config = {}) {
    this.entry = entry;
    this.output = output;
    this.loaders = config.module?.rules || []; // 获取loader配置
    // ... 其他属性
  }

  /**
   * 应用匹配的loaders处理文件内容
   */
  applyLoaders(filePath, source) {
    let transformedSource = source;

    // 遍历所有loader规则
    for (const rule of this.loaders) {
      // 检查文件是否匹配当前规则
      if (rule.test.test(filePath)) {
        // 从右到左执行loaders
        const loaders = Array.isArray(rule.use) ? [...rule.use].reverse() : [rule.use];
        
        for (const loaderName of loaders) {
          const loader = this.loadLoader(loaderName);
          transformedSource = loader(transformedSource);
        }
        break; // 匹配到规则后停止检查其他规则
      }
    }

    return transformedSource;
  }

  /**
   * 加载并返回loader函数
   */
  loadLoader(loaderName) {
    // 在实际应用中,这里会从node_modules加载loader
    // 为了演示,我们使用内置的loader映射
    const builtinLoaders = {
      'json-loader': this.jsonLoader,
      'css-loader': this.cssLoader,
      'style-loader': this.styleLoader
    };

    return builtinLoaders[loaderName] || ((source) => source);
  }

  /**
   * 修改后的文件分析方法
   */
  analyzeFile(filePath) {
    if (this.modules.has(filePath)) {
      return this.modules.get(filePath);
    }

    let sourceCode = fs.readFileSync(filePath, 'utf-8');
    
    // 关键步骤:在AST解析前应用loaders
    sourceCode = this.applyLoaders(filePath, sourceCode);
    
    // 现在sourceCode已经是有效的JavaScript代码,可以安全解析为AST
    const ast = parser.parse(sourceCode, {
      sourceType: 'module'
    });

    // ... 后续AST分析逻辑
  }
}

三、简单实现json-loader

3.1 JSON文件处理需求

// 原始JSON文件 data.json
{
  "name": "webpack-demo",
  "version": "1.0.0"
}

// 期望的转换结果(JavaScript模块)
export default {
  "name": "webpack-demo", 
  "version": "1.0.0"
};

3.2 json-loader实现

/**
 * JSON Loader实现
 * 将JSON文件内容转换为JavaScript默认导出
 */
function jsonLoader(source) {
  // 验证JSON格式
  try {
    JSON.parse(source);
  } catch (error) {
    throw new Error(`Invalid JSON file: ${error.message}`);
  }

  // 转换为JavaScript模块导出语法
  return `export default ${source};`;
}

3.3 使用示例

// webpack配置
{
  test: /.json$/,
  use: ['json-loader']
}

// 在JavaScript中使用
import config from './config.json';
console.log(config.name); // "webpack-demo"

四、手写实现简易style-loader与css-loader

4.1 CSS文件处理的挑战

CSS文件无法直接被JavaScript引擎执行,需要通过DOM操作将样式注入到页面中。

处理策略

  1. css-loader:读取CSS内容并返回字符串
  2. style-loader:将CSS字符串通过DOM操作插入到页面

4.2 css-loader实现

/**
 * CSS Loader实现
 * 将CSS文件内容转换为JavaScript字符串导出
 */
function cssLoader(source) {
  // 简单版本:直接返回CSS内容作为字符串
  const cssString = JSON.stringify(source);
  return `export default ${cssString};`;
}

4.3 style-loader实现

/**
 * Style Loader实现  
 * 将CSS字符串通过DOM操作注入到页面中
 */
function styleLoader(source) {
  // 从css-loader的输出中提取CSS内容
  // css-loader输出格式:export default "css content here";
  
  return `
    // 从css-loader获取CSS内容
    ${source}
    
    // 创建并插入style标签的函数
    function insertCSS(css) {
      if (typeof document === 'undefined') return;
      
      const style = document.createElement('style');
      style.type = 'text/css';
      
      if (style.styleSheet) {
        // IE8及以下版本
        style.styleSheet.cssText = css;
      } else {
        // 现代浏览器
        style.innerHTML = css;
      }
      
      document.head.appendChild(style);
    }
    
    // 立即执行:将CSS插入页面
    insertCSS(__webpack_require__.default || __webpack_require__);
  `;
}

4.4 更完善的style-loader实现

function styleLoader(source) {
  return `
    ${source}
    
    (function() {
      // 获取CSS内容(来自css-loader的输出)
      const css = typeof exports === 'object' && exports.default || exports;
      
      if (typeof css === 'string') {
        // 创建style标签
        const style = document.createElement('style');
        style.type = 'text/css';
        
        // 添加CSS内容
        if (style.styleSheet) {
          style.styleSheet.cssText = css;
        } else {
          style.appendChild(document.createTextNode(css));
        }
        
        // 插入到head中
        document.head.appendChild(style);
        
        // 支持热更新时的样式移除
        if (module.hot) {
          module.hot.dispose(function() {
            document.head.removeChild(style);
          });
        }
      }
    })();
    
    // 导出空对象(CSS不需要导出内容)
    export default {};
  `;
}

4.5 CSS处理流程梳理

完整处理流程

1. 遇到 import './styles.css'
2. 匹配到 test: /.css$/, use: ['style-loader', 'css-loader']
3. 执行顺序(右到左):
   
   原始CSS文件内容:
   ".button { color: red; background: blue; }"
   
   ↓ css-loader处理
   
   "export default ".button { color: red; background: blue; }";"
   
   ↓ style-loader处理  
   
   "// 插入CSS到DOM的JavaScript代码
    const css = ".button { color: red; background: blue; }";
    const style = document.createElement('style');
    style.innerHTML = css;
    document.head.appendChild(style);
    export default {};"
    
4. 生成的JavaScript代码被webpack打包
5. 运行时执行,CSS被注入到页面中

五、总结与扩展

5.1 Loader机制的核心价值

  1. 扩展性:让Webpack能够处理任意类型的文件
  2. 模块化:每个Loader职责单一,可组合使用
  3. 标准化:统一的接口规范,便于开发和维护

5.2 常见Loader类型

  • 转译类:babel-loader, typescript-loader
  • 样式类:css-loader, style-loader, sass-loader
  • 文件类:file-loader, url-loader
  • 代码检查:eslint-loader
  • 模板类:html-loader, vue-loader

5.3 开发Loader的最佳实践

  1. 单一职责:每个Loader只做一件事
  2. 链式调用:设计时考虑与其他Loader的配合
  3. 错误处理:提供清晰的错误信息
  4. 性能优化:缓存计算结果,避免重复处理
  5. 选项支持:通过loader-utils获取用户配置

5.4 实际应用场景

  • 组件化开发:CSS Modules解决样式隔离问题
  • 预处理器:Sass/Less编译为CSS
  • 代码转换:ES6+转换为ES5兼容代码
  • 资源优化:图片压缩、文件合并

Webpack热更新(HMR)原理与实现(自测:说出具体原理和实现流程)

一、HMR解决的具体问题

在没有HMR(Hot Module Replacement)时,修改代码后的开发体验如下:

全量刷新 (Live Reload) :修改代码 -> Webpack重新打包 -> 浏览器自动刷新页面 (window.location.reload())。
问题: 重新打包所有资源并在浏览器重新加载以及状态丢失

HMR的效果
修改代码 -> 浏览器不刷新 -> 仅替换修改的模块代码 -> 保持当前页面状态不变。

二、HMR核心流程拆解

HMR不是单一功能,而是Webpack编译器(服务端)与浏览器运行时(客户端)配合的结果。

涉及的四个核心角色

  1. Webpack Compiler:负责监听文件,编译代码。
  2. HMR Server (通常集成在webpack-dev-server中):建立WebSocket连接,负责将更新通知推送到浏览器。
  3. Bundle Server:提供文件访问服务(http://localhost:8080/bundle.js)。
  4. HMR Runtime:注入到打包后的bundle.js中的一段JS代码,负责在浏览器端接收WebSocket消息,并执行代码替换。

完整更新流程

  1. 监听:Webpack Compiler 监听到文件变化(如 style.cssmath.js)。
  2. 增量编译:Webpack 不会重新打包所有文件,而是生成两个补丁文件:
  • Manifest (JSON) :描述哪些模块变了,新的hash值是多少。
  • Update Chunk (JS) :包含被修改模块的具体代码。
  1. 推送消息:HMR Server 通过 WebSocket 向浏览器发送消息:{"type": "hash", "data": "新的hash值"}{"type": "ok"}
  2. 检查更新:浏览器端的 HMR Runtime 收到消息,对比上一次的 hash,发现有更新。
  3. 请求补丁:Runtime 发起 AJAX 请求获取 Manifest,再通过 JSONP 请求获取 Update Chunk。
  4. 代码替换:Runtime 执行新下载的代码,替换掉 __webpack_modules__ 中对应的旧函数。 image.png

三、手写简易HMR实现逻辑

这里不展示完整的Webpack源码,而是实现HMR最核心的通信模块替换逻辑。

服务端:监听编译与WebSocket通知

在开发服务器启动时,需要注入WebSocket服务。

// server.js (模拟 webpack-dev-server)
const WebSocket = require('ws');
const webpack = require('webpack');
const config = require('./webpack.config.js');

const compiler = webpack(config);
const app = require('express')();
const server = require('http').createServer(app);

// 1. 启动 WebSocket 服务器
const wss = new WebSocket.Server({ server });

// 2. 监听 Webpack 编译完成钩子
compiler.hooks.done.tap('HMRPlugin', (stats) => {
  // 获取新生成的 hash
  const hash = stats.hash;
  
  // 3. 向所有连接的客户端广播消息
  wss.clients.forEach(client => {
    client.send(JSON.stringify({
      type: 'hash',
      data: hash
    }));
    client.send(JSON.stringify({
      type: 'ok'
    }));
  });
});

// 启动编译监视
compiler.watch({}, (err) => {
  console.log('Webpack is watching files...');
});

server.listen(8080);

客户端:Runtime代码注入

Webpack打包时,会将以下代码注入到 bundle.js 的入口处。

// bundle.js 中的注入代码 (简化版)

// 1. 建立连接
const socket = new WebSocket('ws://localhost:8080');
let currentHash = 'old_hash_value';

// 2. 监听消息
socket.onmessage = function(event) {
  const msg = JSON.parse(event.data);
  
  if (msg.type === 'hash') {
    currentHash = msg.data;
  } else if (msg.type === 'ok') {
    // 收到更新完成信号,开始热更新逻辑
    hotCheck();
  }
};

function hotCheck() {
  console.log('检测到更新,准备拉取新代码...');
  // 实际 Webpack 会在这里:
  // 1. fetch('/hash.hot-update.json') -> 拿到变动的模块ID
  // 2. loadScript('/hash.hot-update.js') -> 拿到新模块代码
  // 3. hotApply() -> 执行替换
  
  // 模拟热更新操作
  hotDownloadManifest().then(hotDownloadUpdateChunk);
}

核心:如何在浏览器端替换代码

这是HMR最关键的一步。回顾之前的打包结构,所有模块都存在 __webpack_modules__ 对象中。热更新的本质就是修改这个对象的键值对

假设更新前的 bundle.js 运行时结构:

var __webpack_modules__ = {
  "./src/title.js": function(module, exports) {
    module.exports = "Old Title";
  }
};
// 缓存
var __webpack_module_cache__ = {
  "./src/title.js": { exports: "Old Title", loaded: true }
};

更新发生时 (hotApply 的简化逻辑):

// 这是一个由 JSONP 加载的新代码块
function webpackHotUpdateCallback(chunkId, moreModules) {
  // moreModules 包含了新的模块代码
  // 例如: { "./src/title.js": function() { module.exports = "New Title"; } }
  
  for (let moduleId in moreModules) {
    // 1. 覆盖旧的模块定义
    __webpack_modules__[moduleId] = moreModules[moduleId];
    
    // 2. 删除旧的缓存(关键)
    // 下次 require 这个模块时,会重新执行新函数
    delete __webpack_module_cache__[moduleId];
    
    // 3. 执行 accept 回调(如果有)
    if (hot._acceptedDependencies[moduleId]) {
      hot._acceptedDependencies[moduleId]();
    }
  }
}

总结操作:

  1. 覆盖:用新函数覆盖 __webpack_modules__ 中的旧函数。
  2. 清缓存:删除 __webpack_module_cache__ 中的缓存。
  3. 重执行:当父模块再次执行 __webpack_require__('./src/title.js') 时,会拿到最新的代码。

四、module.hot.accept 与 冒泡机制

仅仅替换模块定义是不够的,如果页面已经渲染了 "Old Title",仅仅替换函数的定义,页面文字不会自动变。需要代码主动响应这个变化。

开发者代码中的设置

在入口文件(如 index.js)中:

import title from './title.js';

document.body.innerText = title;

// 必须添加这段代码才能实现 HMR,否则会回退到整页刷新
if (module.hot) {
  // 注册回调:当 title.js 发生变化时执行
  module.hot.accept(['./title.js'], () => {
    // 重新获取新内容
    const newTitle = require('./title.js'); 
    // 执行具体的 DOM 更新逻辑
    document.body.innerText = newTitle; 
  });
}

冒泡机制 (Bubbling)

如果 title.js 变了,但 title.js 没有 module.hot.accept,Webpack 会怎么做?

  1. 检查自身title.jsaccept 吗?没有。
  2. 向上查找:谁引用了 title.js?是 index.js
  3. 检查父级index.js 有没有 accept('./title.js')
    • :执行 index.js 中定义的回调。更新结束。
    • 没有:继续向上查找 index.js 的父级。
  1. 顶层失败:如果一直冒泡到入口文件(Entry)都没有被 accept 捕获,HMR 宣告失败,触发 window.location.reload() 进行全量刷新。

4.3 为什么Vue/React开发时不需要手写accept?

因为 vue-loaderreact-refresh 自动在编译时注入了 module.hot.accept 代码。

例如 vue-loader 转换后的代码大致如下:

// vue-loader 自动注入的代码
import { render } from './App.vue?vue&type=template';
// ...
export default component.exports;

if (module.hot) {
  module.hot.accept(); // 接受自身更新
  module.hot.accept('./App.vue?vue&type=template', () => {
    // 当模板更新时,重新渲染组件,保留状态
    api.rerender('component-id', render); 
  });
}

image.png

五、总结 Webpack HMR 实现链

  1. 监听:Compiler 监听到文件修改。
  2. 生成:Compiler 生成 Manifest 和 Update Chunk。
  3. 通知:Server 通过 WebSocket 通知 Client "有新 Hash"。
  4. 下载:Client 通过 JSONP 下载新代码块。
  5. 替换:Client 运行时更新 __webpack_modules__ 并清除缓存。
  6. 响应:通过 module.hot.accept 定义的回调函数,执行具体的业务逻辑更新(如重绘 DOM)。

Webpack Plugin实现

一、Plugin的核心作用与Loader的区别

1.1 什么是Plugin

Plugin不处理具体的模块内容,而是监听Webpack构建过程中的生命周期事件(Hooks),在特定的时刻执行特定的逻辑,从而改变构建结果。

1.2 Plugin与Loader的直观对比

特性 Loader Plugin
作用对象 单个文件 (如 .css, .vue) 整个构建过程 (Compiler)
功能 转换代码 (less -> css) 打包优化、资源管理、环境变量注入
运行时机 解析模块依赖时 构建流程的任意时刻 (启动、编译、发射、结束)
配置方式 module.rules 数组 plugins 数组

1.3 常见的Plugin功能

  • 打包前:清除 dist 目录 (CleanWebpackPlugin)。
  • 编译中:定义全局变量 (DefinePlugin)。
  • 打包后:生成 index.html 并自动插入JS脚本 (HtmlWebpackPlugin)。
  • 结束时:压缩CSS/JS代码,上传资源到CDN。

二、Plugin的基本结构(自测:说出Plugin的固定格式)

2.1 基础代码结构

Webpack的Plugin是一个类(Class),它必须包含一个 apply 方法。

class MyPlugin {
  // 1. 接收配置参数
  constructor(options) {
    this.options = options;
  }

  // 2. 必须包含 apply 方法,接收 compiler 对象
  apply(compiler) {
    // 3. 注册钩子,监听事件 (例如 'done' 表示构建完成)
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('构建完成!');
    });
  }
}

module.exports = MyPlugin;

2.2 使用方式

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

module.exports = {
  plugins: [
    new MyPlugin({ param: 'value' }) // 实例化插件
  ]
};

三、两个核心对象:Compiler与Compilation

在编写Plugin时,必须区分两个对象:

3.1 Compiler (编译器)

  • 定义:代表了完整的 Webpack 环境配置。
  • 生命周期:Webpack 启动时创建,直到进程结束。它是全局唯一的。
  • 作用:可以访问所有的配置信息(entry, output, loaders等),用于注册全局级别的钩子。

3.2 Compilation (编译过程)

  • 定义:代表了一次具体的构建过程
  • 生命周期:每次检测到文件变化(热更新)时,都会创建一个新的 compilation 对象。
  • 作用:包含了当前的模块资源、编译生成的文件(assets)、依赖关系图。如果要修改打包输出的内容,必须操作 compilation。

四、手写实现一个文件清单插件 (FileListPlugin)

4.1 需求描述

我们需要实现一个插件,在打包生成文件之前,自动生成一个 file-list.md 文件。 该文件记录所有打包输出的文件名和文件大小。

4.2 实现步骤

  1. 监听钩子:使用 emit 钩子。这个时刻编译已完成,文件即将输出到磁盘,但还未输出。这是修改输出资源的最后机会。
  2. 获取资源:从 compilation.assets 获取所有待输出的文件。
  3. 生成内容:遍历资源,拼接文件名和大小。
  4. 添加资源:将新生成的 file-list.md 添加到 compilation.assets 中。

4.3 代码实现

class FileListPlugin {
  constructor(options) {
    // 允许用户配置输出的文件名,默认为 'file-list.md'
    this.filename = options && options.filename ? options.filename : 'file-list.md';
  }

  apply(compiler) {
    // 1. 注册 emit 钩子(这是一个异步钩子,使用 tapAsync)
    compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
      
      let fileList = '# Bundled Files

';

      // 2. 遍历 compilation.assets (包含所有即将输出的文件)
      for (let filename in compilation.assets) {
        // 获取文件来源对象
        const source = compilation.assets[filename];
        // 获取文件大小
        const size = source.size();
        
        fileList += `- ${filename}: ${size} bytes
`;
      }

      // 3. 将生成的内容添加到输出资源列表
      compilation.assets[this.filename] = {
        // 返回文件内容
        source: function() {
          return fileList;
        },
        // 返回文件大小
        size: function() {
          return fileList.length;
        }
      };

      // 4. 异步处理完成,必须调用 callback 告诉 Webpack 继续执行
      callback();
    });
  }
}

module.exports = FileListPlugin;

4.4 模拟运行效果

假设打包输出了 bundle.js (1000 bytes) 和 style.css (500 bytes),配置插件后,dist 目录下会多出一个 file-list.md

# Bundled Files

- bundle.js: 1000 bytes
- style.css: 500 bytes

五、常用生命周期钩子(Hooks)一览

Webpack 基于 Tapable 库实现了事件流。以下是开发 Plugin 最常用的几个钩子:

钩子名称 归属对象 时机 常用场景 同步/异步
entryOption compiler 初始化配置后 读取或修改 Entry 配置 Sync
compile compiler 开始编译前 提示“开始构建” Sync
compilation compiler 编译过程创建时 注册更细粒度的 compilation 钩子 Sync
emit compiler 生成资源到目录前 修改文件内容、添加新文件 (最常用) Async
done compiler 编译完成 提示构建结束、上传资源、分析耗时 Async

注册方式的区别:

  • 同步钩子tap('PluginName', (params) => { ... })
  • 异步钩子
    • tapAsync('PluginName', (params, callback) => { ... callback(); })
    • tapPromise('PluginName', (params) => { return Promise.resolve(); })

六、总结

Webpack Plugin 的实现核心链条:

  1. 类结构:定义一个类,包含 apply(compiler) 方法。
  2. 事件监听:通过 compiler.hooks 监听 Webpack 的生命周期事件。
  3. 资源操作
    • 如果只关注流程监控(如 build 进度),操作 compiler
    • 如果要修改产物(如添加文件、压缩代码),操作 compilation.assets
  4. 流程控制:如果是异步钩子,处理完逻辑后必须调用 callback 或返回 Promise,否则构建会卡死。

Webpack 模块联邦 (Module Federation) 实现

一、解决的具体问题

在模块联邦出现之前,跨项目共享代码主要有两种方式,各有明显的弊端:

  1. NPM 包模式

    • 流程:项目 B 修改组件 -> 打包发布到 NPM -> 项目 A 更新 package.json -> 项目 A 重新安装依赖 -> 项目 A 重新打包发布。
    • 缺点:更新流程长,无法实现热插拔,所有依赖在构建时必须确定。
  2. Iframe 或 Script 标签引入

    • 流程:项目 A 直接加载项目 B 的打包文件。
    • 缺点:完全隔离(Iframe)导致上下文不通;或者没有依赖共享机制(Script 标签),导致项目 A 和项目 B 各自加载了一份 React,页面体积倍增,且可能导致 React 实例冲突(Hooks 报错)。

模块联邦解决的问题: 在浏览器运行时,项目 A 可以直接引用 项目 B 构建好的代码,并且双方共享底层的依赖(如 React),避免重复加载。

二、基础配置与概念

模块联邦引入了三个核心概念:Host(消费者)Remote(提供者)Shared(共享依赖)

假设场景:

  • App 1 (Remote): 端口 3001,提供一个 Button 组件。
  • App 2 (Host): 端口 3002,想要使用 App 1 的 Button

2.1 提供方 (App 1) 配置

// webpack.config.js (App 1)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  // ...其他配置
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',                  // 唯一标识,对应全局变量 window.app1
      filename: 'remoteEntry.js',    // 暴露出的入口文件名称
      exposes: {
        './Button': './src/Button',  // 映射:外部引入路径 -> 内部文件路径
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

2.2 消费方 (App 2) 配置

// webpack.config.js (App 2)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2',
      remotes: {
        // 键名 'app1':在代码中 import 的前缀
        // 键值 'app1@...':远程应用的 name + 远程应用的地址
        app1: 'app1@http://localhost:3001/remoteEntry.js',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

2.3 消费方代码使用

// App 2 的业务代码
import React, { Suspense } from 'react';

// 像引入本地模块一样引入远程模块
// 'app1' 对应配置中的 remotes 键名
// 'Button' 对应 App 1 exposes 的键名
const RemoteButton = React.lazy(() => import('app1/Button'));

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

三、核心原理:remoteEntry.js 是什么?

当 App 1 构建时,Webpack 会生成一个特殊的入口文件 remoteEntry.js。这是模块联邦通信的桥梁。

这个文件包含三个主要部分:

  1. 模块映射表 (Module Map):记录了 ./Button 对应的是哪个 chunk 文件(例如 src_Button_js.js)。
  2. 获取函数 (Get):用于根据路径加载对应的模块。
  3. 初始化函数 (Init):用于接收 Host 传递过来的共享依赖(Shared Scope)。

浏览器运行时流程:

  1. App 2 加载 http://localhost:3001/remoteEntry.js
  2. remoteEntry.js 执行,在全局 window 上挂载一个变量 app1
  3. App 2 调用 window.app1.init(),将自己(App 2)的 React 版本放入共享作用域。
  4. App 2 调用 window.app1.get('./Button')
  5. App 1 检查共享作用域,发现已有 React,便不再加载自己的 React,而是直接下载 Button 的代码并返回。

四、手写简易模块联邦实现

为了理解 Webpack 内部是如何实现的,我们模拟一下 Host 和 Remote 在浏览器端的交互逻辑。

4.1 模拟 Remote (App 1) 的 remoteEntry.js

这是一个立即执行函数,目的是在全局注册接口。

// 模拟 app1/remoteEntry.js
var app1_modules = {
  './Button': () => {
    // 实际场景这里是通过 JSONP 加载真实文件
    console.log("加载 App1 的 Button 组件");
    return {
      default: "我是来自 App1 的按钮"
    };
  }
};

// 共享作用域容器
var sharedScope = {};

// 在 window 上挂载全局对象
window.app1 = {
  // 1. get: 供 Host 获取模块
  get: function(moduleName) {
    return new Promise((resolve) => {
      if (app1_modules[moduleName]) {
        // 返回模块的工厂函数
        resolve(() => app1_modules[moduleName]());
      } else {
        resolve(null);
      }
    });
  },

  // 2. init: 供 Host 初始化共享依赖
  init: function(scope) {
    // 将 Host 传来的 scope 合并到自己的 scope 中
    sharedScope = scope;
    console.log("App1 初始化完成,已接收共享依赖", scope);
    return Promise.resolve();
  }
};

4.2 模拟 Host (App 2) 的加载逻辑

Host 需要先加载远程脚本,然后按顺序调用 initget

// 模拟 Webpack 内部加载远程模块的逻辑

// 1. 定义加载脚本的辅助函数
function loadScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

// 2. 主流程
(async function() {
  // 步骤 A: 初始化 Host 自身的共享作用域
  const hostSharedScope = {
    react: { version: '17.0.2', loaded: true }
  };
  
  // 步骤 B: 加载 Remote 的入口文件
  await loadScript('http://localhost:3001/remoteEntry.js');
  
  // 此时 window.app1 已经存在
  const container = window.app1;
  
  // 步骤 C: 初始化容器 (交换共享依赖)
  // 告诉 app1:"我有这些依赖,你看看能不能用,别自己重复加载了"
  await container.init(hostSharedScope);
  
  // 步骤 D: 获取组件
  const factory = await container.get('./Button');
  const module = factory();
  
  console.log("最终获取到的模块:", module.default);
})();

五、依赖共享的具体逻辑 (Singleton)

shared 配置中,最关键的是版本控制。Webpack 运行时会进行如下判断:

  1. Host 端:我有 React 17.0.2。
  2. Remote 端:我需要 React ^16.8.0。
  3. 握手阶段 (init):Remote 检查 Host 提供的 React 17.0.2 是否满足 ^16.8.0
    • 满足:Remote 丢弃自己的 React 依赖,使用 Host 提供的全局 React 对象。
    • 不满足:Remote 坚持加载自己打包的 React 副本(除非配置了 singleton: truestrictVersion: true,此时会报错)。

实现简述: Webpack 维护了一个全局对象 __webpack_share_scopes__init 函数的本质就是把不同应用的依赖对象合并到这个全局对象中,通过语义化版本(SemVer)比较函数来决定使用哪一个版本的库。

六、总结模块联邦

  1. 去中心化:没有所谓的“主应用”,任何应用都可以同时是 Host 和 Remote。
  2. 运行时加载:不同于 NPM 的构建时集成,WMF 是在页面打开时动态下载代码。
  3. 双向接口
    • init(scope):输入接口,接收外部环境的共享依赖。
    • get(path):输出接口,向外部暴露内部模块。
  4. 本质:通过全局变量(window.app_name)建立通信协议,实现不同构建产物之间的互操作。

前端知识体系总结-前端工程化(Vite篇)

实现 Vite 核心功能(自测:Vite 核心功能和运行原理有哪些,由最简讲起,具体是怎么实现的)

Webpack 是先打包好文件再放到 dev server 运行

而 Vite 是先运行 dev server ,之后浏览器请求什么文件就在 dev server 中动态编译后再返回。核心是基于浏览器原生支持的 ES Modules (<script type="module">),当浏览器解析到 import 语句时,会向服务器发送 HTTP 请求,服务器拦截这些请求并实时编译文件与响应。

一、搭建服务返回index.html与编译js文件

1.1 搭建基础开发服务器

我们需要一个能拦截请求的 HTTP 服务器。这里使用 Koa (Vite 内部使用 connect,逻辑类似)。

目录结构:

mini-vite/
  ├── src/
  │   ├── main.js
  │   └── App.vue
  ├── index.html
  ├── server.js  (我们将编写的代码)
  └── package.json

index.html: 关键在于 type="module",这告诉浏览器直接以 ES 模块方式加载 js。

<!DOCTYPE html>
<html lang="en">
<body>
  <div id="app"></div>
  <!-- 浏览器会发起 GET /src/main.js 请求 -->
  <script type="module" src="/src/main.js"></script>
</body>
</html>

server.js (第一步:静态文件服务): 浏览器请求 / 返回 HTML,请求 /src/main.js 返回 JS 内容。

const Koa = require('koa');
const fs = require('fs');
const path = require('path');

const app = new Koa();

app.use(async (ctx) => {
  const url = ctx.request.url;
  
  // 1. 根路径返回 index.html
  if (url === '/') {
    ctx.type = 'text/html';
    ctx.body = fs.readFileSync('./index.html', 'utf-8');
    return;
  }
  
  // 2. JS文件请求处理 (如 /src/main.js)
  if (url.endsWith('.js')) {
    const p = path.join(__dirname, url);
    ctx.type = 'application/javascript';
    ctx.body = fs.readFileSync(p, 'utf-8');
    return;
  }
});

app.listen(3000, () => {
  console.log('Vite dev server running at http://localhost:3000');
});

二、实现第三方库导入处理

2.1 问题描述

src/main.js 中,我们通常这样写:

import { createApp } from 'vue'; // ❌ 浏览器报错
import App from './App.vue';

浏览器遇到 import ... from 'vue' 时会报错,因为它不知道 'vue' 在哪里。浏览器只认识相对路径 (./, ../) 或绝对路径 (/)。

2.2 解决方案:路径重写

服务器需要在返回 JS 文件内容给浏览器之前,把内容里的 'vue' 替换成 '/@modules/vue',给它一个特殊标识。

修改 server.js:

// 工具函数:把文件流转成字符串
function readStream(stream) {
  return new Promise((resolve, reject) => {
    let data = '';
    stream.on('data', chunk => data += chunk);
    stream.on('end', () => resolve(data));
  });
}

// 路径重写逻辑
function rewriteImport(content) {
  // 正则匹配: from 'vue' -> from '/@modules/vue'
  // s0: 匹配到的完整字符串
  // s1: 捕获组,即包名 'vue'
  return content.replace(/ from ['"](.*)['"]/g, (s0, s1) => {
    // 如果是相对路径 ./ 或 ../ 或 / 开头,不处理
    if (s1.startsWith('.') || s1.startsWith('/')) {
      return s0;
    }
    // 否则加上 /@modules/ 前缀
    return ` from '/@modules/${s1}'`;
  });
}

app.use(async (ctx) => {
  const url = ctx.request.url;

  if (url.endsWith('.js')) {
    const p = path.join(__dirname, url);
    const content = fs.readFileSync(p, 'utf-8');
    ctx.type = 'application/javascript';
    // 返回修改后的内容
    ctx.body = rewriteImport(content); 
    return;
  }
});

经过这一步,浏览器收到的代码变成了:

import { createApp } from '/@modules/vue'; // ✅ 浏览器会发起新请求
import App from './App.vue';

2.3 获取真实文件路径

当浏览器请求 http://localhost:3000/@modules/vue 时,服务器需要去 node_modules 里找到 vue 的入口文件。

查找步骤:

  1. 找到 node_modules/vue 文件夹。
  2. 读取 package.jsonmodule 字段 (ESM 入口) 或 main 字段。
  3. 读取该入口文件的内容返回。

2.4 server.js 新增逻辑

app.use(async (ctx) => {
  const url = ctx.request.url;

  // 3. 处理第三方模块请求
  if (url.startsWith('/@modules/')) {
    // 提取模块名,例如 'vue'
    const moduleName = url.replace('/@modules/', '');
    
    // 在 node_modules 中找到该模块文件夹
    const prefix = path.join(__dirname, './node_modules', moduleName);
    
    // 读取 package.json
    const packageJSON = require(path.join(prefix, 'package.json'));
    
    // 获取入口文件路径 (优先使用 module 字段,因为是 ESM)
    const entryPath = path.join(prefix, packageJSON.module);
    
    // 读取文件内容
    const content = fs.readFileSync(entryPath, 'utf-8');
    
    ctx.type = 'application/javascript';
    // 第三方库内部可能也引用了其他库,也需要重写路径
    ctx.body = rewriteImport(content);
    return;
  }
  
  // ... 其他逻辑
});

三、处理 .vue 单文件组件 (SFC)

3.1 浏览器不认识 .vue

浏览器请求 App.vue 时,服务器不能直接返回 Vue 源码,需要把 .vue 编译成 JS。

Vite 使用 vue 官方提供的 @vue/compiler-sfc 进行编译。

3.2 server.js 新增 Vue 处理逻辑

const compilerSfc = require('@vue/compiler-sfc');

app.use(async (ctx) => {
  const url = ctx.request.url;
  
  // 4. 处理 .vue 文件
 if (ctx.url.endsWith(".vue")) {
ctx.type = "application/javascript; utf-8";
const content = fs.readFileSync(path.join(__dirname, ctx.url), "utf-8");
const { descriptor } = compilerSfc.parse(content);

// 使用 inlineTemplate 选项,让 compileScript 直接生成包含 render 的完整组件
const compiled = compilerSfc.compileScript(descriptor, {
id: ctx.url,
inlineTemplate: true, // 关键:内联编译模板,setup 直接返回 render 函数
});

ctx.body = rewriteImport(compiled.content);
return;
}
});

四、Vite 核心功能总结

实现一个简易 Vite 只需要解决三个问题:

  1. 服务器:用 Koa 拦截浏览器发起的文件获取 HTTP 请求并实时编译与返回。
  2. JS 处理:遇到 import 'vue' 这种裸模块导入,重写路径为 /@modules/vue,并去 node_modules 里找文件返回。
  3. Vue 处理:遇到 .vue 文件,使用 compiler-sfc 编译。先把 Script 发给浏览器,再让浏览器回头取 Template 的编译结果,最后拼在一起。

这种模式下,开发环境启动速度与项目大小无关,因为只有当你点击了某个页面,浏览器发起了请求,服务器才开始编译那个页面用到的文件。

Vite HMR实现原理(自测:更新一个文件后,wbp和vite分别会经过什么流程进行网页的热更新)

一、先回顾 Webpack 热更新原理

假如你的项目有1000个JS模块,你修改了其中一个文件 src/components/Header.vue

Webpack的处理方式

  1. Wepack Compiler 监听工作区:Webpack监听到文件保存动作。
  2. 重新构建被修改的模块:loader 链转换文件为 JS 可执行代码 -> AST 解析代码并识别 import、export 代码进行依赖图的增加或删除 -> 对新发现的依赖进行递归处理
  3. 打包:生成 Manifest JSON 文件,告诉浏览器这次更新涉及哪些模块;生成 Update Chunk JS 文件,包含被修改那个模块的新代码。
  4. 推送:HMR Server通过WebSocket推送更新通知给浏览器。
  5. 替换:浏览器的 HMR Runtime 请求清单文件,并根据清单文件请求被更新的模块代码,接着找到模块是否有自己的 module.hot.accept ,否则冒泡沿着依赖图向上查找,在 accept 回调中 import 并执行新的JS代码进行视图的更新。

三、Vite HMR 具体工作流程

1. 建立连接

客户端(浏览器)连接 Vite 开发服务器的 WebSocket。

2. 文件修改与通知

当你保存 Header.vue 时:

  1. Vite 文件监听器检测到变化。
  2. 解析该文件导出内容,确定它是Vue组件。
  3. 通过 WebSocket 向客户端发送一段JSON消息。

消息内容示例:

{
  "type": "update",
  "updates": [
    {
      "type": "js-update",
      "timestamp": 1678888888,
      "path": "/src/components/Header.vue",
      "acceptedPath": "/src/components/Header.vue"
    }
  ]
}

3. 浏览器重新请求

Vite 在浏览器端注入的客户端代码(vite/client)收到消息。它不会像 Webpack 那样去执行一段新推过来的 JS 代码块,而是利用浏览器动态导入功能

具体操作: 浏览器构造一个新的 import URL,带上时间戳以强制让浏览器认为这是一个新文件,从而避开缓存。

// 浏览器端逻辑模拟
import('/src/components/Header.vue?t=1678888888')
  .then((newModule) => {
    // 获取到新的模块内容,进行替换
  });

4. 模块替换

对于Vue组件,Vite使用了 vue-loader 类似的逻辑(vite-plugin-vue)。

  • 旧的 Header.vue 组件实例还保留在内存中。
  • 新的模块加载后,框架(Vue/React)利用 HMR API 重新渲染该组件,保留组件内的 data/state 状态,仅更新 render 函数或样式。

四、Vite HMR API:import.meta.hot

Webpack使用 module.hot,而 Vite 使用 ESM 标准的 import.meta.hot

开发者的代码(通常由插件自动注入):

// src/components/Header.vue 编译后的JS代码
// ... 组件代码 ...

export default _sfc_main;

// HMR 逻辑
if (import.meta.hot) {
  // 接受自身更新
  import.meta.hot.accept((newModule) => {
    if (newModule) {
      // 执行组件重渲染逻辑
      __VUE_HMR_RUNTIME__.reload('组件HashID', newModule.default);
    }
  });
}

实现逻辑:

  1. import.meta.hot.accept:告诉 Vite,如果这个文件变了,不需要刷新页面,我自己能处理。
  2. 回调函数:当新文件被 import(...) 加载成功后,执行这个回调,传入新模块内容。

五、所以为什么 Vite HMR 速度快

  1. 无需重构依赖图:文件保存后无需重新分析依赖图的更改,本质是因为 Vite 不需要构建依赖图去生成 bundle,而是通过浏览器 ESM 能力提供所需文件即可。
  2. 无需打包:Vite 只需编译一次文件,而 Webpack 需要将受影响的模块及其相关依赖(修改模块本身、父节点可能更新对子模块的Module ID引用代码、所属Chunk)重新打包与合并,涉及 n 个文件的修改。
  3. 全量代码下发:Webpack 下发包含新代码的 HMR 更新包,而 Vite 只发送一个指向修改该文件的 HTTP 请求,由浏览器重新请求。

Vite Plugin 实现原理与实战(自测:实现一个vite-plugin-svg-icons)

在前文中,我们了解了 Webpack 的打包流程:读取入口 -> 分析 AST -> 递归依赖 -> 转换代码 -> 生成 Bundle

Vite 的工作方式完全不同。在开发环境下,Vite 不打包。它利用浏览器对 ES Modules 的原生支持。当浏览器发起请求(如 GET /src/main.js)时,Vite 服务器拦截请求,进行必要的代码转换,然后直接返回 JS 内容。

Vite 插件 就是用来拦截处理这些请求的工具。

Vite 插件基于 Rollup 的插件接口设计,同时扩展了一些 Vite 独有的钩子(Hooks)。

一、Vite 插件的核心钩子 (Hooks)

Webpack 将功能分为 Loader(转换文件)和 Plugin(监听构建生命周期)。Vite 将这两者合并了。一个 Vite 插件本质上是一个返回配置对象的函数

处理一个文件请求时,主要经过以下三个核心钩子:

  1. resolveId(source, importer): 找文件
    • 输入: 代码中的导入路径(如 import x from './a' 中的 './a')。
    • 作用: 告诉 Vite 这个文件的绝对路径在哪里,或者标记这是一个“虚拟模块”。
    • 返回: 文件的绝对路径或 ID。
  2. load(id): 读文件
    • 输入: resolveId 返回的绝对路径或 ID。
    • 作用: 读取文件内容。通常用于加载磁盘文件或生成虚拟文件内容。
    • 返回: 文件内容的字符串。
  3. transform(code, id): 改代码(相当于 Webpack Loader)
    • 输入: load 返回的代码字符串,以及文件 ID。
    • 作用: 将非 JS 代码(如 Vue, CSS, TS)转换为浏览器能识别的 JS 代码。
    • 返回: 转换后的 JS 代码。

二、实战:实现一个虚拟模块插件

场景:你需要在一个项目中引入一个并不存在于磁盘上的文件,比如构建时的环境变量信息。

目标代码

// main.js
import env from 'virtual:env'; // 这个文件在磁盘上不存在
console.log(env); 

插件实现

export default function myVirtualPlugin() {
  const virtualModuleId = 'virtual:env';
  const resolvedVirtualModuleId = '\0' + virtualModuleId; // \0 是 Rollup 的约定,表示这是一个虚拟模块,不要去磁盘找

  return {
    name: 'my-virtual-plugin', // 插件名称,必填

    // 1. 拦截 import
    resolveId(source) {
      if (source === virtualModuleId) {
        // 如果 import 的是 'virtual:env',返回我们自定义的 ID
        return resolvedVirtualModuleId;
      }
      return null; // 其他文件不管,交给 Vite 处理
    },

    // 2. 加载内容
    load(id) {
      if (id === resolvedVirtualModuleId) {
        // 匹配到自定义 ID,直接返回一段 JS 代码
        return `export default { 
            user: "admin", 
            buildTime: "${new Date().toISOString()}" 
        }`;
      }
      return null; // 其他文件不管,读取磁盘
    }
  };
}

配置 vite.config.js:

import myVirtualPlugin from './plugins/myVirtualPlugin';

export default {
  plugins: [myVirtualPlugin()]
};

三、实战:实现一个 vite-plugin-svg-icons

首先明确插件功能:扫描指定目录下的 SVG 文件 -> 转换<symbol> 标签并合并 -> 提供虚拟模块 virtual:svg-register import 注入页面 -> 支持 HMR 热更新。

为了更好理解插件功能,我们看看在实际场景中它的作用:

你正在开发一个企业级后台管理系统,设计师提供了一套自定义 SVG 图标(如 nav-order.svg, action-edit.svg),要求图标颜色能随文字颜色变化(如菜单 Hover 时变蓝),且会有数十个图标散落在各个页面。

使用img标签,第一个是无法改变颜色需要重新提供另一版本svg,并且还需要根据hover事件动态切换src,非常麻烦;使用内联svg代码,代码很臃肿,可读性差;使用手动import,若一个页面需要的svg很多,会产生大量import语句

我们的插件目标:

  1. 零配置引用:只需将 SVG 文件丢入 src/icons 文件夹,无需任何 import 语句,直接通过文件名即可使用。
  2. CSS 样式控制:插件生成的 SVG Sprite 支持 currentColor,图标就像文字一样,可以用 CSS 随意控制颜色大小
  3. 高性能:所有图标被合并成一段 JS 注入 HTML,零 HTTP 请求,且按需加载。

使用效果演示:

// main.ts
import 'virtual:svg-register' // 一行代码,所有图标自动打包注入
<!-- 无需 import,直接使用 -->
<svg class="icon" aria-hidden="true">
  <use xlink:href="#icon-nav-order" />
</svg>

<style>
.icon {
  color: grey;       /* 默认灰色 */
  font-size: 20px;   /* 控制大小 */
}
.icon:hover {
  color: blue;       /* 悬停自动变蓝,无需 JS */
}
</style>

可以封装为一个组件:

<!-- src/components/SvgIcon.vue -->
<template>
  <svg class="svg-icon" aria-hidden="true">
    <use :xlink:href="symbolId" />
  </svg>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps({
  name: { type: String, required: true }, // 传入图标文件名,如 'truck'
  prefix: { type: String, default: 'icon' }
})

const symbolId = computed(() => `#${props.prefix}-${props.name}`)
</script>

<style scoped>
.svg-icon {
  width: 1em; height: 1em; /* 默认跟随字体大小 */
  vertical-align: -0.15em;
  fill: currentColor; /* 关键:让图标颜色跟随文字颜色 */
  overflow: hidden;
}
</style>

现在我们来实现这个插件功能

首先理解“虚拟模块”

你可以在浏览器端 import 一个不存在于文件系统中的文件

目标:用户在代码里写 import 'virtual:svg-register',插件可以正确识别和拦截。

具体实现:使用 resolvedId 属性进行配置,当文件路径是我们的虚拟模块时,直接返回不需要解析,并且在 load 阶段返回我们自定义的代码交给程序执行

第二步:实战代码编写

新建一个 my-svg-plugin.js

我们可以安装一个依赖来方便找文件:npm install fast-glob

// my-svg-plugin.js
import path from 'path'
import fs from 'fs'
import fg from 'fast-glob'

export default function mySvgPlugin(options) {
  // 1. 配置虚拟模块 ID
  const VIRTUAL_MODULE_ID = 'virtual:svg-register'
  const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID

  return {
    name: 'vite-plugin-my-svg-sprite', // 插件名称

    // 2. resolveId: 告诉 Vite 这个 import 归我管
    resolveId(id) {
      if (id === VIRTUAL_MODULE_ID) {
        return RESOLVED_VIRTUAL_MODULE_ID
      }
    },

    // 3. load: 返回这个虚拟模块的具体代码
    async load(id) {
      if (id === RESOLVED_VIRTUAL_MODULE_ID) {
        
        // --- 核心逻辑开始 ---
        
        // A. 找到所有 SVG 文件
        const { iconDir } = options
        const svgFiles = await fg('**/*.svg', { cwd: iconDir, absolute: true })

        // B. 遍历并读取内容,拼接成 Symbol 字符串
        let symbols = ''
        
        svgFiles.forEach((file) => {
         if (file.endsWith(".svg")) {
 const content = fs.readFileSync(path.join(iconDir, file), "utf-8");
 const viewBox = content.match(/viewBox="([^"]+)"/)?.[1] || "0 0 24 24";
 const pathContent = content.match(/<svg[^>]*>(.*)<\/svg>/s)?.[1] || "";
 const iconName = file.replace(".svg", "");

 symbols += `<symbol id="icon-${iconName}" viewBox="${viewBox}">${pathContent}</symbol>`;
}
});

        // C. 构造最终的 JS 代码
        // 返回在页面中注入 SVG sprite 的代码
return `
                const svgSprite = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
     svgSprite.style.position = 'absolute';
     svgSprite.style.width = '0';
     svgSprite.style.height = '0';
     svgSprite.innerHTML = \`${symbols}\`;
     document.body.insertBefore(svgSprite, document.body.firstChild);
`;
        // --- 核心逻辑结束 ---
      }
    }
  }
}
第三步:在项目中使用 (验证效果)
  1. 配置 vite.config.js:

    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import path from 'path'
    import mySvgPlugin from './my-svg-plugin' // 引入你写的插件
    
    export default defineConfig({
      plugins: [
        vue(),
        mySvgPlugin({ 
            iconDir: path.resolve(__dirname, 'src/icons') // 假设你的图标都在这里
        })
      ]
    })
    
  2. 准备素材: 在 src/icons 下放几个 svg 文件,比如 vue.svgreact.svg

  3. 引入注册: 在 src/main.js (或 main.ts) 中引入虚拟模块:

    import { createApp } from 'vue'
    import App from './App.vue'
    
    // 这一行会触发你插件的 resolveId -> load,
    // 然后在浏览器执行那段插入 DOM 的 JS 代码
    import 'virtual:svg-register' 
    
    createApp(App).mount('#app')
    
  4. 组件使用: 在 Vue 组件里写:

    <template>
      <div>
        <!-- 使用图标 -->
        <svg style="width: 50px; height: 50px; fill: red;">
          <use xlink:href="#icon-vue"></use>
        </svg>
        <svg style="width: 50px; height: 50px; fill: blue;">
          <use xlink:href="#icon-react"></use>
        </svg>
      </div>
    </template>
    

四、Vite 独有的钩子:configureServer

Vite 插件不仅仅是构建工具,还是一个开发服务器。configureServer 钩子允许我们在 Vite 的 Node.js 服务器(基于 connect 库)中添加中间件。这在 Webpack Plugin 中很难直接做到。

场景:实现一个简易的 API Mock 功能。当请求 /api/user 时,拦截请求并返回假数据,不经过后端。

插件实现

export default function myMockPlugin() {
  return {
    name: 'my-mock-plugin',

    configureServer(server) {
      // server 是 Vite 开发服务器实例
      // server.middlewares 是一个 connect 实例,用法类似 Express
      
      server.middlewares.use((req, res, next) => {
        // 拦截 /api/user 请求
        if (req.url === '/api/user') {
          res.setHeader('Content-Type', 'application/json');
          res.end(JSON.stringify({ id: 1, name: 'Mock User' }));
          return; // 结束请求
        }
        
        // 其他请求放行
        next();
      });
    }
  };
}

五、Vite 的热更新 (HMR) 钩子:handleHotUpdate

在 Webpack 中实现 HMR 需要修改打包逻辑。在 Vite 中,插件可以直接介入 HMR 流程。

场景:当 .txt 文件修改时,不刷新页面,只通过自定义事件通知浏览器更新。

插件实现 (服务端)

export default function myHmrPlugin() {
  return {
    name: 'my-hmr-plugin',

    handleHotUpdate({ file, server, modules }) {
      if (file.endsWith('.txt')) {
        // 1. 读取更新后的文件内容
        const content = require('fs').readFileSync(file, 'utf-8');

        // 2. 向浏览器发送自定义 Websocket 消息
        server.ws.send({
          type: 'custom',
          event: 'txt-update',
          data: { file, content } // 发送新内容
        });

        // 3. 返回空数组,告诉 Vite:这个文件我处理了,你不需要执行默认的 HMR 逻辑(默认逻辑通常是重新加载模块)
        return [];
      }
    }
  };
}

客户端代码 (Client)

// 在 main.js 中接收消息
if (import.meta.hot) {
  import.meta.hot.on('txt-update', (data) => {
    console.log(`文件 ${data.file} 变了,新内容是: ${data.content}`);
    // 在这里手动更新 DOM
    document.querySelector('#app').innerText = data.content;
  });
}

六、总结:Webpack vs Vite 插件开发对比

功能点 Webpack 实现方式 Vite 实现方式
引入非 JS 文件 Loader (如 css-loader) Plugintransform 钩子
寻找模块路径 resolve.alias 配置或 Resolver 插件 PluginresolveId 钩子
读取文件内容 Loader 读取 Pluginload 钩子
开发服务器拦截 devServer.before 配置 PluginconfigureServer 钩子
热更新控制 注入 Runtime 代码,较复杂 PluginhandleHotUpdate + import.meta.hot

开发思维转变:

  • Webpack 插件像是在一条已经铺好的流水线(Compiler Hooks)上安装传感器和机械臂。
  • Vite 插件更像是拦截器。浏览器请求文件 -> 你的插件拦截 -> 告诉你 ID -> 你给它内容 -> 你转换内容 -> 返回给浏览器。
❌