阅读视图

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

一文吃透:宏任务、微任务、事件循环、浏览器渲染、Vue 批处理与 Node 差异(含性能优化)

本文从一道面试题切入,系统梳理浏览器与 Node 的事件循环模型;解释宏任务与微任务的区别、执行顺序与常见坑;结合浏览器渲染时机;深入 Vue 3 批处理源码如何巧用微任务实现“同一轮内的合并更新”;最后从任务调度角度讨论性能优化策略。完整成文,便于一次性阅读与复盘。

1. 从一道面试题入手:宏任务微任务混杂的打印顺序(浏览器)

示例代码(Chrome 环境;setImmediate 在浏览器一般不可用,若实现则顺序不稳定,不建议依赖):

jsconsole.log(1);

setImmediate?.(() => {
  console.log(2);
});

setTimeout(() => {
  console.log(3);
  new Promise((resolve) => {
    console.log(10);
    resolve();
  }).then(() => {
    console.log(11);
  });
}, 0);

requestAnimationFrame?.(() => {
  console.log(4);
});

Promise.resolve().then(() => {
  console.log(5);
});

new Promise((resolve) => {
  resolve(6);
}).then(() => {
  console.log(7);
});

(async function () {
  console.log(8);
  await Promise.resolve();
  console.log(9);
})();

requestIdleCallback?.(() => {
  console.log(12);
}); 

常见输出(Chrome):

1, 8, 5, 7, 9, 4, 3, 10, 11, 12

顺序解析:

同步:1、8

微任务(在当前宏任务末尾清空):5、7、9

帧回调:requestAnimationFrame 在下一帧绘制前:4

计时器宏任务:setTimeout(0) 回调:3,回调内同步:10,回调末尾微任务:11

空闲回调:requestIdleCallback 优先级最低:12

2. 事件循环与宏/微任务:区别、原理,微任务会卡死吗?

同步任务:立即执行、阻塞线程。例:普通代码、同步 I/O、大量计算、浏览器强制布局。

宏任务(task):进入事件循环的任务队列,逐个取出执行。例:setTimeout/setInterval、用户事件、I/O 回调等。

微任务(microtask):优先级更高,在“当前宏任务结束后、下一个宏任务开始前”被“清空”。

浏览器:Promise.then/catch/finally、queueMicrotask、MutationObserver

Node:Promise 微任务 + process.nextTick(优先级高于 Promise 微任务)

事件循环关键节拍:

  1. 执行一个宏任务

  2. 清空当前产生的所有微任务(可能再产生微任务,继续清空直到无)

  3. 浏览器可能进入渲染阶段

  4. 进入下一轮宏任务

微任务会卡死吗?会。若在微任务里不断排入新的微任务(微任务“自旋”),规范会“清空”队列,导致渲染和后续宏任务都被饿死。Node 中滥用 process.nextTick 风险更高。

3. 浏览器渲染机制与宏任务、微任务

一帧(简化):

  1. 执行一个宏任务(如点击事件或计时器)

  2. 清空微任务

  3. 渲染步骤(样式计算 → 布局 → 绘制)

  4. 下一帧前执行 requestAnimationFrame

  5. 空闲阶段可能执行 requestIdleCallback(可带 deadline)

要点:

微任务在渲染前清空,因此框架能在“同一轮交互”内合并多次状态到一次 DOM 提交。

requestAnimationFrame 用于动画,回调在下一帧绘制前;回调内产生的微任务会在该帧绘制前被清空。

requestIdleCallback 优先级最低,适合非关键、可延迟任务,最好带超时兜底。

实务建议:

动画逻辑 → requestAnimationFrame

次要任务 → requestIdleCallback

微任务只做“收尾”,避免自旋导致渲染饥饿

4. Node.js 的事件循环与浏览器区别

Node(libuv)阶段(简化):

  1. timers:到期的 setTimeout/setInterval

  2. pending callbacks:系统级 I/O 回调

  3. idle/prepare:内部使用

  4. poll:拉取 I/O 事件,大多数 I/O 回调在此执行;队列空且有 timer 到期会回到 timers;否则可能阻塞等待 I/O

  5. check:执行 setImmediate

  6. close callbacks: 事件等

  7. close

微任务:

process.nextTick:每个阶段切换前先清空,优先于 Promise 微任务

Promise 微任务:与浏览器类似,宏任务后清空

典型对比:

setTimeout(0) vs setImmediate:顶层脚本中通常 setTimeout(0) 先;在 I/O 回调中注册时往往 setImmediate 先。

Node 无 requestAnimationFrame/requestIdleCallback(那是浏览器渲染相关 API)。

把第 1 节示例裁剪为 Node(去掉 rAF/rIC)常见顺序:

1, 8, 5, 7, 9, 3, 10, 11, 2

5.从 Vue 批处理源码看宏/微任务

Vue 3 的“批处理(batching)”是把宏/微任务优势工程化的典型:

副作用函数(ReactiveEffect)包裹渲染与 watch,当依赖变更时不立即执行,而是交给 scheduler(job) 排队。

使用微任务 Promise.resolve().then(flushJobs) 统一触发 flush,把同一 tick 内多次状态变更合并为一次渲染与一次 DOM 提交。

多级队列与顺序:

pre 队列:flush: 'pre' 的 watch

主更新队列:组件更新 job(按 id 排序,保证父先子、稳定)

post 队列:flush: 'post'、onUpdated、指令后置等

Set 去重、执行计数与递归保护,防重复与无限循环。

简化伪码(核心思路):

const queue: Job[] = []
const queued = new Set<Job>()
let isFlushing = false, isFlushPending = false

function queueJob(job) {
  if (!queued.has(job)) {
    queued.add(job)
    queue.push(job)
    if (!isFlushing && !isFlushPending) {
      isFlushPending = true
      Promise.resolve().then(flushJobs) // 微任务触发批处理
    }
  }
}

function flushJobs() {
  isFlushPending = false
  isFlushing = true
  flushPreFlushCbs()
  queue.sort(jobComparatorById)
  for (const job of queue) job()
  queue.length = 0
  queued.clear()
  flushPostFlushCbs()
  isFlushing = false
} 

与渲染契合:

微任务清空发生在渲染之前,故框架可合并更新;nextTick() 等待这次 flush(含 post)完成,保证能读取到更新后的 DOM。

在 Node(SSR):

无浏览器渲染阶段,但同样利用微任务合并计算,避免重复渲染逻辑。

若业务滥用 process.nextTick 可能扰动 flush 节奏,需理解阶段优先级。

6. 从宏任务、微任务来看性能优化(浏览器与 Node)

切片长任务,避免长帧(>16.67ms)

使用 setTimeout/MessageChannel/requestIdleCallback 将大计算拆成小片

示例(MessageChannel 分片):

const chan = new MessageChannel();
const BATCH = 1000;
function work(arr, i = 0) {
  const end = Math.min(i + BATCH, arr.length);
  for (let j = i; j < end; j++) { /* 计算 */ }
  if (end < arr.length) {
    chan.port1.postMessage(null);
    chan.port2.onmessage = () => work(arr, end);
  }
}
work(bigArray); 

动画与交互在正确时机

动画/视觉更新 → requestAnimationFrame

非关键、可延迟 → requestIdleCallback(带超时)

避免强制重排:读写分离,读在一起,写放 rAF

微任务只做“收尾”,避免自旋

.then/queueMicrotask 用来合并与尾部处理,不要无限链式创建

框架已做批处理,业务少叠加微任务循环

Node:避免主线程被 CPU 任务绑死

用 worker_threads/子进程并行重计算;I/O 使用异步 API

合理选择 setImmediate(check 阶段尾部任务),谨慎 process.nextTick

限流与背压

浏览器:节流/防抖高频事件,虚拟列表/切片渲染

Node:流式处理(背压)、并发控制(p-limit/Bottleneck)

监控与诊断

浏览器:Performance 面板(Long Task、帧时间)、Lighthouse(TTI/TBT/LCP/CLS)

Node:perf_hooks.monitorEventLoopDelay()、APM、CPU/GC/堆快照

Vue 最佳实践

顺应批处理:同一 tick 内多次 state 修改会合并;读 DOM 用 nextTick

watch 的 flush 选择恰当;避免在 effect 中无界 setState 循环

任务安排“策略表”:

关键视觉更新 → rAF

