阅读视图

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

拖拽搭建场景下的智能布局算法:栅格吸附、参考线与响应式出码

拖拽搭建场景下的智能布局算法:栅格吸附、参考线与响应式出码

你拖了一个按钮到画布上,松手的瞬间,它"啪"地吸到了网格线上,左右两边自动出现等距参考线,旁边的卡片默默让了个位。你觉得这很自然——直到你要自己实现一个。

这篇文章聊的就是低代码/零代码搭建器背后那套"看起来很聪明"的布局系统。它不是一个功能,而是三个子系统的协作:栅格吸附、参考线对齐、碰撞避让,最后还要把画布状态翻译成能跑在不同屏幕上的真实代码。


一、核心矛盾:自由拖拽 vs 规整布局

用户想要的是"随便拖",设计规范要求的是"对齐、等距、响应式"。这两件事天然矛盾。

你不能真让用户随便拖——最终生成的页面会像贴满小广告的电线杆。你也不能锁死网格——用户会觉得"这破玩意儿还不如我手写 CSS"。

本质问题:如何在不限制用户自由度的前提下,让最终布局收敛到一个"看起来专业"的状态?

答案是:拖拽过程中做实时约束求解。用户以为自己在自由拖拽,其实每一帧都有一个算法在"纠正"他的手。


二、栅格吸附:你以为你拖到了 137px,其实是 144px

2.1 基本模型

栅格吸附的本质是一个最近点投影问题。

interface GridConfig {
  columns: number       // 栅格列数,通常 12 或 24
  gutter: number        // 列间距
  containerWidth: number
}

function snapToGrid(rawX: number, config: GridConfig): number {
  const { columns, gutter, containerWidth } = config
  // 每列实际宽度(含间距)
  const cellWidth = (containerWidth + gutter) / columns

  // 四舍五入到最近的栅格线
  const colIndex = Math.round(rawX / cellWidth)

  // 吸附后的实际像素位置
  return colIndex * cellWidth
}

// 用户拖到 137px → 最近的栅格线在 144px → 吸附过去
// 用户感知:松手后元素"微调"了一下位置

2.2 吸附阈值:不是所有距离都该吸

如果无论多远都吸,用户会觉得元素"飘了"。如果吸附范围太小,又会觉得"对不齐"。

function snapWithThreshold(rawX: number, gridX: number, threshold = 8): number {
  const distance = Math.abs(rawX - gridX)

  // 距离栅格线 8px 以内才吸附,超过就尊重用户意图
  if (distance <= threshold) {
    return gridX  // 吸过去
  }
  return rawX     // 保持原位,用户可能就是想放这儿
}

经验值:阈值设为栅格宽度的 15%~20% 手感最好。太大会"抢",太小会"没反应"。

2.3 多轴吸附的优先级冲突

横向想吸到列网格,纵向想吸到行网格,同时还想跟旁边的元素对齐。三个力同时作用,元素往哪飘?

interface SnapCandidate {
  axis: 'x' | 'y'
  target: number        // 吸附目标位置
  distance: number      // 距离
  priority: number      // 优先级权重
  source: 'grid' | 'element' | 'guideline'
}

function resolveSnap(candidates: SnapCandidate[]): { x: number; y: number } {
  // 按轴分组,每个轴只取优先级最高且距离最近的
  const bestX = candidates
    .filter(c => c.axis === 'x' && c.distance < THRESHOLD)
    .sort((a, b) => b.priority - a.priority || a.distance - b.distance)[0]

  const bestY = candidates
    .filter(c => c.axis === 'y' && c.distance < THRESHOLD)
    .sort((a, b) => b.priority - a.priority || a.distance - b.distance)[0]

  return {
    x: bestX?.target ?? rawX,
    y: bestY?.target ?? rawY
  }
}

// 优先级:元素对齐 > 参考线 > 栅格
// 因为用户更关心"跟旁边那个对齐"而不是"落在网格上"

三、等距参考线:那条蓝色虚线怎么来的

Figma、Sketch 里拖元素时,会冒出蓝色参考线告诉你"你跟左边的间距和右边的间距一样了"。这个功能让用户觉得工具"很聪明",但实现起来涉及 O(n²) 的元素间距计算。

3.1 核心算法

interface Rect {
  id: string
  x: number; y: number
  width: number; height: number
}

function findEqualSpacingGuides(
  dragging: Rect,
  others: Rect[],
  tolerance = 3         // 间距差在 3px 以内视为"相等"
): GuideLine[] {
  const guides: GuideLine[] = []

  // 找水平方向上在同一行的元素(y 轴有交叠)
  const sameRow = others.filter(el =>
    el.y < dragging.y + dragging.height &&
    el.y + el.height > dragging.y
  )

  // 按 x 排序
  const sorted = [...sameRow, dragging].sort((a, b) => a.x - b.x)

  // 计算相邻元素间距
  for (let i = 0; i < sorted.length - 2; i++) {
    const gap1 = sorted[i + 1].x - (sorted[i].x + sorted[i].width)
    const gap2 = sorted[i + 2].x - (sorted[i + 1].x + sorted[i + 1].width)

    if (Math.abs(gap1 - gap2) <= tolerance) {
      // 间距相等!画参考线
      guides.push({
        type: 'equal-spacing',
        positions: [sorted[i], sorted[i + 1], sorted[i + 2]],
        gap: gap1
      })
    }
  }

  return guides
}

3.2 性能问题:元素多了怎么办

画布上有 200 个元素,每次 mousemove 都算一遍 O(n²) 的间距,你的 16ms 帧预算就炸了。

// 空间索引:用 R-tree 或简化版的"条带分区"加速
class SpatialIndex {
  private bands: Map<number, Rect[]> = new Map()
  private bandSize = 100  // 每 100px 一个分区

  insert(rect: Rect) {
    const bandStart = Math.floor(rect.y / this.bandSize)
    const bandEnd = Math.floor((rect.y + rect.height) / this.bandSize)
    for (let b = bandStart; b <= bandEnd; b++) {
      if (!this.bands.has(b)) this.bands.set(b, [])
      this.bands.get(b)!.push(rect)
    }
  }

  // 只查跟拖拽元素"同一条带"的元素,从 200 个缩到 10~20 个
  queryNearby(rect: Rect): Rect[] {
    const bandStart = Math.floor(rect.y / this.bandSize)
    const bandEnd = Math.floor((rect.y + rect.height) / this.bandSize)
    const result = new Set<Rect>()
    for (let b = bandStart; b <= bandEnd; b++) {
      this.bands.get(b)?.forEach(r => result.add(r))
    }
    return [...result]
  }
}

从"对所有元素算间距"变成"只对附近元素算间距",复杂度从 O(n²) 降到 O(n·k),k 是平均每条带的元素数。


四、碰撞检测与自动避让

用户把 A 拖到 B 上面了。怎么办?

三种策略,各有取舍:

策略 体验 实现复杂度 适用场景
阻止重叠 生硬 仪表盘类
推挤避让 自然 自由画布
层叠堆放 灵活 卡片流布局

4.1 推挤避让算法

"推挤"是最像 Figma 的体验:你把 A 推过去,B 自动让开,B 让开后如果碰到 C,C 也让开——像多米诺骨牌。

function resolveCollisions(
  moved: Rect,
  allRects: Rect[],
  direction: 'down' | 'right' = 'down'
): Rect[] {
  const result = [...allRects]
  const queue = [moved]  // BFS:从被拖动的元素开始

  while (queue.length > 0) {
    const current = queue.shift()!

    for (const other of result) {
      if (other.id === current.id) continue
      if (!isOverlapping(current, other)) continue

      // 碰撞了 → 把 other 往下推
      const pushDistance = direction === 'down'
        ? (current.y + current.height) - other.y + SPACING
        : (current.x + current.width) - other.x + SPACING

      if (direction === 'down') {
        other.y += pushDistance
      } else {
        other.x += pushDistance
      }

      queue.push(other)  // other 移动后可能跟别的元素碰撞,继续处理
    }
  }

  return result
}

function isOverlapping(a: Rect, b: Rect): boolean {
  return !(a.x + a.width <= b.x ||
           b.x + b.width <= a.x ||
           a.y + a.height <= b.y ||
           b.y + b.height <= a.y)
}

4.2 防止无限推挤

一个元素推下去撞到另一个,另一个推下去又撞到第三个……如果布局很密,这个链条可能非常长,甚至成环(A 推 B,B 推 C,C 又推回 A)。

// 加个访问标记,防止循环推挤
const visited = new Set<string>()

while (queue.length > 0) {
  const current = queue.shift()!
  if (visited.has(current.id)) continue  // 已经被推过了,跳过
  visited.add(current.id)
  // ...后续逻辑
}

// 同时设置最大推挤深度
const MAX_DEPTH = 10  // 超过 10 层就停,宁可重叠也别卡死

写到这里我开始怀疑人生——一个"拖拽"功能,居然要处理图论里的环检测。


五、约束求解:把吸附、对齐、避让统一起来

前面三个子系统各自为政,经常打架:栅格吸附说"往左 3px",等距参考线说"往右 2px",碰撞避让说"往下 10px"。

统一的方式是把所有规则建模成约束,然后求解。

interface Constraint {
  type: 'snap' | 'align' | 'no-overlap' | 'spacing'
  weight: number        // 权重,越高越优先
  apply: (pos: Position) => Position  // 约束函数
}

function solveConstraints(
  rawPos: Position,
  constraints: Constraint[],
  maxIterations = 5     // 迭代次数,通常 3~5 次就收敛
): Position {
  let pos = { ...rawPos }

  // 按权重排序,高优先级先满足
  const sorted = constraints.sort((a, b) => b.weight - a.weight)

  for (let i = 0; i < maxIterations; i++) {
    let moved = false
    for (const c of sorted) {
      const newPos = c.apply(pos)
      if (newPos.x !== pos.x || newPos.y !== pos.y) {
        pos = newPos
        moved = true
      }
    }
    if (!moved) break  // 所有约束都满足了,提前结束
  }

  return pos
}

这个模式本质上是一个简化版的松弛法(Relaxation),跟物理引擎里解约束的思路一样。权重决定了冲突时谁赢:

  • no-overlap: 100(碰撞必须解决,不然页面重叠)
  • align: 80(对齐很重要,但不能为了对齐搞重叠)
  • snap: 50(栅格吸附锦上添花,不是刚需)
  • spacing: 30(等距是最弱的建议)

六、跨断点响应式出码

画布上拖好了,最终要变成在手机、平板、桌面端都能跑的代码。这是整个系统最难的部分。

6.1 问题:画布是绝对定位,真实页面是流式布局

画布上的每个元素都有精确的 { x, y, width, height },但你不能直接生成 position: absolute; left: 137px——这在手机上会直接飞出去。

需要做的是:从绝对坐标反推出语义化的布局结构

// 输入:画布上的绝对定位元素
const canvasElements = [
  { id: 'logo', x: 0, y: 0, w: 200, h: 60 },
  { id: 'nav', x: 220, y: 10, w: 580, h: 40 },
  { id: 'hero', x: 0, y: 80, w: 800, h: 300 },
]

// 输出:语义化的布局树
// {
//   type: 'row',                          ← logo 和 nav y 接近,判定为同一行
//   children: [
//     { id: 'logo', col: 'span 3' },      ← 200/800 ≈ 3/12 列
//     { id: 'nav', col: 'span 9' }        ← 580/800 ≈ 9/12 列
//   ]
// },
// { id: 'hero', col: 'span 12' }          ← 独占一行

6.2 行检测算法

function detectRows(elements: CanvasRect[]): CanvasRect[][] {
  // 按 y 排序
  const sorted = [...elements].sort((a, b) => a.y - b.y)
  const rows: CanvasRect[][] = [[sorted[0]]]

  for (let i = 1; i < sorted.length; i++) {
    const current = sorted[i]
    const lastRow = rows[rows.length - 1]
    // y 坐标差在 20px 以内,视为同一行
    const rowTop = Math.min(...lastRow.map(el => el.y))

    if (Math.abs(current.y - rowTop) < 20) {
      lastRow.push(current)
    } else {
      rows.push([current])  // 新起一行
    }
  }

  // 每行内部按 x 排序
  return rows.map(row => row.sort((a, b) => a.x - b.x))
}

6.3 断点映射与出码

interface Breakpoint {
  name: string
  minWidth: number
  columns: number
}

const breakpoints: Breakpoint[] = [
  { name: 'mobile', minWidth: 0, columns: 4 },
  { name: 'tablet', minWidth: 768, columns: 8 },
  { name: 'desktop', minWidth: 1024, columns: 12 },
]

function generateResponsiveCSS(
  element: CanvasRect,
  canvasWidth: number
): string {
  // 元素在画布上占的比例
  const ratio = element.w / canvasWidth

  return breakpoints.map(bp => {
    // 比例映射到该断点的栅格列数
    const spanCols = Math.round(ratio * bp.columns)
    // 保底 1 列,不能为 0
    const finalSpan = Math.max(1, Math.min(spanCols, bp.columns))

    if (bp.minWidth === 0) {
      return `.el-${element.id} { grid-column: span ${finalSpan}; }`
    }
    return `@media (min-width: ${bp.minWidth}px) {
  .el-${element.id} { grid-column: span ${finalSpan}; }
}`
  }).join('\n\n')
}

// 桌面端占 6/12 的元素 → 平板占 4/8 → 手机占 2/4
// 比例一致,视觉不跳

6.4 断点间的布局坍缩

桌面端一行三个卡片,手机上放不下怎么办?

function collapseRow(
  row: CanvasRect[],
  targetColumns: number  // 目标断点的总列数
): LayoutNode[] {
  let usedCols = 0
  const lines: LayoutNode[][] = [[]]

  for (const el of row) {
    const span = Math.max(1, Math.round((el.w / canvasWidth) * targetColumns))

    if (usedCols + span > targetColumns) {
      // 这一行放不下了,换行
      lines.push([])
      usedCols = 0
    }

    lines[lines.length - 1].push({ ...el, span })
    usedCols += span
  }

  return lines.flat()
}

// 桌面:[卡片A(4列) 卡片B(4列) 卡片C(4列)]  → 一行
// 手机:[卡片A(4列)] [卡片B(4列)] [卡片C(4列)] → 三行
// 4 列断点下每个卡片独占一行,自动堆叠

七、设计权衡

吸附精度 vs 性能

方案 精度 每帧耗时 适用规模
暴力遍历所有元素 最高 O(n²) <50 个元素
空间索引 + 条带分区 O(n·k) 50~500
Web Worker 异步计算 不阻塞主线程 500+

实际项目中 50~200 个元素最常见,条带分区就够用。上 Web Worker 是为了防御性编程——万一产品经理说"我们要支持 1000 个组件",你不至于重写。

约束求解 vs 规则引擎

约束求解灵活,但调试困难——你很难解释"为什么元素跳到了那个位置"。规则引擎(if-else 链)简单粗暴,但一旦规则超过 20 条就是灾难。

我的建议:先用规则引擎,痛了再迁移到约束求解。过早抽象是万恶之源。

出码质量 vs 还原度

100% 还原画布设计?那只能用绝对定位——响应式全废。用语义化栅格?像素级还原就别想了。

折中方案:大布局用栅格,小组件内部用 Flexbox,极端情况允许局部绝对定位。给用户一个"锁定像素位置"的开关,选了就不做响应式适配,让用户自己承担后果。


八、边界与踩坑

1. 吸附震荡:元素在两条栅格线之间反复横跳。解法:加 hysteresis(滞后区间),吸附后需要移动超过阈值才能脱离。

2. 参考线闪烁:高速拖拽时参考线一闪一闪。解法:参考线渲染加 debounce,或者用 requestAnimationFrame 合并更新。

3. 推挤雪崩:一个元素推动了整个画布向下平移。解法:设最大推挤深度,超过后弹 toast 提示用户"空间不够"。

4. 出码后跟画布长得不一样:这不是 bug,这是特性。栅格系统做的是"近似还原",不是"像素复制"。提前告知用户预期,比事后解释强。


九、总结:一个通用模型

拖拽搭建的智能布局,本质上是一个多约束实时求解 + 坐标空间转换的系统问题。

拆开来看:

  • 栅格吸附 = 连续空间到离散空间的投影
  • 参考线 = 元素间拓扑关系的实时计算
  • 碰撞避让 = 图上的约束传播(BFS/松弛法)
  • 响应式出码 = 绝对坐标系到语义布局树的逆向推断

这四个子问题的组合方式,几乎可以套用到所有"可视化编辑器"场景:PPT 编辑器、白板工具、BI 仪表盘、甚至游戏关卡编辑器。

基于PixiJS的试玩广告开发-续篇

前言

接上篇 基于PixiJS的试玩广告开发中,我们讲到了Moloco平台的试玩广告投放,这次我们的新需求是要兼容更多平台🤣🤣🤣,Facebook、Liftoff、RevX

每个平台的 API 和要求都有些许区别:

  • Facebook: FbPlayableAd.onCTAClick()
  • Liftoff: mraid.open()
  • RevX: 需要注入 trkLinkctaLink

我们使用环境变量区分不同平台,避免维护多份代码。

实施

1. 配置环境变量

在根目录下为每个平台创建对应的 .env 文件:

  • .env.facebook: VITE_AD_PLATFORM=facebook
  • .env.liftoff: VITE_AD_PLATFORM=liftoff
  • .env.moloco: VITE_AD_PLATFORM=moloco
  • .env.revx: VITE_AD_PLATFORM=revx

构建时可通过 import.meta.env.VITE_AD_PLATFORM 获取平台标识。

2. 统一跳转逻辑 (CTA)

src/utils/ad.ts 中封装一个统一的 handleCTA() 函数,屏蔽平台差异。业务层只需要调用这个函数,无需关心底层实现。

// src/utils/ad.ts

/** Liftoff 跳转 */
function ctaLiftoff() {
  // mraid 仅在真机环境存在
  if (typeof mraid !== "undefined") {
    mraid.open("${CLICK_URL}"); // 平台会自动替换宏
  } else {
    alert("【本地测试】Liftoff 跳转");
  }
}

/** Facebook / Moloco 跳转 */
function ctaFacebook() {
  if (typeof FbPlayableAd !== "undefined" && FbPlayableAd.onCTAClick) {
    FbPlayableAd.onCTAClick();
  } else {
    alert("【本地测试】Facebook/Moloco 跳转");
  }
}

/** RevX 跳转 */
function ctaRevX() {
  const w = window as any;
  const trkLink = w["trkLink"]; // 追踪链接
  const ctaLink = w["ctaLink"]; // 落地页链接

  // 本地测试或宏未替换时
  if (!ctaLink || ctaLink === "|CLICK_URL|") {
    alert("【本地测试】RevX 跳转");
    return;
  }

  // 触发点击追踪像素
  if (trkLink && trkLink !== "|CLICK|NOENCODING|") {
    new Image().src = trkLink + encodeURIComponent(ctaLink);
  }

  window.open(ctaLink, "_blank");
}

/** 统一入口 */
export function handleCTA() {
  const platform = import.meta.env.VITE_AD_PLATFORM;

  if (platform === "liftoff") {
    ctaLiftoff();
  } else if (platform === "facebook") {
    ctaFacebook();
  } else if (platform === "revx") {
    ctaRevX();
  } else {
    // 默认 fallback
    ctaFacebook();
  }
}

构建配置

试玩广告通常要求 单文件交付 (Single HTML),即 HTMLCSSJS、图片、音频全部内联到一个 HTML 文件中。

Vite 插件配置

使用 vite-plugin-singlefile 实现内联。针对 RevX 平台,还需要额外注入特定的宏定义脚本。

编写一个简单的插件 plugins/revx-inject.ts

// plugins/revx-inject.ts
import type { Plugin } from "vite";

export function revxInjectPlugin(): Plugin {
  return {
    name: "revx-inject-macros",
    transformIndexHtml(html) {
      // 在 head 顶部注入宏变量
      const snippet = `<script>window.trkLink="|CLICK|NOENCODING|";window.ctaLink="|CLICK_URL|";</script>`;
      return html.replace("<head>", `<head>${snippet}`);
    },
  };
}

配置 vite.config.ts

import { defineConfig, loadEnv } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
import { revxInjectPlugin } from "./plugins/revx-inject";

export default defineConfig(({ mode }) => {
  // 加载对应的 .env 文件
  const env = loadEnv(mode, process.cwd(), "VITE_");
  const isRevX = env.VITE_AD_PLATFORM === "revx";

  return {
    plugins: [
      // RevX 专属插件
      ...(isRevX ? [revxInjectPlugin()] : []),
      // 单文件打包插件
      viteSingleFile(),
    ],
    build: {
      // 强制内联所有资源 (100MB)
      assetsInlineLimit: 100000000,
      minify: "esbuild",
    },
  };
});

自动化构建脚本

配置 package.jsonShell 脚本实现多平台并行构建

package.json:

"scripts": {
  "build:facebook": "vite build --mode facebook",
  "build:liftoff": "vite build --mode liftoff",
  "build:revx": "vite build --mode revx",
  "build:all": "bash scripts/build-all.sh"
}

scripts/build-all.sh:

#!/usr/bin/env bash
set -e

PLATFORMS=("liftoff" "moloco" "facebook" "revx")

echo "▶ 开始并行构建..."

for platform in "${PLATFORMS[@]}"; do
  (
    echo "  [${platform}] Building..."
    npx vite build --mode "$platform" --outDir "dist/${platform}" --logLevel warn
    # 此处可添加 zip 压缩逻辑
  ) &
done

wait
echo "✅ 所有平台构建完成!"

执行 npm run build:alldist 目录下就会生成所有平台的包。

facebook资源内联与跨域问题

原因

Vite 将资源转为 Base64 Data URI,但 PixiJSAssets Loader@pixi/sound 底层默认使用 fetch() / XHR 加载这些 Data URI。在 MRAID 或沙盒 iframe 中,fetch("data:...") 可能会被浏览器安全策略拦截。

解决

绕过 Loader,手动处理 Base64 资源。

1. 图片:使用 Image 对象

不要用 Assets.load,改用原生 Image 对象加载 Base64,再创建 Texture

// src/utils/textures.ts

function loadImg(url: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = url; // url is base64 string
  });
}

// 使用
const img = await loadImg(base64Url);
const texture = Texture.from(img);

2. 音频:使用 AudioContext

@pixi/sound 也会发起请求。我们需要手动将 Base64 解码为 ArrayBuffer

  // src/utils/audio.ts
  /**
   * Preload all audio assets (no fetch / no network).
   */
  public async init() {
    if (this.initialized) return;

    this.ctx = new AudioContext();

    const entries: [string, string][] = [
      ["spinButton", spinButtonUrl],
      ["jackpot", jackpotUrl],
    ];

    await Promise.all(
      entries.map(async ([key, url]) => {
        let arrayBuf: ArrayBuffer;
        if (url.startsWith("data:")) {
          arrayBuf = dataUrlToArrayBuffer(url);
        } else {
          const response = await fetch(url);
          arrayBuf = await response.arrayBuffer();
        }
        this.buffers[key] = await this.ctx.decodeAudioData(arrayBuf);
      }),
    );

    this.initialized = true;
}
  
function dataUrlToArrayBuffer(dataUrl: string): ArrayBuffer {
  const base64 = dataUrl.split(",")[1];
  const binary = atob(base64);
  const len = binary.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

// 解码
const arrayBuf = dataUrlToArrayBuffer(base64Url);
const audioBuf = await ctx.decodeAudioData(arrayBuf);

改用这种方式后,可以解决 Facebook 跨域导致的无法加载问题。

附录:官方文档与测试工具

在开发过程中,常备各平台的官方文档和测试工具,能少走不少弯路。

官方文档

测试工具

v-model 的进阶用法:搞定复杂的父子组件数据通信

前言

在 Vue 开发中,父子组件之间的数据通信是一个核心话题。v-model 作为 Vue 的双向绑定指令,看似简单,实则蕴含着强大的表达能力。很多开发者对 v-model 的理解停留在"表单输入绑定"的层面,殊不知它早已进化为处理复杂父子组件通信的利器。

本文将深入剖析 v-model 的本质,从基础用法到进阶技巧,再到实战案例,帮助我们掌握这一强大的通信工具。

v-model 的本质

语法糖::modelValue + @update:modelValue

v-model 的本质其实是一个语法糖。在 Vue3 中,下面这两种写法是完全等价的:

<!-- 这种写法 -->
<ChildComponent v-model="parentData" />

<!-- 等价于这种写法 -->
<ChildComponent 
  :modelValue="parentData" 
  @update:modelValue="parentData = $event" 
/>

双向绑定的实现原理

v-model 实现双向绑定的核心是 Props 向下传递,Events 向上传递: 双向绑定的实现原理

双向绑定的具体流程

  1. 父组件通过 :modelValue 将数据传递给子组件
  2. 子组件通过 props.modelValue 接收数据并展示
  3. 当子组件内部需要修改数据时,通过 emit('update:modelValue', newValue) 通知父组件
  4. 父组件监听到事件后更新自己的数据
  5. 父组件数据更新后,再次通过 Props 传递给子组件,完成闭环

从 Vue2 的 v-bind.sync 到 Vu3 的 v-model

如果我们想在 Vue2 中处理多个双向绑定需要使用 .sync 修饰符:

<!-- Vue 2 中的 .sync -->
<ChildComponent 
  :name.sync="userName"
  :age.sync="userAge"
/>
<!-- 等价于 -->
<ChildComponent 
  :name="userName" 
  @update:name="userName = $event"
  :age="userAge" 
  @update:age="userAge = $event"
/>

而在Vue 3 统一为 v-model 语法,更加直观:

<!-- Vue 3 中的多 v-model -->
<ChildComponent
  v-model:name="userName"
  v-model:age="userAge"
/>

v-model 基础用法回顾

自定义组件支持 v-model

如果要让一个自定义组件支持 v-model,需要做两件事:

  1. 接收 modelValue :默认名称
  2. 当值变化时,触发 update:modelValue 事件
<!-- 自定义输入框组件 CustomInput.vue -->
<template>
  <div class="custom-input">
    <input
      :value="modelValue"
      @input="handleInput"
    />
  </div>
</template>

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function handleInput(e: Event) {
  const value = (e.target as HTMLInputElement).value
  emit('update:modelValue', value)
}
</script>

<!-- 使用方式 -->
<template>
  <CustomInput 
    v-model="searchText"
  />
</template>

默认 prop 和事件名称

v-model 的默认配置:

  • Prop 名称:modelValue
  • 事件名称:update:modelValue

当然,我们也可以通过修改 v-model 的参数来改变这些名称:

<!-- 指定参数名 -->
<ChildComponent v-model:title="pageTitle" />

<!-- 等价于 -->
<ChildComponent 
  :title="pageTitle" 
  @update:title="pageTitle = $event"
/>

多个 v-model 绑定

场景:一个组件需要双向绑定多个值

想象一下:在用户表单组件中,我们需要同时绑定姓名、年龄、邮箱等多个值:

<!-- 父组件 -->
<template>
  <UserForm
    v-model:name="userName"
    v-model:age="userAge"
    v-model:email="userEmail"
    @submit="handleSubmit"
  />
</template>

<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'

const userName = ref('张三')
const userAge = ref(25)
const userEmail = ref('zhangsan@example.com')

function handleSubmit() {
  console.log('提交表单', {
    name: userName.value,
    age: userAge.value,
    email: userEmail.value
  })
}
</script>

实现:指定不同的参数名

<!-- UserForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>姓名</label>
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      />
    </div>
    
    <div class="form-group">
      <label>年龄</label>
      <input
        type="number"
        :value="age"
        @input="$emit('update:age', Number($event.target.value))"
      />
    </div>
    
    <div class="form-group">
      <label>邮箱</label>
      <input
        :value="email"
        @input="$emit('update:email', $event.target.value)"
        type="email"
      />
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script setup lang="ts">
defineProps<{
  name: string
  age: number
  email: string
}>()

const emit = defineEmits<{
  'update:name': [value: string]
  'update:age': [value: number]
  'update:email': [value: string]
  'submit': []
}>()

function handleSubmit() {
  emit('submit')
}
</script>

复杂数据结构的双向绑定

除了简单的基础类型数据的双向绑定外,有时候我们也需要双向绑定一个复杂对象:

<template>
  <AddressEditor v-model:address="userAddress" />
</template>

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

interface Address {
  province: string
  city: string
  district: string
  detail: string
  zipCode?: string
}

const userAddress = ref<Address>({
  province: '广东省',
  city: '深圳市',
  district: '南山区',
  detail: '科技园路1号'
})
</script>

这其实相当于:

<template>
  <div class="address-editor">
    <div class="address-item">
      <label>省份</label>
      <input
        :value="address.province"
        @input="updateAddress('province', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>城市</label>
      <input
        :value="address.city"
        @input="updateAddress('city', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>区县</label>
      <input
        :value="address.district"
        @input="updateAddress('district', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>详细地址</label>
      <input
        :value="address.detail"
        @input="updateAddress('detail', $event.target.value)"
      />
    </div>
    
    <div class="address-item">
      <label>邮编</label>
      <input
        :value="address.zipCode"
        @input="updateAddress('zipCode', $event.target.value)"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Address {
  province: string
  city: string
  district: string
  detail: string
  zipCode?: string
}

const props = defineProps<{
  address: Address
}>()

const emit = defineEmits<{
  'update:address': [value: Address]
}>()

function updateAddress<K extends keyof Address>(key: K, value: Address[K]) {
  emit('update:address', {
    ...props.address,
    [key]: value
  })
}
</script>

自定义 v-model 修饰符

内置修饰符的作用

修饰符 作用 适用场景
.trim 自动过滤用户输入的首尾空白字符 用户名、留言内容等不需要首尾空格的文本输入
.number 将用户输入自动转换为数值类型 年龄、数量等数字类型的输入
.lazy 将默认的 input 事件改为 change 事件触发同步 减少频繁更新,适合评论框等场景

内置修饰符的处理

在自定义组件中需要手动处理这些修饰符:

<template>
  <CustomInput 
    v-model.trim="text"     <!-- 自动去除首尾空格 -->
    v-model.number="age"    <!-- 自动转换为数字类型 -->
    v-model.lazy="comment"  <!-- 失焦后才更新 -->
  />
</template>

在自定义组件中处理这些修饰符

<!-- CustomInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="handleInput"
    @change="handleChange"
  />
</template>

<script setup>
const props = defineProps<{
  modelValue: string | number
  modelModifiers?: {
    trim?: boolean
    number?: boolean
    lazy?: boolean
  }
}>()

const emit = defineEmits(['update:modelValue'])

function handleInput(e: Event) {
  if (props.modelModifiers?.lazy) {
    // lazy 模式下,只在 change 事件触发
    return
  }
  
  let value = (e.target as HTMLInputElement).value
  
  // 处理 trim 修饰符
  if (props.modelModifiers?.trim) {
    value = value.trim()
  }
  
  // 处理 number 修饰符
  if (props.modelModifiers?.number) {
    const num = parseFloat(value)
    value = isNaN(num) ? value : num
  }
  
  emit('update:modelValue', value)
}

function handleChange(e: Event) {
  if (!props.modelModifiers?.lazy) {
    return
  }
  
  let value = (e.target as HTMLInputElement).value
  
  if (props.modelModifiers?.trim) {
    value = value.trim()
  }
  
  if (props.modelModifiers?.number) {
    const num = parseFloat(value)
    value = isNaN(num) ? value : num
  }
  
  emit('update:modelValue', value)
}
</script>

常见陷阱与解决方案

不要直接修改 props

这是新手最常见的错误:

<!-- ❌ 错误:直接修改 props -->
<template>
  <input v-model="modelValue" />
</template>

<script setup>
defineProps<{
  modelValue: string
}>()
</script>

解决方案:通过事件通知父组件

<template>
  <input 
    :value="modelValue" 
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

处理非字符串类型的 v-model

对于数字、布尔值等类型,我们在使用时需要特别注意类型转换:

<template>
  <!-- ✅ 正确处理数字类型 -->
  <input
    type="number"
    :value="modelValue"
    @input="handleNumberInput"
  />
</template>

<script setup>
const props = defineProps<{
  modelValue: number
}>()

const emit = defineEmits(['update:modelValue'])

function handleNumberInput(e: Event) {
  const value = (e.target as HTMLInputElement).value
  // 转换为数字,处理空值
  const num = value === '' ? 0 : Number(value)
  emit('update:modelValue', num)
}
</script>

v-model 与响应式数据的配合

当使用对象作为 v-model 的值时,一定注意响应式丢失的问题:

<template>
  <!-- 这种情况没问题 -->
  <ChildComponent v-model="user" />
  
  <!-- 但这种情况会导致响应式丢失! -->
  <ChildComponent 
    v-model="user.name" 
    v-model="user.age"
  />
</template>

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

const user = reactive({
  name: '张三',
  age: 25
})
// ❌ 这样使用 v-model 会破坏响应式
</script>

解决方案:使用 ref

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

const user = ref({
  name: '张三',
  age: 25
})
</script>

<template>
  <ChildComponent 
    v-model:name="user.value.name" 
    v-model:age="user.value.age"
  />
</template>

处理异步更新

有时需要在值变化后执行某些操作,但需要注意 Vue 的异步更新机制:

<script setup>
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits(['update:modelValue'])

function handleInput(e: Event) {
  const value = e.target.value
  emit('update:modelValue', value)
  
  // ❌ 这里的 props.modelValue 还是旧值
  console.log(props.modelValue) 
  
  // ✅ 使用 nextTick 获取更新后的值
  import { nextTick } from 'vue'
  nextTick(() => {
    console.log(props.modelValue) // 现在是最新值
  })
}
</script>

最佳实践清单

  • 优先使用多个 v-model 而不是一个包含多个字段的对象
  • 为所有 v-model 定义 TypeScript 类型,包括修饰符
  • 不要直接修改 props,始终通过事件更新
  • 处理非字符串类型时做好类型转换
  • 提供合理的默认值和空状态处理
  • 考虑使用计算属性实现复杂的转换逻辑
  • 为组件暴露 reset 等方法,方便父组件控制
  • 使用 v-model 修饰符实现可复用的输入处理逻辑

结语

好的组件设计应该是使用者友好型。当我们设计的组件让其他开发者或使用者,只需要写 v-model 就能完成复杂的双向绑定,那我们就真正掌握了 v-model 的精髓。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

前端构建工具深度解析:从Webpack到Vite的演进之路

在前端工程化的发展历程中,构建工具扮演着至关重要的角色。从早期的Grunt、Gulp,到Webpack统治时代,再到如今Vite的异军突起,构建工具的演进折射出前端开发理念的深刻变革。本文将深入探讨构建工具的发展脉络,解析Webpack与Vite的核心差异,并分享实际项目中的最佳实践。

Webpack:模块化打包的王者

Webpack作为前端构建工具的集大成者,通过强大的插件系统和灵活的配置能力,统治了前端构建领域多年。其核心优势在于:

模块化处理能力:Webpack能够处理各种类型的模块,包括JavaScript、CSS、图片等,通过loader机制实现资源的转换和打包。

// webpack.config.js 基础配置示例
module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    })
  ]
};

代码分割与懒加载:Webpack提供了强大的代码分割能力,通过动态import()实现按需加载,显著提升应用性能。

// 动态导入示例
button.addEventListener('click', () => {
  import('./heavyModule.js').then(module => {
    module.default();
  });
});

丰富的插件生态:从代码压缩到环境变量注入,Webpack的插件生态几乎覆盖了构建过程的每个环节。

Vite:新一代构建工具的崛起

Vite的出现标志着前端构建工具进入了一个新的时代。它基于ES Modules和Rollup,提供了极致的开发体验和构建性能。

极速的冷启动:Vite利用浏览器原生的ES Modules能力,实现了按需编译,开发服务器启动速度比Webpack快10-100倍。

// vite.config.js 配置示例
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'react-vendor': ['react', 'react-dom'],
          'utils': ['lodash', 'axios']
        }
      }
    }
  }
});

HMR(热模块替换):Vite的HMR机制基于ES Modules,无论应用规模多大,都能保持毫秒级的更新速度。

优化的生产构建:Vite使用Rollup进行生产构建,自动进行代码分割和Tree-shaking,生成高度优化的生产包。

核心差异对比

开发模式:Webpack需要打包整个应用才能启动,而Vite直接启动开发服务器,按需编译请求的模块。

构建速度:在大型项目中,Vite的开发构建速度通常比Webpack快一个数量级,生产构建速度也有明显优势。

配置复杂度:Webpack的配置相对复杂,需要深入了解各种loader和plugin;Vite提供了更简洁的配置方式,大部分场景下零配置即可使用。

生态成熟度:Webpack拥有更成熟的插件生态和社区支持;Vite虽然生态相对年轻,但发展迅速,已能满足大部分开发需求。

迁移实践指南

从Webpack迁移到Vite并非一蹴而就,需要考虑以下关键点:

依赖兼容性:检查项目依赖是否支持ES Modules,对于CommonJS模块,可能需要配置相应的插件。

// 处理CommonJS依赖
export default defineConfig({
  optimizeDeps: {
    include: ['some-commonjs-package']
  }
});

构建配置迁移:将Webpack的loader和plugin配置转换为Vite的对应配置。大部分Webpack配置都有Vite的等价实现。

环境变量处理:Vite使用.env文件管理环境变量,与Webpack的DefinePlugin略有不同。

// 环境变量使用
const apiUrl = import.meta.env.VITE_API_URL;

最佳实践建议

选择策略:对于新项目,优先选择Vite;对于成熟的Webpack项目,评估迁移成本后决定是否迁移。

混合使用:在某些复杂场景下,可以结合两者的优势,开发环境使用Vite,生产环境使用Webpack。

性能监控:无论使用哪种工具,都要建立构建性能监控机制,及时发现和解决性能瓶颈。

持续学习:构建工具技术发展迅速,保持学习新特性和最佳实践,提升开发效率。

构建工具的选择没有绝对的对错,关键是要根据项目特点和团队需求做出合适的决策。随着前端技术的不断发展,我们期待看到更多创新的构建工具和解决方案,为前端开发带来更好的体验和更高的效率。

TypeScript 深度加持:让你的组合式函数拥有“钢筋铁骨”

前言

在 JavaScript 的世界里,自由往往伴随着风险。当你写下一个函数,一个月后回来修改时,你还记得它接受什么参数、返回什么值吗?当团队成员接手你的代码时,他们需要花多少时间去理解函数的使用方式?

TypeScript 的出现改变了这一切。特别是当它与 Vue3 的组合式函数相结合时,TypeScript 不再是可选项,而是构建可靠、可维护应用的必备工具。本文将深入探讨如何为组合式函数添加 TypeScript 支持,让它们从“手工作坊”升级为“工业标准”。

TypeScript 为什么要深度集成?

开发时智能提示:再也不用翻文档

没有 TypeScript 的组合式函数,就像一本没有目录的书:

// 纯 JavaScript 版本
export function useUser() {
  // 这个函数返回什么?怎么用?
  // 只能去看源码或者猜
}

// 使用时
const user = useUser()
// user 里有什么?不知道

有了 TypeScript,一切变得清晰明了:

// TypeScript 版本
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface UseUserReturn {
  user: Ref<User | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  fetchUser: (id: number) => Promise<void>
  updateUser: (data: Partial<User>) => Promise<void>
}

export function useUser(): UseUserReturn {
  // 实现...
}

// 使用时,编辑器会提供完美的智能提示
const { user, loading, fetchUser } = useUser()
// 鼠标悬停在 fetchUser 上,就能看到参数类型
fetchUser(123) // ✅ 正确
fetchUser('abc') // ❌ TypeScript 报错:类型错误

重构时的信心保证:改一处,TypeScript 帮你检查所有使用处

这是 TypeScript 最强大的特性之一。当我们需要修改一个组合式函数的返回类型时,TypeScript 会帮我们找到所有受影响的地方:

// 假设有一个 usePagination 组合式函数
function usePagination(initialPage = 1) {
  const page = ref(initialPage)
  const pageSize = ref(10)
  const total = ref(0)
  
  return { page, pageSize, total }
}

// 现在需要重构,将返回值改为响应式对象
function usePagination(initialPage = 1) {
  const state = reactive({
    page: initialPage,
    pageSize: 10,
    total: 0
  })
  
  return { state } // 返回方式改变了
}

// TypeScript 会立即标记所有使用了 page.value 的地方
const { state } = usePagination()
// ❌ 错误:page 不存在于返回值中
// 必须改为 state.page

这种“编译时检查”的特性,让我们在进行大规模重构时,不用担心遗漏任何使用之处。

运行时错误左移:在编译阶段发现潜在 bug

JavaScript 的错误往往在运行时才暴露,而 TypeScript 能在代码运行前就发现问题:

// ❌ JavaScript:运行时才报错
function processUser(user) {
  return user.name.toUpperCase() // 如果 user 是 null,这里会崩溃
}

// ✅ TypeScript:编译时就能发现问题
function processUser(user: User | null) {
  // ❌ 编译错误:对象可能为 null
  return user.name.toUpperCase() 
  
  // ✅ 正确处理
  return user?.name.toUpperCase() ?? ''
}

常见的 TypeScript 错误

错误1:拼写错误

const user = useUser()
user.nmae // ❌ 编译错误:属性 'nmae' 不存在于类型 'User'

错误2:类型不匹配

function updateProduct(id: number) { /* ... */ }
updateProduct('abc') // ❌ 编译错误:不能将 string 赋值给 number

错误3:忘记处理 undefined

const products = ref<Product[]>([])
const firstProduct = products.value[0]
console.log(firstProduct.price) // ❌ 编译错误:对象可能为 undefined

错误4:错误的参数个数

function fetchData(id: number, options?: FetchOptions) { /* ... */ }
fetchData(123, { cache: true }, 'extra') // ❌ 编译错误:参数过多

组合式函数的基础类型定义

为 ref 和 reactive 定义类型

Vue3 的响应式 API 与 TypeScript 配合得天衣无缝:

import { ref, reactive, computed } from 'vue'

// ref 的类型推导
const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const isActive = ref(false) // Ref<boolean>

// 显式定义 ref 类型
const user = ref<User | null>(null) // Ref<User | null>

// 数组类型
const items = ref<Item[]>([]) // Ref<Item[]>

// reactive 的类型推导
const state = reactive({
  count: 0,
  name: '张三'
}) // { count: number; name: string }

// 显式定义 reactive 类型
interface FormState {
  username: string
  password: string
  remember: boolean
}

const form = reactive<FormState>({
  username: '',
  password: '',
  remember: false
})

// computed 的类型
const double = computed(() => count.value * 2) // ComputedRef<number>

注:基础数据类型,TypeScript 可以自行推导,因此不建议显示定义基础数据类型: const count = ref<number>(0) // ❌ 不建议这样写 const count = ref(0) // ✅

为函数的参数和返回值定义接口

这是组合式函数类型定义的核心,一个好的类型定义应该清晰地表达:

  • 函数接受什么参数
  • 函数返回什么
  • 各种边界情况
interface UseCounterOptions {
  initialValue?: number
  min?: number
  max?: number
  step?: number
}

interface UseCounterReturn {
  count: Ref<number>
  increment: (step?: number) => void
  decrement: (step?: number) => void
  reset: () => void
  set: (value: number) => void
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { 
    initialValue = 0, 
    min = -Infinity, 
    max = Infinity, 
    step = 1 
  } = options
  
  const count = ref(clamp(initialValue, min, max))
  
  function increment(stepSize = step) {
    const newValue = count.value + stepSize
    if (newValue <= max) {
      count.value = newValue
    }
  }
  
  // 其他方法...
  
  return {
    count,
    increment,
    decrement,
    reset: () => { count.value = clamp(initialValue, min, max) },
    set: (value) => { count.value = clamp(value, min, max) }
  }
}

实战:为 useMousePosition 定义完善的类型

我们来看一个完整的实战案例,展示如何为真实的组合式函数添加类型:

// composables/useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'

// 1. 定义位置接口
export interface MousePosition {
  x: number
  y: number
  timestamp: number
}

// 2. 定义配置选项
export interface UseMousePositionOptions {
  /**
   * 节流时间(毫秒),默认 0 表示不节流
   */
  throttle?: number
  
  /**
   * 监听的目标元素,默认 window
   */
  target?: HTMLElement | null | (() => HTMLElement | null)
  
  /**
   * 是否立即开始监听,默认 true
   */
  immediate?: boolean
  
  /**
   * 坐标类型,默认 'client'
   */
  type?: 'client' | 'page' | 'screen'
}

// 3. 定义返回值类型
export interface UseMousePositionReturn {
  /**
   * 当前鼠标位置
   */
  position: Ref<MousePosition>
  
  /**
   * 是否正在监听
   */
  isListening: Ref<boolean>
  
  /**
   * 开始监听
   */
  start: () => void
  
  /**
   * 停止监听
   */
  stop: () => void
  
  /**
   * 重置位置为 (0, 0)
   */
  reset: () => void
}

// 4. 工具函数:获取坐标
function getMousePosition(event: MouseEvent, type: 'client' | 'page' | 'screen'): MousePosition {
  const timestamp = Date.now()
  
  switch (type) {
    case 'client':
      return { x: event.clientX, y: event.clientY, timestamp }
    case 'page':
      return { x: event.pageX, y: event.pageY, timestamp }
    case 'screen':
      return { x: event.screenX, y: event.screenY, timestamp }
  }
}

// 5. 主函数实现
export function useMousePosition(options: UseMousePositionOptions = {}): UseMousePositionReturn {
  const {
    throttle = 0,
    target = window,
    immediate = true,
    type = 'client'
  } = options

  // 创建响应式状态
  const position = ref<MousePosition>({ x: 0, y: 0, timestamp: 0 })
  const isListening = ref(false)
  
  // 获取目标元素
  const getTarget = (): EventTarget | null => {
    if (typeof target === 'function') {
      return target()
    }
    return target
  }
  
  // 节流控制
  let lastRun = 0
  let rafId: number | null = null
  
  // 鼠标移动处理函数
  const handleMouseMove = (event: MouseEvent) => {
    const now = Date.now()
    
    // 节流处理
    if (throttle > 0 && now - lastRun < throttle) {
      return
    }
    
    // 使用 requestAnimationFrame 优化性能
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
    }
    
    rafId = requestAnimationFrame(() => {
      position.value = getMousePosition(event, type)
      lastRun = now
      rafId = null
    })
  }
  
  // 开始监听
  const start = () => {
    if (isListening.value) return
    
    const targetEl = getTarget()
    if (targetEl) {
      targetEl.addEventListener('mousemove', handleMouseMove)
      isListening.value = true
    }
  }
  
  // 停止监听
  const stop = () => {
    const targetEl = getTarget()
    if (targetEl) {
      targetEl.removeEventListener('mousemove', handleMouseMove)
    }
    
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
      rafId = null
    }
    
    isListening.value = false
  }
  
  // 重置位置
  const reset = () => {
    position.value = { x: 0, y: 0, timestamp: 0 }
  }
  
  // 自动开始监听
  if (immediate) {
    onMounted(() => {
      start()
    })
  }
  
  // 清理
  onUnmounted(() => {
    stop()
  })
  
  return {
    position,
    isListening,
    start,
    stop,
    reset
  }
}

泛型约束:让复用更灵活

场景:实现一个通用的 useLocalStorage

没有泛型之前,我们可能会写出这样的代码:

// ❌ 不够通用,只能处理 string
function useLocalStorage(key: string, initialValue: string) {
  const value = ref(initialValue)
  
  onMounted(() => {
    const stored = localStorage.getItem(key)
    if (stored !== null) {
      value.value = stored
    }
  })
  
  watch(value, (newValue) => {
    localStorage.setItem(key, newValue)
  })
  
  return value
}

// 想存储数字?不行
const count = useLocalStorage('count', 0) // 类型错误!

解决方案:使用泛型约束

// ✅ 使用泛型,支持任意可序列化的类型
function useLocalStorage<T>(key: string, initialValue: T) {
  // 指定 ref 的类型为 T
  const value = ref<T>(initialValue) as Ref<T>
  
  onMounted(() => {
    try {
      const stored = localStorage.getItem(key)
      if (stored !== null) {
        // 反序列化,并确保类型正确
        value.value = JSON.parse(stored) as T
      }
    } catch (e) {
      console.error(`Failed to parse localStorage key "${key}":`, e)
    }
  })
  
  watch(value, (newValue) => {
    try {
      localStorage.setItem(key, JSON.stringify(newValue))
    } catch (e) {
      console.error(`Failed to stringify value for key "${key}":`, e)
    }
  }, { deep: true })
  
  return value
}

// 现在可以存储任意类型
const count = useLocalStorage('count', 0) // Ref<number>
const user = useLocalStorage('user', { name: '张三' }) // Ref<{ name: string }>
const items = useLocalStorage('items', [1, 2, 3]) // Ref<number[]>

进阶:添加类型约束和默认值处理

// 定义可序列化类型的约束
type Serializable = 
  | string 
  | number 
  | boolean 
  | null 
  | undefined
  | Serializable[]
  | { [key: string]: Serializable }

// 扩展选项
interface UseStorageOptions<T> {
  /**
   * 存储类型,默认 localStorage
   */
  storage?: 'local' | 'session'
  
  /**
   * 序列化函数
   */
  serializer?: {
    read: (raw: string) => T
    write: (value: T) => string
  }
  
  /**
   * 监听深度
   */
  deep?: boolean
  
  /**
   * 错误处理
   */
  onError?: (error: Error) => void
}

// 增强版的 useStorage
export function useStorage<T extends Serializable>(
  key: string,
  initialValue: T,
  options: UseStorageOptions<T> = {}
): Ref<T> {
  const {
    storage = 'local',
    deep = true,
    onError = (e) => console.error(`Storage error: ${e}`)
  } = options
  
  // 默认使用 JSON 序列化
  const serializer = options.serializer ?? {
    read: (raw: string) => JSON.parse(raw) as T,
    write: (value: T) => JSON.stringify(value)
  }
  
  const storageObj = storage === 'local' ? localStorage : sessionStorage
  const value = ref<T>(initialValue) as Ref<T>
  
  // 读取存储的值
  try {
    const raw = storageObj.getItem(key)
    if (raw !== null) {
      value.value = serializer.read(raw)
    } else {
      // 初始化存储
      storageObj.setItem(key, serializer.write(initialValue))
    }
  } catch (e) {
    onError(e as Error)
  }
  
  // 监听变化
  watch(value, (newValue) => {
    try {
      storageObj.setItem(key, serializer.write(newValue))
    } catch (e) {
      onError(e as Error)
    }
  }, { deep })
  
  return value
}

// 使用示例
const settings = useStorage('settings', {
  theme: 'dark',
  fontSize: 14,
  notifications: true
})

// 类型安全
settings.value.theme = 'light' // ✅
settings.value.theme = 123 // ❌ 类型错误

// 自定义序列化
const dates = useStorage('dates', [new Date()], {
  serializer: {
    read: (raw) => JSON.parse(raw).map((d: string) => new Date(d)),
    write: (value) => JSON.stringify(value.map(d => d.toISOString()))
  }
})

实战:useAsyncData 的泛型设计

// 定义异步操作的状态
interface AsyncState<T> {
  data: T | null
  loading: boolean
  error: Error | null
}

// 定义返回值类型
interface UseAsyncDataReturn<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: (...args: any[]) => Promise<T>
  refresh: () => Promise<T>
}

// 带泛型的异步数据获取组合式函数
export function useAsyncData<T>(
  fetcher: (...args: any[]) => Promise<T>,
  options: {
    immediate?: boolean
    initialData?: T | null
    onSuccess?: (data: T) => void
    onError?: (error: Error) => void
  } = {}
): UseAsyncDataReturn<T> {
  const data = ref<T | null>(options.initialData ?? null)
  const loading = ref(false)
  const error = ref<Error | null>(null)
  
  const execute = async (...args: any[]): Promise<T> => {
    loading.value = true
    error.value = null
    
    try {
      const result = await fetcher(...args)
      data.value = result
      options.onSuccess?.(result)
      return result
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e))
      error.value = err
      options.onError?.(err)
      throw err
    } finally {
      loading.value = false
    }
  }
  
  const refresh = () => execute()
  
  if (options.immediate !== false) {
    execute()
  }
  
  return {
    data,
    loading,
    error,
    execute,
    refresh
  }
}

// 使用示例
interface User {
  id: number
  name: string
  email: string
}

const { data, loading, error } = useAsyncData<User>(
  () => fetch('/api/user').then(r => r.json())
)

// TypeScript 知道 data 是 User | null
if (data.value) {
  console.log(data.value.name) // ✅ 类型安全
}

类型推导的艺术:何时自动推导,何时显式注解?

自动推导的场景

TypeScript 的类型推导非常智能,很多情况下不需要显式注解:

简单值可以自动推导

const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const isActive = ref(false) // Ref<boolean>

对象字面量可以推导

const user = ref({
  name: '张三',
  age: 25
}) // Ref<{ name: string; age: number }>

函数返回值可以推导

function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment } // { count: Ref<number>; increment: () => void }
}

computed 可以推导

const double = computed(() => count.value * 2) // ComputedRef<number>

需要显式注解的场景

有些场景必须显式注解,否则类型会不正确:

空数组无法推导元素类型

const items = ref<Item[]>([]) 

null 初始值无法推导

const user = ref<User | null>(null)

复杂嵌套对象,类型太长

interface AppState {
  user: { name: string; age: number }
  settings: { theme: string }
}
const state = reactive<AppState>({ ... })

导出给外部使用的 API

export function useFeature(): FeatureReturn {
  // 明确告诉使用者返回什么
  return { ... }
}

类型推导原则

原则1:内部实现多用推导,外部接口显式注解

function useInternal() {
  // 内部实现,让 TypeScript 自己推导
  const count = ref(0)
  const double = computed(() => count.value * 2)
  return { count, double }
}

export function usePublic(): PublicAPI {
  // 导出的 API 显式注解
  const { count, double } = useInternal()
  return { count, double }
}

原则2:复杂类型提取为接口

interface User {
  name: string
  age: number
}

interface UpdateUserData {
  name?: string
  age?: number
}

function useUser() {
  const user = ref<User>({ name: '张三', age: 25 })
  const updateUser = (data: UpdateUserData) => {
    Object.assign(user.value, data)
  }
  return { user, updateUser }
}

原则3:使用 satisfies 确保类型正确(TS 4.9+)

const routes = {
  home: { path: '/', component: Home },
  about: { path: '/about', component: About }
} satisfies Record<string, Route>

原则4:使用 const 断言锁定字面量类型

const user = {
  name: '张三',
  role: 'admin'
} as const

高级技巧:类型守卫与类型收窄

使用自定义类型守卫处理异步数据的不同状态

在处理异步数据时,我们经常需要根据状态执行不同的逻辑:

// 定义三种状态类型
interface IdleState {
  status: 'idle'
}

interface LoadingState {
  status: 'loading'
}

interface SuccessState<T> {
  status: 'success'
  data: T
}

interface ErrorState {
  status: 'error'
  error: Error
}

// 联合类型
type AsyncState<T> = 
  | IdleState 
  | LoadingState 
  | SuccessState<T> 
  | ErrorState

// 组合式函数
function useAsyncState<T>(fetcher: () => Promise<T>) {
  const state = ref<AsyncState<T>>({ status: 'idle' })
  
  const execute = async () => {
    state.value = { status: 'loading' }
    
    try {
      const data = await fetcher()
      state.value = { status: 'success', data }
    } catch (e) {
      state.value = { 
        status: 'error', 
        error: e instanceof Error ? e : new Error(String(e))
      }
    }
  }
  
  return {
    state: readonly(state),
    execute
  }
}

// 类型守卫
function isIdle<T>(state: AsyncState<T>): state is IdleState {
  return state.status === 'idle'
}

function isLoading<T>(state: AsyncState<T>): state is LoadingState {
  return state.status === 'loading'
}

function isSuccess<T>(state: AsyncState<T>): state is SuccessState<T> {
  return state.status === 'success'
}

function isError<T>(state: AsyncState<T>): state is ErrorState {
  return state.status === 'error'
}

在组件中使用类型守卫

<template>
  <div>
    <div v-if="isLoading(state)">加载中...</div>
    
    <div v-else-if="isError(state)" class="error">
      错误: {{ state.error.message }}
      <button @click="retry">重试</button>
    </div>
    
    <div v-else-if="isSuccess(state)" class="data">
      <!-- 这里 state.data 的类型是 T -->
      <pre>{{ state.data }}</pre>
    </div>
    
    <div v-else-if="isIdle(state)">
      <button @click="execute">开始加载</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useAsyncState, isSuccess, isError, isLoading, isIdle } from './composables/useAsyncState'

interface UserData {
  id: number
  name: string
}

const { state, execute } = useAsyncState<UserData>(async () => {
  const res = await fetch('/api/user')
  return res.json()
})

// 类型守卫让 TypeScript 能够收窄类型
watch(state, (newState) => {
  if (isSuccess(newState)) {
    // 这里 TypeScript 知道 newState 是 SuccessState<UserData>
    console.log('用户数据:', newState.data.name)
  } else if (isError(newState)) {
    // 这里知道是 ErrorState
    console.error('错误:', newState.error.message)
  }
})

function retry() {
  if (isError(state.value)) {
    // 只有在错误状态下才能看到错误详情
    console.log('重试,之前的错误:', state.value.error)
    execute()
  }
}
</script>

使用判别式联合类型实现状态机

// 更复杂的异步操作状态机
interface PendingState {
  status: 'pending'
}

interface LoadingState {
  status: 'loading'
  progress?: number
}

interface SuccessState<T> {
  status: 'success'
  data: T
  timestamp: number
}

interface ErrorState {
  status: 'error'
  error: Error
  retryCount: number
}

interface CancelledState {
  status: 'cancelled'
  reason?: string
}

type RequestState<T> = 
  | PendingState
  | LoadingState
  | SuccessState<T>
  | ErrorState
  | CancelledState

// 类型守卫函数可以自动生成
const guards = {
  isPending: <T>(s: RequestState<T>): s is PendingState => s.status === 'pending',
  isLoading: <T>(s: RequestState<T>): s is LoadingState => s.status === 'loading',
  isSuccess: <T>(s: RequestState<T>): s is SuccessState<T> => s.status === 'success',
  isError: <T>(s: RequestState<T>): s is ErrorState => s.status === 'error',
  isCancelled: <T>(s: RequestState<T>): s is CancelledState => s.status === 'cancelled'
}

// 使用示例
function handleRequestState<T>(state: RequestState<T>) {
  if (guards.isSuccess(state)) {
    // 这里 state.data 可用
    console.log(`数据获取成功,时间戳: ${state.timestamp}`)
  } else if (guards.isError(state)) {
    // 这里可以访问 state.retryCount
    console.log(`错误,已重试 ${state.retryCount} 次`)
  } else if (guards.isCancelled(state)) {
    // 这里可以访问 state.reason
    console.log(`已取消: ${state.reason}`)
  }
}

TypeScript 配置的最佳实践

项目配置建议