非关键低优先 → rIC(带 timeout)

批处理收束 → 微任务(适度)

重计算 → 切片/并行

I/O 密集 → 异步 + 背压

常见任务类型速查

同步(阻塞):普通 JS、复杂计算、大对象 JSON 处理、浏览器强制布局、Node 同步 I/O/加解密压缩

微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver、Node process.nextTick

宏任务:setTimeout/setInterval、消息通道、浏览器事件回调、XHR/fetch 回调入队、requestAnimationFrame(渲染前)、requestIdleCallback(空闲)、Node I/O 回调(poll)、setImmediate(check)

总结

事件循环主线:一个宏任务 → 清空微任务 →(浏览器)渲染 → 下一轮宏任务。

浏览器侧:微任务在渲染前清空;requestAnimationFrame 在下一帧绘制前;requestIdleCallback 在空闲时。

Node 侧:libuv 分阶段;setTimeout 在 timers,setImmediate 在 check;process.nextTick 优先级最高。

Vue 3 批处理:用微任务统一 flush,多级队列(pre/更新/post)+ 去重排序,合并多次状态为一次 DOM 提交;nextTick 保证读到已更新 DOM。

性能优化要点:根据任务类型和渲染时机安排工作,切片/并行重任务,避免微任务饿死事件循环,利用框架批处理节奏。

前端画布类型编辑器项目,历史记录技术方案调研

image.png

image.png

最近在做一个在线PPT编辑器,其中状态管理用到了Zustand,撤销重做功能用的是zundo,编辑器类型的项目一般都会有历史记录功能,但是记录用户的操作有哪些方案呐?

主流技术方案对比

目前,业界主要有三种实现思路:命令模式状态快照(备忘录模式)和差异-补丁(Diff-Patch)

方案一:命令模式 (Command Pattern)

这是实现撤销/重做功能最经典、最正统的设计模式。

  • 核心思想:

    将用户的每一个“操作”封装成一个包含execute(执行)和undo(撤销)方法的命令对象。

  • 数据结构:

    使用两个栈:undoStack(撤销栈)和redoStack(重做栈)。

  • 工作流程:

    1. 执行操作: 用户执行一个新操作(如“添加矩形”)。
    2. 创建一个AddRectangleCommand对象。
    3. 调用command.execute()来执行该操作(更新画布状态)。
    4. 将该command压入undoStack
    5. 撤销 (Undo):
    6. undoStack弹出一个command
    7. 调用command.undo()
    8. 将该command压入redoStack
    9. 重做 (Redo):
    10. redoStack弹出一个command
    11. 调用command.execute()
    12. 将该command压入undoStack
  • 优点:

    • 内存占用低: 只存储“操作”本身,而不是完整的画布状态。
    • 逻辑清晰: undoexecute逻辑高度内聚,易于理解和测试。
    • 高性能: undo/redo操作通常很快,因为它们只执行逆向/正向操作。
  • 缺点:

    • 实现复杂度高: 必须为每一个可撤销的操作(移动、缩放、变色、删除、组合...)编写一个具体的Command类及其undo逻辑。
    • undo的实现难度: undo操作(如“撤销删除”)可能需要存储被删除对象的状态,这会增加命令对象的复杂度。

方案二:状态快照 (State Snapshots / Memento Pattern)

这是一种实现上更简单粗暴,但在特定场景下非常有效的模式。

  • 核心思想:

    在每次“有效操作”结束时,将整个画布的完整状态(通常是JSON数据)序列化并存储起来。

  • 数据结构:

    一个数组(historyStack)和一个指针(currentIndex)。

  • 工作流程:

    1. 执行操作: 用户完成操作(如拖拽结束)。
    2. 获取当前画布的完整状态newState
    3. newState添加到historyStackcurrentIndex的位置。
    4. currentIndex加一。
    5. 撤销 (Undo):
    6. currentIndex减一。
    7. historyStack[currentIndex]获取previousState
    8. previousState完全覆盖当前画布状态并重新渲染。
    9. 重做 (Redo):
    10. currentIndex加一。
    11. historyStack[currentIndex]获取nextState
    12. nextState覆盖当前状态并重新渲染。
  • 优点:

    • 实现简单: 核心逻辑与业务操作解耦。历史记录系统不需要“理解”什么是“移动”,什么是“变色”,它只负责保存和恢复状态。
    • 绝对可靠: 只要状态的序列化和反序列化是正确的,undo/redo就绝对不会出错。
  • 缺点:

    • 内存占用极大: 如果画布状态有10MB,100步历史就是1GB内存。这在Web端几乎是不可接受的。
    • 性能瓶颈: 序列化/反序列化/深拷贝大型状态对象(Deep Clone)可能非常耗时,导致UI卡顿。

方案三:增量差异 (Diff-Patch)

这是快照模式的进一步演进,也是目前在React等状态驱动框架中非常流行的一种方案。

  • 核心思想:

    结合了命令模式(只存变化)和快照模式(不关心操作逻辑)的优点。它不存储完整的状态,也不存储操作命令,而是存储两个状态之间的差异(Diff/Patch)。

  • 数据结构:

    undoStack(存储逆向Patch)和redoStack(存储正向Patch)。

  • 工作流程:

    1. 执行操作:
    2. (操作前)记录当前状态 State A
    3. (操作后)生成新状态 State B
    4. 计算差异: Patch (A -> B)(正向补丁)和 Inverse Patch (B -> A)(逆向补丁)。
    5. Inverse Patch压入undoStack
    6. Patch压入redoStack(或在undo时再计算)。
    7. 撤销 (Undo):
    8. undoStack弹出Inverse Patch
    9. 将该补丁Apply到当前状态,使其回退到State A
    10. 重做 (Redo):
    11. redoStack弹出Patch
    12. 将该补丁应用到当前状态,使其前进到State B
  • 优点:

    • 内存与性能均衡: 内存占用远小于全量快照(只存Diff),实现复杂度远低于命令模式(自动生成Diff和Patch)
  • 缺点:

    • Diff/Patch的开销: 如果一次操作改变了状态树的很多部分,计算Diff和生成Patch本身也可能有性能开销(但通常快于深拷贝)。
    • 依赖库: 通常需要依赖一个健壮的Diff/Patch库

方案对比总结

特性 命令模式 (Command Pattern) 状态快照 (State Snapshot) 增量差异 (Diff-Patch)
核心 存储“操作” 存储“完整状态” 存储“状态差异”
内存占用 极低 极高 较低
性能开销 undo/redo极快 存取时开销大 (深拷贝/序列化) 存取时有Diff/Patch计算开销
实现复杂度 极高 (需实现所有undo逻辑) 极低 中等 (需依赖Diff库)
适用场景 性能和内存要求苛刻的复杂应用 状态简单的小型应用 现代前端框架 (React/Vue) ,状态驱动型应用

React 第五十二节 Router中 useResolvedPath使用详解和注意事项示例

前言

useResolvedPathReact Router v6 提供的一个实用钩子,用于解析给定路径为完整路径对象。 它根据当前路由上下文解析相对路径,生成包含 pathname、search 和 hash 的完整路径对象。

一、useResolvedPath 核心用途

  1. 路径解析:将相对路径解析为绝对路径
  2. 链接构建:安全地构建导航链接
  3. 路径比较:比较当前路径与目标路径
  4. 动态路由处理:正确处理嵌套路由中的路径

二、useResolvedPath 解析结果对象

useResolvedPath 返回一个包含以下属性的对象: 比如原路径是:const resolved = useResolvedPath('../users?id=123#profile')

// 返回内容为
{ pathname: '/users', search: '?id=123', hash: '#profile' }
  1. pathname: 解析后的绝对路径
  2. search: 查询字符串(如果有)
  3. hash: 哈希值(如果有)

三、useResolvedPath 基本用法示例

import { useResolvedPath } from 'react-router-dom';

function PathInfo() {
  const resolved = useResolvedPath('../users?sort=name#section');
  
  return (
    <div>
      <h3>路径解析结果</h3>
      <p>原始路径: "../users?sort=name#section"</p>
      <p>解析后路径名: {resolved.pathname}</p>
      <p>查询参数: {resolved.search}</p>
      <p>哈希值: {resolved.hash}</p>
    </div>
  );
}

四、useResolvedPath 实际应用场景