// tsconfig.json
{
  "compilerOptions": {
    // 严格模式必须开启
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    
    // Vue 3 推荐配置
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    
    // 路径别名
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@composables/*": ["src/composables/*"]
    }
  },
  
  // 包含的文件
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.vue"
  ],
  
  // 排除的文件
  "exclude": [
    "node_modules",
    "dist"
  ]
}

VSCode 配置建议

// .vscode/settings.json
{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.preferences.autoImportFileExcludePatterns": [
    "vue-router",
    "pinia"
  ],
  "typescript.suggest.autoImports": true,
  "typescript.suggest.completeFunctionCalls": true,
  
  // 保存时自动修复
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  
  // 启用 Vue 语言服务
  "volar.autoCompleteRefs": true,
  "volar.completion.preferredTagNameCase": "kebab",
  "volar.completion.preferredAttrNameCase": "kebab"
}

组合式函数 TypeScript 最佳实践清单

  • 为所有导出函数定义接口:导出的 API 必须有清晰的类型定义
  • 使用泛型增加复用性:对于需要处理多种类型的函数,使用泛型约束
  • 提供完整的 JSDoc 注释:为参数和返回值添加说明
  • 使用 readonly 保护内部状态:对于不应该被修改的 ref,使用 readonly 包装
  • 类型守卫处理联合类型:使用自定义类型守卫收窄类型范围
  • 避免 any 类型:使用 unknown 替代 any,配合类型守卫
  • 提取共用类型:将重复使用的类型提取为接口
  • 测试类型定义:使用 tsddtslint 测试类型定义的正确性

结语

当我们的组合式函数拥有了完善的 TypeScript 支持,它们就不再是普通的函数,而是拥有“钢筋铁骨”的可靠组件。这不仅提升了开发体验,更重要的是让整个应用的质量有了根本性的保障。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

基于 ZXing 的 Vue 在线二维码扫描器实现

这篇只讲功能层 JavaScript:同一个扫描器同时支持“图片上传识别”和“摄像头实时识别”,识别到的内容进入结果列表,并提供复制能力。

在线工具网址:see-tool.com/qrcode-scan…
工具截图:
工具截图.png

识别依赖 ZXing(@zxing/library)的 BrowserMultiFormatReader,主要用到两种解码方式:

  • 图片:decodeFromImageElement(img)
  • 摄像头:decodeFromVideoDevice(deviceId, videoEl, callback)

下面按功能模块拆开讲核心实现。

1)解码器初始化:SSR 下只在客户端创建

Nuxt 有 SSR,setup 会先在服务器执行一次生成 HTML。服务器环境没有 window / navigator,也没有 navigator.mediaDevices 这类摄像头 API;而 BrowserMultiFormatReader 属于浏览器侧解码器,如果在服务端阶段创建,就可能触发 window is not defined / navigator is undefined 这类错误。

处理方式:把初始化放进 onMounted(只在浏览器端执行),并用 process.client 再兜底一次。

import { onMounted, onUnmounted } from "vue";
import { BrowserMultiFormatReader } from "@zxing/library";

let codeReader = null;

onMounted(() => {
  if (process.client) {
    codeReader = new BrowserMultiFormatReader();
  }
});

onUnmounted(() => {
  // 离开页面时释放摄像头相关资源
  if (codeReader) codeReader.reset();
});

reset() 用于停止当前扫描流程,并释放视频流相关资源(切换模式或离开页面时会用到)。

2)上传识别:File -> DataURL -> Image -> decode

上传和拖拽统一走 handleFiles(files):遍历文件,先过滤非图片,再逐个触发识别。

const handleFiles = (files) => {
  if (!files || files.length === 0) return;

  Array.from(files).forEach((file) => {
    if (!file.type.startsWith("image/")) {
      addResult(file.name, "仅支持图片文件", "error");
      return;
    }
    scanImageFile(file);
  });
};

scanImageFile 的流程是把文件读成 DataURL,加载成 Image,再交给 ZXing 解码:

const scanImageFile = (file) => {
  if (!codeReader) return;

  const reader = new FileReader();
  reader.onload = (e) => {
    const img = new Image();
    img.onload = () => {
      codeReader
        .decodeFromImageElement(img)
        .then((result) => addResult(file.name, result.text, result.format))
        .catch(() => addResult(file.name, "未识别到二维码", "error"));
    };
    img.src = e.target.result;
  };
  reader.readAsDataURL(file);
};

这里使用 Image() 的原因:先让浏览器把 DataURL 解码成像素数据,再由 ZXing 从像素中定位并识别二维码。

3)摄像头识别:decodeFromVideoDevice 持续回调

摄像头模式不自行做 getUserMedia + canvas 截帧,而是让 ZXing 直接接管:它会持续从视频帧中尝试识别。

const isCameraActive = ref(false);
const videoElement = ref(null);

const startCamera = () => {
  if (!codeReader) return;
  isCameraActive.value = true;

  codeReader
    .decodeFromVideoDevice(null, videoElement.value, (result, err) => {
      if (result) {
        addResult("摄像头扫描", result.text, result.format);
      }
      // 识别不到时 err 往往只是“没找到”,不需要每帧都弹提示
    })
    .catch(() => {
      isCameraActive.value = false;
      addResult("摄像头", "摄像头启动失败或无权限", "error");
    });
};

const stopCamera = () => {
  if (codeReader) codeReader.reset();
  isCameraActive.value = false;
};

null 表示用默认摄像头;如果你自己做了设备选择,把 deviceId 传进去就行。

4)结果结构:只存“来源 + 内容 + 格式 + 时间”

结果列表使用数组保存,元素结构如下:

// { source, content, format, isError, timestamp }
const results = ref([]);

字段都很直白:来源是“文件名/摄像头”,content 是解出来的文本,format 用来展示二维码类型,timestamp 用来做去重。

5)为什么要去重:摄像头会反复识别同一张码

摄像头模式下,二维码只要还在画面里,就可能被重复识别(可以理解为间隔很短就会再次识别)。如果每次识别成功都写入结果列表,会出现大量重复记录。

这里用“时间窗口去重”:2 秒内内容相同则跳过写入。

const addResult = (source, content, format) => {
  const isError = format === "error";
  const now = Date.now();

  const recentSame = results.value.find(
    (r) => r.content === content && now - r.timestamp < 2000,
  );

  if (recentSame && !isError) return;

  let formatName = format;
  if (!isError && typeof format === "number")
    formatName = getFormatName(format);
  else if (format && format.formatName) formatName = format.formatName;

  results.value.unshift({
    source,
    content,
    format: isError ? "" : String(formatName),
    isError,
    timestamp: now,
  });
};

效果是:镜头对准二维码时,结果只会稳定新增一次,不会被重复记录刷屏。

6)格式显示:把枚举值映射成常见名字

ZXing 的 format 有时候是枚举数字。为了让展示更直观,这里做一个映射表,把常见值转成字符串。

const getFormatName = (format) => {
  const formats = {
    11: "QR_CODE",
    5: "DATA_MATRIX",
    0: "AZTEC",
    10: "PDF_417",
  };
  return formats[format] || format;
};

没覆盖到的就原样返回,至少信息不会丢。

Stepper 小数输入精度丢失 Bug 修复

📋 问题背景

Issue: #4319

现象t-stepper 组件输入小数时,如果小数点后输入 0(如 1.0),会被直接格式化成没有小数点(变成 1)。

涉及文件

  • packages/components/stepper/stepper.ts(小程序原生版)
  • packages/uniapp-components/stepper/stepper.vue(uniapp 版)

涉及方法handleInputhandleBlurformataddsetValue


🔍 根因链路(5 层问题,逐层暴露)

核心教训:数字 ↔ 字符串转换是精度丢失的根源,需要在整个数据流中追踪值的类型变化。修复一个点可能引入新问题,需要全场景验证。

数据流总览

用户输入 → handleInput → filterIllegalChar → format → setValue → updateCurrentValue → 显示
按加减号 → add → setValue → format → updateCurrentValue → 显示
失焦     → handleBlur → filterIllegalChar → format → setValue → updateCurrentValue → 显示

问题 ❶:handleInput 正则过度触发

场景:用户输入 "1.0",被立即格式化成 "1"

根因

// 原代码
if (this.integer || /\.\d+/.test(formatted)) {
  this.setValue(formatted);
}

/\.\d+/ 匹配 "1.0" 成功 → 触发 setValue("1.0")formatNumber("1.0") = 1getLen(1) = 0toFixed(0) = "1" → 小数点消失

修复:正则改为 /\.\d*[1-9]/,要求小数部分至少包含一个非零数字才触发 setValue

if (this.integer || /\.\d*[1-9]/.test(formatted)) {
  this.setValue(formatted);
}
输入值 旧正则 /\.\d+/ 新正则 /\.\d*[1-9]/ 行为
1. ❌ 不匹配 ❌ 不匹配 ✅ 保留,等待继续输入
1.0 ✅ 匹配 → 被格式化为 1 ❌ 不匹配 ✅ 保留,等待继续输入
1.00 ✅ 匹配 → 被格式化为 1 ❌ 不匹配 ✅ 保留,等待继续输入
1.05 ✅ 匹配 ✅ 匹配 ✅ 正常格式化
1.5 ✅ 匹配 ✅ 匹配 ✅ 正常格式化
5 ❌ 不匹配 ❌ 不匹配 ✅ 不触发 setValue,blur 时统一处理

说明integer=false 且输入整数(如 "5")时,正则不匹配,setValue 不会在 input 阶段调用。这是可接受的行为,因为 handleBlur 一定会调用 setValue,最终值和 change 事件不会丢失。无需额外加 !formatted.includes('.') 条件。


问题 ❷:Vue 值回填失效

场景integer = true 时,用户粘贴 "3.5" → 过滤后应显示 "3" 但 input 仍显示 "3.5"

根因:Vue 响应式系统中,currentValue3 设回 "3" 时,Vue 认为值未变化,跳过 DOM 更新

修复:先清空再通过 nextTick 回填,强制触发视图更新

const displayValue = this.integer ? newValue : formatted;
if (String(this.currentValue) === String(displayValue)) {
  this.updateCurrentValue('');
  nextTick().then(() => {
    this.updateCurrentValue(displayValue);
  });
} else {
  this.updateCurrentValue(displayValue);
}

注意:小程序原生版不需要此修复,因为 setData 即使值相同也会强制更新视图。


问题 ❸:format 中 getLen 的隐式类型转换

场景:blur 时 "1.0" 变成 "1"

根因format(value)this.getLen(value),当 value 在 JS 运算中被隐式转为数字时,Number("1.0") = 1(1).toString() = "1"getLen = 0

// 修复前
const len = Math.max(this.getLen(step), this.getLen(value));

// 修复后 —— 用 String(value) 确保字符串形式
const len = Math.max(this.getLen(step), this.getLen(String(value)));

同时 handleBlur 中需先 filterIllegalChar 再传给 format

// 修复前
handleBlur(e) {
  const { value: rawValue } = e.detail;
  const value = this.format(rawValue);
  ...
}

// 修复后
handleBlur(e) {
  const { value: rawValue } = e.detail;
  const formatted = this.filterIllegalChar(rawValue);
  const value = this.format(formatted);
  ...
}

问题 ❹:add 方法返回数字丢失精度

场景currentValue = "3.0",按 + 号(step=1),结果显示 4 而非 4.0

根因add("3.0", 1) 返回数字 4String(4) = "4" 无小数位信息

// 修复前
add(a, b) {
  const maxLen = Math.max(this.getLen(a), this.getLen(b));
  const base = 10 ** maxLen;
  return Math.round(a * base + b * base) / base; // 返回数字,丢失精度
}

// 修复后 —— 保留运算涉及的最大小数位数
add(a, b) {
  const maxLen = Math.max(this.getLen(a), this.getLen(b));
  const base = 10 ** maxLen;
  const result = Math.round(a * base + b * base) / base;
  return maxLen > 0 ? result.toFixed(maxLen) : result; // 返回字符串保留精度
}

问题 ❺:setValue 中 Number() 转换丢失末尾0

场景format 返回 "4.0",但显示 4

根因setValueNumber("4.0") = 4,然后用数字 4 更新显示值

// 修复前
setValue(value) {
  const newValue = Number(this.format(value));
  this.updateCurrentValue(newValue); // Number("4.0") = 4 → 显示 4
}

// 修复后 —— 用字符串更新显示,数字仅用于 change 事件
setValue(value) {
  const formattedStr = this.format(value);      // "4.0"
  const newValue = Number(formattedStr);         // 4(用于 change 事件)
  this.updateCurrentValue(formattedStr);         // "4.0"(用于显示)
  if (this.preValue === newValue) return;
  this.preValue = newValue;
  this._trigger('change', { value: newValue });  // 对外传数字
}

📊 完整修复效果

步骤 修复前 修复后
add("3.0", 1) 返回 4(数字) 返回 "4.0"(字符串)
format("4.0")getLen getLen(4) = 0 getLen(String("4.0")) = 1
format 返回 "4" "4.0"
setValue → 显示更新 Number("4.0") = 4 直接用 "4.0"
输入框显示 4 4.0

💡 通用经验总结

  1. 数字↔字符串转换是精度丢失的核心原因Number("1.0")=1(1).toString()="1"String(4)="4" 这些隐式转换会在链路的每一环吃掉末尾的 0

  2. 修一个点可能引入新 bug:正则从 /\.\d+//\.\d*[1-9]/ 修了末尾0的问题,却让整数输入不触发 setValue,必须全场景验证

  3. 需要全链路追踪:从 handleInputfilterIllegalCharformatsetValueaddupdateCurrentValue,每一步都可能是精度丢失的入口

  4. 平台差异要注意

    • 小程序原生 setData 强制更新视图 vs Vue 响应式值相同时跳过更新
    • 小程序原生版和 uniapp 版的 API 差异(如 input type 绑定方式)
  5. 显示值与数据值分离:input 框的显示值应该用字符串(保留格式),对外 emit 的 change 事件值应该用数字(方便业务使用)

深入理解 JavaScript 中的 this 绑定机制:从原理到实战

为什么要读这篇文章

在日常开发中,你是否遇到过这些困惑:

  • 为什么同一个函数,在不同地方调用,this 指向完全不同?
  • 箭头函数的 this 为什么"不听话"?
  • 面试官问"this 的绑定规则优先级"时,如何系统回答?

this 是 JavaScript 中最容易被误解的概念之一。它不像其他语言那样简单地指向"当前对象",而是具有动态绑定的特性。掌握 this 的核心规则,不仅能让你写出更优雅的代码,还能在排查 bug 时快速定位问题。

本文收益

  • 掌握 this 的 4 种绑定规则及其优先级
  • 理解常见场景下的 this 指向(事件监听、定时器、数组方法等)
  • 学会手写 call/apply/bind 实现
  • 建立完整的 this 知识体系,应对各种边界情况

一、this 的本质:动态绑定的执行上下文

1.1 什么是 this

this 是函数执行时指向"当前执行上下文"的对象引用

这句话包含三个关键信息:

  1. 执行时确定:this 的值在函数被调用时才确定,而非定义时
  2. 执行上下文:每次函数调用都会创建一个函数执行上下文(FEC),this 是其中的一个属性
  3. 动态绑定:同一个函数在不同调用方式下,this 可能指向不同对象

1.2 为什么需要 this

在面向对象编程中,Java、C++ 等语言的 this 通常只出现在类的实例方法中,指向当前实例。但 JavaScript 的 this 更加灵活,这种灵活性既是优势也是挑战。

使用 this 的核心价值

// 不使用 this:代码耦合度高
var obj = {
  name: "小吴",
  eating: function() {
    console.log(obj.name + "在吃东西");
  },
  running: function() {
    console.log(obj.name + "在跑步");
  }
}

// 使用 this:代码可复用性强
var obj = {
  name: "小吴",
  eating: function() {
    console.log(this.name + "在吃东西");
  },
  running: function() {
    console.log(this.name + "在跑步");
  }
}

obj.eating()  // 小吴在吃东西
obj.running() // 小吴在跑步

对比分析

不使用 this 的问题:

  • 方法内部硬编码了对象名称(obj.name)
  • 无法复用方法到其他对象
  • 对象重命名时需要修改所有方法内部代码

使用 this 的优势:

  • 方法与具体对象解耦,提高可维护性
  • 同一套方法可以被多个对象共享
  • 符合面向对象的封装原则

1.3 全局作用域中的 this

在深入绑定规则前,先了解全局 this 的特殊性:

  • 浏览器环境:this 指向 window 对象
  • Node.js 环境:this 指向空对象 {}
// 浏览器环境
console.log(this === window); // true

// Node.js 环境
console.log(this); // {}
console.log(this === module.exports); // true
console.log(this === global); // false

Node.js 中的特殊机制

Node.js 将每个文件视为一个模块,执行时会包装成如下形式:

(function(exports, require, module, __filename, __dirname) {
  // 你的模块代码
  // 顶层 this 被绑定到 module.exports
});

这就是为什么 Node.js 模块顶层的 this 指向 module.exports(初始为空对象),而非 global 对象。

函数内部的 this

function foo() {
  console.log(this);
}

foo.apply("小吴"); // [String: '小吴']

文件被 Node 执行时,会调用 foo.apply({}),将空对象传入作为 this。

1.4 同一函数,不同 this

这是理解 this 的关键案例:

function foo() {
  console.log(this);
}

// 1. 直接调用
foo() // window(浏览器)或 global(Node.js 非严格模式)

// 2. 对象方法调用
var obj = {
  name: "小吴",
  foo: foo
}
obj.foo() // { name: '小吴', foo: [Function: foo] }

// 3. 显式绑定
foo.apply("XiaoWu") // [String: 'XiaoWu']

** 图8-3 函数的三种调用方式效果**

核心结论

  1. this 的绑定与函数定义位置无关
  2. this 的绑定与函数调用方式和调用位置有关
  3. this 是在运行时动态绑定的

执行上下文中的 this

** 图8-4 函数调用内存图**

在函数执行上下文(FEC)中,除了作用域链、变量对象(AO)等,还包含 this 绑定。

二、this 的四种绑定规则

掌握 this 的核心在于理解这四种绑定规则。只有显式绑定可以人为改变 this 指向,其他三种规则的 this 指向是固定的。

2.1 规则一:默认绑定

适用场景:独立函数调用(函数没有被绑定到任何对象上)

绑定结果

  • 非严格模式:指向全局对象(浏览器为 window,Node.js 为 global)
  • 严格模式:指向 undefined

案例 1:最基础的独立调用

function foo() {
  console.log(this);
}

foo() // window(浏览器)

案例 2:函数调用链中的独立调用

function foo1() {
  console.log("foo1", this);
}

function foo2() {
  console.log("foo2", this);
  foo1()
}

function foo3() {
  console.log("foo3", this);
  foo2()
}

foo3()
// 输出:
// foo3 window
// foo2 window
// foo1 window

** 图8-5 案例2代码结果**

虽然函数之间有调用关系,但每个函数都是独立调用的,因此 this 都指向 window。

案例 3:对象方法赋值后的独立调用

var obj = {
  name: "小吴",
  foo: function() {
    console.log(this);
  }
}

var fn = obj.foo
fn() // window

关键理解:this 指向与函数定义位置无关,只与调用方式有关。虽然 foo 定义在 obj 中,但 fn() 是独立调用,因此 this 指向 window。

案例 4:函数引用的独立调用

function foo() {
  console.log(this);
}

var obj = {
  name: "小吴",
  foo: foo
}

var bar = obj.foo
bar() // window

与案例 3 本质相同,bar 获取的是函数引用,调用时是独立调用。

案例 5:闭包中的独立调用

function foo() {
  function bar() {
    console.log(this);
  }
  return bar
}

var fn = foo()
fn() // window

// 改变调用方式后
var obj = {
  name: "why",
  age: fn
}

obj.age() // { name: 'why', age: [Function: bar] }

闭包函数的 this 不是固定指向 window,而是取决于调用方式。这打破了"闭包必定指向 window"的误解。

小结

  • 默认绑定的判断标准:函数是否独立调用(没有通过对象调用,没有使用 call/apply/bind,没有使用 new)
  • 独立调用的 this 指向全局对象(非严格模式)或 undefined(严格模式)
  • 函数定义位置不影响 this,只有调用方式才影响

2.2 规则二:隐式绑定

适用场景:通过对象调用方法(obj.method())

绑定结果:this 指向调用该方法的对象

核心原则:哪个对象发起的方法调用,this 就指向谁。

案例 1:基础隐式绑定

function foo() {
  console.log(this);
}

var obj = {
  name: "why",
  foo: foo
}

obj.foo() // { name: 'why', foo: [Function: foo] }

** 图8-6 隐式绑定案例1效果图**

JavaScript 引擎会将 obj 对象绑定到 foo 函数的 this 中。

案例 2:方法中使用 this

var obj = {
  name: "小吴",
  eating: function() {
    console.log(this.name + "在吃东西");
  },
  running: function() {
    console.log(this.name + "在跑步");
  }
}

obj.eating()  // 小吴在吃东西
obj.running() // 小吴在跑步

// 解除绑定关系
var fn = obj.eating
fn() // undefined在吃东西(this.name 为 undefined)

** 图8-7 obj与eating绑定关系解除前后对比**

一旦解除对象与方法的绑定关系,this 指向就会改变。

案例 3:多层对象调用

var obj1 = {
  name: "obj1",
  foo: function() {
    console.log(this);
  }
}

var obj2 = {
  name: "obj2",
  bar: obj1.foo
}

obj2.bar() // { name: 'obj2', bar: [Function: foo] }

** 图8-8 案例3控制台打印结果**

虽然 bar 引用的是 obj1.foo,但调用时是通过 obj2 发起的,因此 this 指向 obj2。

小结

  • 隐式绑定的判断标准:函数是否通过对象调用(obj.method())
  • this 指向最后调用该方法的对象
  • 赋值操作会丢失隐式绑定,转为默认绑定

2.3 规则三:显式绑定

适用场景:使用 call、apply、bind 方法主动指定 this

绑定结果:this 指向传入的第一个参数对象

隐式绑定是"被动"的,需要对象内部有函数引用才能绑定。显式绑定则是"主动"的,可以直接指定 this 指向。

2.3.1 call 和 apply 的使用

call 语法func.call(thisArg, arg1, arg2, ...) apply 语法func.apply(thisArg, [argsArray])

核心区别:参数传递方式不同

  • call:参数逐个传递
  • apply:参数以数组形式传递
function sum(num1, num2) {
  console.log(num1 + num2, this)
}

sum.call("call", 20, 30)   // 50 [String: 'call']
sum.apply("apply", [20, 30]) // 50 [String: 'apply']

与直接调用的区别

function foo() {
  console.log("函数被调用了", this);
}

var obj = {
  name: "why"
}

foo()              // window
foo.apply("小吴")  // [String: '小吴']
foo.call(obj)      // { name: 'why' }

** 图8-9 直接调用与apply、call调用的不同**

2.3.2 bind 的使用

当需要多次使用相同的 this 绑定时,bind 比 call/apply 更方便。

bind 语法func.bind(thisArg[, arg1[, arg2[, ...]]])

特点

  • 返回一个新函数,不会立即执行
  • 新函数的 this 被永久绑定到指定对象
  • 可以预设部分参数(柯里化)
function foo() {
  console.log(this)
}

// 使用 call 需要重复传参
// foo.call("小吴")
// foo.call("小吴")
// foo.call("小吴")

// 使用 bind 只需绑定一次
var newFoo = foo.bind("小吴")
newFoo() // [String: '小吴']
newFoo() // [String: '小吴']

bind 的特殊性

function foo() {
  console.log(this)
}

var newFoo = foo.bind("小吴")
var bar = foo

console.log(bar === foo)    // true
console.log(newFoo === foo) // false

bind 返回的是一个新函数,与原函数不是同一个引用。这证明 bind 不会修改原函数,而是创建一个新的绑定函数。

2.3.3 三者对比

方法 执行时机 参数形式 返回值 使用场景
call 立即执行 逐个传递 函数执行结果 一次性调用,参数较少
apply 立即执行 数组传递 函数执行结果 一次性调用,参数较多或动态参数
bind 不执行 逐个传递 新函数 需要多次调用或延迟执行

小结

  • 显式绑定可以主动改变 this 指向
  • call/apply 立即执行,bind 返回新函数
  • 显式绑定的优先级高于隐式绑定和默认绑定

2.4 规则四:new 绑定

适用场景:使用 new 关键字调用函数(构造函数)

绑定结果:this 指向新创建的对象

new 的执行过程

  1. 创建一个全新的对象
  2. 将这个对象的原型指向构造函数的 prototype
  3. 将 this 绑定到这个新对象
  4. 执行构造函数代码
  5. 如果构造函数没有返回对象,则返回这个新对象
function Person(name, age) {
  this.name = name
  this.age = age
}

Person() // 普通调用,this 指向 window

var p1 = new Person("小吴", 20)
console.log(p1.name, p1.age) // 小吴 20

var p2 = new Person("why", 35)
console.log(p2.name, p2.age) // why 35

** 图8-10 正常调用与new调用区别**

使用 new 调用时,JavaScript 会创建一个新对象并将其绑定到函数的 this 上。

小结

  • new 绑定会创建新对象并绑定到 this
  • 构造函数只是使用 new 调用的普通函数
  • new 绑定的优先级高于隐式绑定

三、常见场景中的 this 分析

3.1 setTimeout 定时器

// 普通函数
setTimeout(function() {
  console.log("普通函数的this", this); // window(浏览器)或 global(Node.js)
}, 1000)

// 箭头函数
setTimeout(() => {
  console.log("箭头函数的this", this); // 取决于外层作用域
}, 2000)

** 图8-11 node环境下的结果**

原理:setTimeout 内部不会绑定特定的 this,回调函数是独立调用,因此遵循默认绑定规则。

3.2 DOM 事件监听

const boxDiv = document.querySelector(".box")

// 方式1:onclick(只能绑定一个)
boxDiv.onclick = function() {
  console.log(this); // boxDiv 元素对象
}

// 方式2:addEventListener(可以绑定多个)
boxDiv.addEventListener('click', function() {
  console.log(this); // boxDiv 元素对象
})

** 图8-12 监听的对象**

原理:浏览器内部会使用 fn.call(boxDiv) 的方式调用回调函数,将 DOM 元素绑定到 this。

3.3 数组高阶函数

var names = ["ABC", '小吴', 'why']

// 不传第二个参数
names.forEach(function(item) {
  console.log("item", this); // window(三次)
})

// 传入第二个参数绑定 this
names.forEach(function(item) {
  console.log("item", this); // [String: '小吴'](三次)
}, "小吴")

** 图8-13 forEach不加第二个参数**

** 图8-14 forEach加第二个参数**

常见数组方法的 this 绑定

names.forEach(function() {
  console.log("forEach", this);
}, "小吴")

names.map(function() {
  console.log("map", this);
}, "小吴")

names.filter(function() {
  console.log("filter", this);
}, "小吴")

names.find(function() {
  console.log("find", this);
}, "小吴")

** 图8-16 forEach map filter find高阶函数对比情况**

** 图8-15 编辑器提供的语法提示**

实战建议

  • 大多数数组方法的最后一个参数用于绑定 this
  • 使用 TypeScript 或现代编辑器可以看到参数提示
  • 不需要死记硬背,看 API 文档或编辑器提示即可

四、this 绑定规则的优先级

当多个规则同时适用时,需要了解优先级来判断最终的 this 指向。

4.1 优先级排序

从高到低:new 绑定 > 显式绑定 > 隐式绑定> 默认绑定

4.2 优先级验证

1. 显式绑定 > 隐式绑定

var obj = {
  name: "小吴",
  foo: function() {
    console.log(this);
  }
}

obj.foo() // { name: '小吴', foo: [Function: foo] }

// call/apply 优先级更高
obj.foo.call("我是why") // [String: '我是why']

// bind 优先级更高
var bar = obj.foo.bind("小吴666")
bar() // [String: '小吴666']

更明显的对比

function foo() {
  console.log(this)
}

var obj1 = {
  name: "这是bind更明显的比较",
  foo: foo.bind("why")
}

obj1.foo() // [String: 'why']

虽然通过 obj1 调用(隐式绑定),但 foo 已经被 bind 绑定(显式绑定),最终 this 指向 "why"。

2. new 绑定 > 隐式绑定

var obj = {
  name: "why的JS高级课程很不错,强烈推荐来看",
  foo: function() {
    console.log(this);
  }
}

var f = new obj.foo() // foo {}
obj.foo() // { name: '...', foo: [Function: foo] }

** 图8-17 new绑定优先级高于隐式绑定**

3. new 绑定 > 显式绑定(bind)

注意:new 不能与 call/apply 一起使用(都是立即调用函数),只能与 bind 比较。

function foo() {
  console.log(this);
}

var bar = foo.bind("测试一下")
bar() // [String: '测试一下']

var obj = new bar() // foo {}

new 调用时会找到原函数(foo),将其作为构造函数,创建新对象并绑定到 this。

4.3 优先级总结表

绑定类型 描述 优先级 判断方式
new 绑定 使用 new 关键字调用 最高 new func()
显式绑定 call/apply/bind 中高 func.call(obj)
隐式绑定 对象方法调用 中低 obj.func()
默认绑定 独立函数调用 最低 func()

记忆技巧:越主动的绑定方式,优先级越高。

五、特殊情况与边界处理

5.1 忽略显式绑定

当 call/apply/bind 传入 null 或 undefined 时,会被忽略,应用默认绑定规则。

function foo() {
  console.log(this);
}

foo()                // window
foo.apply(null)      // window
foo.apply(undefined) // window

使用场景

  • 不关心 this 指向,只想使用 apply 传递数组参数
  • 使用 bind 进行柯里化,不需要绑定 this

安全实践:传入空对象 Object.create(null) 代替 null,避免意外修改全局对象。

5.2 间接函数引用

赋值表达式返回的是函数引用,调用时属于独立调用。

var obj1 = {
  name: "obj1",
  foo: function() {
    console.log(this);
  }
}

var obj2 = {
  name: "obj2"
}

obj2.foo = obj1.foo
obj2.foo() // { name: 'obj2', foo: [Function: foo] }

// 间接引用
(obj2.foo = obj1.foo)() // window

(obj2.foo = obj1.foo) 返回函数引用,然后立即调用,属于独立调用。

代码规范提醒

var obj2 = {
  name: "obj2"
}
(obj2.foo = obj1.foo)()
// 如果 obj2 后面没有分号,会被解析为:
// var obj2 = { name: "obj2" }(obj2.foo = obj1.foo)()
// 导致错误

解决方案:在对象字面量后加分号,或使用 ESLint 等工具强制规范。

5.3 经典测试题

function foo(el) {
  console.log(el, this);
}

var obj = {
  id: "XiaoWu"
};

[1, 2, 3].forEach(foo, obj)
// 报错:Uncaught TypeError: Cannot read properties of undefined

问题原因:JavaScript 解析器将 [1,2,3] 解释为访问 obj 的属性。

解决方案

// 方案1:使用变量
var names = [1, 2, 3]
names.forEach(foo, obj)

// 方案2:在 obj 后加分号
var obj = {
  id: "XiaoWu"
};
[1, 2, 3].forEach(foo, obj)

这是 JavaScript 自动分号插入(ASI)机制的经典陷阱。

六、实战应用与最佳实践

6.1 判断 this 的决策树

在实际开发中,按以下顺序判断 this 指向:

1. 函数是否使用 new 调用?
   → 是:this 指向新创建的对象

2. 函数是否通过 call/apply/bind 调用?
   → 是:this 指向传入的第一个参数(null/undefined 除外)

3. 函数是否通过对象调用(obj.method())?
   → 是:this 指向该对象

4. 以上都不是?
   → 默认绑定:非严格模式指向全局对象,严格模式为 undefined

6.2 常见陷阱与解决方案

陷阱 1:事件回调中丢失 this

class Button {
  constructor(text) {
    this.text = text
  }

  handleClick() {
    console.log(this.text)
  }
}

const btn = new Button("点击我")
document.querySelector(".btn").addEventListener('click', btn.handleClick)
// 点击后输出 undefined,因为 this 指向 DOM 元素

解决方案

// 方案1:使用 bind
document.querySelector(".btn").addEventListener('click', btn.handleClick.bind(btn))

// 方案2:使用箭头函数
document.querySelector(".btn").addEventListener('click', () => btn.handleClick())

// 方案3:在构造函数中绑定
class Button {
  constructor(text) {
    this.text = text
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() {
    console.log(this.text)
  }
}

陷阱 2:定时器中的 this

var obj = {
  name: "小吴",
  delayLog: function() {
    setTimeout(function() {
      console.log(this.name) // undefined
    }, 1000)
  }
}

obj.delayLog()

解决方案

// 方案1:保存 this 引用
delayLog: function() {
  var self = this
  setTimeout(function() {
    console.log(self.name) // 小吴
  }, 1000)
}

// 方案2:使用箭头函数(推荐)
delayLog: function() {
  setTimeout(() => {
    console.log(this.name) // 小吴
  }, 1000)
}

// 方案3:使用 bind
delayLog: function() {
  setTimeout(function() {
    console.log(this.name) // 小吴
  }.bind(this), 1000)
}

陷阱 3:数组方法中的 this

var obj = {
  name: "小吴",
  friends: ["张三", "李四"],
  printFriends: function() {
    this.friends.forEach(function(friend) {
      console.log(this.name + "的朋友:" + friend)
      // undefined的朋友:张三
      // undefined的朋友:李四
    })
  }
}

解决方案

// 方案1:传入 thisArg 参数
printFriends: function() {
  this.friends.forEach(function(friend) {
    console.log(this.name + "的朋友:" + friend)
  }, this)
}

// 方案2:使用箭头函数(推荐)
printFriends: function() {
  this.friends.forEach(friend => {
    console.log(this.name + "的朋友:" + friend)
  })
}

6.3 团队协作规范建议

1. 代码审查检查点

  • 事件监听器是否正确绑定 this
  • 定时器回调是否需要保持 this 上下文
  • 数组方法回调是否需要访问外层 this

2. 编码规范

  • 优先使用箭头函数处理回调中的 this 问题
  • 避免在构造函数外使用 bind,影响性能
  • 对象字面量后统一加分号,避免 ASI 陷阱

3. TypeScript 辅助

class Component {
  name: string = "组件"

  // 使用箭头函数属性,自动绑定 this
  handleClick = () => {
    console.log(this.name)
  }
}

6.4 性能优化建议

bind 的性能开销

// ❌ 不推荐:每次渲染都创建新函数
render() {
  return <button onClick={this.handleClick.bind(this)}>点击</button>
}

// ✅ 推荐:在构造函数中绑定一次
constructor() {
  this.handleClick = this.handleClick.bind(this)
}

// ✅ 推荐:使用箭头函数属性
handleClick = () => {
  // ...
}

call/apply 的选择

  • 参数少于 3 个:使用 call(性能略优)
  • 参数多或动态参数:使用 apply
  • 需要多次调用:使用 bind

七、总结与进阶路线

7.1 核心要点回顾

this 的本质

  • this 是函数执行时的上下文对象引用
  • 在函数调用时动态绑定,与定义位置无关
  • 不同调用方式决定不同的 this 指向

四种绑定规则

  1. 默认绑定:独立调用 → 全局对象或 undefined
  2. 隐式绑定:对象方法调用 → 调用对象
  3. 显式绑定:call/apply/bind → 指定对象
  4. new 绑定:构造函数调用 → 新创建的对象

优先级:new > 显式 > 隐式 > 默认

特殊情况

  • null/undefined 会被忽略,应用默认绑定
  • 间接引用会导致默认绑定
  • 箭头函数不遵循这些规则(继承外层作用域的 this)

7.2 团队落地建议

阶段一:知识普及(1-2 周)

  • 组织内部分享会,讲解 this 的四种规则
  • 整理常见陷阱案例库,供团队参考
  • 在代码审查中重点关注 this 相关问题

阶段二:规范制定(1 周)

  • 制定团队编码规范(箭头函数使用场景、bind 使用时机等)
  • 配置 ESLint 规则,自动检测潜在问题
  • 建立 this 相关的最佳实践文档

阶段三:工具支持(持续)

  • 引入 TypeScript,利用类型系统减少 this 错误
  • 使用现代框架(React Hooks、Vue 3 Composition API)减少 this 依赖
  • 建立单元测试覆盖 this 相关逻辑

阶段四:持续优化(持续)

  • 定期回顾 this 相关 bug,总结经验
  • 更新团队知识库,补充新的边界情况
  • 在新人培训中加入 this 专题

7.3 进阶学习路线

下一步学习内容

  1. 箭头函数深入

    • 箭头函数为什么没有自己的 this
    • 箭头函数的词法作用域绑定
    • 箭头函数的使用场景与限制
  2. 手写实现

    • 手写 call/apply/bind 方法
    • 理解 arguments 对象
    • 实现 new 操作符
  3. 原型与继承

    • 原型链中的 this
    • 继承模式中的 this 处理
    • ES6 class 中的 this
  4. 框架中的 this

    • React 中的 this 绑定策略
    • Vue 中的 this 代理机制
    • 现代框架如何减少 this 依赖

推荐资源

  • 《你不知道的 JavaScript(上卷)》第二部分
  • MDN Web Docs - this 关键字
  • JavaScript.info - 对象方法与 this

7.4 验证学习成果

自测题

  1. 以下代码输出什么?为什么?
var name = "window"
var obj = {
  name: "obj",
  foo: function() {
    return function() {
      console.log(this.name)
    }
  }
}
obj.foo()()
  1. 如何让以下代码正确输出 "小吴"?
var obj = {
  name: "小吴",
  getName: function() {
    setTimeout(function() {
      console.log(this.name)
    }, 1000)
  }
}
obj.getName()
  1. 以下代码的优先级判断是否正确?
function foo() {
  console.log(this)
}
var obj = {
  foo: foo.bind("bind")
}
new obj.foo() // 输出什么?

答案与解析

  1. 输出 "window"。obj.foo() 返回一个函数,然后独立调用,应用默认绑定。
  2. 使用箭头函数:setTimeout(() => { console.log(this.name) }, 1000)
  3. 输出 foo {}。new 绑定优先级高于显式绑定。

八、写在最后

this 是 JavaScript 中最具争议的特性之一。它的灵活性带来了强大的表达能力,但也增加了理解成本。

关键心态

  • 不要死记硬背,理解背后的执行机制
  • 遇到问题时,按优先级逐一排查
  • 善用工具(TypeScript、ESLint)减少错误
  • 在现代开发中,考虑使用箭头函数或 Hooks 减少对 this 的依赖

实践建议

  • 在真实项目中刻意练习 this 的判断
  • 遇到 bug 时,先检查 this 指向是否正确
  • 代码审查时,关注 this 相关的潜在问题
  • 定期回顾本文,加深理解

掌握 this 不是终点,而是深入理解 JavaScript 执行机制的起点。接下来,我们将探讨箭头函数、手写实现 call/apply/bind,以及原型链等更深入的话题。

持续学习,保持好奇心,我们下期见!

前端哨兵模式(Sentinel Pattern):优雅实现无限滚动加载


一、什么是哨兵模式

在实现无限滚动加载时,传统做法是监听 scroll 事件,计算滚动位置、元素高度、视口距离等,代码复杂且性能开销大。哨兵模式(Sentinel Pattern)换了个思路:在列表底部放一个「哨兵元素」,当它进入视口时触发加载,利用浏览器原生的 Intersection Observer API 实现,代码简洁、性能更好。

核心思想:用一个不可见的 DOM 元素作为触发器,浏览器帮你监测它是否可见,你只需关注「可见时做什么」。

二、为什么选择哨兵模式

传统 scroll 方案的痛点:

- 需要手动计算 scrollTopscrollHeightclientHeight - 高频触发事件,即使加了节流也有性能损耗 - 代码可读性差,维护成本高

哨兵模式的优势:

- 浏览器原生 API 支持,性能优化由浏览器完成 - 代码量少,逻辑清晰 - 支持多个哨兵(比如顶部加载更多、底部加载更多) - 自动处理元素进入/离开视口的状态

三、基础实现

3.1 HTML 结构

<div class="list-container">
  <div class="list-item">Item 1</div>
  <div class="list-item">Item 2</div>
  <!-- 更多列表项 -->
  
  <!-- 哨兵元素 -->
  <div class="sentinel" id="sentinel"></div>
</div>

<div class="loading">加载中...</div>

3.2 CSS 样式

.sentinel {
  height: 1px;
  /* 不可见但占据空间 */
  visibility: hidden;
}

.loading {
  display: none;
  text-align: center;
  padding: 20px;
}

.loading.active {
  display: block;
}

3.3 JavaScript 核心逻辑

let page = 1;
let isLoading = false;

// 创建观察器
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // 哨兵进入视口且未在加载中
    if (entry.isIntersecting && !isLoading) {
      loadMore();
    }
  });
}, {
  // 提前 100px 触发
  rootMargin: '100px'
});