4.1、在面包屑导航中解析路径

import { useResolvedPath, Link, useLocation, useMatches } from 'react-router-dom';

function Breadcrumbs() {
  const location = useLocation();
  const matches = useMatches();
  
  // 获取所有路由匹配项
  const crumbs = matches
    .filter(match => match.handle?.crumb)
    .map(match => {
      // 解析每个路由的路径
      const resolvedPath = useResolvedPath(match.pathname);
      return {
        pathname: resolvedPath.pathname,
        crumb: match.handle.crumb
      };
    });

  return (
    <nav className="breadcrumbs">
      {crumbs.map((crumb, index) => (
        <span key={index}>
          {index > 0 && ' > '}
          {index === crumbs.length - 1 ? (
            <span className="current">{crumb.crumb}</span>
          ) : (
            <Link to={crumb.pathname}>{crumb.crumb}</Link>
          )}
        </span>
      ))}
    </nav>
  );
}

// 在路由配置中使用
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: 'dashboard',
        handle: { crumb: '控制面板' },
        element: <Dashboard />,
        children: [
          {
            path: 'stats',
            handle: { crumb: '统计' },
            element: <StatsPage />
          }
        ]
      },
      {
        path: 'users',
        handle: { crumb: '用户管理' },
        element: <UsersPage />
      }
    ]
  }
]);

4.2、创建自定义导航链接组件

import { 
  useResolvedPath, 
  useMatch, 
  Link 
} from 'react-router-dom';

function CustomNavLink({ to, children, ...props }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: true });
  
  return (
    <div className={`nav-item ${match ? 'active' : ''}`}>
      <Link to={to} {...props}>
        {children}
      </Link>
    </div>
  );
}

// 在导航中使用
function Navigation() {
  return (
    <nav>
      <CustomNavLink to="/">首页</CustomNavLink>
      <CustomNavLink to="/about">关于</CustomNavLink>
      <CustomNavLink to="/products">产品</CustomNavLink>
      <CustomNavLink to="/contact">联系我们</CustomNavLink>
    </nav>
  );
}

4.3、在嵌套路由中正确处理相对路径

import { useResolvedPath, Link, Outlet } from 'react-router-dom';

function UserProfileLayout() {
  return (
    <div className="user-profile">
      <nav className="profile-nav">
        <ProfileNavLink to=".">概览</ProfileNavLink>
        <ProfileNavLink to="activity">活动</ProfileNavLink>
        <ProfileNavLink to="settings">设置</ProfileNavLink>
        <ProfileNavLink to="../friends">好友</ProfileNavLink>
      </nav>
      <div className="profile-content">
        <Outlet />
      </div>
    </div>
  );
}

function ProfileNavLink({ to, children }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: true });
  
  return (
    <Link 
      to={to} 
      className={match ? 'active' : ''}
    >
      {children}
    </Link>
  );
}

// 路由配置
const router = createBrowserRouter([
  {
    path: 'users',
    element: <UsersLayout />,
    children: [
      {
        path: ':userId',
        element: <UserProfileLayout />,
        children: [
          { index: true, element: <ProfileOverview /> },
          { path: 'activity', element: <ProfileActivity /> },
          { path: 'settings', element: <ProfileSettings /> }
        ]
      },
      {
        path: ':userId/friends',
        element: <UserFriends />
      }
    ]
  }
]);

4.4、动态生成侧边栏菜单

import { useResolvedPath, useMatch, Link } from 'react-router-dom';

function SidebarMenu({ items }) {
  return (
    <nav className="sidebar">
      <ul>
        {items.map((item) => (
          <MenuItem key={item.path} to={item.path} label={item.label} />
        ))}
      </ul>
    </nav>
  );
}

function MenuItem({ to, label }) {
  const resolved = useResolvedPath(to);
  const match = useMatch({ path: resolved.pathname, end: false });
  
  return (
    <li className={match ? 'active' : ''}>
      <Link to={to}>{label}</Link>
      
      {/* 显示子菜单(如果存在且匹配) */}
      {match && resolved.pathname === to && (
        <ul className="submenu">
          <li><Link to={`${to}/details`}>详细信息</Link></li>
          <li><Link to={`${to}/analytics`}>分析</Link></li>
        </ul>
      )}
    </li>
  );
}

// 使用示例
const menuItems = [
  { path: '/dashboard', label: '仪表盘' },
  { path: '/projects', label: '项目' },
  { path: '/reports', label: '报告' },
  { path: '/team', label: '团队' }
];

function AppLayout() {
  return (
    <div className="app-layout">
      <SidebarMenu items={menuItems} />
      <main className="content">
        {/* 页面内容 */}
      </main>
    </div>
  );
}

五、useResolvedPath 高级用法:路径比较工具

import { useResolvedPath, useLocation } from 'react-router-dom';

// 自定义钩子:比较当前路径是否匹配目标路径
function usePathMatch(to) {
  const resolvedTo = useResolvedPath(to);
  const location = useLocation();
  
  // 创建当前路径对象(去除可能的尾部斜杠)
  const currentPath = {
    pathname: location.pathname.replace(/\/$/, ''),
    search: location.search,
    hash: location.hash
  };
  
  // 创建目标路径对象
  const targetPath = {
    pathname: resolvedTo.pathname.replace(/\/$/, ''),
    search: resolvedTo.search,
    hash: resolvedTo.hash
  };
  
  // 比较路径是否匹配
  return (
    currentPath.pathname === targetPath.pathname &&
    currentPath.search === targetPath.search &&
    currentPath.hash === targetPath.hash
  );
}

// 在组件中使用
function NavigationItem({ to, children }) {
  const isActive = usePathMatch(to);
  
  return (
    <li className={isActive ? 'active' : ''}>
      <Link to={to}>{children}</Link>
    </li>
  );
}

六、 useResolvedPath 注意事项

6.1、相对路径解析

useResolvedPath 基于当前路由位置解析相对路径

6.2、查询参数和哈希

保留原始路径中的查询字符串和哈希值

6.3、动态路由参数

不会解析路径参数(如 :id),保持原样

6.4、性能考虑

解析操作轻量,但避免在循环中过度使用

6.5、路由上下文

必须在路由组件内部使用(在 <Router> 上下文中)

七、useResolvedPath 与相关钩子对比

在这里插入图片描述

总结

useResolvedPathReact Router v6 中处理路径的强大工具,主要用于:

  1. 在嵌套路由中正确处理相对路径
  2. 构建动态导航组件
  3. 创建面包屑导航等复杂导航结构
  4. 安全地比较路径和构建链接

通过合理使用 useResolvedPath,可以创建更健壮、可维护的路由结构,避免硬编码路径导致的错误,并简化嵌套路由中的路径处理逻辑。

为 CI/CD 装上“眼睛”:App 包大小监控的实践

包大小直接影响下载转化与用户留存。传统人工统计效率低、难溯源。本文将分享如何将包大小监控嵌入 CI/CD 流程,实现自动化采集与分析,为应用性能装上“眼睛”。

一、为什么必须重视包大小监控?数据告诉你答案

包大小与业务增长直接挂钩,谷歌开发者大会 2019 年数据揭示了核心关联:

  • 转化率敏感:包体每上升 6MB,应用下载转化率下降 1%;减少 10MB 时,全球平均转化率提升 1.75%,印度、巴西等新兴市场提升超 2.0%,美国、德国等高端市场提升 1.5%。

  • 用户决策影响:包体超 200MB(2019 年标准)时,App Store 会弹窗提醒流量费用,蜂窝网络下用户放弃率显著上升;同时,下载时长过长、网络波动导致的安装失败,本质都是包大小引发的连锁问题。

这些数据证明,包大小监控不是“可选优化”,而是保障用户转化的“基础工程”。

二、核心目标:让监控实现“三自动化”

基于业务需求,包大小监控需达成三大核心目标,解决传统人工统计的痛点:

  1. 数据采集自动化:覆盖 iOS/Android 双端、全渠道(应用商店/官网/第三方),自动抓取每个版本的包大小数据,无需人工干预。
  2. 数据分析自动化:数据实时同步至神策数据分析平台(这里的平台可以按实际情况进行选择,这里以神策为例进行讲解),支持按版本趋势、端内对比、渠道差异多维度拆解,快速定位变化原因。
  3. 流程集成自动化:嵌入 CI/CD 环节(Jenkins/GitLab CI/Fastlane),每一次构建自动触发监控,确保数据一致性与可追溯性。

三、架构设计:从构建到分析的全链路闭环

整个监控体系遵循“轻量集成、无侵入”原则,核心架构流程如下:

无需新增复杂组件,仅通过脚本工具与 API 调用,即可实现从包体构建到数据呈现的全自动化,不影响原有 CI/CD 流程效率。

四、落地实现:分步骤搭建自动化监控体系

4.1 适用环境

  • 兼容主流 CI/CD 工具链:Jenkins、GitLab CI、Fastlane;
  • 支持双端包体格式:Android(APK/AAB)、iOS(IPA)。

4.2 核心实现逻辑

在构建流程结束后,通过脚本完成“信息采集→大小计算→数据上报”闭环,具体步骤:

  1. 获取构建元信息:自动读取版本号、构建号、渠道、Git 分支/提交记录等关键数据(从配置文件或环境变量中提取)。
  2. 计算包体大小:定位构建产物路径,统一计算文件大小并转换为 MB 单位(确保双端数据标准一致)。
  3. 生成结构化数据:将包大小、文件 MD5、文件名、构建时间等信息整理为 JSON 格式,便于后续分析。
  4. 实时上报数据:调用神策 API 推送数据,内置重试机制,避免网络波动导致数据丢失。

4.3 双端具体实现方案

(1)Android 端(APK/AAB)

  • 构建工具:Fastlane + Gradle + Shell/Python 脚本
  • 关键步骤
    • 信息提取:从app/build.gradle或 CI 环境变量中读取versionName(版本号)、versionCode(构建号),通过 Fastlane 命令入参获取渠道信息。
    • 路径定位:APK 默认路径为app/build/outputs/apk/${channel}/${buildType}/,AAB 默认路径为app/build/outputs/bundle/${channel}Release/。项目具体路径以当前项目为准。
    • 大小计算:通过 Python 脚本封装计算逻辑,传入文件路径即可获取字节数,转换为 MB 并保留 2 位小数(确保精度)。

(2)iOS 端(IPA)

  • 构建工具:Fastlane + Xcode + CI 脚本
  • 关键步骤
    • 信息提取:从Info.plist中读取CFBundleShortVersionString(版本号)、CFBundleVersion(构建号),通过 Fastlane 参数或CHANNEL环境变量指定渠道。
    • 路径定位:使用 Fastlane 的gym工具构建后,通过lane_context[SharedValues::IPA_OUTPUT_PATH]直接获取 IPA 路径,无需手动配置。
    • 大小计算:复用 Android 端 Python 脚本,统一计算逻辑,保证双端数据一致性。

4.4 核心脚本:通用上报工具app_size_reporter.py

脚本封装了文件大小计算、MD5 校验、神策 API 上报等核心功能,支持命令行参数配置,适配不同场景。关键功能拆解:

  • 文件大小计算:通过os.path.getsize获取字节数,转换为 MB 单位(size_mb = round(size_bytes / (1024 * 1024), 2))。
  • MD5 校验:读取文件 4096 字节分片,计算 MD5 值,确保包体完整性可追溯。
  • 数据上报:集成神策 Python SDK,支持 Debug/生产环境切换,测试环境使用 DebugConsumer 进行逐个数据验证,生产环境可启用批量上报(BatchConsumer)优化性能。
  • 灵活调用:支持通过命令行传入--app-path(包体路径)、--build-version(版本号)、--channel(渠道)等参数,示例如下:

🚀上报脚本🚀

#!/usr/bin/env python3
"""
神策数据上报脚本 - App 包大小监控
用于 Jenkins CI/CD 流程中上报应用包大小相关数据
"""

import os
import sys
import json
import hashlib
import sensorsanalytics

from datetime import datetime

# 发送数据的超时时间,单位秒
SA_REQUEST_TIMEOUT = 10
# Debug 模式下,是否将数据导入神策分析
SA_DEBUG_WRITE_DATA = True
# 神策项目名称
SA_PROJECT = 'default'
# 神策接收地址(通过 SA_PROJECT 动态拼接)
SA_SERVER_URL = f"https://xxx/sa?project={SA_PROJECT}"

class AppSizeReporter:
    def __init__(self, project='xxx'):
        """
        初始化神策上报器
        
        Args:
            project: 项目名称
        """
        try:
            # 初始化神策 SDK
            self.sa = sensorsanalytics.SensorsAnalytics(
                sensorsanalytics.DebugConsumer(SA_SERVER_URL, SA_DEBUG_WRITE_DATA, SA_REQUEST_TIMEOUT)
                # 生产环境建议使用以下方式:
                # sensorsanalytics.BatchConsumer(SA_SERVER_URL, 1, 1)
            )
            self.project = project
            print(f"✅ 神策 SDK 初始化成功,项目: {project}")
        except Exception as e:
            print(f"❌ 神策 SDK 初始化失败: {e}")
            sys.exit(1)
    
    def get_file_size(self, file_path):
        """获取文件大小(MB)"""
        if not os.path.exists(file_path):
            raise FileNotFoundError(f"文件不存在: {file_path}")
        
        size_bytes = os.path.getsize(file_path)
        size_mb = round(size_bytes / (1024 * 1024), 2)
        return size_mb
    
    def get_file_md5(self, file_path):
        """计算文件的 MD5 值"""
        hash_md5 = hashlib.md5()
        with open(file_path, "rb") as f:
            for chunk in iter(lambda: f.read(4096), b""):
                hash_md5.update(chunk)
        return hash_md5.hexdigest()
    
    def collect_app_size_info(self, app_path, build_info=None):
        """
        收集 App 包大小信息
        
        Args:
            app_path: App 文件路径(可以是 .apk, .ipa, .aab 等)
            build_info: 构建信息字典
        """
        file_name = os.path.basename(app_path)
        file_extension = os.path.splitext(file_name)[1].lower()
        
        # 基础文件信息
        size_mb = self.get_file_size(app_path)
        file_md5 = self.get_file_md5(app_path)
        
        # 构建默认的构建信息
        if build_info is None:
            build_info = {}
        
        default_build_info = {
            'build_user': os.environ.get('BUILD_USER_ID', os.environ.get('USER', 'unknown'))
        }
        
        # 合并构建信息
        build_info = {**default_build_info, **build_info}
        
        # 组装上报数据
        event_data = {
            'size_mb': size_mb,
            'file_name': file_name,
            'file_type': file_extension,
            'file_md5': file_md5,
            'report_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            **build_info
        }
        
        return event_data
    
    def report_app_size(self, distinct_id, app_path, build_info=None, event_name='AppPackageSize'):
        """
        上报 App 包大小数据
        
        Args:
            distinct_id: 用户标识(可以是项目名、构建ID等)
            app_path: App 文件路径
            build_info: 构建信息
            event_name: 事件名称
        """
        try:
            # 收集数据
            event_data = self.collect_app_size_info(app_path, build_info)
            
            # 上报数据
            self.sa.track(distinct_id, event_name, event_data)
            
            # 立即提交数据(对于 DebugConsumer 会自动提交,BatchConsumer 需要 flush)
            if hasattr(self.sa, 'flush'):
                self.sa.flush()
            
            print(f"✅ 成功上报 {event_name} 事件")
            print(f"   环境: {SA_PROJECT}")
            print(f"   文件: {event_data['file_name']}")
            print(f"   大小: {event_data['size_mb']} MB")
            print(f"   MD5: {event_data['file_md5']}")
            
            return True
            
        except Exception as e:
            print(f"❌ 数据上报失败: {e}")
            return False
    
    def close(self):
        """关闭神策 SDK"""
        if hasattr(self.sa, 'close'):
            self.sa.close()
        print("🔚 神策 SDK 已关闭")