// 开始观察哨兵元素
const sentinel = document.getElementById('sentinel');
observer.observe(sentinel);

// 加载更多数据
async function loadMore() {
  isLoading = true;
  document.querySelector('.loading').classList.add('active');
  
  try {
    const data = await fetchData(page);
    renderItems(data);
    page++;
  } catch (error) {
    console.error('加载失败', error);
  } finally {
    isLoading = false;
    document.querySelector('.loading').classList.remove('active');
  }
}

// 模拟数据获取
async function fetchData(page) {
  const response = await fetch(`/api/items?page=${page}`);
  return response.json();
}

// 渲染列表项
function renderItems(items) {
  const container = document.querySelector('.list-container');
  const sentinel = document.getElementById('sentinel');
  
  items.forEach(item => {
    const div = document.createElement('div');
    div.className = 'list-item';
    div.textContent = item.title;
    // 插入到哨兵之前
    container.insertBefore(div, sentinel);
  });
}

四、React 实现

import { useEffect, useRef, useState } from 'react';

function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !loading && hasMore) {
          loadMore();
        }
      },
      { rootMargin: '100px' }
    );

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => observer.disconnect();
  }, [loading, hasMore]);

  const loadMore = async () => {
    setLoading(true);
    try {
      const response = await fetch(`/api/items?page=${page}`);
      const data = await response.json();
      
      if (data.length === 0) {
        setHasMore(false);
      } else {
        setItems(prev => [...prev, ...data]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      console.error('加载失败', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {items.map((item, index) => (
        <div key={index} className="list-item">
          {item.title}
        </div>
      ))}
      
      {/* 哨兵元素 */}
      <div ref={sentinelRef} style={{ height: 1, visibility: 'hidden' }} />
      
      {loading && <div className="loading">加载中...</div>}
      {!hasMore && <div className="no-more">没有更多了</div>}
    </div>
  );
}

五、进阶技巧

5.1 双向加载

同时支持向上和向下加载:

// 顶部哨兵
const topSentinel = document.getElementById('top-sentinel');
const bottomSentinel = document.getElementById('bottom-sentinel');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.target === topSentinel && entry.isIntersecting) {
      loadPrevious(); // 加载上一页
    }
    if (entry.target === bottomSentinel && entry.isIntersecting) {
      loadNext(); // 加载下一页
    }
  });
});

observer.observe(topSentinel);
observer.observe(bottomSentinel);

5.2 虚拟滚动结合

对于超长列表,结合虚拟滚动优化性能:

// 只渲染可见区域 + 缓冲区的元素
const visibleItems = items.slice(startIndex, endIndex);

return (
  <div style={{ height: totalHeight }}>
    <div style={{ transform: `translateY(${offsetY}px)` }}>
      {visibleItems.map(item => (
        <div key={item.id}>{item.title}</div>
      ))}
    </div>
    <div ref={sentinelRef} />
  </div>
);

5.3 错误重试

加载失败时显示重试按钮:

const [error, setError] = useState(null);

const loadMore = async () => {
  setLoading(true);
  setError(null);
  
  try {
    // 加载逻辑
  } catch (err) {
    setError(err.message);
  } finally {
    setLoading(false);
  }
};

// 渲染
{error && (
  <div className="error">
    加载失败:{error}
    <button onClick={loadMore}>重试</button>
  </div>
)}

六、注意事项

1. 防止重复触发:用 isLoading 标志位避免并发请求 2. 提前加载:通过 rootMargin 设置提前触发距离,提升体验 3. 结束判断:后端返回空数组时停止观察,避免无效请求 4. 内存泄漏:组件卸载时记得 observer.disconnect() 5. 兼容性:Intersection Observer 支持 IE11+,旧浏览器需 polyfill

七、与其他方案对比

方案 性能 代码复杂度 兼容性
scroll 事件
Intersection Observer IE11+
第三方库(react-infinite-scroll)

哨兵模式适合现代浏览器环境,追求性能和代码简洁的场景。

总结

哨兵模式用一个不可见元素作为触发器,配合 Intersection Observer API 实现无限滚动,代码简洁、性能优秀。核心要点:

- 在列表底部放置哨兵元素 - 用 IntersectionObserver 监听其可见性 - 可见时触发加载,加防重复逻辑 - 支持双向加载、虚拟滚动等进阶场景

比传统 scroll 方案更优雅,推荐在新项目中使用。

彻底讲透浏览器渲染原理,吊打面试官

第一层:幼儿园阶段 —— 渲染到底在干嘛?

首先,我们要明白浏览器的核心使命:将一堆乱七八糟的代码(HTML/CSS/JS),变成用户能点、能看的网页。

想象一下:你是一家建筑公司的总监理(浏览器引擎)。

  1. HTML 是建筑蓝图(结构)。
  2. CSS 是装修方案(颜色、布局)。
  3. JS 是工地的突击队(动态修改结构和装修)。
  4. 渲染过程 就是施工队按照蓝图和方案,把房子盖好并刷好漆的过程。

总结: 渲染就是把字符流转换成像素点的过程。


第二层:小学阶段 —— 经典“五步走”流水线

这是所有面试官都会问的基础流程,请形成肌肉记忆:

  1. 构建 DOM 树(Parsing):解析 HTML,把标签变成树状结构的节点。
  2. 构建 CSSOM 树:解析 CSS,计算出每个节点的样式。
  3. 构建渲染树(Render Tree):把 DOM 和 CSSOM 合并。注意:display: none 的节点不会出现在这。
  4. 布局(Layout / Reflow):计算每个节点在屏幕上的精确位置和几何大小(算盒子模型)。
  5. 绘制(Painting):遍历渲染树,调用操作系统的底层 API 把像素点画在屏幕上。

口诀: 走 DOM -> 算样式 -> 合树 -> 算位置 -> 像素点。


第三层:中学阶段 —— 关键路径阻塞(解题关键)

面试官常问:“为什么 CSS 建议放头部,JS 建议放底部?”

  1. CSS 是渲染阻塞的: 浏览器在得到 CSSOM 之前不会渲染页面。为什么?因为如果不等 CSS 拿到了再画,页面会先跳出丑陋的原始结构,再突然变漂亮(FOUC 现象)。
  2. JS 是解析阻塞的: 当 HTML 解析器遇到 <script> 标签时,必须停下所有活儿,去下载并执行 JS。为什么?因为 JS 可能会写一句 document.write 直接修改当前的 HTML。
    • 必杀技: 提到 defer(异步下载,HTML 解析完执行)和 async(异步下载,下载完立刻中断解析并执行)。

第四层:大学阶段 —— 重排(Reflow)与 重绘(Repaint)

这是性能优化的重灾区。

  1. 重排(Reflow/Layout)动作大。 只要元素的几何属性(宽、高、位置、字体大小)变了,浏览器就要重新计算整个页面的布局。这会触发“连锁反应”,性能损耗极大。
  2. 重绘(Repaint)动作小。 只改颜色、背景色、透明度。不需要重新计算布局,直接重画。

面试坑点:

  • 重排必定触发重绘,但重绘不一定触发重排。
  • 读取属性也会触发重排! 比如你读取 offsetTopgetComputedStyle。为了给你最准的数据,浏览器会强制立刻执行一次布局计算。

第五层:博士阶段 —— 现代浏览器的必杀技:合成(Compositing)

如果你只想到了重绘重排,那面试分只有 80。现代浏览器(Chrome/Safari)引入了 GPU 加速

  1. 分层(Layering): 浏览器会把页面分成很多层(就像 Photoshop 的图层)。
  2. 合成(Compositing): 有些属性的改变(如 transformopacitywill-change),既不需要重排,也不需要重绘,而是直接在 合成线程 中处理,调用 GPU 完成。
    • 为什么 transform 性能好? 因为它不占用主线程,不会触发重排重绘,直接由 GPU 移动图层。

第六层:上帝视角 —— 浏览器一帧的“生死时速”

对应 Event Loop,渲染管线在一帧(16.6ms)内是这样排班的:

  1. 处理输入事件(点击、滚动)。
  2. 执行定时器/JS
  3. Begin Frame
  4. 执行 requestAnimationFrame (rAF):这是修改 DOM 的黄金时间。
  5. 样式计算 -> 布局 -> 分层 -> 绘制
  6. requestIdleCallback:如果还有空,干点杂活。

必杀技问题: “如果你在 JS 里写死循环,为什么页面会变白?” 答案: 因为主线程被 JS 霸占,渲染管线第 5 步永远跑不到,显示器只能一直显示旧的帧或者留白。


第七层:框架层 —— React Fiber 的“时间管理”与 Vue 的“预知感应”

如果说浏览器渲染是底层的“搬砖工”,那么 React 和 Vue 就是两家风格迥异的“装修公司”。

1. React Fiber:给主线程装上“呼吸机”

在 React 16 之前,React 采用的是 Stack Reconciler(栈协调器)

  • 痛点: 就像一个停不下来的递归施工队。一旦开始比对(Diff)虚拟 DOM 树,主线程就会被死死占用。如果树很大,计算需要 100ms,那么浏览器渲染管线就会直接“断层” 100ms,用户看到的画面就是卡死的。

Fiber 架构的本质:

  • 工作单元化: React 把渲染过程拆成了一个个微小的 Fiber 节点(单元任务)。
  • 双缓存机制(Double Buffering): 内存中永远有两棵树。一棵是正在显示的 current 树,一棵是后台偷偷排练的 workInProgress 树。施工完了,直接交换指针,瞬间切换画面。
  • Render 阶段(异步可中断):
    • 这是最吃 CPU 的 Diff 过程。React 借用了 MessageChannel(宏任务)来实现时间切片。
    • 施工习惯: 干 5ms 活,停下来喘口气,问一下浏览器:“有高优先级的活(用户点击、动画)吗?”如果有,React 立即让出主线程,把当前的 Diff 进度存起来,等浏览器忙完了再回来。
  • Commit 阶段(同步不可中断):
    • 一旦 Diff 完成,真正要把改动应用到真实 DOM 时,必须一次性干完。否则页面会出现“一半新、一半旧”的怪异现象。

面试杀招:

问:“为什么 React 不直接用 requestIdleCallback?” 答:“因为 requestIdleCallback 的触发频率不稳定(1s 甚至只触发几次),且在不同浏览器表现差异大。React 为了保证每秒 60 帧的丝滑感,自己实现了一套基于 Lane(车道)模型 的优先级调度器,利用宏任务模拟了更高频率的调度。”


2. Vue 3:全自动“精准爆破”与“静态预判”

Vue 的哲学完全不同:它不搞时间切片,因为它认为**“只要我算得足够快,主线程就感不到卡顿”**。

Vue 3 的编译器黑科技:

  • 静态提升(Static Hoisting): Vue 在编译阶段就像开了天眼。它发现这块 HTML 永远不会变,就会把它提到渲染函数之外。
    • React 每次更新都要重新创建所有虚拟 DOM 对象,而 Vue 发现是静态的,直接复用旧对象,连 Diff 都省了。
  • 补丁标记(Patch Flags): 这是最吊的地方。Vue 在生成的虚拟 DOM 上打了个“补丁码”。
    • 比如它告诉渲染器:“这个 div 只有 class 属性是动态的,文字和 id 都是死的。”
    • 当数据变化时,Vue 的 Diff 算法会直接跳过所有死属性,只盯着 class 算。这种“定向追踪”让性能提升了几个数量级。
  • 响应式系统与批量更新: Vue 借用了 Event Loop 的微任务(Microtask)
    • 当你在一行代码里连改 10 次数据,Vue 不会触发 10 次渲染。它会把所有的 Watcher 塞进一个队列,在当前宏任务结束后的微任务阶段,一次性清空队列,触发一次 DOM 更新。

面试杀招:

问:“既然 Vue 这么快,为什么不需要 Fiber 架构?” 答:“React 因为缺乏对数据的追踪能力,更新时倾向于‘全量 Diff’,所以需要 Fiber 来防止长任务阻塞。而 Vue 的响应式系统配合编译器优化,已经将更新粒度精确到了组件级甚至节点级,Diff 的开销极小,绝大多数情况下不会产生阻塞主线程的长任务。”


第八层:实战精细化调度 —— 如何避免“渲染地狱”?

理解了框架层,我们在写业务代码时就要利用这些特性来吊打性能瓶颈:

1. 读写分离(防患于未然)

浏览器为了性能,会推迟重排。但如果你在 JS 里写:

const h1 = el1.offsetHeight; // 强制浏览器立即重排以获取最新值
el1.style.height = h1 + 10 + 'px'; // 写入
const h2 = el2.offsetHeight; // 再次强制重排
el2.style.height = h2 + 10 + 'px'; // 再次写入

这叫**“布局抖动”(Layout Thrashing)**。一帧之内你强行让施工队算了好几次位置。 优化: 先统一读,再统一写(或者用 FastDOM 这种库)。

2. 善用 will-change 的双刃剑

will-change: transform; 相当于给元素办了张“VIP 绿卡”,让它直接升到独立合成层,走 GPU 加速。

  • 警告: 不要给所有元素都办绿卡!图层过多会导致显存溢出(Layer Explosion),反而让手机发烫、页面崩溃。

3. 消失的“中间帧”:requestAnimationFrame

如果你要做动画,千万别用 setTimeout

  • setTimeout 属于 Event Loop 的宏任务,它的执行时机和浏览器的 16.6ms 刷新频率是不同步的。可能在一帧里执行了两次,也可能丢了一帧。
  • rAF 会在浏览器每次渲染管线开始前准时触发。它是正牌的“帧同步”工具。

4. 大数据渲染:从卡顿到丝滑

  • 方案 A(React 模式): 时间切片。用 setTimeout 把 10 万条数据拆成每组 100 条,分批塞进主线程,给渲染管线留出呼吸口。
  • 方案 B(通用模式): 虚拟列表。只渲染用户眼睛看到的 20 条 DOM,剩下的全靠计算偏移量来模拟。

总结:如何向面试官收网?

当面试官问到渲染原理时,你最后的陈述应该是:

“渲染原理不只是 DOM 树的构建。它涉及到**主线程(Main Thread)合成线程(Compositor Thread)**的分工。

优秀的框架如 React 通过 Fiber 架构解决了大树 Diff 占用主线程的问题;而 Vue 则通过编译器静态分析减少了 Diff 的计算量。

在实际业务中,我们会通过读写分离避免布局抖动,通过 rAF 保证动画同步,以及通过 will-change 合理利用 GPU 加速。

我们的目标是:让 JS 逻辑在微任务中批量处理,让 UI 变更在合成线程中平滑过渡,最终确保主线程永远能响应用户的下一次点击。


第九层:真正的深坑 —— 字体加载与渲染

这是一个 99% 的前端都会忽视的细节:Web Fonts 加载。

  • FOIT (Flash of Invisible Text):字体没下好,文字先看不见(Safari)。
  • FOUT (Flash of Unstyled Text):先显示系统默认字体,等 Web Font 好了突然变样(Chrome)。
  • 方案: font-display: swap; 告诉浏览器先让用户看见内容。

第十层:未来标准 —— OffscreenCanvas 与渲染线程化

现在的瓶颈是:渲染虽然有合成线程,但 DOM 的计算依然在主线程。

  1. Web Workers:在后台处理计算。
  2. OffscreenCanvas:允许你在 Worker 线程里画图。
  3. 未来的 Houdini API:让 JS 直接插手浏览器的布局和绘制阶段,把 CSS 的能力开放给 JS。

终极回答策略:速记核心关键词

面试官问“谈谈浏览器渲染原理”时,按这四个维度收网:

  1. 流水线视角:DOM -> CSSOM -> RenderTree -> Layout -> Paint -> Composite。
  2. 阻塞视角:CSS 阻塞渲染,JS 阻塞解析,defer/async 的差异。
  3. 性能视角:重排(几何变化)vs 重绘(样式变化)vs 合成(GPU 加速)。
  4. 优化视角:读写分离、rAF 动画、will-change 分层、虚拟 DOM 批量更新。

面试杀招: “其实浏览器渲染不只是画画,它是一个复杂的调度系统。比如在 Composite 阶段,如果图层太多(Layer Explosion),反而会导致内存暴增。所以优化不仅仅是减少重排,还要权衡**‘空间换时间’**的代价。”

用 AudioContext.suspend()/resume() 作为流式音视频的同步门控

在"边收帧边播放"的场景中,传统做法是缓冲 N 帧后再启动——本质上是在猜网络速度,猜错了音画就不同步。更可靠的方案:把 AudioContext.suspend() 当作同步门,帧没到就冻结音频时钟,帧到了再放行。这个做法成立的原因是 suspend() 同时冻结音频输出 currentTime 时钟,天然是一个同步原语。

本文假设你了解 Web Audio API 基础和 requestAnimationFrame


问题:帧和音频跑在两条不同的轨道上

考虑一个流式口型同步播放器——后端通过 SSE 逐帧推送视频帧,客户端边收边解码边渲染,同时播放对应的音频。

朴素实现:音频立即开始播放,requestAnimationFrame 根据 audioContext.currentTime 计算当前应显示哪一帧:

// ❌ 朴素实现:音频驱动帧
audioSource.start(0);
const startTime = audioContext.currentTime;

function renderLoop() {
  const elapsed = audioContext.currentTime - startTime;
  const targetFrame = Math.floor(elapsed * fps);

  if (frames[targetFrame]) {
    drawFrame(frames[targetFrame]);
  }
  // 若 frames[targetFrame] 为 null,保持上一帧
  requestAnimationFrame(renderLoop);
}

这在网络良好时能工作。一旦后端帧到达速度跟不上播放速度:

音频时钟:  0s ────────── 2s ────────── 4s ────────────
已到达帧:  ████████████░░░░░░░░░░░░░░░░░░░░░░░░ (帧停在第 60 帧)
实际显示:  [正常]        [嘴型冻结]    [嘴型冻结,音频继续]

嘴型冻结,但语音继续——这是用户最容易感知到的 A/V 失同步。


天真的修复:缓冲阈值

最常见的修复是在开始播放前先缓冲足够的帧:

// ❌ 缓冲阈值方案
const BUFFER_THRESHOLD = 60; // 缓冲 60 帧再开始

onFrameDecoded(() => {
  decodedCount++;
  if (!started && decodedCount >= BUFFER_THRESHOLD) {
    started = true;
    audioSource.start(0);
    renderLoop();
  }
});

这把问题延后了,但没有解决:

  • 阈值是拍脑袋定的:60 帧在慢网络下可能还不够,在快网络下只是增加延迟
  • 播放开始后依然会失同步:后端如果在中途卡顿,帧再次追不上音频
  • 首帧延迟增加:用户要等 ~60 帧(约 1 秒)才看到第一个画面

根本问题是:音频时钟在独立运行,不关心帧有没有到


关键洞察:suspend() 同时冻结两样东西

AudioContext 有一个经常被忽视的性质:

audioContext.suspend() 不仅暂停音频输出,还暂停 currentTime 时钟本身。

const ctx = new AudioContext();
ctx.currentTime; // 0.0

// ... 播放一段时间后 ...
ctx.currentTime; // 2.341

await ctx.suspend();
// 此时 currentTime 冻结在 2.341,不再增加
await sleep(3000);

ctx.currentTime; // 仍然是 2.341(不是 5.341)

await ctx.resume();
// currentTime 从 2.341 继续,而非跳到 5.341

这意味着 suspend() 可以暂停整个时间轴,而不仅仅是静音。

这个性质让它成为一个天然的同步原语——用帧的到达来控制时间轴的开闸:

帧 N+1 已就绪?
  → 是: resume() → 时钟流动 → 绘制帧 → rAF 调度下一次检查
  → 否: suspend() → 时钟冻结 → 等待帧到达事件 → 重新触发检查

实现

音频解码和帧流接收是并行进行的——帧 0 可能在音频解码完成之前就到达,也可能更晚。实现中用 audioReady flag 跟踪音频是否就绪,调度器在两个事件上都能被触发:帧到达时,或音频就绪时。

1. AudioContext 创建后立即 suspend

const AudioCtx = window.AudioContext;
const audioCtx = new AudioCtx();

// 创建后立即挂起——由帧驱动何时 resume
await audioCtx.suspend();

const buffer = await audioCtx.decodeAudioData(rawAudioBuffer);

const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start(0); // start(0) 在 suspended 状态下不会实际播放
const startTime = audioCtx.currentTime;

注意 start(0) 在 suspended 状态下是合法的——它把播放位置固定在 0,但不产生声音。

2. suspend/resume 门控函数

let audioCtx: AudioContext | null = null;
let audioRunning = false;