def main():
    """主函数 - 用于命令行调用"""
    import argparse
    
    parser = argparse.ArgumentParser(description='上报 App 包大小数据到神策')
    parser.add_argument('--app-path', required=True, help='App 文件路径')
    parser.add_argument('--project', default='xxx', help='项目名称')
    parser.add_argument('--distinct-id', required=True, help='唯一标识(建议使用项目名)')
    parser.add_argument('--event-name', default='AppPackageSize', help='事件名称')
    parser.add_argument('--build-version', help='构建版本号')
    parser.add_argument('--build-number', help='构建号')
    parser.add_argument('--build-type', help='构建类型(fat/uat/pro)')
    parser.add_argument('--git-branch', help='Git 分支')    
    parser.add_argument('--git-commit', help='Git 提交 ID')
    parser.add_argument('--build-user', help='构建用户')
    parser.add_argument('--channel', help='渠道')
    parser.add_argument('--link', help='下载链接')
    parser.add_argument('--extra', help='额外信息')

    args = parser.parse_args()
    
    # 构建信息
    build_info = {}
    if args.build_version:
        build_info['build_version'] = args.build_version
    if args.build_type:
        build_info['build_type'] = args.build_type
    if args.build_number:
        build_info['build_number'] = args.build_number
    if args.git_branch:
        build_info['git_branch'] = args.git_branch
    if args.git_commit:
        build_info['git_commit'] = args.git_commit
    if args.build_user:
        build_info['build_user'] = args.build_user
    if args.channel:
        build_info['channel'] = args.channel
    if args.link:
        build_info['link'] = args.link
    if args.extra:
        build_info['extra'] = args.extra

    # 创建上报器并执行上报
    reporter = AppSizeReporter(project=args.project)
    
    try:
        success = reporter.report_app_size(
            distinct_id=args.distinct_id,
            app_path=args.app_path,
            build_info=build_info,
            event_name=args.event_name
        )
        
        sys.exit(0 if success else 1)
        
    finally:
        reporter.close()

if __name__ == "__main__":
    main()

🚀CLI 中调用测试🚀

python3 scripts/app_size_reporter.py \
  --app-path "/Users/xxx/xxx_20251031.apk" \
  --distinct-id "xxx-app-android" \
  --build-version "2.3.1" \
  --build-type "pro" \
  --channel "google_play" \
  --git-branch "main" \
  --git-commit "$(git rev-parse --short HEAD)"

五、数据接入与分析:让包大小变化“有据可查”

5.1 神策上报数据结构

上报数据包含“补充信息、构建信息、产物信息”三大类字段,支持多维度筛选,核心字段如下表:

字段名 类型 分类 含义 示例值
event_name string 补充信息 事件名称(固定) AppPackageSize
extra string 额外信息(debug 标识) 测试过程传 debug,正式传空,用于过滤测试数据
build_user string 构建信息 构建用户 jenkins
report_time datetime 构建时间(毫秒级) 上报内容为1762157537000 格式化后2025-11-03 16:12:17
build_version string App 版本号 1.2.3
build_number string 构建号(迭代标识) 2025102701
build_type string 构建类型 取 bundle exec fastlane ios/android 后面的变量dev/stg/prd/release
git_branch string 构建代码的 git 分支信息 main
git_commit string 构建代码的 git 提交信息 b6208a101b8f94049d69ea4b38f6d232f19e84de
channel string 产物信息 渠道名称 app_store/google_play
file_name string 文件名称 xxx.apk xxx.ipa
file_type string 文件类型 .ipa/.apk/.aab
file_md5 string 文件 MD5 792f6395012401d981f3239ebd68b1ab
link string 包地址 安装包地址
size_mb number 包大小(MB) 25.00

5.2 可视化与查询

  • Dashboard 展示:在神策平台配置版本趋势图、双端对比表、渠道差异图,直观呈现包大小变化。

  • 数据查询:通过 SQL 快速筛选目标数据,例如查询调试环境近 1000 条记录:
SELECT
  date,
  SUBSTRING(CAST(time AS STRING), 1, 19) as fmt_time,
  extra,
  distinct_id,
  report_time,
  build_user,
  build_type,
  build_version,
  build_number,
  channel,
  size_mb,
  file_name,
  file_type,
  file_md5,
  link,
  git_branch,
  git_commit
FROM
  events
WHERE
  event = 'AppPackageSize'
  AND extra = 'debug'
ORDER BY
  `time` DESC
LIMIT
  1000;

六、落地价值:从“被动应对”到“主动管控”

集成 CI/CD 后,包大小监控实现了三大关键转变:

  1. 效率提升:从人工统计 10 分钟/版本,变为构建完成自动上报,效率提升 100%。
  2. 数据可靠:统一计算逻辑与单位(MB),避免人工误差,数据一致性达 100%。
  3. 响应及时:异常增长可快速定位到分支、提交记录或渠道,例如某版本第三方渠道包体突增 50MB,排查发现是渠道 SDK 未按需打包,及时优化后恢复正常。

七、总结

包大小监控的核心,是将“隐性指标”转化为“显性数据”。通过嵌入 CI/CD 流程,无需额外开发成本,即可实现全链路自动化,为应用下载转化与用户体验保驾护航。未来可进一步增加阈值预警(如增长超 10%触发告警)、冗余资源检测,让包大小优化从“被动排查”升级为“主动预防”。

Vue 权限控制神技!自定义 auth 指令优雅实现按钮级权限管理

Vue 权限控制神技!自定义 auth 指令优雅实现按钮级权限管理

在中后台系统开发中,按钮级别的权限控制是常见需求 —— 不同角色用户看到的操作按钮可能不同,直接写 if-else 判断又会导致代码冗余混乱。今天分享一个 Vue 自定义指令v-auth,一行代码就能搞定按钮的显示、隐藏或禁用,大幅提升代码整洁度!

一、指令核心功能

这个v-auth指令基于 Vuex 存储的用户权限数据,实现两大核心能力:

  1. 超级管理员自动放行,无需额外判断

  2. 普通用户支持两种权限控制模式:

    • 隐藏模式:无权限时直接移除 DOM 元素
    • 禁用模式:无权限时保留元素但添加禁用状态和样式

二、完整代码实现

// 权限控制指令:v-auth
Vue.directive('auth', {
  async inserted(el, binding) {
    const { value, modifiers } = binding;
    
    // 确保权限数据已加载,未加载则异步获取
    if (!store.getters.permissions) {
      //获取权限数据
    }
    
    const permissions = store.getters.permissions || []; // 兜底处理,避免报错

    // 超级管理员特权:拥有所有权限直接放行
    if (permissions.includes('*:*:*')) return;

    // 权限校验核心逻辑
    const isDisabled = modifiers.disabled; // 是否启用禁用模式
    const hasPermission = permissions.includes(value); // 校验用户是否拥有目标权限
    
    if (!hasPermission) {
      if (isDisabled) {
        // 禁用模式:添加禁用属性和自定义样式
        el.disabled = true;
        el.classList.add('disabled-button');
      } else {
        // 隐藏模式:从DOM中移除元素
        el.parentNode?.removeChild(el);
      }
    }
  }
});

三、代码逻辑逐行解析

1. 指令触发时机

使用inserted钩子,在元素插入 DOM 后执行校验,确保操作目标元素存在。

2. 权限数据加载

  • 先检查 Vuex 中是否已缓存权限数据
  • 未加载则调用user/getInfo异步接口获取,等待加载完成再继续校验