// 不检查 audioCtx.state——resume()/suspend() 是异步的,
// state 属性的切换有延迟,检查它会导致 flag 与真实状态分裂。
// 只用本地 flag 做幂等守卫。
const ensureRunning = () => {
  if (!audioRunning && audioCtx) {
    audioRunning = true;
    audioCtx.resume().catch(() => {});
  }
};

const ensureSuspended = () => {
  if (audioRunning && audioCtx) {
    audioRunning = false;
    audioCtx.suspend().catch(() => {});
  }
};

为什么不检查 audioCtx.state

resume()suspend() 都是异步的,state 属性要等微任务队列清空后才切换。如果在同一个 rAF 周期内先调 resume() 再调 ensureSuspended()state 可能还停留在 'suspended'(resume 尚未完成),导致 ensureSuspended 认为"不需要 suspend"而跳过——但 resume 已经在途中,随后完成后音频就失控地开始播放。本地 flag 的翻转是同步的,不存在这个窗口。

3. 核心调度器

let audioReady = false; // 音频解码完成后置 true
let lastDrawnFrame = -1;

const tryAdvance = () => {
  const nextNeeded = lastDrawnFrame + 1;

  // 结束条件
  if (streamEnded && nextNeeded >= totalFrames) {
    ensureSuspended();
    onPlayEnd();
    return;
  }

  if (frames[nextNeeded] !== null) {
    if (!audioReady) {
      // 音频尚未就绪,rAF 轮询等待
      // 此时 audioCtx 还不存在,不能调 ensureRunning
      requestAnimationFrame(tryAdvance);
      return;
    }

    // 有帧且音频就绪 → 打开闸门
    ensureRunning();

    // 检查时钟是否已到该帧时间(后端快于实时时限速)
    const elapsed = audioCtx.currentTime - startTime;
    const frameTime = nextNeeded / fps;

    if (elapsed >= frameTime) {
      draw(frames[nextNeeded]);
      lastDrawnFrame = nextNeeded;
      requestAnimationFrame(tryAdvance); // 立即尝试下一帧
    } else {
      requestAnimationFrame(tryAdvance); // 等时钟追上
    }
  } else {
    // 帧未到 → 关闭闸门,等帧到达事件唤醒
    ensureSuspended();
    // 不调度 rAF——由 onFrameArrived 触发
  }
};

// 每次有新帧解码完成时调用
const onFrameArrived = (index: number) => {
  frames[index] = decodedBitmap;

  // 只有下一顺序帧到达才触发调度(乱序帧不触发)
  if (index === lastDrawnFrame + 1) {
    tryAdvance();
  }
};

4. 乱序帧的处理

后端可能乱序推送帧(帧 N+1 比帧 N 先到):

帧到达顺序: 0, 1, 3, 2, 4, 5 ...
              ↑ 帧 2 迟到

onFrameArrived(3) 时,lastDrawnFrame = 1index !== lastDrawnFrame + 1,不触发调度。 onFrameArrived(2) 时,index === lastDrawnFrame + 1,触发 tryAdvance(),绘制帧 2 后继续绘制已等待的帧 3、4、5。

这个模式保证严格顺序播放,无需额外排序逻辑。


边界情况

后端快于实时:所有帧在音频解码前到达。tryAdvance 看到 frames[nextNeeded] !== nullaudioReady = false,进入 rAF 轮询等待音频就绪。音频就绪后 elapsed >= frameTime 判断阻止帧提前绘制。

0 帧响应streamEnded = truenextNeeded >= totalFrames(0 >= 0),立即 resolve。

播放中断:外部调用 stop() → isPlaying = falsetryAdvance 首行 early return → rAF 循环自然终止。


取舍

优点 缺点
严格音画同步,无论网络如何抖动 网络卡顿时音频会有可感知的停顿
首帧延迟极低(~1 帧,而非缓冲 N 帧) 停顿期间无音频,用户可能误以为播放器崩了
不需要 AudioWorklet 或 ScriptProcessorNode 需要处理 resume() 的异步性(见上文 flag 而非 state)
与现有 close() 清理逻辑完全兼容 Safari 较旧版本在 suspend 过渡中 close() 有内存泄漏风险

何时不适用:如果视频帧来源稳定(本地文件、已缓冲的 HLS),直接用音频时钟驱动帧渲染更简单,不需要这套门控。这个模式的价值在于网络不稳定的流式推送场景。


这个方案把"同步"的责任从业务代码转移到了 AudioContext 本身——不需要手动计算音频和帧之间的时间差,不需要调整缓冲策略,状态也只有 audioRunning 这一个 flag。代价是引入了对 suspend/resume 异步性的理解门槛,以及停顿时无声音的 UX 取舍。


完整代码

audiocontext-sync-gate.ts


延伸阅读

CSS滚动条样式从入门到实战:打造跨浏览器的自定义滚动条

作为前端开发者,你是否曾为浏览器默认滚动条那“格格不入”的外观而苦恼?明明设计稿精致优雅,滚动条却像一块补丁贴在旁边。别担心,今天我们就来彻底掌握 CSS 滚动条样式,让你的滚动栏也能成为设计亮点!

为什么需要自定义滚动条?

默认滚动条在不同操作系统下样式各异:Windows 上略显厚重,macOS 下虽然简洁但无法融入品牌色。自定义滚动条能带来:

  • 品牌一致性:使用品牌色、圆角、渐变,让滚动条成为界面一部分。
  • 细节体验:滑块悬停反馈、平滑滚动、隐藏冗余按钮,提升品质感。
  • 空间利用:通过纤细滚动条节省横向空间,尤其适合移动端或侧边栏。

滚动条的组成与浏览器差异

滚动条主要由 轨道(Track)滑块(Thumb)  和 按钮(Button,上下箭头)  组成。但不同浏览器渲染机制截然不同:

  • WebKit 浏览器(Chrome、Safari、Edge) :支持 ::-webkit-scrollbar 私有伪元素,可精细控制每一部分,甚至圆角和渐变。
  • Firefox:从 64 版本开始支持标准属性 scrollbar-width 和 scrollbar-color,可设定宽度和颜色,但不支持圆角。
  • IE/旧版 Edge:几乎无法自定义,但市场份额已可忽略。

因此,我们的策略是:以 WebKit 伪元素实现丰富样式,同时用标准属性为 Firefox 提供基本配色,实现渐进增强。

核心属性详解

1. WebKit 私有伪元素

在 WebKit 内核中,滚动条各部分对应以下伪元素:

伪元素 描述
::-webkit-scrollbar 滚动条整体,可设置宽度/背景
::-webkit-scrollbar-track 轨道(凹槽)
::-webkit-scrollbar-thumb 滑块(可拖动部分)
::-webkit-scrollbar-button 两端按钮(通常隐藏)
::-webkit-scrollbar-corner 水平和垂直滚动条交汇处

常用技巧

  • 设置 width 控制滚动条粗细。
  • 给滑块和轨道设置 border-radius 实现圆角。
  • 利用 background-clip: padding-box 配合 border 制造留白效果。
  • 隐藏按钮:display: none

2. 标准属性(Firefox / 新 Chrome)

  • scrollbar-width:可选 auto(默认)、thin(细)、none(隐藏滚动条,但仍可滚动)。
  • scrollbar-color:接受两个颜色值,分别指定滑块和轨道颜色,例如 scrollbar-color: #4a90e2 #d9e2ec;

注意:这些属性目前仅影响颜色和宽度,无法设置圆角。但 Chrome 从 121 版本开始也支持它们,与 WebKit 伪元素并存时,后者的优先级更高。

实战示例:聊天卡片列表

我们构建一个聊天消息卡片列表,容器高度固定,垂直滚动。滚动条采用渐变滑块、圆角轨道,并带有悬停反馈,同时兼容 Firefox。 效果:

ScreenShot_2026-03-10_120825_433.png

完整 HTML 代码

将以下代码保存为 .html 文件,在浏览器中打开即可看到效果。