3. 权限判断逻辑

  • 超级管理员通过*:*:*标识直接放行,适配系统最高权限场景
  • 普通用户通过binding.value获取需要校验的权限标识(如"user:add"
  • 通过binding.modifiers.disabled切换控制模式,灵活适配不同 UI 需求

四、实际使用场景

1. 隐藏模式(默认)

无权限时直接隐藏按钮,适用于非核心操作按钮:

<el-button v-auth="'user:add'">新增用户</el-button>

2. 禁用模式

无权限时保留按钮但禁用,适用于需要提示用户权限不足的场景:

<el-button v-auth.disabled="'user:edit'">编辑用户</el-button>

六、总结

按钮权限控制的工作流程图:

flowchart TD
    A(("① 用户登录")) --> B[系统初始化]
    B --> C[渲染带v-auth的组件]
    C --> D{指令解析}
    
    D --> |解析到v-auth节点| E["② inserted钩子触发"]
    E --> F{权限数据已加载?}
    F -- 否 --> G["③ 调用store.dispatch()"]
    G --> H[获取用户权限数据]
    H --> F
    F -- 是 --> I{是超级管理员?}
    I -- 是 --> J["✅ 放行渲染"]
    I -- 否 --> K["④ 权限校验"]
    
    K --> L{检查修饰符}
    L -- disabled --> M["⑤ 禁用模式处理"]
    L -- 无修饰符 --> N["⑤ 隐藏模式处理"]
    
    M --> O{权限匹配?}
    O -- 匹配 --> P["✅ 保持可用状态"]
    O -- 不匹配 --> Q["🛑 添加disabled属性"]
    
    N --> R{权限匹配?}
    R -- 匹配 --> S["✅ 正常显示"]
    R -- 不匹配 --> T["🛑 移除DOM节点"]
    
    Q --> U[结束]
    T --> U
    P --> U
    S --> U
    
    style A fill:#4CAF50,color:white
    style G fill:#2196F3,color:white
    style Q fill:#FF5722,color:white
    style T fill:#FF5722,color:white
    style J fill:#4CAF50,color:white

这个v-auth指令将权限控制逻辑封装复用,摆脱了模板中大量的权限判断代码,让权限管理更优雅、维护成本更低。适用于各类中后台系统的按钮、菜单等元素权限控制,搭配 Vuex 的状态管理可实现全系统权限统一管控。

Rspack 插件架构原理:从 Tapable 到 Rust Hook

背景

在上一篇文章《Rspack 原理:webpack,我为什么不要你》中,我们了解到 Rspack 的核心编译流程是通过 JavaScript 调用 Rust 封装的 JsCompiler 方法完成的。尽管底层由高性能的 Rust 实现驱动,但 Rspack 却依然兼容传统的 JavaScript 插件生态,像 HtmlWebpackPluginAssetsWebpackPlugin 这样的常用插件也能正常运行。更多兼容插件可以前往 Plugin 兼容 页面进行查看。

好奇你的估计会疑问,Rspack 是如何让这些 JS 插件在 Rust 引擎中正常工作的?

细心的你估计发现在 JsCompiler 方法调用时,传参里有一个非常关键的参数,就是第四个参数 this.#registers,该参数是 this.#createHooksRegisters 方法的返回值。那么 this.#registers 的作用是啥?它在 Rspack 插件架构里扮演着什么样的角色?该参数传到 Rust 层又是如何被处理?

要回答这些问题,就要了解这背后的关键:Rspack 的插件架构设计。本篇文章将为你一一解答,另外你还能进一步了解:

  • Rspack 插件架构是如何实现,和 webpack 的 Tapable 在实现上有什么不一样?
  • Rust 是怎么定义和执行 hook 的?
  • Tapable 和 Rust Hook 的钩子都有哪些特点?
  • JavaScript 插件是如何注册进来的?
  • 当 Rust 层编译流程触发 compiler.hooks.make.call() 时,底层发生了什么?
  • 为什么能同时执行 JavaScript 和 Rust 的插件?顺序是怎么保证的?

本篇文章涉及的代码片段可能相对比较多,如果你在阅读中感觉压力比较大的话,可以跳过这些代码片段,直接看解释,我会针对每段代码都做通俗的解释。

什么是插件

我们先理解一下插件的基本概念,在构建工具中, 插件不仅是一种扩展模块 ,更是一种基于钩子机制或生命周期扩展点的架构模式,这种架构的核心思想是:在核心流程的关键节点上暴露一系列可注册的钩子(Hooks),允许外部插件注入自定义逻辑。这些钩子支持多种执行方式:同步/异步、串行/并行,从而实现灵活而强大的流程控制能力。

这本质上借鉴了发布/订阅模式的思想,但它比单纯的事件系统更加可控:每次钩子触发时都会携带当前上下文(如 compilercompilation),插件可以通过操作这些上下文对象来影响后续编译行为。

以 webpack Tapable 的经典插件为例:

class MyPlugin {
  apply(compiler) {
    // 注册 hooks
    compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
      // 可以访问 compilation 对象,添加资源、修改模块等
    })
  }
}

在这个例子中:

  • apply(compiler) 是插件入口,接收编译器实例;
  • compiler.hooks.compilation 是一个钩子;
  • .tap() 表示在此钩子上注册一个同步回调;
  • 回调函数接收 compilation 上下文,用于干预构建过程。

Rspack 并不是直接使用 webpack 的 Tapable 库,而是自己实现了一套轻量化的 LiteTapable,并与 Rust 层的 Hook 系统进行双向桥接。这套架构分为两层:

  • JS 层:使用 TypeScript 实现的 LiteTapable ,处理 JS 插件注册,收集 Hooks 回调;
  • Rust 层:使用 Rust 实现的自研 Hook 系统,驱动编译流程,在关键节点触发 Hook。

这两者之间通过一个桥梁机制连接,而这正是开篇我们提到的 this.#createHooksRegisters 方法的使命。该方法执行赋值给 this.#registers 作为 JsCompiler 的第四个参数:

image.png

this.#createHooksRegisters 的作用是:将 JavaScript 层所有已注册的插件钩子封装成一种能被 Rust 识别的数据格式,然后传递给 Rust 底层编译器。

换句话说,this.#createHooksRegisters 是一个钩子注册表,记录了所有 JS 插件希望监听的生命周期事件。当 Rust 核心在编译过程中走到某个阶段(比如 make),它会查询这个注册表,并通知 JS 层执行对应的回调。

这样一来,即便核心流程运行在 Rust 中,也能准确地调用 JavaScript 编写的插件逻辑。那么 this.#createHooksRegisters 方法实现是怎样的呢?

#createHooksRegisters 实现

#createHooksRegisters(): binding.RegisterJsTaps {
  const ref = new WeakRef(this);
  const getCompiler = () => ref.deref()!;
  const createTap = this.#createHookRegisterTaps.bind(this);
  const createMapTap = this.#createHookMapRegisterTaps.bind(this);
  
  return {
    ...createCompilerHooksRegisters(getCompiler, createTap, createMapTap),
    ...createCompilationHooksRegisters(getCompiler, createTap, createMapTap),
    // ... 更多注册器函数
  };
}

#createHooksRegisters 方法最终返回的是一个符合 binding.RegisterJsTaps 接口的对象,可以理解为一个专为 Rust 编译器准备的钩子注册表。

每个 create***HooksRegisters 负责某一类钩子的注册。例如:

  • createCompilerHooksRegisters:负责 Compiler 对象的钩子;
  • createCompilationHooksRegisters:负责 Compilation 对象的钩子;
  • 其他还包括 ModuleFactoryNormalModule 等不同组件的钩子,上面没有列出来。

我们看到 create***HooksRegisters 工厂函数接收三个通用参数:

  • getCompiler:获取当前 compiler 实例;
  • createTap:用于注册普通钩子上的插件回调;
  • createMapTap:用来注册那些按 key 动态分发的钩子(比如 ruleloader)上的回调。

createTapcreateMapTap 这两个参数可能有点不好理解,反正我们知道它们的作用是把 JS 插件的回调包装成 Rust 能调用的形式就可以了。

接下来,我们以 createCompilerHooksRegisters 为例,看看这些函数到底是怎么工作的:

export const createCompilerHooksRegisters: CreatePartialRegisters<'Compiler'> = (
  getCompiler,
  createTap
) => {
  return {
    registerCompilerMakeTaps: createTap(
      binding.RegisterJsTapKind.CompilerMake,

      // 第二个参数: 获取 JS 层对应的 Hook 实例
      function () {
        return getCompiler().hooks.make;
      },

      // 第三个参数: 包装回调函数,支持异步执行
      function (queried) {
        return async function () {
          return await queried.promise(
            getCompiler().__internal__get_compilation()!
          );
        };
      }
    ),
  };
};

以上展示了 Compilermake 钩子的注册逻辑,这里的关键在于 createTap 接收了两个函数式参数(重点看第二和第三个参数):

  • 第二个参数:返回一个 Hook ,延迟获取 JS 层的 hooks.make 实例;
  • 第三个参数:将原始 tap 回调包装成一个可被 Rust 调用的异步函数,并注入当前 compilation 上下文。

这里的 queried.promise(...) 并非原生 Promise,而是 Rspack 自定义的一种待执行任务表示方式,用于桥接到 Rust 的异步运行时。

createTap 绑定的函数是 #createHookRegisterTaps

#createHookRegisterTaps(registerKind, getHook, createTap) {
  return function getTaps(stages: number[]): JsTap[] {
    const hook = getHook();
    if (!hook.isUsed()) return [];

    // 1. 构建阶段断点 [min, stage1, stage2, ..., max]
    const breakpoints = [minStage, ...stages.sort(), maxStage];
    const jsTaps = [];

    // 2. 按区间遍历,查询每个 stage 范围内的 taps
    for (let i = 0; i < breakpoints.length - 1; i++) {
      const from = breakpoints[i];
      const to = breakpoints[i + 1];

      // 查询 [from, to) 区间内注册的所有插件
      const queried = hook.queryStageRange([from, to]);

      if (queried.taps.length === 0) continue;

      jsTaps.push({
        function: createTap(queried),  // 包装为可调用函数
        stage: from + 1                // 对齐执行优先级
      });
    }

    return jsTaps;
  };
}

看完上面的代码和注释是不是好累,没关系,我们主要记住它是做了几件关键的事情就行:

  • 支持 Stage 分段查询:Rust 传入 [10, 20] 表示希望触发该阶段的插件;
  • 按序排列,精准控制执行流程:所有返回的 JsTapstage 排序,确保插件严格按照注册时声明的优先级运行;
  • 懒加载 + 延迟求值getHook() 和回调包装都在实际触发时才执行,避免提前访问未初始化的上下文。

上述所有操作的前提,JS 层必须有一套钩子系统来支撑插件注册与查询。Rspack 使用的是自研的 @rspack/lite-tapable 库:

import * as liteTapable from "@rspack/lite-tapable";

this.hooks = {
  initialize: new liteTapable.SyncHook([]),
  shouldEmit: new liteTapable.SyncBailHook(["compilation"]),
  make: new liteTapable.AsyncParallelHook(["compilation"]),
  // ...
};

可以看到,API 设计几乎完全兼容 webpack 的 Tapable,不过从名字我们可以知道,它是一个更加轻量的 Tapable。接下来我们深入 @rspack/lite-tapable 的源码,看看它相比 webpack 的 Tapable 到底做了哪些优化。

LiteTapable 架构

LiteTapable 和 webpack 的 Tapable 一样,都是基于订阅/发布模式的设计思想,这意味着你熟悉的 .tap().call().callAsync() 等写法都可以正常运行。不同的是 LiteTapable 针对 JS 与 Rust 跨语言协作做了特殊优化。LiteTapable 提供如下钩子类型:

同步钩子(Sync Hooks)

同步钩子有如下几种类型:

  • SyncHook:最基础的同步钩子,依次执行所有回调,不传返回值,也不中断;
  • SyncBailHook:熔断式钩子,一旦某个回调返回非 undefined 值,立即停止后续执行。常用于获取结果类场景;
  • SyncWaterfallHook:瀑布流模式,前一个回调的返回值作为下一个回调的第一个参数,形成链式传递。

我们来看个示例,SyncHook 的典型用法:

const { SyncHook } = require('@rspack/lite-tapable');

const syncHook = new SyncHook();

syncHook.tap('first', () => console.log('第一个'));
syncHook.tap('second', () => console.log('第二个'));
syncHook.tap('third', () => console.log('第三个'));

syncHook.call();

输出:

// 第一个
// 第二个
// 第三个

示例中,所有回调按注册顺序执行,互不影响,这种钩子适合做初始化或通知类操作。

异步钩子(Async Hooks)

异步钩子常常用于处理涉及 I/O、网络请求或延迟执行等复杂场景。有如下几种类型:

  • AsyncSeriesHook:异步串行执行,前一个完成后再执行下一个,支持 callbackPromise 风格;
  • AsyncParallelHook:所有回调并行执行,全部完成后触发结束回调,适合资源并行加载等场景;
  • AsyncSeriesBailHook:异步版熔断钩子,任意一个回调返回非 undefined 值即终止后续执行;
  • AsyncSeriesWaterfallHook:异步瀑布流,前一个 Promise 的返回值传递给下一个。

同样看个示例,AsyncSeriesHook 实现任务串行化:

const { AsyncSeriesHook } = require('@rspack/lite-tapable');

const asyncHook = new AsyncSeriesHook();

asyncHook.tapAsync('step1', (callback) => {
  console.log('步骤1: 开始');
  setTimeout(() => {
    console.log('步骤1: 完成');
    callback(); 
  }, 100);
});

asyncHook.tapAsync('step2', (callback) => {
  console.log('步骤2: 开始');
  setTimeout(() => {
    console.log('步骤2: 完成');
    callback();
  }, 50);
});

asyncHook.callAsync((err) => {
  if (!err) console.log('所有步骤已完成!');
});

输出:

// 步骤1: 开始
// 步骤1: 完成
// 步骤2: 开始
// 步骤2: 完成
// 所有步骤已完成!

可以看到,即使 step2 耗时更短,也必须等 step1 结束后才开始执行,实现了真正的串行控制。

指定阶段范围查询

针对 JavaScript 与 Rust 交互需求,LiteTapable 进行了特定优化,这其中关键的特性就是 queryStageRangeQueriedHook。为什么要这两个功能?我们回想一下 Rspack 的架构:

  • JS 层注册插件;
  • Rust 层驱动编译流程;
  • 当 Rust 触发某个 Hook 时,需要知道哪些 JS 插件应该被调用。

如果每次都把整个 Hook 上的所有 taps 全量传给 Rust,效率极低。于是 LiteTapable 引入了按需筛选机制:queryStageRange(stageRange),按区间提取 taps。

// 查询 stage 在 [0, 10) 区间内的所有插件
const queried = hook.queryStageRange([0, 10]);

该方法返回一个 QueriedHook 实例,只包含落在指定阶段范围 [from, to) 内的插件(左闭右开)。QueriedHook 的结构如下:

class QueriedHook {
  stageRange: StageRange;           // 查询区间
  hook: HookBase;                   // 原始 hook
  tapsInRange: FullTap[];           // 已筛选出的 tap 列表
}

QueriedHookcall() / callAsync() 方法只会执行 tapsInRange 中的回调,避免重复筛选。这里的设计是非常巧妙的,因为筛选是发生在 JS 层,所以执行信息会以精简结构传给 Rust,极大减少了跨语言调用的开销。

Rust Hook 架构

前面我们讲到,Rspack 通过 createCompilerHooksRegisters 等工厂函数,将 JS 插件的注册信息打包成一个对象(如 RegisterJsTaps),传递给 Rust 层:

{
  registerCompilerMakeTaps: () => [...],
  registerThisCompilationTaps: () => [...],
  // 更多 hooks...
}

但这只是数据传递。真正的问题是:

  • Rust 是如何接收这些函数,并在合适时机调用它们的?
  • Rust 自己写的插件和 JS 插件,是怎么一起工作的?

这个涉及到了 Rspack Rust Hook 架构的核心设计:基于宏的 Hook 系统 + 拦截器(Interceptor)模式

define_hook! 宏

在 Rust 侧,所有钩子都通过 define_hook! 宏声明。例如:

宏(macro) 是 Rust 的一种编译时代码生成机制,可以在编译时自动生成重复或复杂的代码。这里的define_hook! 宏会在编译阶段生成完整的 Hook 类型系统,从而让 Hook 的定义更简洁高效。

// 定义 CompilerMake hook
define_hook!(CompilerMake: Series(compilation: &mut Compilation));

这行代码的意思是:

  • 定义一个名为 CompilerMake 的 hook;
  • 执行模式为 Series(串行执行);
  • 接收参数:一个可变引用的 compilation 对象。

这行代码在编译期会自动生成三部分代码:

1、Trait:插件的行为契约

pub trait CompilerMake {
  async fn run(&self, compilation: &mut Compilation) -> Result<()>;
  fn stage(&self) -> i32 { 0 } // 默认阶段
}

任何想监听 CompilerMake 的插件,都必须实现这个 trait。

trait 类似于 JavaScript 中的 interface。

2、Hook 结构体:统一管理两类插件

每个 Hook 都对应一个结构体,用来存储两类监听者:

pub struct CompilerMakeHook {
  taps: Vec<Box<dyn CompilerMake + Send + Sync>>, // Rust 原生插件
  interceptors: Vec<Box<dyn Interceptor<Self> + Send + Sync>>, // JS 插件拦截器
}
  • taps:存放直接用 Rust 实现的插件;
  • interceptors:拦截器,用于动态获取 JavaScript 插件。

为什么需要拦截器?因为 JS 插件可能在任意时刻注册,Rust 无法在启动时预知所有回调。所以采用懒加载策略,只在真正触发 Hook 时,才去 JS 层查询当前有效的插件列表。