html

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS滚动条样式 · 实战示例</title>
    <style>
        /* ===== 基础重置与排版 ===== */
        * {
            box-sizing: border-box;
            margin: 0;
        }

        body {
            background: linear-gradient(145deg, #f0f4f8 0%, #d9e2ec 100%);
            min-height: 100vh;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            padding: 1.5rem;
        }

        /* 主卡片容器 — 带自定义滚动条的“聊天卡片列表” */
        .scroll-demo {
            width: min(90%, 420px);
            max-width: 480px;
            background-color: #ffffffcc;
            backdrop-filter: blur(4px);
            border-radius: 28px;
            box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255,255,255,0.6);
            padding: 1.8rem 1.2rem 1.8rem 1.8rem;
            border: 1px solid rgba(255,255,255,0.7);
        }

        /* 标题 + 副标题 */
        .demo-header {
            margin-bottom: 1.5rem;
            padding-right: 0.6rem;
        }
        .demo-header h2 {
            font-size: 1.7rem;
            font-weight: 600;
            background: linear-gradient(130deg, #1e3c72, #2a5298);
            -webkit-background-clip: text;
            background-clip: text;
            color: transparent;
            letter-spacing: -0.01em;
            margin-bottom: 0.2rem;
        }
        .demo-header p {
            font-size: 0.9rem;
            color: #2d4059;
            opacity: 0.75;
            font-weight: 400;
            border-left: 3px solid #4a90e2;
            padding-left: 0.7rem;
        }

        /* ---------- 核心滚动容器(所有滚动条魔法发生的地方) ---------- */
        .custom-scroll-area {
            height: 340px;                  /* 固定高度触发垂直滚动条 */
            overflow-y: auto;                /* 允许垂直滚动 */
            overflow-x: hidden;              /* 隐藏水平滚动条,保持干净 */
            padding-right: 0.8rem;            /* 给滚动条留出呼吸空间,避免内容贴边 */
            display: flex;
            flex-direction: column;
            gap: 12px;                       /* 卡片间距 */
            scroll-behavior: smooth;          /* 平滑滚动 (可选) */
        }

        /* ----- 卡片样式(纯粹为了内容丰富)----- */
        .message-card {
            background: white;
            border-radius: 20px;
            padding: 1.2rem 1.2rem 1.2rem 1.5rem;
            box-shadow: 0 6px 12px -6px rgba(0, 32, 64, 0.12), 0 0 0 1px rgba(0,0,0,0.02);
            transition: all 0.2s ease;
            border: 1px solid rgba(74, 144, 226, 0.1);
        }
        .message-card:hover {
            border-color: #4a90e2;
            box-shadow: 0 12px 20px -12px #2a5298, 0 0 0 1px #4a90e240;
            transform: translateY(-2px);
        }

        .card-title {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 8px;
        }
        .card-title h3 {
            font-size: 1.1rem;
            font-weight: 600;
            color: #1e2f4b;
            margin: 0;
        }
        .badge {
            background: #e9ecf3;
            border-radius: 40px;
            padding: 3px 8px;
            font-size: 0.7rem;
            font-weight: 600;
            color: #2a5298;
            letter-spacing: 0.3px;
        }
        .message-card p {
            margin: 0;
            font-size: 0.9rem;
            line-height: 1.5;
            color: #334e68;
            opacity: 0.9;
        }

        /* ===== 1. WebKit 滚动条样式 (Chrome, Safari, Edge 等) ===== */
        .custom-scroll-area::-webkit-scrollbar {
            width: 10px;                     /* 垂直滚动条宽度 */
            background: transparent;          /* 整体背景透明,后面由track决定 */
        }

        /* 滚动条轨道(凹槽) */
        .custom-scroll-area::-webkit-scrollbar-track {
            background: #d9e2ec60;            /* 半透明轨道,带一点底色 */
            border-radius: 20px;
            margin-block: 8px;                /* 上下留白,让滑块不顶天立地 */
            border: 2px solid transparent;     /* 预留“空气感” */
            background-clip: padding-box;
        }

        /* 滚动条滑块(可拖动的小方块) */
        .custom-scroll-area::-webkit-scrollbar-thumb {
            background: linear-gradient(145deg, #8aa9cc, #5f7fa3);  /* 渐变滑块 */
            border-radius: 20px;
            border: 2px solid #d9e2ec;        /* 使用轨道相近颜色制造内边距错觉 */
            background-clip: padding-box;
            min-height: 48px;                  /* 滑块最小高度,更易点击 */
        }

        /* 滑块悬停效果 */
        .custom-scroll-area::-webkit-scrollbar-thumb:hover {
            background: linear-gradient(145deg, #4a90e2, #2a5f9e);
            border-width: 2px;
        }

        /* 滚动条两端按钮(上下箭头)—— 彻底隐藏,保持简洁 */
        .custom-scroll-area::-webkit-scrollbar-button {
            display: none;
        }

        /* 滚动条角落(水平和垂直条相交处)—— 也隐藏掉 */
        .custom-scroll-area::-webkit-scrollbar-corner {
            background: transparent;
        }

        /* ===== 2. Firefox 滚动条样式 (标准属性) ===== */
        /* 使用 @supports 优雅降级:只有支持 scrollbar-color 的浏览器(Firefox / 新版Chrome)才会应用 */
        @supports (scrollbar-color: auto) {
            .custom-scroll-area {
                /* scrollbar-width: thin;      thin | auto | none */
                scrollbar-width: thin;                /* 纤细滚动条 */
                /* scrollbar-color: 滑块颜色 轨道颜色 */
                scrollbar-color: #7fa3c4 #d9e2ec;      /* 与WebKit滑块/轨道近似,保持视觉一致 */
                /* 注意:标准属性不支持圆角和渐变,但至少颜色匹配,且宽度变细 */
            }
        }

        /* 为了让Firefox下滚动条更贴合设计,可以稍微调整轨道色(但无法设圆角,接受自然状态) */
        /* 对于新版Chrome(同时支持scrollbar-color和-webkit-scrollbar),两者可能叠加。
           但我们已让颜色相似,无论哪套生效,都不会突兀。 */

        /* ---------- 额外小美化:自定义占位符 ---------- */
        .footnote {
            margin-top: 1.5rem;
            font-size: 0.8rem;
            color: #30689e;
            background: rgba(255,255,255,0.4);
            backdrop-filter: blur(4px);
            padding: 0.5rem 1.2rem;
            border-radius: 40px;
            border: 1px solid white;
            text-align: center;
            width: fit-content;
            margin-left: auto;
            margin-right: auto;
        }
        .footnote span {
            font-weight: 600;
            background: #1e3c72;
            color: white;
            padding: 2px 12px;
            border-radius: 30px;
            margin: 0 4px;
        }

        /* 提示小箭头 */
        .scroll-hint {
            display: flex;
            justify-content: center;
            margin-top: 10px;
            color: #2f5575;
            font-size: 0.85rem;
            gap: 6px;
            align-items: center;
        }
    </style>
</head>
<body>

<div class="scroll-demo">
    <div class="demo-header">
        <h2>⏃ 滚动条 · 美学</h2>
        <p>自定义垂直滚动栏 · 兼容 Chrome / Firefox / Safari</p>
    </div>

    <!-- 滚动容器:所有滚动条样式绑定在此 -->
    <div class="custom-scroll-area">
        <!-- 卡片 1 -->
        <div class="message-card">
            <div class="card-title">
                <h3>现代CSS技巧</h3>
                <span class="badge">热门</span>
            </div>
            <p>🎨 使用 <code>::-webkit-scrollbar</code> 精细控制滑块圆角、渐变色,Firefox 则通过 <code>scrollbar-color</code> 保持颜色统一。</p>
        </div>
        <!-- 卡片 2 -->
        <div class="message-card">
            <div class="card-title">
                <h3>兼容方案</h3>
                <span class="badge">实战</span>
            </div>
            <p>📦 通过 <code>@supports (scrollbar-color: auto)</code> 隔离 Firefox 样式,同时保留 WebKit 私有伪元素。两者不冲突。</p>
        </div>
        <!-- 卡片 3 -->
        <div class="message-card">
            <div class="card-title">
                <h3>滑块悬停反馈</h3>
                <span class="badge">交互</span>
            </div>
            <p>✨ 在 WebKit 浏览器中,滑块悬停会变成深邃的蓝渐变,提升细节体验。</p>
        </div>
        <!-- 卡片 4 -->
        <div class="message-card">
            <div class="card-title">
                <h3>隐藏按钮</h3>
                <span class="badge">极简</span>
            </div>
            <p>🧹 使用 <code>::-webkit-scrollbar-button</code> 隐藏了上下箭头,滚动条更加干净利落。</p>
        </div>
        <!-- 卡片 5 -->
        <div class="message-card">
            <div class="card-title">
                <h3>轨道留白</h3>
                <span class="badge">透气</span>
            </div>
            <p>🌬️ 轨道设置 <code>margin-block: 8px</code>,滑块不会贴到顶部/底部,视觉更舒适。</p>
        </div>
        <!-- 卡片 6 -->
        <div class="message-card">
            <div class="card-title">
                <h3>Firefox 细条</h3>
                <span class="badge">标准属性</span>
            </div>
            <p>🦊 <code>scrollbar-width: thin</code> 让滚动条纤细优雅,搭配自定义颜色,融入设计。</p>
        </div>
        <!-- 卡片 7 -->
        <div class="message-card">
            <div class="card-title">
                <h3>未来标准</h3>
                <span class="badge">CSS 新趋</span>
            </div>
            <p>🔮 新版 Chrome 已支持 <code>scrollbar-color</code>,但圆角仍需 WebKit 伪元素。本示例两者兼顾。</p>
        </div>
        <!-- 卡片 8 再加一条让滚动条必然出现 -->
        <div class="message-card">
            <div class="card-title">
                <h3>滚动条哲学</h3>
                <span class="badge">细节</span>
            </div>
            <p>🧘 滚动条虽小,却是品质感的试金石。试拖拽看看,手感圆润。</p>
        </div>
    </div>

    <!-- 滚动提示(仅装饰) -->
    <div class="scroll-hint">
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#2a5298" stroke-width="2"><path d="M12 5v14M8 15l4 4 4-4"/><path d="M8 9l4-4 4 4"/></svg>
        <span>拖动右侧自定义滑块</span>
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#2a5298" stroke-width="2"><path d="M12 5v14M8 15l4 4 4-4"/><path d="M8 9l4-4 4 4"/></svg>
    </div>
</div>

<div class="footnote">
    ⚡ 实战兼容:<span>WebKit</span> 渐变圆角 + <span>Firefox</span> 标准属性   ·   滚动条样式完美融合
</div>

<!-- 技术说明(不影响样式) -->
<div style="margin-top: 2rem; max-width: 600px; text-align: center; font-size: 0.85rem; color: #336699; background: rgba(255,255,255,0.3); padding: 10px 20px; border-radius: 60px; backdrop-filter: blur(8px);">
    📌 当前示例在 Chrome / Edge 下显示渐变滑块+圆角;在 Firefox 下为细条+自定义颜色(虽无圆角但配色一致)。完全可投入生产。
</div>
</body>
</html>

代码解析

1. 滚动容器设置

css

.custom-scroll-area {
    height: 340px;
    overflow-y: auto;
    overflow-x: hidden;
    padding-right: 0.8rem; /* 避免内容紧贴滚动条 */
    scroll-behavior: smooth;
}

固定高度触发垂直滚动条,隐藏水平滚动条,并添加内边距让内容与滚动条保持距离。

2. WebKit 滚动条美化

css

.custom-scroll-area::-webkit-scrollbar {
    width: 10px;
}
.custom-scroll-area::-webkit-scrollbar-track {
    background: #d9e2ec60;
    border-radius: 20px;
    margin-block: 8px; /* 上下留白 */
    border: 2px solid transparent;
    background-clip: padding-box;
}
.custom-scroll-area::-webkit-scrollbar-thumb {
    background: linear-gradient(145deg, #8aa9cc, #5f7fa3);
    border-radius: 20px;
    border: 2px solid #d9e2ec;
    background-clip: padding-box;
    min-height: 48px;
}
.custom-scroll-area::-webkit-scrollbar-thumb:hover {
    background: linear-gradient(145deg, #4a90e2, #2a5f9e);
}
  • width: 10px 定义滚动条宽度。
  • 轨道与滑块均设圆角,并利用 border 和 background-clip 制造内边距效果,让滑块看起来悬浮。
  • margin-block 让轨道上下留出空间,滑块不顶头。
  • min-height 保证滑块在内容较少时仍可点击。
  • 悬停时加深滑块颜色,提升交互感。

3. Firefox 兼容

css

@supports (scrollbar-color: auto) {
    .custom-scroll-area {
        scrollbar-width: thin;
        scrollbar-color: #7fa3c4 #d9e2ec;
    }
}

使用 @supports 检测是否支持标准属性,仅对 Firefox 和后续支持这些属性的浏览器应用。颜色尽量与 WebKit 版本匹配,宽度设为 thin 使滚动条更精致。

注意事项

  1. 隐藏水平滚动条:如果内容不会水平溢出,务必设置 overflow-x: hidden,否则可能出现双滚动条。
  2. 兼容性检测:可以使用 @supports 为不同浏览器做渐进增强,也可以直接编写两套规则,浏览器会忽略不识别的属性。
  3. Firefox 圆角限制:目前 Firefox 无法实现圆角滚动条,但未来标准可能会扩展,目前只能接受这一差异。
  4. 留白与点击区域:给轨道上下留白(margin-block)能让滑块更舒适,同时注意滑块最小高度以保证可用性。
  5. 性能:对滚动条应用复杂渐变或阴影不会影响滚动性能,放心使用。

总结

自定义滚动条已不再是难事。通过组合 WebKit 私有伪元素和标准属性,我们能够打造出品牌统一、细节丰富的滚动栏,且兼容主流浏览器。下次当你再为默认滚动条发愁时,不妨试试这些技巧,让界面更加完美。

如果你有更多奇妙的滚动条创意,欢迎在评论区分享。别忘了点赞收藏,方便以后实战时查阅哦!

可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树

可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树

你做低代码搭建平台,撤销重做是第一个"看起来简单、做起来怀疑人生"的功能。

用户拖了个组件,改了个属性,调了个层级,然后按了 Ctrl+Z——页面回去了。再按 Ctrl+Shift+Z——页面又回来了。看起来就是个栈操作,对吧?

直到有一天:

  • 用户撤销了三步,然后做了一个新操作——中间那些"被撤销的未来"怎么办?丢掉?还是保留成分支?
  • 两个人同时编辑同一个画布,A 撤销了,B 的操作还在——这算冲突吗?
  • 一个复合操作(批量对齐 20 个组件)撤销时要原子回滚,但其中第 15 个组件已经被别人删了。

这时候你才发现,撤销重做不是两个栈的事,是一棵树、一套冲突解决策略、一个时间旅行引擎。


从两个栈到一棵树:撤销重做的本质问题

经典方案:双栈模型

大部分教程告诉你的版本:

const undoStack: Command[] = []
const redoStack: Command[] = []

function execute(cmd: Command) {
  cmd.execute()
  undoStack.push(cmd)
  redoStack.length = 0 // 新操作一来,redo 全清空
}

function undo() {
  const cmd = undoStack.pop()
  cmd?.undo()
  if (cmd) redoStack.push(cmd)
}

function redo() {
  const cmd = redoStack.pop()
  cmd?.execute()
  if (cmd) undoStack.push(cmd)
}

能用,但有个致命问题:redoStack.length = 0 这一行,把用户的"平行宇宙"直接抹杀了。

用户撤销三步后做了新操作,之前的三步操作永远消失了。在文本编辑器里这可以接受,但在可视化搭建引擎里,用户可能花了十分钟拖出来的布局——说没就没了。

本质问题

撤销重做的本质不是"线性回退",而是操作历史的版本管理。你需要的是 Git,不是浏览器的前进后退。


Command 模式:让每一步操作都可逆

为什么不直接存快照?

先回答一个绕不开的问题:为什么不每一步存一份完整状态快照,撤销就直接恢复快照?

因为搭建引擎的状态可能有几百个组件、上千个属性,每步都深拷贝整棵树,内存直接爆炸。况且快照方案无法回答"这一步到底做了什么"——在协同场景下,这个信息至关重要。

Command 模式的核心思路:不存状态,存变化。

interface Command {
  readonly type: string
  execute(): void    // 正向执行
  undo(): void       // 反向撤销
  merge?(other: Command): Command | null  // 可选:合并连续同类操作
}

// 移动组件的命令
class MoveCommand implements Command {
  type = 'move' as const

  constructor(
    private node: CanvasNode,
    private from: Position,  // 记住旧位置
    private to: Position     // 记住新位置
  ) {}

  execute() {
    this.node.position = { ...this.to }
  }

  undo() {
    this.node.position = { ...this.from } // 反向操作:回到旧位置
  }

  // 连续拖拽只保留首尾位置,不然 undo 一次只回退 1px
  merge(other: Command): Command | null {
    if (other instanceof MoveCommand && other.node === this.node) {
      return new MoveCommand(this.node, this.from, other.to)
    }
    return null
  }
}

复合命令:批量操作的原子性

批量对齐 20 个组件,撤销时必须一起回去,不能一个一个撤:

class CompoundCommand implements Command {
  type = 'compound' as const

  constructor(private commands: Command[]) {}

  execute() {
    this.commands.forEach(cmd => cmd.execute()) // 顺序执行
  }

  undo() {
    // ✅ 反序撤销!先执行的最后撤销,保证状态一致
    ;[...this.commands].reverse().forEach(cmd => cmd.undo())
  }
}

// 使用:批量对齐
function alignComponents(nodes: CanvasNode[], baseline: number) {
  const commands = nodes.map(node =>
    new MoveCommand(node, node.position, { ...node.position, y: baseline })
  )
  const batch = new CompoundCommand(commands)
  historyManager.execute(batch)
}

从链表到树:操作历史的分支管理

历史树的数据结构

关键转变:把线性的 undo/redo 栈变成一棵树。每个节点代表一次操作,撤销后做新操作时不丢弃旧分支,而是创建新分支。

interface HistoryNode {
  id: string
  command: Command
  parent: HistoryNode | null
  children: HistoryNode[]       // 多个子节点 = 多个分支
  timestamp: number
  branchLabel?: string          // 可选:给分支起名
}

class HistoryTree {
  root: HistoryNode
  current: HistoryNode          // 当前指针,指向"现在"

  execute(cmd: Command) {
    cmd.execute()
    const node: HistoryNode = {
      id: nanoid(),
      command: cmd,
      parent: this.current,
      children: [],
      timestamp: Date.now(),
    }
    this.current.children.push(node)  // 挂到当前节点下面
    this.current = node               // 指针前进
  }

  undo() {
    if (!this.current.parent) return   // 已经在根节点,没得撤了
    this.current.command.undo()
    this.current = this.current.parent // 指针回退,但子节点还在
  }

  redo(branchIndex = 0) {
    const next = this.current.children[branchIndex]
    if (!next) return                  // 没有可 redo 的分支
    next.command.execute()
    this.current = next
  }
}

children.length > 1 时,用户面临一个分支选择。UI 上可以展示成一棵可视化的历史树,让用户点击任意节点"穿越"回去。

穿越到任意历史节点

不只是一步步 undo/redo,用户可能想直接跳到历史树的某个节点:

class HistoryTree {
  // ...接上文

  travelTo(target: HistoryNode) {
    // 1. 找到 current 和 target 的最近公共祖先(LCA)
    const currentPath = this.getPathToRoot(this.current)
    const targetPath = this.getPathToRoot(target)
    const lca = this.findLCA(currentPath, targetPath)

    // 2. 从 current 撤销到 LCA
    let node = this.current
    while (node !== lca) {
      node.command.undo()
      node = node.parent!
    }

    // 3. 从 LCA 重做到 target
    const replayPath = targetPath.slice(0, targetPath.indexOf(lca)).reverse()
    for (const step of replayPath) {
      step.command.execute()
    }

    this.current = target
  }

  private getPathToRoot(node: HistoryNode): HistoryNode[] {
    const path: HistoryNode[] = []
    while (node) {
      path.push(node)
      node = node.parent!
    }
    return path
  }
}

这就是为什么叫"时间旅行引擎"——你不是在前进后退,你是在一棵操作树上随意跳转。


Immutable 快照:Command 模式的保险丝

纯 Command 模式有个隐患:undo/redo 链条一旦断裂,整个历史就废了。

比如某个 Command 的 undo() 实现有 bug,或者外部直接修改了状态绕过了 Command 系统,后续所有的 undo 都会产生错误的结果,而且这个错误会累积。

解决方案:在关键节点插入 Immutable 快照作为"存档点"。

import { produce, freeze } from 'immer'

interface HistoryNode {
  id: string
  command: Command
  parent: HistoryNode | null
  children: HistoryNode[]
  timestamp: number
  snapshot?: Readonly<CanvasState>  // 关键节点的完整状态快照
}

class HistoryTree {
  private operationsSinceSnapshot = 0
  private SNAPSHOT_INTERVAL = 20   // 每 20 步存一次快照

  execute(cmd: Command) {
    cmd.execute()
    const node: HistoryNode = {
      id: nanoid(),
      command: cmd,
      parent: this.current,
      children: [],
      timestamp: Date.now(),
    }

    this.operationsSinceSnapshot++

    // 每 N 步自动存一次"存档"
    if (this.operationsSinceSnapshot >= this.SNAPSHOT_INTERVAL) {
      node.snapshot = freeze(structuredClone(this.getState()))
      this.operationsSinceSnapshot = 0
    }

    this.current.children.push(node)
    this.current = node
  }

  // 快照校验:检测 command 链是否出了问题
  verify() {
    const nearestSnapshot = this.findNearestSnapshot(this.current)
    if (!nearestSnapshot?.snapshot) return true

    // 从快照重放到当前位置
    const expectedState = this.replayFrom(nearestSnapshot)
    const actualState = this.getState()

    // 不一致说明有 command 的 undo/redo 实现出了 bug
    return deepEqual(expectedState, actualState)
  }
}

这样做的好处是双重保障:Command 负责增量操作,Snapshot 负责兜底校验和快速恢复。 就像 Redis 的 AOF + RDB 策略,一个记操作日志,一个存完整快照。


协同场景:当两个人同时操作一棵历史树

这是真正让人头秃的部分。

问题场景

  • A 把按钮拖到了右边
  • B 同时把同一个按钮改成了红色
  • A 按了撤销——按钮回到左边。但 B 的红色怎么办?

操作转换(OT)的简化思路

每个 Command 需要支持 transform——当与另一个并发操作冲突时,转换自己:

interface CollaborativeCommand extends Command {
  targetId: string              // 操作目标的组件 ID
  vectorClock: VectorClock      // 逻辑时钟,判定因果关系

  // 核心:当检测到并发冲突时,转换命令
  transform(against: CollaborativeCommand): CollaborativeCommand
}

class CollaborativeMoveCommand implements CollaborativeCommand {
  // ...基本属性

  transform(against: CollaborativeCommand): CollaborativeCommand {
    // 不同组件,互不影响
    if (against.targetId !== this.targetId) return this

    // 同一组件,对方也在移动 → 以时间戳晚的为准
    if (against instanceof CollaborativeMoveCommand) {
      if (against.vectorClock.isAfter(this.vectorClock)) {
        // 对方操作更晚,我的操作变成 no-op
        return new NoOpCommand()
      }
    }

    return this // 其他情况保持不变
  }
}

每人一棵本地历史树

协同编辑中,每个用户维护自己的本地历史树,撤销只回退自己的操作:

class CollaborativeHistoryManager {
  private localTree: HistoryTree        // 我的操作历史
  private userId: string

  undo() {
    // 只撤销"我自己的"最近一次操作
    let node = this.localTree.current
    while (node && node.command.userId !== this.userId) {
      node = node.parent!  // 跳过别人的操作
    }
    if (node) {
      // 撤销时需要对中间别人的操作做 transform
      this.undoWithTransform(node)
    }
  }

  // 收到远程操作时
  applyRemote(remoteCmd: CollaborativeCommand) {
    // 对本地未同步的操作做 OT 转换
    const localPending = this.getUnsynced()
    let transformed = remoteCmd
    for (const local of localPending) {
      transformed = transformed.transform(local)
    }
    transformed.execute()
  }
}

说实话,写到这里已经能感受到协同冲突解决的复杂度了。这就是为什么很多搭建平台选择"锁定编辑"而不是"自由协同"——不是不想做,是性价比的考量。


设计权衡:没有银弹

Command vs 纯快照

维度 Command 模式 纯快照
内存占用 低(只存 diff) 高(每步存全量)
实现复杂度 高(每种操作都要写 undo) 低(clone 一把就完事)
协同支持 好(可以做 OT) 差(快照无法合并)
调试难度 中(链条断裂难追踪) 低(直接对比快照)
适用场景 组件多、操作频繁 状态小、原型阶段

实际工程建议:混合方案。 Command 为主,关键节点存快照做校验和快速恢复。就像前面写的那样。

历史树 vs 线性栈

历史树的代价是 UI 复杂度显著上升。你得给用户展示分支、提供选择入口、处理分支合并。如果你的产品场景是"普通运营人员搭页面",线性栈可能就够了——用户根本不理解什么叫"分支"。

历史树适合:专业设计工具、开发者向的搭建平台、需要"方案对比"的场景。

OT vs CRDT

协同冲突解决还有另一条路——CRDT(无冲突复制数据类型)。OT 需要中心服务器做转换,CRDT 可以完全去中心化。但 CRDT 对搭建引擎的树形结构支持还不够成熟,目前大部分生产级方案(Google Docs、Figma)仍然基于 OT 或其变体。


边界与踩坑

1. 命令的序列化

操作历史如果要持久化(刷新不丢失),Command 必须可序列化。这意味着 Command 里不能存组件的引用,只能存 ID:

// ❌ 存引用,序列化直接炸
class BadCommand {
  constructor(private node: CanvasNode) {} // 引用无法序列化
}

// ✅ 存 ID,执行时再查
class GoodCommand {
  constructor(private nodeId: string) {}
  execute() {
    const node = store.getNodeById(this.nodeId) // 执行时动态查找
    if (!node) return // 组件可能已被删除,需要防御
  }
}

2. 快照的内存策略

无限制存快照迟早 OOM。需要 LRU 淘汰或者按时间窗口清理:

  • 最近 50 步的快照全保留
  • 超过 50 步的,每 10 步保留一个
  • 超过 200 步的,只保留分支点的快照

3. 外部副作用

有些操作有外部副作用——比如"发布页面"。这种操作即使放进了 Command,undo 也不可能真的"取消发布"。对这类操作,要么不纳入撤销体系,要么 undo 时只回退本地状态并提示用户"线上版本需手动处理"。


技术升华:这到底是什么问题?

退一步看,撤销重做系统本质上是一个事件溯源(Event Sourcing)系统

  • 每个 Command 就是一个 Event
  • 操作历史就是 Event Log
  • 当前状态 = 初始状态 + 按序重放所有 Event
  • 快照就是物化视图的 Checkpoint

这个模型不只在前端出现。数据库的 WAL(预写日志)、Redux 的 Action/Reducer、区块链的交易记录——底层都是同一个思路:不存结果,存过程。需要结果时,重放过程。

下次遇到类似的问题——需要回溯、需要审计、需要协同——先问自己:

  1. 操作是否可逆?→ Command 模式
  2. 历史是否需要分支?→ 树形结构
  3. 是否需要兜底恢复?→ 关键节点快照
  4. 是否多人操作?→ OT/CRDT

这四个问题的答案组合,决定了你的撤销系统的复杂度上限。选哪个方案不重要,重要的是清楚自己在哪个复杂度等级上——别用杀鸡的刀去宰牛,也别拿牛刀去削苹果。

搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术

一、为什么要做这件事

在搜索系统中, C++ 引擎长期扮演着底层核心基础设施的角色:性能敏感、逻辑复杂、变更频繁,同时承载着大规模线上流量的稳定运行。随着业务持续发展和技术架构不断演进,我们逐步意识到:在高频迭代背景下,回归能力也需要同步升级。

过去一年,我们围绕搜索 C++ 引擎展开了一次系统性的回归能力工程化建设。本文将介绍这次能力升级的背景思考、核心设计思路以及落地实践。

高频迭代背景下:回归能力需要同步升级

搜索 C++ 引擎的升级主要来自三类需求:业务功能需求、重要技术项目(有 QA 深度参与)、大量技术优化与结构性改造需求。

在实际迭代节奏中,技术优化与结构性改造类需求占比较高,引擎整体呈现出多人并行开发、持续迭代推进的状态。随着规模扩大,我们发现:现有回归环境更适用于单次项目式验证。多需求并行时,资源调度与复用能力仍有提升空间,回归准出标准尚未完全工程化。这意味着,在稳定性要求不断提升的背景下,我们有必要构建更加标准化、流程化的回归体系,让质量保障能力与迭代节奏匹配。

现有测试方式的演进空间

当前搜索引擎主要依赖两类测试手段:DIFF 测试和压测,这些手段在长期实践中发挥了重要作用,但随着业务复杂度提升,我们也逐步看到进一步优化的空间:流量获取依赖下载日志、手工上传,自动化程度仍可提升。DIFF 过程中存在自然噪音。需要更精细化处理(AA DIFF、排序不稳定)。报告与分析信息分散在不同工具中,定位效率有优化空间。多套工具并行使用,缺乏统一平台化沉淀。整体来看,测试能力更多体现为“工具能力集合”,而在流程标准化、资产沉淀与统一治理方面仍有提升空间。

二、我们要解决什么问题

这次建设的目标,并不是简单“再做一个工具”,而是希望系统性解决以下问题:让 DIFF 和压测成为搜索 C++ 引擎的标配回归能力、让回归结果具备可分析、可归因能力、让回归成为发布的硬性准出标准、保证工具本身的稳定性,不成为新风险、整体提升引擎的回归效率和交付质量、通过流程和流水线,降低对“人”的依赖。一句话总结:把回归这件事,从“靠自觉”,变成“靠系统”。

三、整体方案概览

围绕上述目标,我们将建设拆分为五个关键方向:流量录制:一次录制,多处复用。环境建设:稳定、可复用的 DIFF/ 压测环境。DIFF 工具体系:从“能跑”到“好分析”。一键压测能力:降低执行门槛。工具与索引平台集成:让回归真正被用起来。

下面将会按模块展开说明。

流量录制:回归的基础设施

为什么先做流量录制

DIFF 和压测的核心前提只有一个:真实、稳定、可复用的流量。因此我们优先建设了搜索 C++ 引擎的流量录制链路,作为后续所有测试能力的基础。

流量如何触发

  • 索引平台集群详情页直接发起流量录制。
  • 索引平台更新 ARK 配置中心中的录制配置。
  • 搜索 C++ 引擎实时监听配置变化。

录制配置设计

所有配置统一收敛在 dsearch3#test.properties,支持:

  • 全局开关。
  • 指定 app / group。
  • 截止时间。
  • 指定 IP。
  • 采样率(0~100)。

这使得录制行为可控、可回收、可精细化管理。

流量生成与存储

  • 引擎侧根据配置生成 Kafka 消息。
  • 多业务场景复用同一 ARK 集。
  • 多场景流量复用同一个 Kafka Topic。

最终流量落入 ODPS,按天分区,字段包含:

  • 请求体。
  • 流量场景。
  • 实验信息。
  • 环境信息(生产 / 预发)。

这为后续 DIFF、压测、问题复现提供了统一数据源。

流量存储字段说明:

request_type:流量标签(原C++引擎请求类型)
app_name:C++引擎appName
group_name:C++引擎groupName
request_body:录制的C++引擎请求体
env:录制的流量环境:预发/生产
graph_name:图名称
experiments:实验列表(搜索新增)
pt:ODPS分区,按天分

DIFF 测试:从无到“可归因”

DIFF 执行流程:

DIFF 的入口统一在索引平台:查询流量 →选择流量→配置参数→触发 DIFF→查看报告。底层由测试服务 + 脚本完成:流量筛选与改造、请求转发、去噪、报告生成与存储。

DIFF 对比方式:

对照组部署 master 分支,实验组部署预发布分支。指定行或者指定集群方式请求对照组和实验组环境。打开新功能开关进行响应比对,生成预期有DIFF报告。

DIFF 环境设计

支持两种模式:

  • 指定集群:对照组 / 实验组两套完整集群。
  • 指定行 精确绑定 search / rank IP。

通过该设计,保证对比的唯一变量只有代码和配置。

流量筛选与回放改造

支持多维度筛选:

  • 搜索场景(交易 / 社区 / 聚合等)。
  • 流量标签(综合 / 销量 / 新品等)。
  • 实验命中情况。

同时解决了生产流量无法直接在预发回放的问题(表名、图参数、模型等适配)。

DIFF 策略设计

我们不只关注“有没有 DIFF ”,而是关注这个 DIFF 是否符合预期,因此 DIFF 被拆为两类:

响应 DIFF
  • 响应字段对比。
  • 漏斗算子字段对比。
指标 DIFF
  • 相似度分布(忽略/不忽略排序)。
  • 漏斗算子一致率。
  • 字段增删改统计。
  • 定制化指标。

DIFF 去噪

DIFF 不可用,往往不是因为“真问题”,而是噪音太多。我们重点处理了:AA DIFF(排序不稳定、非确定性逻辑)、可忽略字段、数值微小波动、内部超时导致的异常结果,目标只有一个:让开发看到的DIFF,尽可能都是真问题。

DIFF 报告设计

报告展示

DIFF 汇总报告:

  • 应用、集群、请求接口、流量标签、路由信息、对比数量、DIFF 数量、完全一致率、query_tag 平均召回数、score 平均分等。
  • 相似度分布统计报告(不忽略排序/忽略排序)。
  • 漏斗算子一致率统计报告。
  • 字段增删改统计。

DIFF 详情报告:

  • traceId、一致率、增删改字段、请求体等。
  • 漏斗算子 DIFF 明细。
  • 响应 DIFF 明细。
报告通知

通知到群 @个人,添加报告链接。

压测:一键完成性能回归

压测执行流程:

  • 索引平台作为压力测试发起入口,查询流量->选择流量->填写压测参数->压测触发->压测记录查看。
  • 测试服务提供索引平台操作的接口能力,查询流量->流量筛选->压测文件生成->压测任务触发->压测状态更新。
  • 压测平台提供实际压测能力,启动压测任务->生成压测报告。

整个过程无需人工干预。

执行方式:

  • 对照组:master 分支。
  • 实验组:预发布分支。
  • 开启新功能开关。
  • 阶梯式加压,对比性能曲线。

压测环境设计

同 DIFF 环境建设。

压测报告设计

报告展示

压测平台报告。

报告通知

通知到群 @个人,添加报告链接。

发布流水线与准出机制

回归能力建设的最终目标,是进入发布流程。当前已完成:UT / MR 流水线初步建设,后续规划中将:把 DIFF 和压测作为发布硬性卡点、回归不通过,禁止上线、回归过程自动扩缩容,避免长期占用资源、自动生成准出报告。

四、后续规划

回归执行率 100%:解决“忘跑回归”。

准出流水线全自动化。

横向覆盖更多搜索场景(流控、商业化、国际搜索等)。

形成统一的上线 SOP 规范。

五、总结

搜索 C++ 引擎回归能力建设,并不是一次“工具升级”,而是一场工程化治理:把经验变成流程、把自觉变成约束、把风险前移到上线之前,最终目标只有一个:让搜索引擎的每一次升级,都更可控、更可信。

往期回顾

1.得物社区搜推公式融合调参框架-加乘树3.0实战

2.深入剖析Spark UI界面:参数与界面详解|得物技术 

3.Sentinel Java客户端限流原理解析|得物技术

4.社区推荐重排技术:双阶段框架的实践与演进|得物技术

5.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

文 /耿辉

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

2026 年了,多 Agent 编码该怎么选?agent-team vs Claude Agent Teams vs Claude Squad vs Met

2026 年了,多 Agent 编码该怎么选?agent-team vs Claude Agent Teams vs Claude Squad vs MetaGPT 深度对比

当一个 AI Agent 不够用的时候,你需要的不是更强的模型,而是一支 AI 团队。

目录

前言

2026 年,AI 辅助编码已经从"单兵作战"演进到了"多 Agent 协作"时代。面对一个中大型项目,让一个 Agent 从头做到尾不仅效率低,还容易因为上下文爆炸而"失忆"。

于是问题来了:如何高效地编排多个 AI Agent,让它们像一个真正的开发团队一样协作?

市面上已经涌现出多种方案,但思路各不相同。本文将从实际开发场景出发,横向对比四个主流方案:

项目 一句话定位
Claude Agent Teams Anthropic 官方内置的实验性多 Agent 协作功能
Claude Squad 社区驱动的 TUI 多会话管理器
MetaGPT 模拟软件公司的 Python 多 Agent 框架
agent-team 基于 Role + Worker 模型的跨平台多 Agent 编排工具

一、四个方案速览

1. Claude Agent Teams —— 官方"实验室产品"

Anthropic 在 2026 年初随 Opus 4.6 推出的实验性功能。一个 Claude Code 会话担任"Team Lead",可以启动多个"Teammate"会话并行工作。

核心机制:

  • 共享任务列表,Teammate 自主认领任务
  • 支持 Teammate 之间直接消息通信
  • 两种显示模式:内嵌 Tab 或 tmux/iTerm2 分屏

局限:

  • 需要手动开启实验开关 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
  • 仅支持 Claude Code(绑定单一平台)
  • 无角色复用体系,每次都要重新描述角色职责
  • 无 Git worktree 隔离,Teammate 在同一工作目录操作
  • Session 恢复存在已知 Bug
  • 需要 Pro/Max 订阅($20-200/月)
  • Token 消耗极高(每个 Teammate 都是完整的 Claude 实例)

2. Claude Squad —— 社区版"多窗口管理器"

smtg-ai/claude-squad 是一个 Go 编写的 TUI 工具,用 tmux 管理多个 AI 编码助手实例。

核心机制:

  • 每个实例运行在独立的 tmux session + Git worktree 中
  • 提供 TUI 界面实时查看各实例状态
  • 支持暂停/恢复实例(暂停时提交变更、移除 worktree,恢复时重建)
  • --autoyes 模式实现无人值守自动化

局限:

  • 无角色/技能复用体系,每次手动配置
  • 无生命周期 Hook,无法做质量门禁
  • 无任务状态机,缺乏正式的任务管理
  • 无角色市场,无法共享和安装预制角色
  • Worker 之间无双向通信机制
  • 强依赖 tmux + gh CLI

3. MetaGPT —— 学术派"AI 软件公司"

geekan/MetaGPT 是一个 Python 框架,模拟完整的软件公司组织结构——从产品经理到 QA 工程师,通过消息传递进行协作。

核心机制:

  • 预定义角色:ProductManager → Architect → ProjectManager → Engineer → QaEngineer
  • 基于 observe-think-act 循环的角色行为模型
  • 结构化消息传递系统(包含发送者、原因、接收者)
  • 从一行需求生成完整项目(PRD → 架构 → 代码 → 测试)

局限:

  • 纯 Python 框架,不与实际 CLI 编码工具集成
  • 无 Git worktree 隔离(使用 ProjectRepo 管理文件)
  • 角色链是固定流水线,灵活性较低
  • 处理大型既有项目时成功率不高(>500 行代码容易出错)
  • 受 LLM 上下文窗口限制严重
  • 不支持人在回路中实时介入编码过程

4. agent-team —— 跨平台"AI 团队编排器"

agent-team 采用 Role(角色包)+ Worker(隔离实例) 模型,将多 Agent 协作抽象为可复用的角色技能包 + 独立 Git worktree 工作空间。

核心机制一览:

                    ┌─────────────────────────┐
                          Main Controller     
                       (你的主 Agent 会话)     
                    └──────────┬──────────────┘
                                双向通信
              ┌────────────────┼────────────────┐
                                              
     ┌────────────┐   ┌────────────┐   ┌────────────┐
       Worker-001      Worker-002      Worker-003 
       Role: PM       Role: FE       Role: BE    
       Branch:        Branch:        Branch:     
       team/pm-001│    team/fe-002│    team/be-003 
       Worktree:      Worktree:      Worktree:   
      .worktrees/     .worktrees/│    .worktrees/ 
       Provider:      Provider:      Provider:   
       claude         gemini         codex       
     └────────────┘   └────────────┘   └────────────┘

接下来展开讲讲 agent-team 的核心能力。


二、agent-team 核心特性深度解析

特性 1:Role 角色复用体系

agent-team 的 Role 不是一次性的 prompt,而是可安装、可共享、可组合的技能包

.agents/teams/pm/
├── SKILL.md          # 技能描述(触发条件、适用场景)
├── system.md         # 系统提示词(角色行为、约束、工作流)
└── references/
    └── role.yaml     # 作用域边界(in-scope / out-of-scope)、
                      # 依赖技能列表、元数据

亮点:

  • 内置多个开箱即用的角色:PM(集成 46 种产品方法论)、frontend-architect、vite-react-dev、pencil-designer 等
  • 支持通过 agent-team role-repo add <owner/repo> 从 GitHub 安装社区角色
  • roles-lock.json 锁定版本,团队成员使用一致的角色配置
  • 支持项目级(.agents/teams/)和全局级(~/.agents/roles/)双作用域

对比差异:

Claude Agent Teams 和 Claude Squad 都没有角色复用机制——每次新建 Agent 都要从零描述职责。MetaGPT 有预定义角色,但是硬编码在框架中,扩展性有限。

特性 2:Git Worktree 真隔离

每个 Worker 运行在独立的 Git worktree 中,拥有自己的分支和文件系统。

# 创建 Worker 时自动完成:
# 1. 创建 worktree: .worktrees/frontend-dev-001/
# 2. 创建分支: team/frontend-dev-001
# 3. 生成 .gitignore(排除 .claude/, .codex/ 等 provider 目录)
# 4. 复制技能包到 worker 目录
# 5. 注入角色提示词
# 6. 打开独立终端会话

agent-team worker create frontend-dev claude

为什么 worktree 隔离很重要?

  • 多个 Agent 同时编辑代码不会互相覆盖
  • 每个 Worker 的修改可以独立 review 和 merge
  • 失败的实验可以直接丢弃分支,零风险
  • 和 Git 原生工作流无缝集成(PR、Code Review)

对比差异:

Claude Agent Teams 的 Teammate 共享同一工作目录,存在文件冲突风险。Claude Squad 也使用 worktree 隔离,但缺少角色注入和技能同步。MetaGPT 通过 ProjectRepo 管理文件,非 Git 原生方案。

特性 3:跨平台 Provider 支持

agent-team 是唯一同时支持 4 种 AI 编码工具的方案:

Provider CLI 值 Hook 支持 启动方式
Claude Code claude 完整(Plugin) claude --dangerously-skip-permissions
Gemini CLI gemini 完整(Extension) gemini --approval-mode yolo
OpenCode opencode 完整(NPM Plugin) opencode
OpenAI Codex codex 仅 Prompt codex --dangerously-bypass-approvals-and-sandbox

实际场景: 你可以让 PM 角色跑在 Claude 上(擅长分析和文档),前端开发跑在 Gemini 上(免费额度充裕),后端开发跑在 Codex 上——混合编排,按需分配

agent-team worker create pm claude          # PM 用 Claude
agent-team worker create frontend-dev gemini # 前端用 Gemini
agent-team worker create backend-dev codex   # 后端用 Codex

对比差异:

Claude Agent Teams 仅限 Claude。Claude Squad 支持 Claude/Aider/Codex/Gemini 但无 Hook 集成。MetaGPT 通过 API 接入模型但不与 CLI 工具集成。

特性 4:5 层 Skill 技能搜索链

技能解析遵循严格的优先级链,确保项目定制优先、全局兜底:

Plugin 内嵌技能
    ↓ 未命中
项目角色技能 (.agents/teams/<skill>)
    ↓ 未命中
项目本地技能 (skills/<skill>)
    ↓ 未命中
用户本地技能 (~/.claude/skills/<skill>)
    ↓ 未命中
远程下载兜底 (npx skills install <scoped-name>)

支持 scoped 格式(如 antfu/skills@vite)自动从 npm 注册表下载。Worker 创建时会根据 role.yaml 中声明的依赖技能自动安装到工作目录。

特性 5:7 个生命周期 Hook

通过 Hook 系统,agent-team 在 Agent 工作流的关键节点注入质量控制:

Hook 触发时机 作用
SessionStart 会话启动 注入角色提示词、技能工作流、Git 规范
PreToolUse 写入/编辑文件前 头脑风暴门禁:无 design.md 则阻止代码编写
PostToolUse 写入/编辑文件后 后编辑质量检查
TaskCompleted 任务完成 自动归档、通知主控制器
Stop 会话退出 未归档变更警告
SubagentStart 子 Agent 启动 注入上下文
TeammateIdle Worker 空闲 空闲检测

头脑风暴门禁(Brainstorming Gate) 是一个独特的设计:在 PreToolUse 阶段,如果 Worker 尝试写代码但还没有对应的 design.md,Hook 会阻止操作并提示先完成设计文档。

这意味着:先设计,后编码。不是口号,而是机制强制。

对比差异:

Claude Agent Teams 有基础的 Hook 事件但无质量门禁。Claude Squad 完全没有 Hook 系统。MetaGPT 通过固定流水线隐式保证流程,但无法自定义。

特性 6:双向通信系统

Worker 和主控制器之间可以双向发送消息:

# 主控 → Worker:分配任务或发送指令
agent-team reply frontend-dev-001 "请优先处理登录页面的响应式布局"

# Worker → 主控:汇报进展或请求帮助
agent-team reply-main "登录页面已完成,但发现 API 接口缺少字段定义,需要后端协助"

通信基于终端多路复用器的 pane 机制(wezterm/tmux),无需网络服务,延迟极低。

特性 7:Role Hub 云端角色市场

类似于 skills.sh/hot 的角色发现平台,agent-team 拥有自己的云端角色市场 —— Role Hub

在线浏览: 访问 role-hub.vercel.app/ 即可搜索和预览社区共享的角色包。

CLI 集成:

# 搜索社区角色
agent-team role-repo find "react"

# 从 GitHub 仓库安装角色到项目
agent-team role-repo add JsonLee12138/agent-team --role pm

# 安装到全局(跨项目复用)
agent-team role-repo add JsonLee12138/agent-team --role pm -g

# 检查已安装角色的更新
agent-team role-repo check
  • 云端浏览 + CLI 安装,两种方式互补
  • roles-lock.json 版本锁定,团队一致性保障
  • 支持项目级和全局级双作用域安装
  • 角色标准化校验,确保安装包质量

特性 8:TDD 驱动的任务生命周期

agent-team 的任务系统不只是状态追踪,而是内置了 RED-GREEN-REFACTOR 的 TDD 工作机制:

draft → assigned → implementing → verifying → done → archived
                        ↑              │
                        └──── RED ─────┘  验证失败自动回退

核心 TDD 工作流:

  1. 定义验收标准:创建 Change 时配置验证命令
# .tasks/changes/20260309-auth-ui/change.yaml
verify:
  command: "go test ./auth/... -run TestAcceptance"
  timeout: 120s
  1. 实现阶段(implementing):Worker 编写代码和测试
  2. 验证阶段(verifying):运行验证命令
agent-team task verify frontend-dev-001 "auth-ui"
# Running verification for 'auth-ui'...
# PASSED (exit code: 0, duration: 1.234s)
# Status updated to: done
  1. 自动状态流转
    • GREEN(测试通过) → 自动流转到 done
    • RED(测试失败) → 自动回退到 implementing,Worker 继续修复

这意味着:没有通过验证的代码,不可能到达 "done" 状态。测试不是可选项,而是完成的证明。

agent-team task create frontend-dev-001 "auth-ui" "实现登录注册页面"
agent-team task list frontend-dev-001
agent-team task done frontend-dev-001 "auth-ui" 1   # 标记子任务完成
agent-team task verify frontend-dev-001 "auth-ui"   # 运行验证(TDD 门禁)
agent-team task archive frontend-dev-001 "auth-ui"  # 归档完成的任务

每个 Change 支持自定义验证命令和超时时间(默认 5 分钟),支持全局默认配置和 Change 级覆盖,可与 CI/CD 集成。

对比差异:

Claude Agent Teams 的任务列表没有验证机制。Claude Squad 没有任务管理。MetaGPT 的流水线是隐式的,无法自定义验证命令。agent-team 是唯一将 TDD 作为内置机制强制执行的方案。


三、全维度对比

架构理念对比

维度 Claude Agent Teams Claude Squad MetaGPT agent-team
设计理念 内嵌式团队协作 多会话窗口管理 AI 软件公司模拟 角色包 + 隔离工作空间
隔离机制 无(共享目录) Git worktree ProjectRepo Git worktree
角色复用 硬编码角色 可安装的技能包
Provider 仅 Claude Claude/Aider/Codex/Gemini API 级(不限) Claude/Gemini/OpenCode/Codex
Hook/门禁 基础事件 固定流水线 7 个生命周期 Hook
通信方式 消息邮箱 结构化消息传递 终端 pane 双向通信
角色市场 云端 Role Hub + CLI
任务管理 共享任务列表 隐式流水线 TDD 状态机 + 验证门禁
终端后端 tmux/iTerm2 仅 tmux 无(Python 进程) wezterm + tmux
技术栈 内置(TS) Go Python Go(单二进制)
付费要求 Pro/Max 订阅 免费 免费 免费

开发者体验对比

场景 Claude Agent Teams Claude Squad MetaGPT agent-team
上手成本 低(内置) 中(需装 tmux) 高(Python 环境) 低(单二进制 + 自然语言)
角色定义 每次手写 prompt 每次手写 prompt 修改 Python 代码 一次定义,处处复用
新成员加入 重新描述角色 重新配置 重新部署 role-repo add 一键同步
质量保证 人工 review 人工 review 流水线隐式保证 Hook 门禁 + TDD 验证门禁
混合 Provider 不支持 支持但无集成 不适用 原生支持 + Hook 适配

四、选型建议

选 Claude Agent Teams 如果你:

  • 已经是 Claude Pro/Max 用户
  • 项目较小,不需要复杂的角色体系
  • 想要最快上手,不介意实验性功能的不稳定性
  • 所有开发工作都在 Claude 生态内

选 Claude Squad 如果你:

  • 需要一个轻量的多会话管理器
  • 只关心"并行跑多个 Agent",不需要角色/技能体系
  • 喜欢 TUI 界面实时监控各实例状态
  • 团队统一使用 tmux

选 MetaGPT 如果你:

  • 做全新项目的原型生成(从需求到代码一键生成)
  • 研究多 Agent 协作的学术场景
  • 不需要与现有代码库深度集成
  • 更看重端到端自动化而非人机协作

选 agent-team 如果你:

  • 在真实项目中需要多 Agent 协作开发
  • 团队使用不同的 AI 编码工具(Claude + Gemini + Codex 混用)
  • 需要角色标准化和团队间复用
  • 重视代码质量(设计先行 → TDD 验证 → 归档交付)
  • 需要 Git 原生的隔离和合并工作流
  • 想要一个可扩展的、有生态的方案(角色市场、技能包、Hook 系统)

五、实战:用自然语言搭建一个 AI 开发团队

agent-team 的核心体验是自然语言驱动——你不需要记住任何命令,只需要和你的 Agent 对话。

Step 1:安装

npx skills add JsonLee12138/agent-team -a claude -y

然后告诉你的 Agent:

"Install agent-team and initialize the project."

Step 2:组建团队

直接用自然语言描述你想要的团队:

"帮我创建一个 3 人开发团队:一个 PM 用 Claude 负责需求分析,一个前端开发用 Gemini 负责页面实现,一个后端开发用 Codex 负责 API 开发。"

Agent 会自动完成:创建角色 → 分配 Provider → 建立 Git worktree → 打开独立终端会话。几秒钟后,三个 Worker 已经在各自的隔离工作空间中就绪。

Step 3:分配任务

"让 PM 分析用户登录模块的需求并输出 PRD 文档。"

"让前端开发根据 PRD 实现登录注册页面 UI。"

"让后端开发实现登录注册的 API 接口。"

Agent 会将任务分发到对应的 Worker,每个 Worker 在自己的 worktree 中独立工作。

Step 4:查看进展 & 合并成果

"查看团队当前状态。"

"前端开发的任务完成了,合并他的代码并删除 Worker。"

整个过程:你只需要用自然语言说"做什么",agent-team 负责"怎么做"。 三个 Agent 在各自隔离的 worktree 中并行工作,互不干扰,最后通过 Git 合并汇总成果。

和其他方案相比,你不需要学习新的 DSL、编写 Python 脚本或手动管理 tmux session——对话即编排


六、总结

Claude Agent Teams Claude Squad MetaGPT agent-team
适合阶段 实验/尝鲜 快速并行 原型生成 生产级团队协作
成熟度 实验性 社区维护 学术驱动 生产就绪
核心价值 官方集成 轻量简洁 端到端自动化 角色复用 + 隔离 + 跨平台

多 Agent 编码编排这个赛道还处于早期,但趋势已经明确:未来的 AI 辅助开发不会是一个 Agent 打天下,而是一个专业化的 AI 团队

agent-team 的设计哲学是:让 AI Agent 像真正的开发者一样工作——有明确的职责边界、独立的工作空间、标准化的协作流程、可验证的交付成果。


GitHub: github.com/JsonLee1213…

Role Hub: role-hub.vercel.app

安装只需两步:

# 1. 安装 Skill(替换 <platform> 为 claude / gemini / opencode / codex)
npx skills add JsonLee12138/agent-team -a <platform> -y

# 2. 告诉你的 Agent
# "Install agent-team and initialize the project."

或手动安装:

brew tap JsonLee12138/agent-team && brew install agent-team

如果觉得有用,欢迎 Star 支持!有问题或建议也欢迎提 Issue 讨论。

Flutter使用screenshot进行截屏和截长图以及分享保存的全流程指南

1. 前言

最近在项目里做了截屏和长图分享功能,所以结合自己的使用经历,写一个教程和总结。本文基于 screenshot: ^2.1.0 版本编写,适配 Flutter 3.0+,支持 iOS、Android、Windows和 Web 平台。可捕获屏幕上可见/不可见的 Widget,满足截图、长图、保存、分享等场景需求。

官方文档:github.com/SachinGanes…

2. 插件核心 API

类/方法 类型 参数 说明
Screenshot Widget controller: ScreenshotControllerchild: Widget 包裹需要截图的 Widget,为截图提供渲染边界
ScreenshotController 控制器 - 截图调度核心实例,负责触发截图、处理截图结果
capture() 方法 pixelRatio: double(可选)delay: Duration(可选) 捕获已渲染 Widget,返回 Uint8List 类型图片数据
captureFromWidget() 方法 Widget(必传)、pixelRatio(可选)、delay(可选) 捕获未渲染(不可见)Widget,无需包裹在 Screenshot 内
captureFromLongWidget() 方法 Widget、context、delay、constraints(可选) 捕获超长不可见列表 Widget,需传入上下文和约束
captureAndSave() 方法 path(必传)、fileName(可选)、pixelRatio(可选) 捕获并保存到指定路径(Web 不支持该方法)

3. 基础使用

下面是分别是依赖项引入和最基础的截图示例:

3.1. 引入依赖

dependencies:
  screenshot: ^3.0.0

3.2. 创建控制器与包裹 Widget

final ScreenshotController _controller = ScreenshotController();

Screenshot(
  controller: _controller,
  child: const Text("Flutter Screenshot Demo"),
)

3.3. 执行截图

Uint8List? _image;

_controller.capture(
  pixelRatio: 1.5,
  delay: const Duration(milliseconds: 10),
).then((value) {
  setState(() => _image = value);
}).catchError((e) {
  debugPrint(e.toString());
});

4. 高级功能

4.1. 捕获不可见的 Widget

_controller.captureFromWidget(
  Container(
    padding: const EdgeInsets.all(20),
    color: Colors.blue,
    child: const Text("Invisible Widget"),
  ),
).then((value) {
  // 处理图片(如保存、展示)
});

4.2. 捕获超长的列表 Widget

final _longWidget = Column(
  mainAxisSize: MainAxisSize.min,
  children: List.generate(50, (i) => Text("Item $i")),
);

_controller.captureFromLongWidget(
  InheritedTheme.captureAll(context, Material(child: _longWidget)),
  context: context,
  delay: const Duration(milliseconds: 100),
).then((value) {
  // 处理长图
});

4.3. 保存到指定路径

import 'package:path_provider/path_provider.dart';

final dir = await getApplicationDocumentsDirectory();
await _controller.captureAndSave(
  dir.path,
  fileName: "screenshot_${DateTime.now().millisecondsSinceEpoch}.png",
);

4. 截图的像素与大小控制

截图的像素清晰度和尺寸大小,主要通过以下两种方式控制,适配不同场景需求:

4.1. 像素调整(控制清晰度)

通过 capture()captureFromWidget() 等方法的 pixelRatio 参数控制,该参数表示“像素密度比”,默认值为 1.0。

  • pixelRatio = 1.0:默认清晰度,适配设备基础像素,文件体积较小;
  • pixelRatio = 1.5~2.0:常用清晰值,兼顾清晰度和文件体积,适合大多数场景;
  • pixelRatio ≥ 3.0:高清模式,适合需要放大查看的场景(如海报、凭证),但文件体积会显著增大。
// 高清截图示例(pixelRatio设为2.0)
Uint8List? _highDefImage = await _controller.capture(pixelRatio: 2.0);

4.2. 大小控制(控制图片尺寸)

分两种场景控制,核心是约束目标 Widget 的尺寸,截图会自动适配 Widget 实际渲染尺寸:

  • 可见 Widget:通过父容器的 widthheight 约束 Screenshot 组件的尺寸,截图尺寸与约束尺寸一致;
  • 不可见 Widget:在 captureFromWidget() 中,给目标 Widget 包裹 Container 并设置固定宽高,或通过 constraints 参数指定尺寸。
// 控制不可见Widget截图尺寸(固定宽高300x200)
_controller.captureFromWidget(
  Container(
    width: 300,
    height: 200,
    child: const Text("Fixed Size Widget"),
  ),
);

5. 实现原理

screenshot 插件的核心实现依赖 Flutter 渲染机制中的 RenderRepaintBoundary 组件。该组件会为其包裹的 Widget 创建独立的渲染图层,避免与其他 Widget 重复渲染,同时允许单独捕获该图层的像素数据。

其流程为:通过 Screenshot 组件给目标 Widget 绑定 RenderRepaintBoundary;ScreenshotController 调用截图方法时,会获取该渲染边界的 RenderObject;通过 RenderObject 的 toImage() 方法将渲染结果转为 Image 对象;再将 Image 编码为 Uint8List(图片字节流),最终返回给开发者用于后续处理(保存、分享等)。

对于不可见/长图捕获,插件通过手动创建渲染上下文,强制 Widget 完成渲染后再执行截图操作,突破了“仅能捕获可见 Widget”的限制。

6. 保存到系统相册

该功能使用 gal 插件实现相册保存。gal 插件适配性强,支持多平台,当前使用稳定版本 gal: ^2.1.0

PS:也可以使用image_gallery_saver插件,有啥用啥。

6.1. 引入依赖

dependencies:
  screenshot: ^3.0.0
  gal: ^2.1.0
  permission_handler: ^10.2.0 # 用于申请存储权限

6.2. 权限配置

Android:在 AndroidManifest.xml 中添加存储权限

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" android:minSdkVersion="30"/>

iOS:在 Info.plist 中添加相册权限

<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要访问相册以保存截图</string>

6.3. 保存代码实现

import 'package:gal/gal.dart';
import 'package:permission_handler/permission_handler.dart';

// 截图并保存到相册
void saveScreenshotToGallery() async {
  // 申请存储/相册权限
  var status = await Permission.storage.request();
  if (!status.isGranted) {
    debugPrint("权限申请失败,无法保存相册");
    return;
  }

  // 执行截图
  Uint8List? image = await _controller.capture(pixelRatio: 1.5);
  if (image == null) {
    debugPrint("截图失败");
    return;
  }

  // 调用gal插件保存到相册
  await Gal.putImageBytes(image, album: "Flutter截图");
  debugPrint("保存相册成功");
}

7. 分享图片

使用 share_plus: ^7.2.2插件实现截图分享,支持多平台(iOS、Android、Web 等),可分享图片给其他应用。

7.1. 引入依赖

dependencies:
  screenshot: ^3.0.0
  share_plus: ^7.2.2
  path_provider: ^2.1.1 # 用于临时存储图片

7.2. 分享代码实现

import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';

// 截图并分享
void shareScreenshot() async {
  // 执行截图
  Uint8List? image = await _controller.capture(pixelRatio: 1.5);
  if (image == null) {
    debugPrint("截图失败,无法分享");
    return;
  }

  // 保存图片到临时目录(分享需要本地文件路径)
  final tempDir = await getTemporaryDirectory();
  final tempFile = File("${tempDir.path}/screenshot_share.png");
  await tempFile.writeAsBytes(image);

  // 调用分享功能
  await Share.shareXFiles(
    [XFile(tempFile.path)],
    text: "分享一张Flutter截图",
  );
}

8. 注意事项

  • 不支持 Platform View(如地图、相机、WebView),捕获此类组件会出现黑屏或空白。
  • 图片模糊可提高 pixelRatio(建议 1.5–3.0),但会增加文件体积和截图耗时。
  • 若出现截图黑屏/空白,大概率是 Widget 未渲染完成,添加delay: Duration(milliseconds: 10–100) 即可解决。
  • captureAndSave() 不支持 Web 平台,Web 端需通过 capture() 获取字节流后,通过浏览器 API 下载。
  • 使用 gal 插件保存相册时,Android 11+ 需申请 MANAGE_EXTERNAL_STORAGE 权限,iOS 需配置相册权限描述。

9. 总结

screenshot 是 Flutter 稳定的 Widget 截图方案,基于 RenderRepaintBoundary 实现,支持可见/不可见/长图捕获。配合 gal 插件可实现相册保存,结合 share_plus 可完成分享,通过 pixelRatio 和尺寸约束可控制截图效果。使用时注意权限、渲染延迟与平台限制,可满足多数商业应用截图需求。

本文仅是简单案例,实际项目中有各种奇怪的需求,可以在评论区讨论,大家一起集思广益,分享解决方案。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

嗯…微信小程序主包又双叒叕不够用了!!!

作者:潘宇

一、背景

又到新的一年。随着古茗点单小程序在过去一年的持续迭代,主包体积也在一路“膨胀”。最终,我们再次直面一个熟悉的问题——主包容量,又双叒叕不够用了。

以下是我们已经使用过的优化手段:

  • 分包异步加载:通过微信提供的require.async方法可以实现跨分包 JS 代码引用
  • 页面分包:主包仅保留默认「启动页/TabBar页」,其他页面模块均迁移至分包
  • 图片资源优化:上传本地资源,并通过Babel插件实现本地资源替换为网络地址。
  • 公共模块分包:Taro项目配置mini.optimizeMainPackage,将主包未使用的公共模块打包至对应分包。

那么还有什么我们能做的呢?比如:

减少兼容代码

随着移动设备硬件性能的提升以及微信版本的不断升级,用户设备对ES6及以上语法的支持度已显著提高。在这一背景下,大量为兼容ES5而引入的降级与垫片代码逐渐失去必要性,反而成为包体体积的负担,具备明确的优化空间。

分包异步加载样式文件

上一次主包体积优化的分享中,我们提到通过 动态import配合splitChunks,将 React 组件的 JavaScript 逻辑拆分并编译到分包中,从而有效降低了主包的 JS 体积。然而,由于微信小程序本身缺乏样式层面的异步加载机制,这些异步组件所依赖的样式文件最终仍然需要在主包的app.wxss中统一引入。

对打包产物进行分析后可以发现,这部分异步组件的样式体积占比不小,并且会随着异步组件数量的增加而持续膨胀。由此可见,样式加载策略成为当前主包体积优化中的一个关键突破点,也是后续重点的优化方向之一。

二、目标

  • 删除为兼容ES5而引入的降级与垫片代码
  • 实现样式文件的分包异步加载

三、优化方案

ES5兼容代码剔除

实现思路

调整Browserslist

Browserslist 用来描述代码最终运行环境的能力范围,构建工具会根据它决定哪些语法需要被转成 ES5、哪些兼容代码可以被剔除。因此,编译产物中是否包含 ES5 兼容代码,本质上取决于 Browserslist 的配置。

通过MiniProgram ECMAScript compatibility table可以了解到,微信小程序的语法兼容性主要取决于小程序基础库版本;再结合微信官方提供的基础库版本分布信息,确定当前需要支持的最低基础库版本。

微信官方同时提供了 miniprogram-compat,用于将基础库能力转换为 Browserslist 配置。 在此基础上,只需要确认最低支持的基础库版本,并通过 miniprogram-compat 生成对应的 Browserslist 配置,在构建阶段由发布脚本将该配置注入 process.env.BROWSERSLIST,实现 ES5 兼容代码的精准剔除。

踩的一些坑

灰度过程中,部分 iOS 14 设备出现白屏

最终确认原因是 Browserslist 配置过于激进,低版本基础库在老 iOS 环境下对部分 ES 能力支持不稳定,导致必要的转译和兼容代码被提前剔除。

最后将微信端最低兼容基础库版本调整为 miniprogram-compat 的配置调整为 2.29.0 后,问题消失。

代码实现

import { getBrowsersList } from 'miniprogram-compat'
import type { IPluginContext } from '@tarojs/service'

interface Opt {
    minBaseLibraryVersion: string
}

export default (ctx: IPluginContext, pluginOpts:Opt) => {
  ctx.onBuildStart(() => {
    if (process.env.TARO_ENV !== 'weapp') return

    const browsersList = getBrowsersList(pluginOpts.minBaseLibraryVersion)

    if (!browsersList) return

    process.env.BROWSERSLIST = browsersList?.join(',')

    console.log('✅ Successfully set BROWSERSLIST from minBaseLibraryVersion')
  })
}

样式文件分包异步加载(失败了😭,就在这里分享一下踩坑的过程吧)

实现思路

分包页面样式是否能影响主包?

我们试着在分包里加一个空页面,希望分包加载时能把这个页面的样式一起带进来,从而影响主包页面的样式。

但实际测试下来,这条路走不通。(ps:也可能是实现有问题不一定对)

查阅微信小程序组件系统文档可以确认,组件之间本身就存在样式隔离。虽然文档里没有明确说明页面级别的隔离规则,但结合这次尝试的结论猜测页面之间同样是隔离的。因此单纯指望通过分包页面来加载样式,并不能实现样式文件分包异步加载。

分包自定义组件样式是否能影响主包?

查阅微信小程序自定义组件样式隔离文档后发现,组件的样式隔离其实是可以配置的。只要把自定义组件的styleIsolation设置为shared,组件样式就可以直接作用到页面上。

再结合跨分包自定义组件引用这一能力,就可以把样式放进分包组件里,随着组件的加载一起生效,从而实现样式的分包异步加载。

经过实际验证,这套方案是可行的。既然机制成立,下一步就是把思路变成工程能力。将其实现为一个 Taro 插件,把样式拆分、分包组件生成以及跨分包引用等逻辑自动化,尽量减少对业务侧的改造成本。

无法彻底解决的问题

我们在实际用下来之后,还是存在一个无法解决的问题:页面会短暂闪一下“没样式的 DOM”

本质原因其实也很简单:样式是动态加载的,但它到得比 JS 慢。JS 已经跑完了,组件也渲染出来了,但样式还没到位,于是用户就能看到一瞬间的“裸页面”。

结合下来,主要有这么几种情况:

冷启动的时候顺序不对

在冷启动场景下,用来注入样式的自定义组件,挂到页面上的时机会比 require.async 加载的 JS 还慢

结果就是:

  • JS 先执行
  • React 组件先渲染
  • 样式对应的自定义组件还没准备好

于是首屏就会闪一下没样式的内容。

JS 有缓存,样式没有

异步 JS 这块,用的是 React.lazy加载过一次之后是有缓存的,后面再进页面基本就是同步的。

但样式这边不一样:自定义组件注入样式是没法缓存的,每次进页面都得重新走一遍流程。

所以就会出现这种情况:

  • JS 一下子就执行完了
  • 样式却还是慢悠悠地在加载
  • 最终样式总是追不上渲染节奏
无法准确的知道样式文件生效的时机

在组件实例进入页面节点树时触发的 attached,以及渲染线程初始化完成时触发的 ready,都无法作为“样式已生效”的可靠判定时机。

在小程序渲染线程繁忙的情况下,样式实际生效可能会明显延迟,导致页面短暂出现“无样式 DOM”的闪烁现象

这也是该方案无法从根本上解决样式分包问题的核心原因

当前可行的缓解方案

为了解决「冷启动的时候顺序不对」与「JS 有缓存,样式没有」,我们在样式真正注入完成之前,不让异步组件内容出来

具体做法是:在自定义组件把样式注入完之前,先展示 Suspense 的 fallback,等样式 ready 了再一起把内容放出来,这样用户就看不到那一下没样式的闪屏了。

所以后面我们自己实现了一套 react-lazy-enhanced:在每次页面渲染异步组件的时候,都会额外等一下自定义组件的样式注入完成。在这之前,页面就一直停在 fallback 状态,不会提前渲染真实 DOM。

简单说就是一句话:JS 可以先到,但组件一定要等样式准备好再出来

最终放弃的方案

针对「无法准确判断样式文件何时真正生效」的问题,我们也考虑过一种方案: 在页面中插入一个用户不可见的检测节点,通过轮询该节点的计算样式(getComputedStyle),判断目标样式是否已经生效,从而人为构造一个“样式 ready”钩子。 也就是说,不再依赖组件生命周期,而是直接通过渲染结果反向推断样式是否生效。

但这种方式存在明显问题:检测节点本身可能会受到其他用户自定义样式、全局样式或样式覆盖规则的影响,导致计算结果不稳定。也就是说,我们无法保证检测到的样式变化一定来自目标样式文件本身。 一旦样式优先级、合并策略或运行环境发生变化,判断逻辑就可能失效。

因此,这种方案本质上仍然是基于副作用的黑盒推断,稳定性和可维护性都难以保证,最终没有采用。

代码实现(基于上一次主包体积优化的分享中提到的插件进行改造)

尽管方案没有达到预期目标,但其中的探索过程和实现思路仍然值得讨论,欢迎感兴趣的同学查看我们的代码实现并交流想法。

插件入口
  • 改造splitChunk逻辑,让样式文件和js文件统一输出到分包目录
  • 调用transformWebpackRuntimeTransformBeforeCompressionPluginName修改runtime.js
  • 拷贝编译后SingletonPromisedist目录
  • 调用transformAppConfig修改app.json输出
  • 调用transformPagesWXml为全局页面插入自定义组件
import fs from 'fs';
import path from 'path';
import type { IPluginContext } from '@tarojs/service';
import { RawSource } from 'webpack-sources';
import {
  InjectStyleComponentPlugin,
  PLUGIN_NAME as InjectStyleComponentPluginName,
  InjectStyleComponentName,
} from './inject-style-component';
import { transformAppConfig } from './transform-app-config';
import {
  TransformOpt,
  TransformBeforeCompressionPlugin,
  PLUGIN_NAME as TransformBeforeCompressionPluginName,
} from './transform-before-compression-plugin';
import { transformPagesWXml } from './transform-pages-wxml';
import { transformWebpackRuntime } from './transform-webpack-runtime';
import { AsyncPackOpts } from './types';

export { AsyncPackOpts } from './types';

const dynamicPackOptsDefaultOpt: AsyncPackOpts = {
  dynamicPackageName: 'dynamic-common',
};

export default (ctx: IPluginContext, pluginOpts: AsyncPackOpts) => {
  const finalOpts = { ...dynamicPackOptsDefaultOpt, ...pluginOpts };

  if (process.env.TARO_ENV !== 'weapp') return;

  ctx.modifyWebpackChain(({ chain }) => {
    // 动态获取现有的 splitChunks 配置
    const existingSplitChunks = chain.optimization.get('splitChunks') || {};

    const { common, vendors } = existingSplitChunks.cacheGroups;

    const newCommonChunks = common ? { ...common, chunks: 'initial' } : common;

    const newVendorsChunks = vendors ? { ...vendors, chunks: 'initial' } : vendors;

    chain.optimization.merge({
      splitChunks: {
        ...existingSplitChunks,
        cacheGroups: {
          ...existingSplitChunks.cacheGroups,
          common: newCommonChunks,
          vendors: newVendorsChunks,
        },
      },
    });

    chain.merge({
      output: {
        chunkFilename: `${finalOpts.dynamicPackageName}/[chunkhash].js`, // 异步模块输出路径
        path: ctx.paths.outputPath,
        clean: true,
      },
    });

    chain.plugin('miniCssExtractPlugin').tap((args) => {
      const [options] = args;
      const chunkFilename = `${finalOpts.dynamicPackageName}/[chunkhash].wxss`;
      const finalOption = { ...options, chunkFilename };
      return [finalOption];
    });

    chain.module
      .rule('script')
      .use('babelLoader')
      .tap((opts) => {
        const pluginConfig = path.resolve(__dirname, './transform-react-lazy');
        return { ...opts, plugins: [pluginConfig, ...(opts.plugins || [])] };
      });

    chain.plugin(TransformBeforeCompressionPluginName).use(TransformBeforeCompressionPlugin, [
      {
        test: /^(runtime\.js)$/,
        transform: (opt: TransformOpt) => {
          const { source, assets } = opt;
          const transformOpts = { ...finalOpts, assets };
          return transformWebpackRuntime(source as string, transformOpts);
        },
      },
    ]);

    chain.plugin(InjectStyleComponentPluginName).use(InjectStyleComponentPlugin, [finalOpts]);
  });

  ctx.modifyBuildAssets(({ assets }) => {
    const hasDynamicModule = Object.keys(assets).some((key) =>
      key.startsWith(`${finalOpts.dynamicPackageName}/`)
    );

    if (!hasDynamicModule) return;

    const asyncComponentPath = `./${InjectStyleComponentPlugin.generateComponentPath(finalOpts)}`;

    const asyncComponents = { [InjectStyleComponentName]: asyncComponentPath };

    const singletonPromisePath = path.resolve(__dirname, './singleton-promise.js');

    const fileContent = fs.readFileSync(singletonPromisePath, { encoding: 'utf-8' });

    assets['singleton-promise.js'] = new RawSource(fileContent);

    transformAppConfig({ ...finalOpts, assets, asyncComponents });

    transformPagesWXml({ assets, asyncComponents });
  });
};

实现增强lazy
  • 用于每次组件加载触发Suspensefallback
import React, {
  ComponentPropsWithoutRef,
  ComponentRef,
  ComponentType,
  ForwardRefExoticComponent,
  useEffect,
} from 'react';

enum Status {
  Uninitialized = 'uninitialized',
  Pending = 'pending',
  Resolved = 'resolved',
  Rejected = 'rejected',
}

interface Result<T> {
  default: T;
}

interface LoadData<T> {
  status: Status;
  result?: T | Error;
  promise?: Promise<void>;
}

type Factory<T extends ComponentType<any>> = () => Promise<Result<T>>;

export const lazy = <T extends ComponentType<any>>(factory: Factory<T>) => {
  const LazyComponent = React.lazy(factory) as ForwardRefExoticComponent<any>;
  const loadData: LoadData<T> = { status: Status.Uninitialized };

  const load = () => {
    if (loadData.status !== Status.Uninitialized) return;
    const successCallback = (res: Result<T>) => {
      loadData.status = Status.Resolved;
      loadData.result = res.default;
    };
    const errorCallback = (err: Error) => {
      loadData.status = Status.Rejected;
      loadData.result = err;
    };
    loadData.promise = factory().then(successCallback, errorCallback);
    loadData.status = Status.Pending;
  };

  const resetLoadData = () => {
    loadData.status = Status.Uninitialized;
    loadData.result = undefined;
    loadData.promise = undefined;
  };

  return React.forwardRef<ComponentRef<T>, ComponentPropsWithoutRef<T>>((props, ref) => {
    if (loadData.status === Status.Uninitialized) load();

    if (loadData.status === Status.Pending) throw loadData.promise;

    if (loadData.status === Status.Rejected) throw loadData.result;

    useEffect(() => {
      return resetLoadData();
    }, []);

    return <LazyComponent {...props} ref={ref} />;
  });
};

实现单例promise(singleton-promise)
  • 用于自定义组件通知runtime.js样式文件已加载完成
export class SingletonPromise {
  // 静态属性存放单例
  private static instance?: Map<string, SingletonPromise>;

  static getInstance() {
    if (!SingletonPromise.instance) SingletonPromise.instance = new Map();
    return SingletonPromise.instance;
  }

  static wait() {
    const instance = this.getInstance();
    const currentPages = getCurrentPages();
    return Promise.all(
      currentPages.map((page) => {
        if (!instance.has(page.route)) instance.set(page.route, new SingletonPromise());
        return instance.get(page.route)?.promise;
      })
    );
  }

  static loaded(pageRoute: string) {
    const instance = this.getInstance();
    if (!instance.has(pageRoute)) instance.set(pageRoute, new SingletonPromise());
    instance.get(pageRoute)?.resolve?.();
  }

  static unloaded(pageRoute: string) {
    const instance = this.getInstance();
    instance.delete(pageRoute);
  }

  private promise?: Promise<void>;

  private resolve?: () => void;

  constructor() {
    this.resetPromise();
  }

  private resetPromise() {
    this.promise = new Promise<void>((resolve) => {
      this.resolve = resolve;
    });
  }
}

插入样式自定义组件(inject-style-component)
  • 调用singleton-promise通知runtime.js样式注册完成
import path from 'path';
import { Compiler, Compilation } from 'webpack';
import { AsyncPackOpts } from './types';

export const PLUGIN_NAME = 'InjectStyleComponent';

export const InjectStyleComponentName = 'inject-style';

const injectStyleComponentCode = `
const { SingletonPromise } = require('~/singleton-promise.js')
Component({
  properties: {
    route: String,
  },
  lifetimes: {
    attached: function () {
      // 在这里通知页面样式已加载完成(但实际测试当渲染进程忙时还是会出现裸样式的情况)
      return SingletonPromise.loaded(this.data.route)
    },
    detached: function () {
      return SingletonPromise.unloaded(this.data.route)
    }
  }
})
`;

type Opt = AsyncPackOpts;

export class InjectStyleComponentPlugin {
  static generateComponentPath(opt: Opt) {
    return `${opt.dynamicPackageName}/${InjectStyleComponentName}`;
  }

  private readonly opt: Opt;

  private readonly WXmlContent: string = '<block/>';

  private readonly JsonContent: string = '{"component": true,"styleIsolation": "shared"}';

  private readonly JsContent: string = injectStyleComponentCode;

  constructor(opt: Opt) {
    this.opt = opt;
  }

  apply(compiler: Compiler) {
    compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: Compilation) => {
      const stage = compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL; // 最早阶段,在优化前

      compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, (assets) => {
        const { dynamicPackageName } = this.opt;

        const dynamicPackageStyleFileRegExp = new RegExp(`^${dynamicPackageName}\\/.*\\.wxss$`);

        const styleFileContent = Object.keys(assets).reduce((result, assetPath) => {
          if (!dynamicPackageStyleFileRegExp.test(assetPath)) return result;
          const relativePath = path.relative(dynamicPackageName, assetPath);
          const code = `@import './${relativePath}';`;
          return `${result + code}\n`;
        }, '');

        const componentPath = InjectStyleComponentPlugin.generateComponentPath(this.opt);

        const { RawSource } = compiler.webpack.sources;

        compilation.assets[`${componentPath}.wxss`] = new RawSource(styleFileContent);
        compilation.assets[`${componentPath}.wxml`] = new RawSource(this.WXmlContent);
        compilation.assets[`${componentPath}.json`] = new RawSource(this.JsonContent);
        compilation.assets[`${componentPath}.js`] = new RawSource(this.JsContent);
      });
    });
  }
}

转换页面wxml(transformPagesWXml)
  • 为所有页面插入自定义组件
import { AppConfig } from '@tarojs/taro';
import { RawSource } from 'webpack-sources';

interface Opts {
  assets: Record<string, RawSource>;
  asyncComponents: Record<string, string>;
}

const appConfigAssetKey = 'app.json';

export const transformPagesWXml = (opts: Opts) => {
  const { assets, asyncComponents } = opts;

  const curAppConfig: AppConfig = JSON.parse(assets[appConfigAssetKey].source() as string);

  const pageWXmlPaths = (() => {
    const { pages = [], subpackages, subPackages, tabBar } = curAppConfig;
    const tabBarPagePaths = tabBar?.list?.map((item) => item.pagePath) || [];
    const curSubPackages = subPackages || subpackages || [];
    const subPackagePagePaths = curSubPackages.reduce<string[]>((result, item) => {
      const subPackagePagePath = item.root || '';
      return [...result, ...(item.pages || []).map((page) => `${subPackagePagePath}/${page}`)];
    }, []);
    return [...pages, ...tabBarPagePaths, ...subPackagePagePaths].map((item) => `${item}.wxml`);
  })();

  Object.keys(assets).forEach((assetPath) => {
    if (!pageWXmlPaths.includes(assetPath)) return;
    const source = assets[assetPath].source() as string;
    const pageRoute = assetPath.replace(/\.wxml$/, '');
    const asyncComponentCode = Object.keys(asyncComponents).map(
      (item) => `<${item} route="${pageRoute}"/>`
    );
    const tempCode = [source, ...asyncComponentCode].join('\n');
    assets[assetPath] = new RawSource(tempCode);
  });
};

转换Appconfig(transform-app-config)
  • 注册异步分包
  • 注册异步样式自定义组件
  • 注册路径别名
import { RawSource } from 'webpack-sources';
import type { AsyncPackOpts } from './types';

interface Opts extends AsyncPackOpts {
  assets: Record<string, RawSource>;
  asyncComponents: Record<string, string>;
}

const appConfigAssetKey = 'app.json';

export const transformAppConfig = (opts: Opts) => {
  const { dynamicPackageName, asyncComponents, assets } = opts;

  const curAppConfig = JSON.parse(assets[appConfigAssetKey].source() as string);

  const {
    subPackages,
    subpackages,
    resolveAlias = {},
    usingComponents = {},
    componentPlaceholder = {},
    ...otherAppJSON
  } = curAppConfig;

  const finalSubPackages = subPackages || subpackages || [];

  const dynamicPackagesConfig = { root: dynamicPackageName, pages: [] };

  const asyncComponentPlaceholder = Object.keys(asyncComponents).reduce((result, item) => {
    return { ...result, [item]: 'block' };
  }, {});

  const finalAppConfig = {
    ...otherAppJSON,
    usingComponents: { ...usingComponents, ...asyncComponents },
    componentPlaceholder: { ...componentPlaceholder, ...asyncComponentPlaceholder },
    subPackages: [...finalSubPackages, dynamicPackagesConfig],
    resolveAlias: { ...resolveAlias, '~/*': '/*' },
  };

  assets[appConfigAssetKey] = new RawSource(JSON.stringify(finalAppConfig));
};

全局替换React.lazy
import path from 'path';
import { PluginObj, NodePath, PluginPass } from '@babel/core';
import * as types from '@babel/types';
import { CallExpression, ImportDeclaration, Program, VariableDeclarator } from '@babel/types';

const customLazySource = '@guming/taro-plugin-async-pack/build/esm/react-lazy-enhanced';

const normalFilename = (filename: string) => path.normalize(filename).replace(/\\/g, '/');

interface State extends PluginPass {
  reactNamespaces: Set<string>;
  reactLazyBindings: Set<string>;
}

export default (): PluginObj<State> => {
  return {
    visitor: {
      Program: {
        enter(programPath: NodePath<Program>, state: State) {
          const { filename = '' } = state;

          if (new RegExp(customLazySource).test(normalFilename(filename))) return;

          state.reactNamespaces = new Set();

          state.reactLazyBindings = new Set();

          programPath.traverse({
            ImportDeclaration(path: NodePath<ImportDeclaration>) {
              if (!types.isStringLiteral(path.node.source, { value: 'react' })) return;

              const { specifiers } = path.node;

              specifiers.forEach((spec) => {
                const { name } = spec.local;

                //  import React from 'react'
                if (types.isImportDefaultSpecifier(spec)) return state.reactNamespaces.add(name);

                //  import * as React from 'react'
                if (types.isImportNamespaceSpecifier(spec)) return state.reactNamespaces.add(name);

                // import  { lazy } from 'react'
                // eslint-disable-next-line prettier/prettier
                if (types.isImportSpecifier(spec) && types.isIdentifier(spec.imported, { name: 'lazy' })) {
                  state.reactLazyBindings.add(name);
                }
              });
            },
          });

          programPath.traverse({
            VariableDeclarator(path: NodePath<VariableDeclarator>) {
              const { id, init } = path.node;

              // const a = React.lazy
              if (types.isIdentifier(id)) {
                if (!types.isMemberExpression(init)) return;

                if (!types.isIdentifier(init.object)) return;

                if (!types.isIdentifier(init.property, { name: 'lazy' })) return;

                if (!state.reactNamespaces.has(init.object.name)) return;

                state.reactLazyBindings.add(id.name);
              }

              // const { lazy: a } = React;
              if (types.isObjectPattern(id) && types.isIdentifier(init)) {
                const { properties } = id;

                if (!state.reactNamespaces.has(init.name)) return;

                properties.forEach((prop) => {
                  if (!types.isObjectProperty(prop)) return;

                  if (!types.isIdentifier(prop.key, { name: 'lazy' })) return;

                  if (!types.isIdentifier(prop.value)) return;

                  state.reactLazyBindings.add(prop.value.name);
                });
              }
            },
          });
        },

        exit(programPath: NodePath<Program>, state: State) {
          let needInject = false;

          const customLazyId = programPath.scope.generateUidIdentifier('customLazy');

          const { filename = '' } = state;

          if (new RegExp(customLazySource).test(normalFilename(filename))) return;

          programPath.traverse({
            CallExpression(path: NodePath<CallExpression>) {
              const { callee } = path.node;

              if (types.isMemberExpression(callee)) {
                if (!types.isIdentifier(callee.object)) return;

                if (!state.reactNamespaces.has(callee.object.name)) return;

                if (!types.isIdentifier(callee.property, { name: 'lazy' })) return;

                path.node.callee = customLazyId;

                needInject = true;
              }

              if (types.isIdentifier(callee) && state.reactLazyBindings.has(callee.name)) {
                path.node.callee = customLazyId;
                needInject = true;
              }
            },
          });

          const hasImport = programPath.node.body.some((node) => {
            return types.isImportDeclaration(node) && node.source.value === customLazySource;
          });

          if (hasImport || !needInject) return;

          const specifier = types.importSpecifier(customLazyId, types.identifier('lazy'));

          const nodes = types.importDeclaration([specifier], types.stringLiteral(customLazySource));

          programPath.unshiftContainer('body', nodes);
        },
      },
    },
  };
};

实现插件在webpack压缩前进行代码转换
  • 用于调用transform-webpack-runtime
import webpack from 'webpack';
import { CompilationAssets } from './types';

export interface TransformOpt {
  assetName: string;
  source: string | Buffer;
  assets: CompilationAssets;
}

export interface PluginOpt {
  test?: RegExp;
  transform: (opt: TransformOpt) => string;
}

export const PLUGIN_NAME = 'TransformBeforeCompression';

export class TransformBeforeCompressionPlugin {
  private readonly opt: PluginOpt;

  constructor(opt: PluginOpt) {
    this.opt = opt;
  }

  apply(compiler: webpack.Compiler) {
    compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: webpack.Compilation) => {
      const stage = webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE; // 压缩前

      compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, (assets) => {
        const { test, transform } = this.opt;

        const assetNames = Object.keys(assets);

        assetNames.forEach((assetName) => {
          if (!test || !test.test(assetName)) return;

          const source = assets[assetName].source();

          const transformResult = transform({ assetName, source, assets } as TransformOpt);

          compilation.updateAsset(assetName, new webpack.sources.RawSource(transformResult));
        });
      });
    });
  }
}