3、call 方法:统一执行入口

当某个阶段需要触发 Hook 时(如 make),Rspack 会调用:

compiler.hooks.make.call(&mut compilation).await?;

这会进入宏生成的执行逻辑:

impl CompilerMakeHook {
  pub async fn call(&self, compilation: &mut Compilation) -> Result<()> {
    let mut all_taps = Vec::new();

    // 1. 通过拦截器获取 JS 插件
    for interceptor in &self.interceptors {
      let js_taps = interceptor.call(self).await?;
      all_taps.extend(js_taps);
    }

    // 2. 加入 Rust 插件
    all_taps.extend(&self.taps);

    // 3. 按 stage 排序
    all_taps.sort_by_key(|tap| tap.stage());

    // 4. 串行执行(Series 模式)
    for tap in all_taps {
      tap.run(compilation).await?;
    }

    Ok(())
  }
}

看到这里你会发现,无论来源是 JS 还是 Rust,最终都被统一成相同的类型,按 stage 排序后依次执行。

如何编写一个 Rust 插件

在 Rspack 编写一个 Rust 插件是使用到了两个过程宏:

  • #[plugin]:标记插件结构体;
  • #[plugin_hook(HookName for Plugin)]:自动生成符合 Hook 约定的回调,还记得上面我们提到的插件的行为契约吗?#[plugin_hook] 宏会自动为你生成 run 方法(以及完整的 trait 实现)。

示例:监听 compilation 钩子:

#[plugin]
#[derive(Debug)]
pub struct MyPlugin;

// 实现构造函数
impl MyPlugin {
  pub fn new() -> Self {
    Self::new_inner()
  }
}

#[plugin_hook(CompilerCompilation for MyPlugin)]
async fn compilation(
  &self,
  _compilation: &mut Compilation,
  _params: &mut CompilationParams,
) -> Result<()> {
  // 插件逻辑
  println!("compilation 阶段执行");
  Ok(())
}

impl Plugin for MyPlugin {
  fn name(&self) -> &'static str {
    "MyPlugin"
  }

  fn apply(&self, ctx: &mut ApplyContext<'_>) -> Result<()> {
    // 注册 hooks
    ctx.compiler_hooks.compilation.tap(compilation::new(self));
    Ok(())
  }
}

不同类型 Hook 的执行模式

define_hook! 支持多种执行模式,对应 Tapable 的经典模式:

执行模式 类比 Tapable 说明
Series AsyncSeriesHook 串行执行,全部完成后结束
SeriesBail AsyncSeriesBailHook 串行执行,遇到 Some(value) 即终止并返回
SeriesWaterfall AsyncSeriesWaterfallHook 串行执行,每个 tap 的返回值作为下一个 tap 的输入
Parallel AsyncParallelHook 并发执行,等待全部完成

关键桥梁:JsHooksAdapterPlugin

前面提到,每个 Hook 结构体都包含一个 interceptors 字段,用于在运行时动态获取 JavaScript 插件。那么,这些在 JS 中通过 .tap() 注册的回调,是如何被转换为 Rust 可调用的拦截器(interceptor)的呢?

关键正是 JsHooksAdapterPlugin ,它负责将 JavaScript 插件注册信息适配为 Rust 侧的 Interceptor 实例,并注入到对应 Hook 的 interceptors 列表中。

初始化:接收 JS 注册函数

Rust 在创建 JsCompiler 时,它会接收到一组来自 JavaScript 侧的插件注册信息,这些信息由 register_js_taps 对象承载:

let js_hooks_plugin = JsHooksAdapterPlugin::from_js_hooks(env, register_js_taps)?;
plugins.push(js_hooks_plugin.clone().boxed());

这个 JsHooksAdapterPlugin 的核心职责是:为每一个支持的 Hook(如 makecompilation 等)生成一个对应的 Interceptor 实例:

image.png

register_js_taps.register_compiler_make_taps 被包装成 RegisterCompilerMakeTaps 结构体,该结构体实现了 Interceptor<CompilerMakeHook> trait。

注册为拦截器

在插件系统的 apply 阶段,JsHooksAdapterPlugin 会将这些拦截器注册到对应的 Hook 上:

impl Plugin for JsHooksAdapterPlugin {
  fn apply(&self, ctx: &mut ApplyContext<'_>) -> Result<()> {
    // 将 JS 的 make 钩子注册为 CompilerMakeHook 的 interceptor
    ctx.compiler_hooks.make.intercept(self.register_compiler_make_taps.clone());
        
    // 同样处理 compilation、emit 等其他钩子
    ctx.compiler_hooks.compilation.intercept(self.register_compilation_taps.clone());
    // ...
    Ok(())
  }
}

这里的 .intercept(...) 方法会把拦截器存入 Hook 结构体的 interceptors 字段中:

pub struct CompilerMakeHook {
  taps: Vec<Box<dyn CompilerMake + Send + Sync>>,
  interceptors: Vec<Box<dyn Interceptor<Self> + Send + Sync>>, // 注意这里
}

完整调用流程:以 compiler.hooks.make 为例

看完上面的逻辑估计你会觉得有点乱,我们以 compiler.hooks.make.call() 为例,走一遍完整的调用链。当 Rspack 执行到 Compiler::compile 中的 make 阶段时,会发生以下步骤:

1、触发 make hook

image.png

该方法会进入 CompilerMakeHook::call() 方法。前面我们已经介绍过 CompilerMakeHookcall 方法。

2、call 方法会遍历所有拦截器,查询 JS 插件

for interceptor in &self.interceptors {
  let js_taps = interceptor.call(self).await?; // 关键看这里的 call 方法
  all_taps.extend(js_taps);
}

注意上面的 interceptor.call(),它实际上是调用了 RegisterCompilerMakeTaps::call()

3、调用 JavaScript 的函数

async fn call(&self, _hook: &CompilerMakeHook) -> Result<Vec<CompilerMakeTap>> {
  let used_stages = hook.used_stages(); // 获取当前活跃的 stage 范围
  // 调用 JS 函数,拿到该 Hook 下的所有 taps
  let js_taps = self.register.call_with_sync(used_stages).await?;
  // 包装成 Rust 可执行的 Tap 对象
  let rust_taps = js_taps.into_iter()
    .map(|t| Box::new(CompilerMakeTap::from_js_tap(t)))
    .collect();

  Ok(rust_taps)
}

其中 self.register 就是你传进来的那个 registerCompilerMakeTaps 函数。

4、JavaScript 层查询 LiteTapable

还记得我们在 JS 层有一个轻量级的 LiteTapable 吗?它的作用就在这里:

function registerCompilerMakeTaps(stages: number[]) {
  const hook = compiler.hooks.make;
  if (!hook.isUsed()) return [];
  const taps = hook.queryStageRange(stages); // 查询指定 stage 范围内的插件
  return taps.map(wrapTapAsFunction); // 包装成函数数组
}

这个设计很巧妙:只有在真正调用 Hook 时才去查询 JS 插件,避免了提前加载的开销,也支持动态注册。

5、合并与排序

收集完 JS 插件后,和本地的 Rust 插件合并:

all_taps.extend(&self.taps); // 加入 Rust 插件
all_taps.sort_by_key(|tap| tap.stage()); // 按 stage 升序排列

6、统一执行

最后按照执行模式逐一调用:

for tap in &all_taps {
  tap.run(compilation).await?;
}

所有插件,无论是 JS 还是 Rust,都被当作同一个抽象类型来处理。

总结

到目前为止,我们已经完整地介绍了 Rspack 的插件架构原理,恭喜你能坚持看到这里,Rspack 的插件架构比 webpack 的插件架构要复杂得多,从 Tapable 到 Rust Hook 的架构设计,Rspack 不仅能完全兼容 JavaScript 的插件体系,还构建了自身高性能的 Rust Hook 插件系统。值得强调的是,Rspack 并不是简单地用 Rust 重写 Tapable,而是一次非常用心的架构设计。

这实现过程中,Rspack 插件架构融合了多个优秀的设计思想。例如,通过统一执行模型来抹平语言差异;通过延迟查询实现性能与兼容兼顾;通过 interceptors 拦截器的引入,体现了对扩展开放,对修改封闭的设计原则等。

❌