转换webpack产物runtime
  • 转换产物runtime
import { NodePath, template } from '@babel/core';
import generator from '@babel/generator';
import * as parser from '@babel/parser';
import traverse, { Node } from '@babel/traverse';
import * as types from '@babel/types';
import type {
  AssignmentExpression,
  ObjectMethod,
  ObjectProperty,
  SpreadElement,
  VariableDeclarator,
} from '@babel/types';
import type { CompilationAssets, AsyncPackOpts } from './types';

interface Opts extends AsyncPackOpts {
  assets: CompilationAssets;
}

const webpackLoadDynamicModuleTemplateDep = `
  var loadedDynamicModules = {};
  var loadDynamicModule = function (dynamicModulePath) {
    var loadDynamicModuleFn = loadDynamicModuleFnMap[dynamicModulePath];
    return loadDynamicModuleFn ? loadDynamicModuleFn() : Promise.reject();
  };
  var promiseRetry = function (apply,retries = 6,delay = 500) {
    return apply().catch(function (error) {
      if (retries <= 0) return Promise.reject(error);
      return new Promise(function (resolve) {
          setTimeout(resolve, delay);
       })
       .then(function () {
          return promiseRetry(apply, retries - 1, delay)
       });
    })
  }
`;

const webpackLoadDynamicModuleTemplate = `
  __webpack_require__.l = function (dynamicModulePath, done, key, chunkId) {
  if (inProgress[dynamicModulePath]) {
    inProgress[dynamicModulePath].push(done);
    return;
  }

  const target = { src: dynamicModulePath };

  if (loadedDynamicModules[dynamicModulePath]) return done({ type: 'loaded', target });

  promiseRetry(function () {
    return loadDynamicModule(dynamicModulePath);
  }).then(function () {
    return done({ type: 'loaded', target });
  }).catch(function () {
    return done({ type: 'error', target });
  });
};
`;

const replaceWebpackLoadScriptFn = (
  assignmentExpressionNodePath: NodePath<AssignmentExpression>,
  opts: Opts
) => {
  const { left, right } = assignmentExpressionNodePath.node || {};

  if (!types.isMemberExpression(left)) return;

  if (!types.isFunctionExpression(right)) return;

  if (!types.isIdentifier(left.object, { name: '__webpack_require__' })) return;

  if (!types.isIdentifier(left.property, { name: 'l' })) return;

  const isProcessed = right.params.some((item) => {
    return types.isIdentifier(item, { name: 'dynamicModulePath' });
  });

  if (isProcessed) return;

  const { assets, dynamicPackageName } = opts;

  const dynamicJsAssets = Object.keys(assets).filter((assetName) => {
    return new RegExp(`^${dynamicPackageName}/.*\\.js$`).test(assetName);
  });

  const dynamicWXssAssets = Object.keys(assets).filter((assetName) => {
    return new RegExp(`^${dynamicPackageName}/.*\\.wxss$`).test(assetName);
  });

  const loadDynamicModuleFnMapCode = (() => {
    const dynamicAssetsRequireTempCode = dynamicJsAssets.map((dynamicJsAsset) => {
      return `'/${dynamicJsAsset}':function (){ return require.async('~/${dynamicJsAsset}'); }`;
    });

    return `var loadDynamicModuleFnMap = {${dynamicAssetsRequireTempCode.join(',')}}`;
  })();

  const hasStyleDynamicAssetsListCode = (() => {
    const hasStyleDynamicAssetsList = dynamicJsAssets.filter((dynamicJsAsset) => {
      const matchWXssAssets = dynamicJsAsset.replace(/\.js$/, '.wxss');
      return dynamicWXssAssets.includes(matchWXssAssets);
    });
    return `var hasStyleDynamicModuleList = [${hasStyleDynamicAssetsList
      .map((item) => `'/${item}'`)
      .join(',')}]`;
  })();

  const templateCodeAst = template.ast(webpackLoadDynamicModuleTemplate);

  const templateCodeDepAst = template.ast(webpackLoadDynamicModuleTemplateDep);

  const loadDynamicModuleFnMapAst = template.ast(loadDynamicModuleFnMapCode);

  const hasStyleDynamicAssetsListAst = template.ast(hasStyleDynamicAssetsListCode);

  assignmentExpressionNodePath.replaceWith(templateCodeAst as Node);

  assignmentExpressionNodePath.insertBefore(hasStyleDynamicAssetsListAst);

  assignmentExpressionNodePath.insertBefore(loadDynamicModuleFnMapAst);

  assignmentExpressionNodePath.insertBefore(templateCodeDepAst);
};

const webpackLoadDynamicStylesheetTemplate = `
  __webpack_require__.f.miniCss = function (dynamicStylesheetChunkId, promises) {
    var cssChunks = CSS_CHUNKS;
    if(installedCssChunks[dynamicStylesheetChunkId] !== 0 && cssChunks[dynamicStylesheetChunkId]){
      promises.push(loadStylesheet())
    }
  }
`;

const replaceWebpackLoadDynamicModuleStylesheetFn = (
  assignmentExpressionNodePath: NodePath<AssignmentExpression>
) => {
  const { left, right } = assignmentExpressionNodePath.node || {};

  if (!types.isMemberExpression(left)) return;

  if (!types.isFunctionExpression(right)) return;

  if (!types.isMemberExpression(left.object)) return;

  if (!types.isIdentifier(left.object.object, { name: '__webpack_require__' })) return;

  if (!types.isIdentifier(left.object.property, { name: 'f' })) return;

  if (!types.isIdentifier(left.property, { name: 'miniCss' })) return;

  const isProcessed = right.params.some((item) => {
    return types.isIdentifier(item, { name: 'dynamicStylesheetChunkId' });
  });

  if (isProcessed) return;

  const cssChunksValueAst: Array<ObjectMethod | ObjectProperty | SpreadElement> = [];

  assignmentExpressionNodePath.traverse({
    VariableDeclarator: (nodePath: NodePath<VariableDeclarator>) => {
      const { id, init } = nodePath.node || {};
      if (!types.isIdentifier(id, { name: 'cssChunks' })) return;
      if (!types.isObjectExpression(init)) return;
      cssChunksValueAst.push(...init.properties);
    },
  });

  const CSS_CHUNKS = types.objectExpression(cssChunksValueAst);

  const templateCodeAst = template.statement(webpackLoadDynamicStylesheetTemplate)({ CSS_CHUNKS });

  assignmentExpressionNodePath.replaceWith(templateCodeAst as Node);
};

const webpackLoadStylesheetTemplate = `
loadStylesheet = function () {
  const { SingletonPromise } = require('~/singleton-promise.js');
  return SingletonPromise.wait();
}
`;

const replaceLoadStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
  const { id, init } = nodePath.node || {};
  if (!types.isIdentifier(id, { name: 'loadStylesheet' })) return;
  if (!types.isFunctionExpression(init)) return;
  const isProcessed = !init.params.length;
  if (isProcessed) return;
  const templateCodeAst = template.ast(webpackLoadStylesheetTemplate);
  nodePath.replaceWith(templateCodeAst as Node);
};

const removeCreateStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
  const { id } = nodePath.node || {};
  if (!types.isIdentifier(id, { name: 'createStylesheet' })) return;
  nodePath.remove();
};

const removeFindStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
  const { id } = nodePath.node || {};
  if (!types.isIdentifier(id, { name: 'findStylesheet' })) return;
  nodePath.remove();
};

export const transformWebpackRuntime = (code: string, opts: Opts) => {
  const ast = parser.parse(code); // 将代码解析为 AST
  traverse(ast, {
    AssignmentExpression: (nodePath: NodePath<AssignmentExpression>) => {
      replaceWebpackLoadScriptFn(nodePath, opts);
      replaceWebpackLoadDynamicModuleStylesheetFn(nodePath);
    },
    VariableDeclarator(nodePath: NodePath<VariableDeclarator>) {
      replaceLoadStylesheetFn(nodePath);
      removeCreateStylesheetFn(nodePath);
      removeFindStylesheetFn(nodePath);
    },
  });
  return generator(ast).code;
};

类型定义
import { Source } from 'webpack-sources';

export interface AsyncPackOpts {
  dynamicPackageName: string;
}

export type CompilationAssets = Record<string, Source>;

四、总结

通过剔除 ES5 兼容代码,我们成功优化了小程序主包体积;但在样式分包的异步加载能力上,仍然无法做到真正完善。

核心问题始终在于:开发者无法准确获知“样式已真正生效”的时机。缺少一个明确、可靠的生命周期或回调信号,使得我们的方案在机制层面始终存在不确定性。

在此也想向微信官方表达一个小小的期待:未来是否有可能提供一个明确的“样式已生效”钩子或事件?哪怕只是一个样式注入完成的回调机制,也足以让这类能力变得真正可控、可维护。

或者进一步,是否可以提供官方支持的异步样式加载 API,使样式的加载、注入与生效具备可观测、可编排的生命周期。

从“死了么”到“我在”:用uniCloud开发一款温暖人心的App

大家好,我是前端大鱼。

前几个月,“死了么”App火了。几个人很短的时间做了一个极简功能——每天签到,两天不签就发邮件给紧急联系人。就这么简单,冲上了付费榜第一。

评论区最高赞的留言我一直记得:“名字太晦气了,为什么不叫‘活着么’?”

我想了很久。活着么?还是有点丧。后来有个读者留言说:“叫‘我在’吧,两个字,既回答了活着,也说出了陪伴。”

就它了。

「我在」——双层含义:我在(活着)、我在这里(守护你)。

今天这篇文章,是一个完整的项目规划书,从产品构想到技术实现,希望能给想做独立开发的朋友一些启发。


一、「我在」是什么?

1.1 产品定位

“死了么”的核心逻辑很简单:每日签到,两天不签就发邮件通知紧急联系人。它的成功在于直面死亡的黑色幽默。

但「我在」不想做第二个“死了么”。

我的产品定位是:从“怕死”到“惜活”,从“被动通知”到“主动记录”,从“孤独一人”到“有人陪伴”。

简单说:

  • “死了么”是在你可能死了的时候通知别人
  • 「我在」是在你确定活着的时候记录自己,同时告诉在乎你的人:我还在

1.2 核心功能

功能模块 具体内容 免费/付费
每日签到 一键打卡“我在”,记录心情和今日小事 免费
守护者机制 绑定一位守护者,渐进式提醒,48小时未签发送邮件 免费
心情日记 记录每天的情绪和琐事,形成时光相册 付费
时光胶囊 写给未来的自己,1/3/5/10年后打开 付费(免费限3个)
生命树 连续签到养成虚拟树,30天长叶,365天开花 付费皮肤
陪伴地图 匿名查看全国用户的“我在”状态 免费

1.3 渐进式提醒机制

这是「我在」最核心的守护功能:

  • 12小时未签到:App推送提醒用户自己:“今天记得说‘我在’哦”
  • 24小时未签到:通知守护者:“你守护的人今天还没说‘我在’”
  • 36小时未签到:守护者需确认是否联系上你
  • 48小时未签到:发送邮件给紧急联系人:“您的亲友已48小时未说‘我在’”

二、为什么叫「我在」?

这两个字,我想了很久。

第一层含义:活着。 当你在App里点击“我在”,就是在告诉世界:今天我也在好好地活着。

第二层含义:陪伴。 当你成为别人的守护者,你的存在本身就是一种承诺——“别怕,我在。”

第三层含义:回响。 在这个孤独的时代,有人问你“在吗”,你可以回一句“我在”。简单,却温暖。

比起“活着么”的质问,「我在」更像是一个回答,一个承诺,一个拥抱。


三、为什么选uniCloud + UniApp?

作为一个独立开发者,我的选型原则是:一次编写,多端运行,免运维,低成本

3.1 uniCloud的核心优势

  1. 一体化开发:前端直接调用云函数,不用配域名、HTTPS、跨域
  2. 定时任务内置:通过trigger配置,比node-cron更稳定
  3. 推送集成:uni-push 2.0直接可用,支持离线推送
  4. 免费额度够用:阿里云或腾讯云空间,每月有免费调用次数
  5. 自动扩缩容:不用关心服务器压力

四、技术架构

4.1 整体架构图

4.2 云函数结构

cloudfunctions/
├── user/                  # 用户相关
├── checkin/               # 签到相关
├── capsule/               # 时光胶囊
├── timer/                 # 定时任务
└── common/                # 公共模块

五、核心代码实现

5.1 数据库设计(简版)

users集合

{
  "_id": "用户ID",
  "nickname": "昵称",
  "guardian_id": "守护者ID",
  "emergency_email": "紧急联系人邮箱",
  "last_checkin": "最后签到时间",
  "continuous_days": "连续签到天数"
}

checkins集合

{
  "user_id": "用户ID",
  "mood": "心情",
  "note": "今日小事",
  "create_date": "签到时间"
}

5.2 签到云函数:说“我在”

// cloudfunctions/checkin/create.js
exports.main = async (event, context) => {
  const { mood, note } = event;
  const { uid } = context.auth;
  
  const db = uniCloud.database();
  const dbCmd = db.command;
  
  // 检查今天是否已签到
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  
  const exist = await db.collection('checkins').where({
    user_id: uid,
    create_date: dbCmd.gte(today)
  }).get();
  
  if (exist.data.length > 0) {
    return { code: 400, msg: '今天已经说过“我在”了' };
  }
  
  // 获取用户信息
  const user = await db.collection('users').doc(uid).get();
  const userData = user.data[0];
  
  // 计算连续天数
  let continuousDays = 1;
  if (userData.last_checkin) {
    const last = new Date(userData.last_checkin);
    const yesterday = new Date(today);
    yesterday.setDate(yesterday.getDate() - 1);
    
    if (last >= yesterday) {
      continuousDays = (userData.continuous_days || 0) + 1;
    }
  }
  
  // 开启事务
  const transaction = await db.startTransaction();
  
  try {
    // 插入签到记录
    await transaction.collection('checkins').add({
      user_id: uid,
      mood,
      note,
      create_date: new Date(),
      continuous_days: continuousDays
    });
    
    // 更新用户信息
    await transaction.collection('users').doc(uid).update({
      last_checkin: new Date(),
      continuous_days: continuousDays,
      total_checkins: dbCmd.inc(1)
    });
    
    await transaction.commit();
    
    // 通知守护者(如果有)
    if (userData.guardian_id) {
      uniCloud.callFunction({
        name: 'sendPush',
        data: {
          userId: userData.guardian_id,
          title: '❤️ 你守护的人说“我在”了',
          content: `${userData.nickname}今天打卡了,连续${continuousDays}天`
        }
      });
    }
    
    return { code: 0, msg: '打卡成功', data: { continuous_days: continuousDays } };
  } catch (e) {
    await transaction.rollback();
    throw e;
  }
};

5.3 定时任务:检查未签到用户

// cloudfunctions/timer/checkReminder.js
'use strict';

exports.main = async (event, context) => {
  const db = uniCloud.database();
  const dbCmd = db.command;
  const now = new Date();
  
  // 查找48小时未签到的用户
  const cutoff48 = new Date(now - 48 * 3600 * 1000);
  const users48 = await db.collection('users').where({
    last_checkin: dbCmd.lt(cutoff48),
    emergency_email: dbCmd.exists(true)
  }).get();
  
  for (const user of users48.data) {
    // 发送邮件给紧急联系人
    await sendEmail({
      to: user.emergency_email,
      subject: '【紧急提醒】您的亲友可能失联',
      html: `${user.nickname}已48小时未打卡,请确认其安全。`
    });
  }
  
  // 查找24小时未签到的用户
  const cutoff24 = new Date(now - 24 * 3600 * 1000);
  const users24 = await db.collection('users').where({
    last_checkin: dbCmd.lt(cutoff24),
    guardian_id: dbCmd.exists(true)
  }).get();
  
  for (const user of users24.data) {
    // 通知守护者
    await sendPushToUser(user.guardian_id, 
      '你守护的人还没打卡', 
      `${user.nickname}已24小时未说“我在”`
    );
  }
  
  return { code: 0 };
};

// 发送推送
async function sendPushToUser(userId, title, content) {
  const uniPush = uniCloud.getPushManager();
  await uniPush.sendMessage({ user_id: userId, title, content });
}

// 发送邮件(使用nodemailer)
async function sendEmail({ to, subject, html }) {
  const nodemailer = require('nodemailer');
  const transporter = nodemailer.createTransport({
    host: 'smtp.qq.com',
    port: 465,
    secure: true,
    auth: {
      user: process.env.EMAIL_USER,
      pass: process.env.EMAIL_PASS
    }
  });
  
  await transporter.sendMail({
    from: `"我在" <${process.env.EMAIL_USER}>`,
    to, subject, html
  });
}

5.4 前端:首页调用云函数

<template>
  <view class="container">
    <view class="streak-card">
      <text class="streak-num">{{ continuousDays }}</text>
      <text class="streak-label">连续说“我在” {{ continuousDays }} 天</text>
    </view>
    
    <view v-if="!todayChecked">
      <button @click="handleCheckin">说「我在」</button>
    </view>
    
    <view v-else>
      <text>✅ 今天已打卡</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      continuousDays: 0,
      todayChecked: false
    }
  },
  onLoad() {
    this.checkTodayStatus();
  },
  methods: {
    async checkTodayStatus() {
      const res = await uniCloud.callFunction({
        name: 'checkin-status'
      });
      this.continuousDays = res.result.data.continuous_days;
      this.todayChecked = res.result.data.today_checked;
    },
    
    async handleCheckin() {
      uni.showLoading({ title: '打卡中...' });
      
      const res = await uniCloud.callFunction({
        name: 'checkin-create',
        data: { mood: 'happy', note: '今天很好' }
      });
      
      if (res.result.code === 0) {
        this.todayChecked = true;
        this.continuousDays = res.result.data.continuous_days;
        uni.showToast({ title: '打卡成功', icon: 'success' });
      }
      
      uni.hideLoading();
    }
  }
}
</script>

六、成本估算

免费额度(阿里云uniCloud)

资源项 免费额度 说明
云函数调用 10万次/月 支撑1000日活
云数据库 2GB 存10万条记录
云存储 5GB 存放照片
CDN流量 5GB/月 图片加载

1000日活成本:0元


七、写在最后

「我在」这个名字,是我能想到的最温暖的回答。

如果你也想做独立开发,欢迎评论区聊聊:

  1. 你最想要「我在」有什么功能?
  2. 你会为哪些功能付费?
  3. 这个名字,你喜欢吗?

评论区抽三位送终身会员(如果App真做出来的话😂)


关注公众号" 大前端历险记",掌握更多前端开发干货姿势!

在 HTTP/3 普及的 2026 年,那些基于 Webpack 的性能优化经验,有一半该扔了

screenshot-20260310-112029.png

最近面了几个号称精通前端工程化的候选人,看着他们简历里大段大段的 Webpack 性能优化实战,我心情挺复杂的。🤷‍♂️

现在已经是 2026 年了,HTTP/3 早就成了基建标配。可是很多人脑子里的优化八股文,还停留在 2018 年 HTTP/1.1 和早期 HTTP/2 的时代。

他们在面试时背的流水:怎么配 SplitChunks,怎么做域名分片,怎么把小图片转 Base64,怎么拼雪碧图。说实话,听得我直皱眉头😖。

脱离了网络协议谈打包优化,全是在耍流氓。 在 HTTP/3(QUIC协议)普及,以及被 Vite 等打包工具加速淘汰的今天,你引以为傲的那些 Webpack 神级配置,有一半不仅没用,反而正在拖慢你的首屏速度。

今天我就直白点,扒一扒在 HTTP/3 时代,哪些老掉牙的优化经验该直接扔进垃圾桶。


打包成大 Chunk,你还在合并 Vendor 吗?

以前我们用 Webpack,最核心的诉求是什么?减少 HTTP 请求数。

因为 HTTP/1.1 有队头阻塞(Head-of-Line Blocking),浏览器对同一个域名还有 6 个并发连接的限制。所以我们要把 reactlodash 这些第三方库死死地打成一个 vendor.js,把业务代码打成 app.js

但在 HTTP/3 面前,这种做法极其愚蠢😒。

HTTP/3 底层是基于 UDP 的 QUIC 协议。它不仅解决了 TCP 层面的队头阻塞,还把多路复用(Multiplexing)做到了极致。几百个并发请求在 QUIC 看来成本极低,通道之间互不干扰。

现在的反直觉真相是:细粒度的模块加载(Fine-grained Loading, 推荐好文章😁),远比打包成大块更高效。

image.png

如果你把 20 个依赖打成一个 2MB 的 vendor.js,只要其中一个依赖升级了小版本,整个 2MB 的缓存全部失效,用户得重新下载。

所以咱们得顺应 ESM 和当前主流的构建工具(比如 Vite/Rspack/Turbopack)的趋势,把依赖拆碎。按包名输出单文件,利用 HTTP/3 的高并发特性,让浏览器自己去精准命中强缓存。

域名分片?

image.png

我看到还有人的简历里写着:通过配置多个 CDN 域名(static1.domain.com, static2.domain.com, static3.domain.com)突破浏览器并发限制,提升加载速度。

这在 HTTP/1.1 时代是标答,但在 HTTP/3 时代,这是纯纯的愚蠢😖。

  • 握手成本: HTTP/3 虽然支持 0-RTT,但建立一个新的 QUIC 连接,依然需要 DNS 解析和初始的握手计算。
  • 拥塞窗口重置: QUIC 连接刚建立时,为了探测网络情况,发送窗口是比较小的(Slow Start)。如果你把资源散布在 4 个域名上,浏览器就要建立 4 个 QUIC 连接,每个连接都要经历一次缓慢的热身过程。

所以结合以上👆特点,把所有静态资源集中在一个域名下。这样不仅只发生一次 DNS 解析和握手,还能让这个唯一的 QUIC 连接迅速撑大拥塞窗口,后续的并发请求速度会快得飞起。连接复用率越高,HTTP/3 的优势才越大。

Base64 内联与雪碧图(CSS Sprites):拿 CPU 算力换网络,亏本买卖

Webpack 时代,url-loader 的标配是:小于 8KB 的图片直接转 Base64 塞进 JS 或 CSS 里。前端甚至为了几个 icon 专门搞一套 webpack-spritesmith 自动化拼图。

为什么?还是为了省那几个可怜的 HTTP 请求。

但在 2026 年,这样做弊大于利:

解析成本太高: Base64 字符串的体积比原图大 30% 左右。更致命的是,浏览器解析巨型 JS/CSS 文件中的 Base64 非常消耗主线程 CPU。在低端移动设备上,直接导致长时间的 Long Task,页面会卡死。

image.png

而且雪碧图里只要改了一个 10x10 的小图标,整张大图的缓存直接作废😒。

别再折腾了,直接用 HTTP/3 并发请求原生的 WebP 或 AVIF 格式图片和算法优势。既省下了转码带来的体积膨胀,又释放了主线程的解析压力,还能做到完美的单文件缓存。

Tree-shaking 依然很重要,但重心变了

image.png

有人可能会杠:既然 HTTP/3 并发这么牛,那我是不是不需要构建工具了,全裸奔上 ESM?

当然不是。网络协议再快,也救不了你几兆的无用代码。浏览器下载完 JS 是要 Parsing 和 Compiling 的,这段 CPU 执行时间 HTTP/3 帮不了你。

但在 HTTP/3 时代,工程化的重心已经从如何把文件拼得更好看(Bundling),彻底转移到了如何精准剔除废代码(Dead Code Elimination)和极致的按需加载

这也就是为什么基于 Rust 的无打包/轻打包工具(No-bundle / Bundleless)在近几年彻底取代 Webpack 成为了主流。因为它们顺应了底层网络协议的演进方向👍。


技术的演进是自下而上的。从 TCPUDP,从 HTTP/1.1HTTP/3,基础设施变了,上层建筑就得跟着翻修。

作为 9 年经验的老兵,我给还在死磕 Webpack 复杂配置的同行一句忠告😊:

停下来,打开 Chrome 的 Network 面板,看看 Protocol 那一栏是不是已经全是 h3 了。如果是,请把你脑子里那些为了减少请求数而做的扭曲 Hack 手段,干脆利落地删掉。

你的前端架构应该顺应浏览器的天性,而不是去填补十年前的网络缺陷。

祝大家面试好运🙌🙌🙌

好好运,好好好好好好好好.gif

❌