普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月5日首页

14_React 中的更新队列 updateQueue

2026年5月5日 09:47

一、概述

updateQueue 是挂在 Fiber / Hook 上的更新队列(链表),用于缓存 setState 产生的 update,并在 render 阶段按优先级(lane)依次计算出新的 state。

在 React 中,有许多触发状态更新的方法,比如:

  • ReactDOM.createRoot
  • setState
  • useState dispatcher
  • useReducer dispatcher

这些方法使用相同的更新流程,因为它们都使用 updateQueue 这个数据结构。

二、两套 updateQueue

React 里有两套队列:

1️⃣ 类组件

fiber.updateQueue = {
  baseState, // 初始 state,update 基于该 state 计算新的 state
  firstBaseUpdate, // 更新前该 FiberNode 中已保存的 update 链表,表头为 firstBaseUpdate
  lastBaseUpdate, // 链表尾部为 lastBaseUpdate
  // 触发更新后,产生的 update 会保存在 shared.pending 中形成单向环状链表
  // 计算 state 时,该环状链表会被拆分并拼接在 lastBaseUpdate 后面。
  shared: {
    pending
  }
}

2️⃣ 函数组件 Hooks

每个 useState / useReducer 都有一个 queue:

hook.queue = {
  pending: Update | null, // 环形链表
  dispatch: Function
}

三、Update 数据结构

Update 节点

type Update = {
  lane: Lane;        // 优先级
  action: any;       // setState 传入的值/函数
  next: Update | null;
}

队列结构(环形链表)

pending
   ↓
update1 → update2 → update3
   ↑                 ↓
   ← ← ← ← ← ← ← ← ←

为什么是环形?

  • O(1) 插入
  • 不需要区分头尾

四、dispatch(setState)发生了什么?

setCount(c => c + 1)
function dispatchSetState(fiber, queue, action) {
  const update = {
    lane: requestUpdateLane(), // 分配优先级
    action,
    next: null
  };

  enqueueUpdate(queue, update);

  scheduleUpdateOnFiber(fiber);
}

enqueueUpdate

function enqueueUpdate(queue, update) {
  const pending = queue.pending;

  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }

  queue.pending = update;
}

插入效果:永远插在“尾部”,但保持环形。

五、render 阶段:如何消费 updateQueue?

processUpdateQueue()

执行流程

let newState = baseState;

let update = firstUpdate;

do {
  if (lane 满足当前优先级) {
    newState = reducer(newState, update.action);
  } else {
    // 跳过(并发关键)
    // 执行在下一次 render 开始的时候,和下一 render 的 updates 组成新的链表
  }

  update = update.next;
} while (update !== null);

reducer 本质

function reducer(state, action) {
  return typeof action === 'function'
    ? action(state)
    : action;
}

六、baseState & baseQueue(并发核心)

React 19 支持并发:低优先级更新可能被跳过

hook.memoizedState // 当前 state

hook.baseState     // 上一次稳定 state

hook.baseQueue     // 未处理的 update

执行逻辑

高优先级 → 先执行
低优先级 → 留在 baseQueue

示例

setCount(1)        // 高优先级
startTransition(() => {
  setCount(2)      // 低优先级
})

render 结果:

先执行 1
2 留在队列,下次再算

七、lane(优先级系统)

React 19 的核心:

每个 update 都有 lane(优先级)

判断逻辑

if ((update.lane & renderLanes) !== 0) {
  // 执行
} else {
  // 跳过
}

不同的 update 是有不同的优先级,高优先级的 update 能够中断低优先级的 update,当高优先级的 update 完成更新之后,后续的低优先级更新会在高优先级 update 更新后的 state 的基础上再来进行更新。

八、批处理(Batching)

React 18+ 自动 batching

setCount(1)
setCount(2)

结果:

只触发一次 render

因为 多个 update 进入同一个 queue。

执行顺序:

1 → 2 → 最终 state = 2

错误写法:

setCount(count + 1)
setCount(count + 1)

结果:+1(不是 +2)

正确写法:

setCount(c => c + 1)
setCount(c => c + 1)

原因:每个 update 都基于“上一个结果”

九、和 effectQueue 的区别

本质:

updateQueue → render 阶段
effectQueue → commit 阶段

十、完整流程总结

setState
   ↓
创建 update(带 lane)
   ↓
加入 updateQueue(环形链表)
   ↓
scheduleUpdateOnFiber
   ↓
render 阶段:
   ↓
processUpdateQueue(计算 state)
   ↓
commit 阶段:
   ↓
更新 DOM + 执行 effect

代码写成一锅粥?3个设计模式让你的项目“起死回生”

作者 kyriewen
2026年5月4日 21:01

你的组件里是不是全是if-else?改一个地方,崩三个地方?新来的同事改完你的代码,你看着他,他看你,两人都沉默了。今天我们不背理论,直接用3个前端最常用的设计模式——单例、观察者、策略,把业务从“屎山”变成“积木”。学完你就能拍着胸脯说:“我的代码,谁都敢动。”

前言

设计模式不是“面试八股文”,而是前辈们踩过的坑总结成的“套路”。就像做饭有菜谱,写代码也有标准解法。今天我们把场景摆出来:弹窗多次打开、购物车更新通知到处写、表单校验if-else十几层……然后一个个用设计模式把它们治好。

一、单例模式:全局只有一个的“独生子”

场景:你写了个全局弹窗(Modal),用户点按钮就打开。结果用户连续点三次,页面上冒出三个弹窗叠在一起,像俄罗斯方块。

问题代码

function showModal() {
  const div = document.createElement('div');
  div.className = 'modal';
  div.innerHTML = '我是弹窗';
  document.body.appendChild(div);
}
// 点三次,三个弹窗

单例模式解决

确保无论调用多少次,只创建同一个实例。

class GlobalModal {
  constructor() {
    if (!GlobalModal.instance) {
      this.element = null;
      GlobalModal.instance = this;
    }
    return GlobalModal.instance;
  }
  show() {
    if (!this.element) {
      this.element = document.createElement('div');
      this.element.className = 'modal';
      this.element.innerHTML = '我是弹窗';
      document.body.appendChild(this.element);
    }
    this.element.style.display = 'block';
  }
  hide() {
    if (this.element) this.element.style.display = 'none';
  }
}
const modal1 = new GlobalModal();
const modal2 = new GlobalModal();
console.log(modal1 === modal2); // true

真实项目更简单的写法:直接导出实例对象。

// modal.js
export const globalModal = {
  element: null,
  show() { /* ... */ },
  hide() { /* ... */ }
};

应用:全局Store(Pinia/Vuex就是单例)、全局轮询管理器、WebSocket连接。

二、观察者模式:让不相干的组件“悄悄对话”

场景:用户点击“添加购物车”,需要同时做三件事:更新购物车角标、弹出“添加成功”提示、发送埋点数据。如果直接在购物车里调用其他模块的方法,代码会变成:

function addToCart(item) {
  // 添加逻辑...
  header.updateBadge(count);
  toast.show('添加成功');
  analytics.track('add_to_cart', item);
}

每加一个功能,addToCart就要改一次,耦合得像麻花。

观察者模式解决(事件总线)

// eventBus.js
class EventBus {
  constructor() {
    this.events = {};
  }
  on(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
  }
  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(cb => cb(data));
    }
  }
  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
}
export const bus = new EventBus();
// 购物车模块
import { bus } from './eventBus';
function addToCart(item) {
  // 添加逻辑...
  bus.emit('cartUpdated', { count: newCount, item });
}
// 头部模块
import { bus } from './eventBus';
bus.on('cartUpdated', (data) => {
  updateBadge(data.count);
});
// 埋点模块
bus.on('cartUpdated', (data) => {
  analytics.track('add_to_cart', data.item);
});

现在,要加新功能只管bus.on,不用改购物车代码。Vue的emitter、React的useContext+useReducer其实都用了这个思想。

三、策略模式:消灭if-else毒瘤

场景:用户等级不同,商品折扣不同。你写了一个函数:

function getDiscount(level, price) {
  if (level === 'normal') return price * 0.95;
  else if (level === 'gold') return price * 0.9;
  else if (level === 'platinum') return price * 0.8;
  else return price;
}

这还好。但当你需要增加“钻石会员”、“企业会员”、“节日特惠”……函数越来越大,改一次心惊胆战。

策略模式解决:把算法抽成独立对象

const discountStrategies = {
  normal: (price) => price * 0.95,
  gold: (price) => price * 0.9,
  platinum: (price) => price * 0.8,
};
function getDiscount(level, price) {
  const strategy = discountStrategies[level];
  return strategy ? strategy(price) : price;
}

新增会员等级,只需要加一个策略,不用改getDiscount

更复杂的例子:表单验证

const validators = {
  required: (val) => val && val.trim() !== '',
  minLength: (val, len) => val.length >= len,
  email: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
};
function validateField(value, rules) {
  for (let rule of rules) {
    const [name, param] = rule.split(':');
    const validator = validators[name];
    if (validator && !validator(value, param)) {
      return false;
    }
  }
  return true;
}
// 使用
const isValid = validateField('abc@test.com', ['required', 'email']);

以后增加“手机号验证”,加一个mobile策略即可,完全符合开闭原则(对扩展开放,对修改封闭)。

四、组合实战:一个购物车结算页面

  • 单例:全局唯一的购物车实例(存储商品列表)。
  • 观察者:商品数量变化时,触发价格重算、优惠券校验、埋点。
  • 策略:根据用户等级计算折扣;根据优惠券类型(满减、打折)计算优惠。
// 购物车单例
class Cart {
  static instance = null;
  static getInstance() {
    if (!Cart.instance) Cart.instance = new Cart();
    return Cart.instance;
  }
  items = [];
  addItem(item) {
    // 添加逻辑
    bus.emit('cartChanged', this.items);
  }
}
// 价格计算模块监听变化并应用折扣策略
bus.on('cartChanged', (items) => {
  const total = items.reduce((sum, item) => sum + item.price * item.count, 0);
  const discount = discountStrategies[user.level](total);
  renderTotal(discount);
});

各模块独立,改折扣策略不影响购物车;加埋点不影响价格计算。

五、总结:模式是工具,不是教条

  • 单例:保证全局唯一,适合共享资源。
  • 观察者:解耦事件发布和订阅,适合跨组件通信。
  • 策略:消除if-else,算法可互换,适合规则多变场景。

不要为了用模式而用模式。当你的代码出现重复、难维护、改一处动全身时,想想哪种模式能帮你“抽出来”。写代码就像搭积木,模式就是那些标准接口的积木块,让你搭得又快又稳。

昨天以前首页

用Viem替代ethers.js:从一次签名失败到完整迁移的实战记录

作者 竹林818
2026年5月4日 18:01

用Viem替代ethers.js:从一次签名失败到完整迁移的实战记录

摘要

我在开发一个跨链DeFi聚合器时,被ethers.js的签名兼容性和类型错误折磨了两天,最终决定迁移到Viem。这篇文章记录了我从踩坑、分析到用Viem重写合约交互的全过程,包括签名验证、事件监听和Gas估算等核心场景的代码实现。

背景

上个月接了一个跨链DeFi聚合器的前端开发,核心功能是让用户在一条链上签名交易,然后在另一条链上执行。项目用了ethers.js v5,配合MetaMask做钱包连接。本来一切顺利,直到我需要在Polygon上签名一个EIP-712结构化数据,然后在Optimism上验证并执行。结果签名总是对不上,不是报invalid signature就是recovered address mismatch。我排查了两天,发现ethers.js在处理某些链的签名格式时,会有奇怪的行为——尤其是当签名中的v值不是27或28时,它会自动调整,导致跨链验证失败。当时我就想,这库用了几年了,怎么还有这种坑?

问题分析

我的最初思路是:既然ethers.js对签名做了"友好"处理,那我手动把v值标准化成27/28不就行了?于是我写了段代码:

const signature = await signer._signTypedData(domain, types, value);
// 手动解析并调整v值
const { v, r, s } = ethers.utils.splitSignature(signature);
const adjustedV = v === 0 ? 27 : v === 1 ? 28 : v;

结果更糟了。因为splitSignature内部已经把v调整了一次,我再调整一次,签名直接废了。而且ethers.js v5的TypeScript类型定义不够严谨,signer._signTypedData返回的是Promise<string>,但你传进去的domain类型是TypedDataDomain,它和EIP-712规范里的字段名有细微差异(比如chainId在ethers里是number,但规范里是uint256),导致某些链(比如Arbitrum)直接报invalid argument

我后来查了GitHub issues,发现ethers.js团队在v6里改进了签名处理,但v6的API变化太大,迁移成本高。这时我想到了Viem——一个更轻量、类型更严格的Web3库。当时我犹豫了一下:换库意味着重写所有合约交互代码,但既然已经被ethers.js坑了一次,不如彻底解决。

核心实现

第一步:搭建Viem环境,替换钱包连接

我用的React框架,之前用@web3-react/core配合ethers.js。Viem官方提供了wagmi(一个React Hooks库),但我不想引入太多依赖,所以直接用Viem的createWalletClientcreatePublicClient自己封装。

这里有个坑:Viem的createWalletClient默认不包含window.ethereum,需要手动传入transport。我一开始忘了传,结果walletClient.getAddresses()一直返回空数组。

import { createWalletClient, createPublicClient, custom, http } from 'viem';
import { mainnet, polygon, optimism } from 'viem/chains';

// 初始化公共客户端(用于读链上数据)
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http()
});

// 初始化钱包客户端(需要用户授权)
const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' });
const walletClient = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum)
});

注意:createWalletClienttransport参数必须用custom(window.ethereum),不能用http()。我当时用http()测试,结果报TransportError: The transport does not support signing

第二步:用Viem实现EIP-712签名

这是我最头疼的部分。Viem的signTypedData方法要求传入的参数类型非常严格——domain里的chainId必须是number,不能是bigintstring。而且它不会像ethers.js那样自动转换v值,签名结果就是原始格式。

import { signTypedData, recoverTypedDataAddress } from 'viem';

// 定义EIP-712类型
const domain = {
  name: 'CrossChainSwap',
  version: '1',
  chainId: 137, // Polygon的chainId,必须是number
  verifyingContract: '0x...' as `0x${string}`
};

const types = {
  Swap: [
    { name: 'fromToken', type: 'address' },
    { name: 'toToken', type: 'address' },
    { name: 'amount', type: 'uint256' },
    { name: 'nonce', type: 'uint256' }
  ]
};

const value = {
  fromToken: '0x...' as `0x${string}`,
  toToken: '0x...' as `0x${string}`,
  amount: BigInt('1000000000000000000'),
  nonce: BigInt(Date.now())
};

// 签名
const signature = await walletClient.signTypedData({
  account,
  domain,
  types,
  primaryType: 'Swap',
  message: value
});

// 验证签名(在另一条链上)
const recoveredAddress = await recoverTypedDataAddress({
  domain,
  types,
  primaryType: 'Swap',
  message: value,
  signature
});
console.log('Recovered:', recoveredAddress); // 应该等于account

这里有个关键细节:Viem的signTypedData返回的签名是0x开头的十六进制字符串,长度是132个字符(包含0x),这是标准的RSV格式。ethers.js返回的也是同样的格式,但它内部对v做了处理。Viem不会——所以跨链验证时,只要你在两条链上用相同的参数签名,结果就是一致的。我当时测试了Polygon和Optimism,签名完全匹配。

第三步:合约调用和Gas估算

替换合约调用时,我遇到了第二个坑:Viem的writeContractestimateGas是分离的,不像ethers.js那样直接在交易对象里传gasLimit。我需要先估算Gas,然后手动设置。

import { getContract } from 'viem';

// 创建合约实例
const contract = getContract({
  address: '0x...' as `0x${string}`,
  abi: swapAbi,
  client: { public: publicClient, wallet: walletClient }
});

// 估算Gas
const gasEstimate = await publicClient.estimateContractGas({
  address: contract.address,
  abi: contract.abi,
  functionName: 'swap',
  args: [value.fromToken, value.toToken, value.amount, signature],
  account
});

// 发送交易
const hash = await walletClient.writeContract({
  address: contract.address,
  abi: contract.abi,
  functionName: 'swap',
  args: [value.fromToken, value.toToken, value.amount, signature],
  account,
  gas: gasEstimate // 注意:Viem里gas参数是`gas`不是`gasLimit`
});

注意这个参数名:Viem用gas,ethers.js用gasLimit。我当时习惯性写了gasLimit,结果交易一直报错missing gas limit。排查了半小时才发现这个命名差异。

第四步:事件监听

事件监听是另一个让我头疼的地方。ethers.js的contract.on是回调式的,Viem用的是watchContractEvent,返回一个取消监听的函数。

// 监听Swap事件
const unwatch = publicClient.watchContractEvent({
  address: contract.address,
  abi: contract.abi,
  eventName: 'SwapExecuted',
  args: { user: account }, // 过滤条件
  onLogs: (logs) => {
    const [log] = logs;
    console.log('Swap executed:', log.args);
    // 更新UI
    setTxStatus('confirmed');
  }
});

// 组件卸载时取消监听
useEffect(() => {
  return () => unwatch();
}, []);

这里有个坑:Viem的watchContractEvent在监听时,如果链的区块时间很短(比如Polygon每2秒一个块),回调会被频繁触发。我一开始没做防抖,结果UI刷新了几百次。后来加了throttle才解决。

第五步:链切换

跨链聚合器需要频繁切换链。Viem的switchChain比ethers.js更直观,但要注意:walletClient.switchChain只切换钱包的链,不影响publicClient。我需要同时更新两个客户端。

import { polygon, optimism } from 'viem/chains';

async function switchChain(targetChain: typeof polygon | typeof optimism) {
  try {
    // 切换钱包链
    await walletClient.switchChain({ id: targetChain.id });
    // 更新公共客户端
    publicClient = createPublicClient({
      chain: targetChain,
      transport: http()
    });
  } catch (error) {
    // 如果用户没有目标链,请求添加
    if (error.code === 4902) {
      await walletClient.addChain({ chain: targetChain });
      await walletClient.switchChain({ id: targetChain.id });
      publicClient = createPublicClient({
        chain: targetChain,
        transport: http()
      });
    }
  }
}

注意:createPublicClient每次调用都会创建一个新的客户端实例。如果项目中有多个组件依赖同一个publicClient,需要把它放到Context里管理。我当时没注意,导致不同组件用了不同的客户端实例,有的读的是旧链的数据。

完整代码

下面是一个完整的React组件,实现了跨链签名-验证-执行的全流程:

import React, { useState, useEffect } from 'react';
import {
  createWalletClient,
  createPublicClient,
  custom,
  http,
  signTypedData,
  recoverTypedDataAddress,
  getContract
} from 'viem';
import { polygon, optimism } from 'viem/chains';

const SWAP_ABI = [
  {
    inputs: [
      { name: 'fromToken', type: 'address' },
      { name: 'toToken', type: 'address' },
      { name: 'amount', type: 'uint256' },
      { name: 'signature', type: 'bytes' }
    ],
    name: 'swap',
    outputs: [],
    stateMutability: 'nonpayable',
    type: 'function'
  },
  {
    anonymous: false,
    inputs: [
      { indexed: true, name: 'user', type: 'address' },
      { indexed: false, name: 'amount', type: 'uint256' }
    ],
    name: 'SwapExecuted',
    type: 'event'
  }
];

const CrossChainSwap: React.FC = () => {
  const [account, setAccount] = useState<`0x${string}`>();
  const [status, setStatus] = useState<'idle' | 'signing' | 'executing' | 'done'>('idle');
  const [error, setError] = useState<string>('');

  // 初始化客户端
  const [publicClient, setPublicClient] = useState(() =>
    createPublicClient({ chain: polygon, transport: http() })
  );
  const [walletClient, setWalletClient] = useState<ReturnType<typeof createWalletClient>>();

  useEffect(() => {
    const init = async () => {
      const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });
      setAccount(address as `0x${string}`);
      setWalletClient(
        createWalletClient({
          chain: polygon,
          transport: custom(window.ethereum)
        })
      );
    };
    init();
  }, []);

  const handleSwap = async () => {
    if (!account || !walletClient) return;

    try {
      setStatus('signing');

      // 1. 在Polygon上签名
      const domain = {
        name: 'CrossChainSwap',
        version: '1',
        chainId: 137,
        verifyingContract: '0x...' as `0x${string}`
      };
      const types = {
        Swap: [
          { name: 'fromToken', type: 'address' },
          { name: 'toToken', type: 'address' },
          { name: 'amount', type: 'uint256' },
          { name: 'nonce', type: 'uint256' }
        ]
      };
      const value = {
        fromToken: '0x...' as `0x${string}`,
        toToken: '0x...' as `0x${string}`,
        amount: BigInt('1000000000000000000'),
        nonce: BigInt(Date.now())
      };

      const signature = await walletClient.signTypedData({
        account,
        domain,
        types,
        primaryType: 'Swap',
        message: value
      });

      // 2. 验证签名(可选,用于调试)
      const recovered = await recoverTypedDataAddress({
        domain,
        types,
        primaryType: 'Swap',
        message: value,
        signature
      });
      if (recovered !== account) {
        throw new Error('Signature recovery failed');
      }

      // 3. 切换到Optimism执行
      setStatus('executing');
      await walletClient.switchChain({ id: optimism.id });
      setPublicClient(createPublicClient({ chain: optimism, transport: http() }));

      // 4. 估算Gas
      const contract = getContract({
        address: '0x...' as `0x${string}`,
        abi: SWAP_ABI,
        client: { public: publicClient, wallet: walletClient }
      });

      const gasEstimate = await publicClient.estimateContractGas({
        address: contract.address,
        abi: contract.abi,
        functionName: 'swap',
        args: [value.fromToken, value.toToken, value.amount, signature],
        account
      });

      // 5. 发送交易
      const hash = await walletClient.writeContract({
        address: contract.address,
        abi: contract.abi,
        functionName: 'swap',
        args: [value.fromToken, value.toToken, value.amount, signature],
        account,
        gas: gasEstimate
      });

      // 6. 等待确认(简化版)
      await publicClient.waitForTransactionReceipt({ hash });
      setStatus('done');
    } catch (err) {
      setError(err.message);
      setStatus('idle');
    }
  };

  return (
    <div>
      <p>Account: {account}</p>
      <button onClick={handleSwap} disabled={!account || status !== 'idle'}>
        {status === 'signing' ? 'Signing...' : status === 'executing' ? 'Executing...' : 'Start Swap'}
      </button>
      {error && <p style={{ color: 'red' }}>Error: {error}</p>}
      {status === 'done' && <p>Swap completed!</p>}
    </div>
  );
};

export default CrossChainSwap;

踩坑记录

  1. v值冲突:ethers.js的splitSignature会调整v值到27/28,但Viem不会。如果你在同一个项目里混用两个库,签名验证会失败。我的解决方案是:完全切换到Viem,统一签名处理逻辑。

  2. Gas参数命名:Viem用gas而不是gasLimit,这个命名差异让我排查了半小时。Viem的文档里写的是gas,但很多教程示例用的还是gasLimit,容易混淆。

  3. createPublicClient实例管理:每次切换链都重新创建publicClient,导致多个组件引用了不同的实例。我把客户端放到了React Context里,用useMemo缓存实例,只在链切换时更新。

  4. watchContractEvent回调频率:在Polygon上监听事件时,回调被频繁触发。我加了一个throttle函数,每500ms只处理一次日志。

小结

迁移到Viem后,签名问题彻底解决了,而且类型安全让我少了很多运行时错误。核心收获是:对于跨链场景,Viem的原始签名处理更可靠。如果你也在被ethers.js的签名兼容性折磨,可以试试Viem。下一步我打算研究Viem的account abstraction支持,看看能不能进一步简化钱包集成。

关于普通函数和箭头函数的this

2026年5月4日 14:19

箭头函数的作用以及函数的二义性

函数的两种作用

  1. 作为指令序列,直接调用
  2. 作为构造函数,使用new关键字创建实例对象

JS中的this

  • this作为全局上下位的一部分,仅在函数被调用时才创建
// ❌ 错误理解sadasda'张三',
    this: '???',  // 这只是一个普通属性,不是 this
    thisValue: this  // 这里的 this 是全局对象,不是 obj
};

// ✅ 正确理解:this 是在函数执行时确定的
const obj2 = {
    name: '李四',
    sayName() {
        console.log(this.name);  // this 在执行时才绑定到 obj2
    }
};

obj2.sayName(); // '李四' - this 指向 obj2
  • 普通函数this的绑定时机:普通函数定义时 this 未绑定
function showThis() {
    console.log(this);
}

const obj1 = { name: 'obj1', show: showThis };
const obj2 = { name: 'obj2', show: showThis };

// 同样的函数,不同的调用方式,this 指向不同
obj1.show(); // { name: 'obj1', show: f }
obj2.show(); // { name: 'obj2', show: f }
showThis();  // window/global (严格模式 undefined)

// 证明:函数没有固定的 this
console.log(showThis === obj1.show); // true (同一个函数)
  • 箭头函数的this是在定义时决定的,在函数作用域内向上查找最近的普通函数并继承
const obj = {
    name: 'obj',
    fun1: function() {
        console.log(this.name) // 'obj'
        const fun1Inner = () => {
            console.log(this.name) // 定义时的作用域绑定,此时作用域时fun1,而fun1的this指向obj
        };
        fun1Inner();
    };
    fun2: () => {
        console.log(this.name) // 调用时因为obj是一个对象,对象没有this,此时this指向window,而window没有name属性,输出undefined
    }

}

obj.fun1(); 
// 输出两个obj
obj.fun2();
// 输出undefiner

ESModule和Commonjs模块的区别

作者 Rkgua
2026年5月3日 22:12

ES Module(ESM)和 CommonJS(CJS)是 JavaScript 中两种主流的模块化规范。ESM 是 ES6 推出的官方标准,而 CommonJS 则是 Node.js 早期采用的模块化方案。

以下从几个核心角度为你详细拆解:

1. 核心差异速览表

对比角度 CommonJS (CJS) ES Module (ESM)
基本语法 require() 导入,module.exports 导出 import 导入,export 导出
加载时机 运行时加载(动态) 编译时加载(静态)
加载方式 同步加载 异步加载(浏览器端)
导出本质 值的拷贝(浅拷贝) 值的引用(Live Binding)
代码优化 不支持 Tree Shaking 支持 Tree Shaking
顶层 this 指向 module.exports undefined(严格模式)

2. 深度解析各个角度

语法与规范来源

  • CommonJS:是社区提出的规范,主要用于 Node.js 服务端环境。它的语法非常直观,使用 require() 来引入模块,使用 module.exportsexports 来向外暴露功能。
  • ES Module:是 ECMAScript 2015 (ES6) 的官方语言标准,旨在统一浏览器和服务端的模块化。它使用 importexport 关键字,语法更加语义化,支持命名导出和默认导出。

加载时机与方式(最核心的区别)

  • CommonJS 是“运行时同步加载”:当你代码执行到 require() 这一行时,才会去加载并执行对应的模块文件。这种方式在服务端(读取本地硬盘文件)非常高效,但在浏览器端会因为网络请求阻塞页面渲染,所以浏览器不原生支持。
  • ES Module 是“编译时静态加载”:JS 引擎在解析代码的阶段(编译时),就会通过分析 importexport 语句,提前确定好模块之间的依赖关系。在浏览器中,ESM 默认是异步加载的,不会阻塞 HTML 的解析。

导出的本质:值拷贝 vs 值的引用 这是两者在实际开发中最容易产生 Bug 的差异点:

  • CommonJS(值拷贝):导出的是模块内部变量的一个副本。如果模块内部修改了这个变量,外部引入的地方是感知不到的。
    // CommonJS 示例
    // counter.js
    let count = 0;
    module.exports = { count };
    setTimeout(() => { count = 1; }, 1000); // 内部修改
    
    // main.js
    const { count } = require('./counter.js');
    console.log(count); // 0
    setTimeout(() => { console.log(count); }, 1100); // 依然是 0,因为是拷贝的旧值
    
  • ES Module(值的引用 / Live Binding):导出的是对模块内部变量的动态引用。当模块内部修改了变量,所有引入该变量的地方都会同步更新。
    // ESM 示例
    // counter.js
    export let count = 0;
    setTimeout(() => { count = 1; }, 1000); // 内部修改
    
    // main.js
    import { count } from './counter.js';
    console.log(count); // 0
    setTimeout(() => { console.log(count); }, 1100); // 1,实时同步了最新值
    

代码优化(Tree Shaking)

  • ES Module:由于它是静态的,打包工具(如 Webpack、Rollup、Vite)可以在打包阶段就分析出哪些代码被使用了,哪些没有。未被使用的代码(Dead Code)会被直接剔除,这个过程叫 Tree Shaking(摇树优化),能显著减小打包体积。
  • CommonJS:由于 require() 可以在代码运行时动态执行(比如写在 if 判断里),打包工具很难在编译阶段确定到底引用了哪些模块,因此无法有效支持 Tree Shaking。

运行环境与兼容性

  • CommonJS:Node.js 的默认模块规范,生态极其成熟。在浏览器中无法直接使用,必须通过 Webpack、Browserify 等工具打包转换。
  • ES Module:现代浏览器原生支持(通过 <script type="module">),也是现代前端框架(Vue3, React)和构建工具(Vite)的首选。Node.js 从 v12 版本后也开始支持 ESM,但需要在 package.json 中配置 "type": "module" 或使用 .mjs 后缀。

总结建议: 在现代前端开发和新的 Node.js 项目中,优先推荐使用 ES Module,因为它更标准、性能更好且支持代码优化。但在维护一些老旧的 Node.js 项目或依赖某些仅支持 CJS 的第三方库时,你依然会频繁接触到 CommonJS。

Java和JavaScript的关系真是雷峰和雷峰塔的关系吗?

作者 Linsk
2026年5月3日 18:47

前端圈一直流传着一个经典段子:Java和JavaScript是什么关系?就是雷峰和雷峰塔的关系。听过后令人会心一笑。但静下来想想🤔,真是这样吗?

什么是雷峰和雷峰塔的关系

雷峰(人)和雷峰塔(建筑)的关系非常明确:除了名字读音相似之外,两者在血缘、历史、物理构成等任何维度上,都百分之百毫无关联。

那么,Java和Javascript是否只是名字有点相似,实则毫无关系呢?

Java和Javascript的关系

如果抛开段子,翻开真实的计算机史,你会发现Java和JavaScript不仅不是“毫无关系”,反而有着千丝万缕的渊源。

Javascript是Sun和Netscape联合发布的,Sun(现在是Oracle)是Javascript的商标持有者。

时间回到1995年,网景公司(Netscape)为了在浏览器里加入交互能力,搞出了一门脚本语言(最初叫Mocha,后改LiveScript)。当时Sun公司推出的Java语言正如日中天,被媒体炒作战无不胜的“神器”。网景为了蹭上这波热度,与Sun公司达成了战略合作,将这门语言正式更名为JavaScript
更硬核的事实是:直到今天,JavaScript的商标权依然掌握在Sun的继承者甲骨文(Oracle)手里。如果是毫无关系的两者,怎么可能共用一个具有法律效力的名字?

JavaScript就是按像Java设计的

网景公司在给语言改名的同时,也给开发者(Brendan Eich)提出了一个明确的需求:“让它的语法看起来像Java”。因此,JavaScript在诞生之初,大量借鉴了Java的基础语法结构。它的 if/else 分支、for 循环结构、try/catch 异常处理机制,甚至是 new 关键字的使用,看起来和Java几乎如出一辙。因此,JavaScript 不是巧合像,是故意设计成像 Java

Java中内置了JavaScript运行时

从JDK 6引入Rhino引擎,到JDK 8内置Nashorn引擎(后在JDK 15中移除),再到如今通过GraalVM JS等现代方案实现深度互操作,Java官方生态长期保持着对JavaScript运行时的支持。这意味着,你完全可以在Java程序里直接调用JavaScript代码,把它们当作业务中的“动态脚本层”。这绝不是两座毫无交集的孤岛,两者在运行层面长期深度集成。

Java脚本化后就是JavaScript的样子

如果说设计一门Java的脚本语言,要类似Java的语法,但是要脚本语言的特性,要能解释执行、方便灵活、宽松,还要高扩展性。那么设计出来就是JavaScript这个样子。

这是一个非常有趣的逻辑推导。假设1995年你需要为Java生态设计一门“附属脚本语言”,你的需求清单是这样的:

  • 融入Java生态:语法必须像Java;
  • 运行机制:不需要编译,直接解释执行,轻量级;
  • 类型系统:不能像Java那么严苛,要宽松、动态,写起来方便;
  • 高扩展性:面对复杂多变的Web环境,必须允许开发者随时往内置类中添加方法;

当你按照这份需求文档写出一门语言时,恭喜你,你重新发明了JavaScript。它从一出生,就是带着“ Java的轻量化脚本兄弟 ”这个定位来的。

JavaScript和Java是两门不同的语言,但是不代表毫无关系。

有些人觉得JavaScript还是不够 “像” Java,比如JavaScript的类的实现是基于原型链的,和Java类有本质不同。对此我想说,脚本语言和编译型的语言本来就是为不同场景设计的。JavaScript和Java的差异确实足够大,大到应当作为两门不同语言分别学习,但是不能否认它们的历史渊源。JavaScript和Java的差异更多的可以用不同场景设计来解释。比如原型链问题,在Java中,你的API更新了,你只要升级JDK;而浏览器环境上应当让内置类有较高的扩展性,原型链无疑是最优解,让你重新设计一遍Javascript你也会设计成这样。

总结

Java 和 JavaScript 并非毫无关系,把它俩比作雷锋和雷峰塔,实在是冤枉了这两门语言。它俩的关系更接近于 VB 和 VBScript 的关系:VB 是微软推出的完整版编译型语言,适合开发大型桌面应用,VBScript 则是基于 VB 语法设计的轻量级脚本语言,灵活简洁,用于自动化、网页脚本,二者语法同源、定位互补,是同体系下不同分工的语言。

微软推出JScript

故事的走向在浏览器大战时期变得更加复杂。微软为了让自家的Internet Explorer浏览器兼容已有网站,迅速搞出了一个 JScript。JScript虽然名字刻意避开了“Java”字眼,但就是为了兼容JavaScript而生的,可以看作微软的JavaScript。但由于JavaScript并非开放标准,微软是照着Netscape的JavaScript行为猜着做的,只能仿个大概,对边界场景可能存在不一致。

EcmaScript出现

为了推动Web标准化,1996年,网景将JavaScript提交给了欧洲计算机制造商协会(ECMA)进行标准化。第二年,ECMA出台了ECMA-262标准,这便是大名鼎鼎的 ECMAScript(简称ES)。

从此,技术界有了一个清晰的共识:JScript和JavaScript,本质上都只是ECMAScript标准的不同实现。 这个标准的诞生,把JavaScript从网景和微软的商业战中抽离出来,成为了一门真正开放的语言。

近年来ES朝着越来越不像Java的方向发展

随着Web标准化,ECMAScript已经不是Sun或Oracle可以控制的。如今ECMAScript的更新由TC39委员会主导,采用五阶段提案流程。如今ECMAScript的发展已经偏离的像Java的目的,比如Map/Set的API刻意避开了Java的命名规范。ES刻意都划清了界限。这导致JavaScript作为ES最核心的实现,如今越来越不像Java。

因此我更愿意把现在的js叫es,而不是否定Javascript和Java的关系

我知道有些人极度反感Java,甚至否定Javascript和Java的关系。对于这种掩耳盗铃的行为,我倒有个建议:既然这么嫌弃,不如彻底抛弃“JavaScript”这个带Java基因的名字,以后只准叫它ECMAScript。叫它ES,确实是对它如今独立设计哲学的最好宣告,证明它不再是任何人的附庸。也顺理成章地把“JavaScript”这个名字,留给那些真正需要“Java脚本化”的人。

浏览器文本复制到剪贴板:企业级最佳实践

2026年5月3日 17:27

1. 背景与需求分析

在 Web 开发中,复制文本到剪贴板是一个常见需求,比如:

  • 复制分享链接、邀请码
  • 复制代码片段
  • 一键复制表单内容

现代浏览器提供了 navigator.clipboard API,但存在兼容性和安全上下文的限制;传统的 document.execCommand('copy') 虽然兼容性更好,但使用方式较为繁琐。本质上,我们需要一个统一的工具函数来屏蔽这些差异。

2. API 介绍与演进

2.1 传统方案:document.execCommand

const textarea = document.createElement('textarea')
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)

优点:兼容性好,支持所有主流浏览器 缺点:需要创建临时 DOM 元素,代码冗长

2.2 现代方案:navigator.clipboard

await navigator.clipboard.writeText(content)

优点:简洁直观,直接操作剪贴板 缺点:需要安全上下文(HTTPS),部分浏览器支持受限

3. 核心实现解析

export interface CopyTextOptions {
  /** 是否允许复制空白内容(空字符串或纯空格),默认 false */
  allowWhitespace?: boolean
  /** 是否使用旧版复制方法(不支持空白内容复制),默认 false */
  legacy?: boolean
}

export interface CopyTextReturn {
  success: boolean
  message: string
}

export async function copyText(content: string, options: CopyTextOptions = {}): Promise<CopyTextReturn> {
  try {
    const { allowWhitespace = false, legacy = false } = options
    if (!allowWhitespace && (!content || content.trim() === '')) {
      return { success: false, message: '复制内容不能为空' }
    } else if (navigator.clipboard && window.isSecureContext && !legacy) {
      await navigator.clipboard.writeText(content)
    } else {
      const textarea = document.createElement('textarea')
      textarea.style.cssText = 'position:fixed; opacity:0; z-index:-9999; left:-9999px; top:-9999px;'
      textarea.value = content
      document.body.appendChild(textarea)
      textarea.select()
      textarea.setSelectionRange?.(0, content.length)
      const copied = document.execCommand('copy')
      document.body.removeChild(textarea)
      if (!copied) throw new Error('浏览器限制或无法复制')
    }
    return { success: true, message: '复制成功' }
  } catch (error: unknown) {
    const errMsg = error instanceof Error ? error.message : '未知错误'
    return { success: false, message: `${errMsg}` }
  }
}

关键逻辑说明

参数一:allowWhitespace

控制是否允许复制空白内容。默认 false 会过滤空字符串和纯空格内容,避免用户误操作。

参数二:legacy

强制使用传统 execCommand 方案。某些场景下(如在 iframe 内)可能需要降级处理。

优先级判断

navigator.clipboard 可用?
  └─ 是 → 判断 isSecureContext(安全上下文)
           └─ 是 → 使用现代 API
           └─ 否 → 降级到 execCommand
  └─ 否 → 降级到 execCommand

4. 兼容性处理策略

方案 兼容性 安全要求 代码复杂度
navigator.clipboard 现代浏览器 必须 HTTPS 简洁
execCommand 所有浏览器 较繁琐
// 降级逻辑核心代码
const textarea = document.createElement('textarea')
textarea.style.cssText = 'position:fixed; opacity:0; z-index:-9999; left:-9999px; top:-9999px;'
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange?.(0, content.length) // 兼容 iOS Safari
const copied = document.execCommand('copy')
document.body.removeChild(textarea)

iOS Safari 兼容要点setSelectionRange 在 iOS 设备上需要显式调用才能正确选中文本。

5. 安全上下文要求

navigator.clipboard 要求页面必须处于安全上下文:

  • HTTPS 协议
  • localhost 开发环境
  • Chrome Extension 内部页面

开发环境下通常没问题,但部署到生产环境务必确保使用 HTTPS,否则会自动降级到传统方案。

6. 使用场景与示例

6.1 基础用法

const result = await copyText('hello world')
if (result.success) {
  console.log('复制成功')
} else {
  console.error(result.message)
}

6.2 允许空白内容

// 复制可能为空的文本时
const result = await copyText(userInput, { allowWhitespace: true })

6.3 强制使用传统方案

// 在特殊场景下强制降级
const result = await copyText(content, { legacy: true })

6.4 集成提示组件

注释掉的 TipModal 部分可根据项目实际使用的 UI 库进行适配:

// Element Plus 示例
import { ElMessage } from 'element-plus'

if (!allowWhitespace && (!content || content.trim() === '')) {
  ElMessage.error('复制内容不能为空')
  return { success: false, message: '复制内容不能为空' }
}

// 复制成功后
ElMessage.success('复制成功')

7. 核心总结

copyText 函数的核心设计要点:

  • 自动降级:优先使用 navigator.clipboard,不支持时自动降级到 execCommand
  • 安全优先:判断 isSecureContext 确保在安全环境下使用现代 API
  • 灵活配置:通过 allowWhitespacelegacy 参数适配不同业务场景
  • 统一返回:返回 { success, message } 结构化结果,便于调用方处理

这个不到 50 行的工具函数覆盖了浏览器复制场景的绝大多数需求,可直接集成到项目中。

10_从 React Hooks 本质看 useState

2026年5月3日 17:22

一、Hooks 的本质

Hooks 是挂在 Fiber 上的一条“有序链表”,通过“调用顺序”来定位状态

每个函数组件对应一个 Fiber:

type Fiber = {
  memoizedState: Hook | null; // Hook 链表头
}

对于一个 Hook,有三种类型的 dispatcher(可以认为是操作策略):

/* 函数组件初始化用的 hooks */
// 初始化信息挂载到 fiber 上
const HooksDispatcherOnMount: Dispatcher = {
  ...
  useCallback: mountCallback,
  useEffect: mountEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  ...
};

/* 函数组件更新用的 hooks */
// 组件更新执行对应的方法,更新 fiber 信息
const HooksDispatcherOnUpdate: Dispatcher = {
  ...
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  ...
};

/* 当 hooks 不是函数组件内部调用或者嵌套 hooks 等“非正确使用”情况,调用这些报错相关的 dispatcher */
const ContextOnlyDispatcher: Dispatcher = {
  ...
  useCallback: throwInvalidHookError,
  useContext: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  useMemo: throwInvalidHookError,
  useReducer: throwInvalidHookError,
  useRef: throwInvalidHookError,
  useState: throwInvalidHookError,
  ...
};

二、Hook 的数据结构

type Hook = {
  memoizedState: any; // 当前值
  baseState: any;
  queue: UpdateQueue | null;
  next: Hook | null;
}

注意:这里要和 FiberNode 的 memoizedState 区分开:

  • FiberNode.memoizedState:保存的是 Hook 链表里面的第一个链表
  • hook.memoizedState:某个 Hook 自身的数据
Fiber.memoizedState
   ↓
[useState] → [useEffect] → [useMemo] → null

完全依赖调用顺序!

不同的 hook,memoizedState 所存储的内容不同:

  • useState:对于 const [state, updateState] = useState(initialState),memoizedState 保存的是 state 的值
  • useReducer:对于 const [state, dispatch] = useReducer(reducer, { } ),memoizedState 保存的是 state 的值
  • useEffect:对于 useEffect( callback, [...deps] ),memoizedState 保存的是 callback、[...deps] 等数据
  • useRef:对于 useRef(initialValue),memoizedState 保存的是 { current: initialValue}
  • useMemo:对于 useMemo( callback, [...deps] ),memoizedState 保存的是 [callback( )、[...deps]] 数据
  • useCallback:对于 u seCallback( callback, [...deps] ),memoizedState 保存的是 [callback、[...deps]] 数据
  • useContext:不需要 memoizedState 保存自身数据

三、执行流程(mount 阶段)

1️⃣ render 开始

function Component() {
  const [count, setCount] = useState(0);
}
// render 开始先执行
export function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;

  // 每一次执行函数组件之前,先清空 FiberNode 状态 (用于存放 hooks 列表)
  workInProgress.memoizedState = null;
  // 清空更新队列(用于存放 effect 列表)
  workInProgress.updateQueue = null;
  // ...
  // 根据不同的组件状态初始化不同的 dispatcher 对象和上下文
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // 执行函数组件,所有的 hooks 将依次执行
  let children = Component(props, secondArg);

  // ...
  
  // 兜底
  finishRenderingHooks(current, workInProgress);
  return children;
}

function finishRenderingHooks(current, workInProgress) {
    // 防止 hooks 在不合规的情况下调用,如果调用直接报错
    ReactCurrentDispatcher.current = ContextOnlyDispatcher;
    // ...
}

2️⃣ mountState 做了什么

如果组件是挂载阶段:

function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null,  // Hook 自身的状态
    baseState: null,
    baseQueue: null,
    queue: null, // hook 自身队列
    next: null, // next 指向下一个 hook
  };

  // 判断当前的 hook 是否是链表的第一个
  if (workInProgressHook === null) {
    // 如果当前组件的 Hook 链表为空,那么就将刚刚新建的 Hook 作为 Hook 链表的第一个节点(头结点) 
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 果当前组件的 Hook 链表不为空,那么就将刚刚新建的 Hook 添加到 Hook 链表的末尾(作为尾结点)
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

function mountStateImpl(initialState) {
  // 获取 hook 对象
  const hook = mountWorkInProgressHook();
  
  //...
  
  // 初始化 memoizedState 
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer, // useState 内置的 reducer
    lastRenderedState: (initialState: any),
  };
  // 初始化 queue
  hook.queue = queue;
  return hook;
}

function mountState(initialState) {
  // 获取 hook 对象
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ));
  // 初始化 dispatch (dispatch 就是用来修改状态的方法)
  queue.dispatch = dispatch;
  // 返回 [当前状态, dispatch函数]
  return [hook.memoizedState, dispatch];
}

其实 useReducer 和 useState 非常像,在源码层面:

  1. mount 阶段:mountState 和 mountReducer 的大体流程是一样的。但是有一个区别,mountState 的 queue 里面的 lastRenderedReducer 对应的是 basicStateReducer,而 mountReducer 的 queue 里面的 lastRenderedReducer 对应的是开发者自己传入的 reducer,这里说明了一个问题,useState 的本质就是 useReducer 的一个简化版,只不过在 useState 内部,会有一个内置的 reducer
  2. update 阶段:在 update 阶段,updateState 内部直接调用的就是 updateReducer,传入的 reducer 仍然是 basicStateReducer。
function mountReducer(reducer, initialArg, init) {
  // 创建 hook 对象
  const hook = mountWorkInProgressHook();
  let initialState;
  // 如果有 init 初始化函数,就执行该函数,并将执行的结果赋值给 initialState
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = initialArg;
  }
  // 赋值给 hook 对象的 memoizedState
  hook.memoizedState = hook.baseState = initialState;

  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: reducer, // 手动传入的 reducer
    lastRenderedState: initialState,
  };
  hook.queue = queue;
  const dispatch = (queue.dispatch = dispatchReducerAction.bind(
    null,
    currentlyRenderingFiber,
    queue
  ));
  return [hook.memoizedState, dispatch];
}

3️⃣ 构建 Hook 链表

第一次 render:

Fiber.memoizedState → Hook1 → Hook2 → Hook3

举个例子~

该示例来源:渡一教育。

function App() {
  const [number, setNumber] = React.useState(0); // 第一个hook
  const [num, setNum] = React.useState(1); // 第二个hook
  const dom = React.useRef(null); // 第三个hook
  React.useEffect(() => {
    // 第四个
    hookconsole.log(dom.current);
  }, []);
  return (
    <div ref={dom}>
    <div onClick={() => setNumber(number + 1)}> {number} </div>
    <div onClick={() => setNum(num + 1)}> {num}</div></div>
  );
}

四、更新阶段(update)

不再创建 Hook,而是“复用”

function updateWorkInProgressHook(){
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    // 从 alternate 上获取到 fiber 对象
    const current = currentlyRenderingFiber.alternate;
    
    // 获取第一个 hook
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    // 获取下一次 hook
    nextCurrentHook = currentHook.next;
  }

  // workInProgressHook 会指向下一个要工作的 hook
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // 已经存在,直接复用
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.
    // 如果 nextWorkInProgressHook 不为 null,那么就会复用之前的 hook
    // 划重点!!!
    // 更新的过程中,如果通过条件语句增加或者删除了 hook,复用的时候就会产生当前 hook 的顺序和之前 hook 的顺序不一致的问题
    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      if (currentFiber === null) {
        // This is the initial render. This branch is reached when the component
        // suspends, resumes, then renders an additional hook.
        // Should never be reached because we should switch to the mount dispatcher first.
        throw new Error(
          'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
        );
      } else {
        // This is an update. We should always have a current hook.
        throw new Error('Rendered more hooks than during the previous render.');
      }
    }

    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

function updateReducer() {
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, ((currentHook)), reducer);
}

function updateState<S>(initialState) {
  return updateReducer(basicStateReducer, initialState);
}

接着上面的示例~

示例来源:渡一教育。

function App({ showNumber }) {
  let number, setNumber
  showNumber && ([ number,setNumber ] = React.useState(0)) // 第一个hooks
  const [num, setNum] = React.useState(1); // 第二个hook
  const dom = React.useRef(null); // 第三个hook
  React.useEffect(() => {
    // 第四个hook
    console.log(dom.current);
  }, []);
  return (
    <div ref={dom}>
    <div onClick={() => setNumber(number + 1)}> {number} </div>
    <div onClick={() => setNum(num + 1)}> {num}</div></div>
  );
}

假设第一次父组件传递过来的 showNumber 为 true,此时就会渲染第一个 hook;第二次渲染的时候,假设父组件传递过来的是 false,那么第一个 hook 就不会执行,那么逻辑就会变得:

第一次:useState -> useState

第二次:useState -> useRef

体现在我们开发者眼中就是报错。

五、setState 到底做了什么?

dispatch 流程

function dispatchSetState(action) {
  const update = {
    action,
    next: null
  };

  enqueueUpdate(queue, update);

  scheduleUpdateOnFiber(fiber);
}

UpdateQueue 结构

hook.queue
   ↓
update1 → update2 → update3(环形链表)

执行更新

function processUpdateQueue(queue) {
  let state = baseState;

  queue.forEach(update => {
    state = reducer(state, update.action);
  });

  return state;
}

六、调度机制(Hooks 如何触发更新)

scheduleUpdateOnFiber(fiber)
setState
   ↓
scheduleUpdate
   ↓
标记 lane(优先级)
   ↓
render(可中断)
   ↓
commit(不可中断)

Hooks 如何保证并发下的 hooks 行为正确?

关键:

  • 每次 render 都重新走一遍 Hook 链
  • 不依赖“执行次数”,只依赖“顺序”

事件流模型是什么和DOM事件模型等关系

作者 Rkgua
2026年5月2日 21:03

(对事件流和DOM,BOM误解,所发出的疑惑) 问题涉及 JavaScript 事件机制的核心概念。我们来系统梳理:

“事件流模型”是什么?它和‘浏览器事件模型’、‘文档事件模型’有什么关系或区别?”

实际上,“浏览器事件模型”和“文档事件模型”并不是标准术语,它们很可能是对以下两个概念的模糊表述:

  • DOM 事件模型(Document Object Model Event Model):即 W3C 标准定义的事件处理规范。
  • 浏览器对事件流的具体实现:所有现代浏览器都遵循 W3C DOM 事件模型。

因此,更准确地说,我们应该讨论的是:


一、什么是 事件流模型(Event Flow Model)

事件流描述的是:当一个事件(如点击)发生时,浏览器如何在 DOM 树中传播这个事件的顺序

✅ W3C 标准定义的事件流包含 三个阶段

  1. 捕获阶段(Capturing Phase)

    • 事件从 windowdocumenthtmlbody → ... → 目标元素的父级
    • 目的:允许祖先元素“提前拦截”事件
  2. 目标阶段(Target Phase)

    • 事件到达实际触发的元素(即 e.target
    • 此时既可视为捕获结束,也可视为冒泡开始
  3. 冒泡阶段(Bubbling Phase)

    • 事件从目标元素 → 父元素 → ... → bodyhtmldocumentwindow
    • 这是最常用的阶段,事件委托就依赖它

🌰 举例:点击一个 <button>,事件会先“下潜”到 button(捕获),然后在 button 上触发(目标),再“上浮”回页面顶部(冒泡)。


二、什么是 DOM 事件模型(Event Model)

“事件模型”指的是 如何为元素绑定事件监听器的机制,历史上有三种主要模型:

模型 名称 特点 是否支持事件流
DOM0 级 原始模型 element.onclick = fn只能绑定一个处理函数简单、兼容性好 ❌ 不支持捕获✅ 部分浏览器以冒泡方式实现
IE 事件模型 IE 特有(已淘汰) attachEvent()只支持冒泡 ✅ 仅冒泡
DOM2 级 现代标准 addEventListener(type, handler, useCapture)可绑定多个监听器支持捕获/冒泡 ✅ 完整支持三阶段事件流

🔍 关键点

  • 事件流 是“事件如何传播”的过程
  • 事件模型 是“如何注册监听器”的接口规范
  • DOM2 级事件模型 实现了 完整的事件流

三、“浏览器事件模型” vs “文档事件模型”?——澄清误解

这两个说法并非官方术语,但可以这样理解:

说法 实际指代 说明
浏览器事件模型 浏览器对 W3C DOM 事件标准的实现 所有现代浏览器(Chrome、Firefox、Safari 等)都遵循 W3C DOM Level 2/3 Events 规范
文档事件模型 DOM(文档对象模型)中的事件处理机制 即 W3C 定义的 EventTarget.addEventListener 等 API

结论

它们本质上是同一套东西 —— 浏览器实现了 W3C 定义的 DOM 事件模型,而该模型规定了 事件流 的行为。


四、如何控制事件流?

虽然不能“关闭”事件流,但可以干预:

1. 在捕获 or 冒泡阶段监听

// 捕获阶段
parent.addEventListener("click", handler, true);

// 冒泡阶段(默认)
child.addEventListener("click", handler, false); // 或省略第三个参数

2. 阻止传播

e.stopPropagation(); // 阻止继续冒泡或捕获
e.stopImmediatePropagation(); // 还阻止同元素其他监听器执行

3. 判断当前处于哪个阶段

e.eventPhase === Event.CAPTURING_PHASE; // 1
e.eventPhase === Event.AT_TARGET; // 2
e.eventPhase === Event.BUBBLING_PHASE; // 3

五、总结:核心关系图

W3C DOM 事件标准
│
├── 定义了「事件模型」→ 如何绑定监听器(addEventListener)
│
└── 定义了「事件流」→ 事件传播的三阶段(捕获 → 目标 → 冒泡)
     │
     └── 浏览器(Chrome/Firefox等)实现了这一标准
          → 所谓“浏览器事件模型”就是 DOM 事件模型的实现

💡 记住

  • e.target 始终是最初触发事件的元素
  • e.currentTarget当前执行回调的监听器所绑定的元素
  • 默认监听的是冒泡阶段,这是事件委托的基础

掌握这些,你就真正理解了 JS 事件的底层逻辑!

前端测试:别为了100%覆盖率而写测试,那是自欺欺人

作者 kyriewen
2026年5月2日 18:46

你写了测试,覆盖率100%,感觉稳了。结果上线后,用户点了个按钮,页面直接白屏。你纳闷:覆盖率不是100%吗?因为你测的都是“天气好不好”,没测“会不会地震”。今天我们就来聊聊前端测试的正确姿势——怎么测才能真的有用,而不是为了指标好看写一堆废话。

前言

前端测试常走两个极端:要么完全不测,上线随缘;要么为了覆盖率,测了等于没测(比如测个1 + 1 = 2)。真正有效的测试,不是越多越好,而是该测的测,不该测的别浪费生命

今天我们用“测试金字塔”模型,帮你理清单元测试、组件测试、E2E测试的分工。看完你会知道:哪部分代码必须测,哪部分可以跳过,哪部分用哪个工具。

一、测试金字塔:三分天下,各司其职

       /\
      /E2E\      ← 少而精,关键路径
     /------\
    /集成测试\    ← 中等,组件间交互
   /----------\
  /  单元测试   \  ← 多而快,纯逻辑
 /--------------\
  • 底座(单元测试):测最小的代码单元(函数、工具类)。多、快、便宜。
  • 中层(组件测试/集成测试):测几个单元结合后的行为(比如一个表单组件提交数据)。
  • 顶层(E2E测试):模拟真实用户,测整个流程(从打开页面到点击到结果)。

比例大概是:单元测试占70%,集成测试20%,E2E 10%。不是死规定,但原则:底层测试成本低,多写;顶层测试维护成本高,只写关键路径

二、单元测试:测逻辑,不测实现细节

单元测试的目标:给定输入,输出是否正确。不关心函数内部怎么实现的,只关心结果。

适合测的

  • 纯函数(输入输出确定,无副作用)。
  • 业务规则(比如calculateDiscount(price, level))。
  • 工具函数(formatDateparseQuery)。

不适合测的(测了也白测):

  • 框架内部逻辑(React的setState、Vue的响应式——那是框架的事)。
  • 简单的getter/setter。
  • 常量定义。

工具:Jest + Vitest(Vite项目推荐Vitest)。

例子

// 要测的函数
function formatPrice(price, currency = '¥') {
  return `${currency}${price.toFixed(2)}`;
}

// 测试
test('格式化价格', () => {
  expect(formatPrice(10.5)).toBe('¥10.50');
  expect(formatPrice(10.5, '$')).toBe('$10.50');
});

黄金法则:如果重构代码不破坏测试,说明你测的是行为,不是实现。

三、组件测试:测交互,不测样式

组件测试(React Testing Library / Vue Test Utils)的目标:模拟用户行为,检查组件渲染和交互是否正确。不关心DOM结构细节,只关心用户能看到什么、能做什么。

适合测的

  • 根据props渲染正确的内容。
  • 点击按钮触发正确回调。
  • 表单输入后数据变化。
  • 异步加载显示loading状态。

不适合测的

  • CSS样式(那是视觉回归测试的事,交给视觉测试工具)。
  • 内部state的具体值(优先测渲染结果)。
  • 第三方UI库的行为(假设它没问题)。

工具:React Testing Library + Jest(官方推荐),Vue Test Utils + Vitest。

例子(React Testing Library):

import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('点击按钮增加计数', () => {
  render(<Counter />);
  const button = screen.getByText('增加');
  fireEvent.click(button);
  expect(screen.getByText('计数: 1')).toBeInTheDocument();
});

原则:测用户能看到的东西,不要测内部实现。

四、E2E测试:测关键用户旅程,不测所有交互

E2E测试模拟真实浏览器,跑完整的用户流程。它最像真实用户,但也最慢、最脆弱(网络波动、页面改动容易挂)。

适合测的(3-5个核心流程):

  • 登录 → 访问个人主页 → 修改头像。
  • 搜索商品 → 加入购物车 → 结算 → 支付成功。
  • 未登录访问受保护页面 → 跳转到登录页。

不适合测的

  • 每个细节(比如每个按钮的悬浮效果)。
  • 容易变化的面包屑导航。
  • 第三方依赖的页面。

工具:Cypress(最友好)、Playwright(更可靠)、Puppeteer(较底层)。

例子(Cypress):

describe('用户登录', () => {
  it('输入正确账号密码后跳转到首页', () => {
    cy.visit('/login');
    cy.get('[data-cy=username]').type('user@example.com');
    cy.get('[data-cy=password]').type('password123');
    cy.get('[data-cy=submit]').click();
    cy.url().should('include', '/dashboard');
    cy.contains('欢迎回来', { timeout: 10000 });
  });
});

维护技巧:给关键元素加上data-cy属性,避免改样式或文本时测试挂掉。

五、测试覆盖率的谎言

很多团队追求100%覆盖率,结果工程师花大量时间测无关紧要的代码(比如测Redux的action creator是个纯对象)。覆盖率工具(Istanbul)只能告诉你“哪些代码没执行过”,不能告诉你“没测到的重要逻辑”。有时100%覆盖率,却漏掉了一个关键的空值判断。

正确的覆盖率指标

  • 核心业务逻辑达到80%以上就行。
  • UI组件覆盖率参考即可,不必强求。
  • 关注未覆盖的重要代码,而不是数字。

六、组合策略:一个电商网站的例子

  • 单元测试:计算折扣、格式化价格、校验表单规则。Jest跑得快,每次提交都跑。
  • 组件测试:商品卡片(渲染正确信息)、购物车弹窗(增加/删除商品)、地址表单(提交按钮禁用直到填写完整)。
  • E2E测试:1. 用户搜索“手机” → 2. 添加第一个商品到购物车 → 3. 登录 → 4. 结算 → 5. 确认订单。就这一个核心流程,保证不崩。

日常开发:单元测试 + 组件测试在CI里跑(每次push)。E2E测试单独流水线,部署前跑一次(因为慢)。

七、测试不是银弹,别为了写而写

  • 重构旧代码,没测试?先别补。补一个挂一个,浪费时间。优先补新功能。
  • 一个bug反复出现,才需要补测试
  • UI改得频繁的区域,不写E2E,写组件测试更稳。

测试是手段,不是目的。目的是信心:当你改完代码,测试全绿,你能放心上线。

八、总结:测试就像买保险

  • 单元测试:车险,便宜,必须买。
  • 组件测试:医疗险,中等,按需买。
  • E2E测试:地震险,贵,只买最关键的。

别买一大堆没用的险,也别裸奔。

wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑

作者 竹林818
2026年5月2日 18:00

背景

上个月,我接手了一个"Uniswap 精简版"项目——一个支持 Ethereum、Polygon、Arbitrum 三条链的 DEX 前端。项目用 wagmi v2 + RainbowKit 做钱包连接,React + Vite 开发。需求听起来很简单:用户连接钱包后,能选择任意一条链进行交易,并且钱包会自动切换到对应链。

我当时想,wagmi 不是有 useSwitchChainuseAccount 吗?直接调用就完事了。结果呢?我花了整整三天,经历了无数个"为什么钱包没反应"、"为什么链没切换但页面状态变了"的抓狂时刻。这篇文章,就是把我踩过的坑和最终的解决方案完整记录下来。

问题分析

一开始,我的思路很直接:用 useAccount 获取当前链 ID,用 useSwitchChain 切换链。代码大概长这样:

// 我最初的错误写法
const { chain } = useAccount();
const { switchChain } = useSwitchChain();

const handleChainChange = (targetChainId: number) => {
  if (chain?.id !== targetChainId) {
    switchChain({ chainId: targetChainId });
  }
};

看起来没问题对吧?但实际运行时,问题来了:

问题 1: 在 MetaMask 上切换链后,useAccount 返回的 chain 更新了,但 UI 上的交易对信息没有更新。我明明用了 useEffect 监听 chain 变化,但页面就是不刷新。

问题 2: 切换到一条不支持的链(比如用户自己添加了 BSC)时,useSwitchChain 会报错,但错误信息非常不友好,而且 chain 状态会被污染。

问题 3: 最诡异的是——当用户手动在钱包里切换链,而不是通过我写的按钮切换时,useSwitchChain 根本不会触发,但 useAccountchain 变了。这就导致我的代码里有两套"当前链":一套来自按钮操作,一套来自钱包事件,它们经常不同步。

排查了两天,我翻遍了 wagmi 的文档和 GitHub Issues,终于发现了关键点:wagmi v2 中 useAccountchain 是只读的,它只反映钱包当前连接的链,不会触发 React 组件的重新渲染(至少在特定场景下)。而 useSwitchChain 返回的 isSuccess 状态才是可靠的切换完成标志。

核心实现

1. 重新理解 wagmi v2 的状态管理

我做的第一件事,是抛弃了"用 useAccount 驱动 UI"的思维。wagmi v2 推荐的做法是:useChainId 获取当前链 ID,用 useSwitchChain 处理切换,用 useEffect 监听切换完成事件

这里有个坑:useChainId 返回的是 wagmi 配置中的当前链 ID,而不是钱包实际连接的链 ID。如果用户手动在钱包里切换,useChainId 不会自动更新!所以,我最终决定自己维护一个"同步的链状态"。

我创建了一个自定义 hook useSyncedChain

// hooks/useSyncedChain.ts
import { useChainId, useSwitchChain, useAccount, usePublicClient } from 'wagmi';
import { useEffect, useState, useCallback } from 'react';

export function useSyncedChain() {
  // 从 wagmi 获取基础状态
  const configChainId = useChainId(); // wagmi 配置中的链 ID
  const { chain: accountChain, isConnected } = useAccount(); // 钱包实际连接的链
  const { switchChain, isPending, error } = useSwitchChain();
  const publicClient = usePublicClient(); // 用来做链验证

  // 我们自己的"权威"链 ID
  const [activeChainId, setActiveChainId] = useState<number>(configChainId);

  // 核心逻辑:同步钱包状态和配置状态
  useEffect(() => {
    if (!isConnected || !accountChain) {
      // 未连接时,使用配置默认链
      setActiveChainId(configChainId);
      return;
    }

    // 如果钱包连接的链和配置链不同,说明用户手动切换了
    if (accountChain.id !== configChainId) {
      // 这里有个坑:不要直接 setActiveChainId,因为配置链可能不支持
      // 应该检查 accountChain 是否在我们支持的链列表中
      const supportedChains = [1, 137, 42161]; // Ethereum, Polygon, Arbitrum
      if (supportedChains.includes(accountChain.id)) {
        setActiveChainId(accountChain.id);
      } else {
        // 不支持的话,尝试切回默认链
        switchChain({ chainId: configChainId });
      }
    } else {
      setActiveChainId(configChainId);
    }
  }, [configChainId, accountChain, isConnected, switchChain]);

  // 封装的切换函数
  const switchToChain = useCallback(async (targetChainId: number) => {
    try {
      await switchChain({ chainId: targetChainId });
      // switchChain 成功后,wagmi 会自动更新 configChainId
      // 但为了保险,我们手动更新
      setActiveChainId(targetChainId);
    } catch (err) {
      console.error('切换链失败:', err);
      throw err;
    }
  }, [switchChain]);

  return {
    activeChainId,
    switchToChain,
    isSwitching: isPending,
    error,
  };
}

这个 hook 的核心思路是:不要信任任何一个单一来源,而是用钱包状态、配置状态、用户操作事件三者做交叉验证

2. 处理链切换后的数据刷新

链切换后,我们需要重新获取交易对数据、用户余额等。一开始我用 useEffect 监听 activeChainId,但发现会触发两次:一次是状态更新,一次是钱包实际切换完成。

后来我用了 wagmi 的 useWatchChainId 来做精细控制:

// hooks/useChainDataRefresh.ts
import { useEffect, useRef } from 'react';
import { useChainId } from 'wagmi';

export function useChainDataRefresh(callback: (chainId: number) => void) {
  const chainId = useChainId();
  const prevChainIdRef = useRef(chainId);

  useEffect(() => {
    // 只在链真正变化时触发,避免初始化时的重复调用
    if (prevChainIdRef.current !== chainId) {
      console.log(`链已切换: ${prevChainIdRef.current} -> ${chainId}`);
      callback(chainId);
      prevChainIdRef.current = chainId;
    }
  }, [chainId, callback]);
}

然后在组件中使用:

// 在 Swap 组件中
const { activeChainId, switchToChain, isSwitching } = useSyncedChain();
const { data: pairData, refetch: refetchPair } = useQuery({
  queryKey: ['pair', activeChainId, tokenA, tokenB],
  queryFn: () => fetchPairData(activeChainId, tokenA, tokenB),
  enabled: !!activeChainId && !!tokenA && !!tokenB,
});

useChainDataRefresh((newChainId) => {
  // 链切换后,重新获取数据
  refetchPair();
  // 同时重置用户输入状态
  setTokenA('');
  setTokenB('');
});

3. 处理钱包手动切换和 UI 同步

最头疼的是用户手动在 MetaMask 里切换链。wagmi v2 的 useAccount 会更新,但 useChainId 不会。我之前的 useSyncedChain hook 已经通过 accountChain 处理了这种情况,但还有一个细节:切换完成后,需要等待钱包确认,期间 UI 应该显示加载状态

我添加了一个"切换中"的状态管理:

// 在 useSyncedChain 中增加 pendingChainId
const [pendingChainId, setPendingChainId] = useState<number | null>(null);

const switchToChain = useCallback(async (targetChainId: number) => {
  setPendingChainId(targetChainId);
  try {
    await switchChain({ chainId: targetChainId });
    setPendingChainId(null);
    setActiveChainId(targetChainId);
  } catch (err) {
    setPendingChainId(null);
    throw err;
  }
}, [switchChain]);

// 在 UI 中显示加载
const isLoading = isSwitching || pendingChainId !== null;

4. 最终的多链切换组件

把所有逻辑整合到一个组件中:

// components/ChainSwitcher.tsx
import { useSyncedChain } from '../hooks/useSyncedChain';
import { useChainDataRefresh } from '../hooks/useChainDataRefresh';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';

const SUPPORTED_CHAINS = [
  { id: 1, name: 'Ethereum', nativeCurrency: 'ETH' },
  { id: 137, name: 'Polygon', nativeCurrency: 'MATIC' },
  { id: 42161, name: 'Arbitrum', nativeCurrency: 'ETH' },
];

export function ChainSwitcher() {
  const { activeChainId, switchToChain, isSwitching, error } = useSyncedChain();

  // 链切换后刷新数据
  useChainDataRefresh((chainId) => {
    console.log('链已切换,刷新数据');
    // 这里可以触发其他数据获取
  });

  const handleChainClick = async (chainId: number) => {
    if (chainId === activeChainId) return;
    try {
      await switchToChain(chainId);
      // 切换成功后,UI 会自动更新,因为 activeChainId 变了
    } catch (err) {
      // 显示错误 toast
      alert(`切换失败: ${(err as Error).message}`);
    }
  };

  return (
    <div>
      <h2>选择链</h2>
      {SUPPORTED_CHAINS.map((chain) => (
        <button
          key={chain.id}
          onClick={() => handleChainClick(chain.id)}
          disabled={isSwitching}
          style={{
            fontWeight: chain.id === activeChainId ? 'bold' : 'normal',
            opacity: isSwitching ? 0.5 : 1,
          }}
        >
          {chain.name} ({chain.nativeCurrency})
          {isSwitching && ' 切换中...'}
        </button>
      ))}
      {error && <p style={{ color: 'red' }}>错误: {error.message}</p>}
    </div>
  );
}

完整代码

我把所有代码整合到一个可运行的示例中。假设你使用 Vite + React + TypeScript,安装依赖:

npm install wagmi viem @tanstack/react-query react
// main.tsx - 入口文件
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit';
import { ChainSwitcher } from './components/ChainSwitcher';

const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

const queryClient = new QueryClient();

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <ChainSwitcher />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// hooks/useSyncedChain.ts - 上面已给出完整代码
// hooks/useChainDataRefresh.ts - 上面已给出完整代码
// components/ChainSwitcher.tsx - 上面已给出完整代码

踩坑记录

坑 1:useAccountchain 在切换后不会立即更新 现象:调用 switchChain 后,useAccount 返回的 chain 还是旧的,导致 UI 显示错误。解决:用 useChainId 配合 useEffect 监听,而不是依赖 useAccountchain

坑 2:useSwitchChainisSuccess 有时为 false 现象:钱包已经切换成功,但 isSuccess 一直是 false。原因:wagmi v2 中 isSuccess 只在第一次成功时为 true,后续切换不会重置。解决:用 errorisPending 做判断,或者自己维护状态。

坑 3:在非浏览器环境(如测试时)调用 switchChain 会报错 现象:在 Node.js 或 React Native 中,window.ethereum 不存在,导致切换失败。解决:用 try-catch 包裹,并在错误时回退到配置默认链。

坑 4:链切换后,之前订阅的事件没有清理 现象:切换到 Polygon 后,Ethereum 上的事件监听还在运行,导致内存泄漏。解决:在 useEffect 中返回清理函数,或者用 wagmi 的 watchContractEvent 自动管理。

小结

多链切换的核心不是调用 switchChain,而是同步钱包状态、配置状态和用户操作状态。wagmi v2 提供了基础工具,但需要自己组合成可靠的解决方案。如果你也遇到类似问题,可以试试我写的 useSyncedChain hook,或者深入看看 wagmi 的源码——里面有很多有趣的细节。

接下来,你可以探索如何用 wagmi 的 watchChainId 做更精细的控制,或者结合 viem 的 publicClient 做链验证。

在线PDF拆分工具核心JS实现

作者 滕青山
2026年5月2日 17:57

这篇只讲本项目里“PDF拆分”工具的功能层 JavaScript 实现。主流程可以概括为:

选择 PDF -> 读取页数 -> 生成拆分页组 -> 复制指定页面 -> 生成多个 PDF -> 单文件下载或 ZIP 打包下载

工具基于 Vue 组织交互状态,核心 PDF 操作使用 pdf-lib,多文件结果打包使用 JSZip,页面预览和书签读取由 pdfjs-dist 辅助完成。

在线工具网址:see-tool.com/pdf-split
工具截图:
工具截图.png

1. 文件进入流程前先做 PDF 判断

文件选择和拖拽上传共用同一套入口。真正加载前,先判断文件类型:

export function isPdfSplitFile(file) {
  if (!file) {
    return false;
  }

  var fileType = String(file.type || "").toLowerCase();
  var fileName = String(file.name || "");
  return fileType === "application/pdf" || /\.pdf$/i.test(fileName);
}

这里同时判断 MIME 和文件后缀,是因为部分浏览器环境下 file.type 可能为空,只依赖 MIME 会误拦正常 PDF。

加载文件时,会把同一份原始字节切成两份用途:

var rawBytes = await file.arrayBuffer();
var splitBytes = rawBytes.slice(0);
var previewBytes = rawBytes.slice(0);
var sourceDoc = await PDFDocument.load(splitBytes);

splitBytes 用于后续拆分,previewBytes 用于预览和书签读取。这样拆分主链路和辅助信息链路互不影响。

2. 页码输入解析成统一的拆分页组

拆分逻辑不是直接处理输入框字符串,而是先转成统一结构:

{
  label: "1-3",
  indices: [0, 1, 2]
}

label 用于文件命名,indicespdf-lib 需要的零基页码数组。

页码范围解析支持逗号分隔,也支持倒序区间:

function buildPageIndices(start, end) {
  var indices = [];
  var page;

  if (start <= end) {
    for (page = start; page <= end; page += 1) {
      indices.push(page - 1);
    }
    return indices;
  }

  for (page = start; page >= end; page -= 1) {
    indices.push(page - 1);
  }

  return indices;
}

所以用户输入 1-3,5,8-6 时,会生成三个输出段:第 1 到 3 页、第 5 页、第 8 到 6 页。

3. 多种拆分模式最终都归一到 groups

工具支持按页码范围、每 N 页、每页单独、奇偶页、可视化选择、书签、平均拆成 N 份。虽然入口不同,但最终都会变成 groups

buildSplitGroups: function () {
  if (this.splitMode === "ranges") {
    return parsePdfSplitRangeGroups(this.rangeInput, this.totalPages);
  }

  if (this.splitMode === "everyN") {
    return buildPdfSplitCountGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.everyNInput),
    );
  }

  if (this.splitMode === "everyPage") {
    return buildPdfSplitEveryPageGroups(this.totalPages);
  }

  if (this.splitMode === "evenOdd") {
    return buildPdfSplitEvenOddGroups(this.totalPages, this.evenOddMode);
  }

  if (this.splitMode === "visual") {
    return buildPdfSplitVisualGroups(this.selectedPages);
  }

  if (this.splitMode === "bookmarks") {
    return buildPdfSplitBookmarkGroups(this.bookmarkItems, this.totalPages);
  }

  if (this.splitMode === "nTimes") {
    return buildPdfSplitNPartsGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.nTimesInput),
    );
  }

  return [];
}

这个设计的好处是,真正拆分 PDF 时不关心用户选择了哪种模式,只消费统一的页码分组。

4. 可视化选择会自动合并连续页

可视化模式下,用户点选的是离散页码。工具会先排序、去重,再把连续页合并成一个输出段:

export function buildPdfSplitVisualGroups(selectedPages) {
  var uniquePages = Array.isArray(selectedPages)
    ? selectedPages
        .map(function (page) {
          return Number(page);
        })
        .filter(function (page) {
          return Number.isInteger(page) && page > 0;
        })
        .sort(function (left, right) {
          return left - right;
        })
        .filter(function (page, index, source) {
          return index === 0 || page !== source[index - 1];
        })
    : [];

  if (!uniquePages.length) {
    throw createPdfSplitInputError("emptySelection");
  }

  var groups = [];
  var start = uniquePages[0];
  var end = uniquePages[0];

  for (var i = 1; i < uniquePages.length; i += 1) {
    if (uniquePages[i] === end + 1) {
      end = uniquePages[i];
      continue;
    }

    pushMergedSelectionGroup(groups, start, end);
    start = uniquePages[i];
    end = uniquePages[i];
  }

  pushMergedSelectionGroup(groups, start, end);
  return groups;
}

比如选择 1、2、3、7、9、10,结果会拆成 1-379-10 三个文件。

5. 书签拆分按顶层书签生成区间

书签模式先读取 PDF 的 outline,再把书签所在页转换成拆分区间。核心逻辑是:当前书签页作为开始页,下一个书签前一页作为结束页。

export function buildPdfSplitBookmarkGroups(bookmarks, totalPages) {
  var normalizedBookmarks = Array.isArray(bookmarks)
    ? bookmarks
        .filter(function (item) {
          return (
            item &&
            Number.isInteger(Number(item.pageNumber)) &&
            Number(item.pageNumber) >= 1 &&
            Number(item.pageNumber) <= totalPages
          );
        })
        .map(function (item) {
          return {
            title: String(item.title || "").trim() || "bookmark",
            pageNumber: Number(item.pageNumber),
          };
        })
        .sort(function (left, right) {
          return left.pageNumber - right.pageNumber;
        })
    : [];

  var groups = [];

  if (normalizedBookmarks[0].pageNumber > 1) {
    groups.push({
      label: "preface",
      indices: buildPageIndices(1, normalizedBookmarks[0].pageNumber - 1),
      title: "preface",
    });
  }

  for (var index = 0; index < normalizedBookmarks.length; index += 1) {
    var current = normalizedBookmarks[index];
    var next = normalizedBookmarks[index + 1];
    var start = current.pageNumber;
    var end = next ? next.pageNumber - 1 : totalPages;

    groups.push({
      label: current.title,
      indices: buildPageIndices(start, end),
      title: current.title,
    });
  }

  return groups;
}

如果第一个书签不在第一页,前面的内容会单独生成一个 preface 分段。

6. 真正拆分 PDF 的核心是 copyPages

拆分主函数先构建 groups,然后每个分组创建一个新的 PDF:

for (index = 0; index < groups.length; index += 1) {
  var group = groups[index];
  var outputDoc = await PDFDocument.create();
  var copiedPages = await outputDoc.copyPages(
    this.sourceDoc,
    group.indices,
  );

  copiedPages.forEach(function (page) {
    outputDoc.addPage(page);
  });

  var outputBytes = await outputDoc.save();
  var outputBlob = new Blob([outputBytes], {
    type: "application/pdf",
  });

  nextOutputs.push({
    name: this.buildOutputName(group, index, groups.length),
    blob: outputBlob,
    size: outputBlob.size,
  });
}

这里不是修改原 PDF,也不是切割二进制文件,而是把源文档里的指定页面复制到一个新文档。group.indices 决定当前输出文件包含哪些页。

7. 输出文件名根据拆分模式生成

文件名会先清理原 PDF 名称,再结合模式和页码标签生成:

export function buildPdfSplitOutputName(options) {
  var config = options || {};
  var baseName = safePdfSplitBaseName(config.baseName);
  var index = Number(config.index) || 0;
  var total = Number(config.total) || 0;
  var label = String(config.label || "");
  var mode = String(config.mode || "ranges");
  var sequence = String(index + 1).padStart(3, "0");
  var safeLabel = sanitizePdfSplitFileLabel(label) || sequence;

  if (mode === "everyPage") {
    return baseName + "_page_" + safeLabel + ".pdf";
  }

  if (mode === "bookmarks") {
    return baseName + "_" + sequence + "_" + safeLabel + ".pdf";
  }

  if (total === 1) {
    return baseName + "_split.pdf";
  }

  return baseName + "_split_" + sequence + "_p" + safeLabel + ".pdf";
}

这样拆出多个文件时,用户能从文件名看出顺序和页码范围。

8. 单结果直接下载,多结果打包 ZIP

导出时先判断结果数量。只有一个 PDF 时直接下载;多个 PDF 时放进 ZIP:

downloadResult: async function () {
  if (!this.outputs.length) {
    return;
  }

  if (this.outputs.length === 1) {
    this.downloadOutput(this.outputs[0]);
    return;
  }

  var zip = new JSZip();
  this.outputs.forEach(function (item) {
    zip.file(item.name, item.blob);
  });

  var zipBlob = await zip.generateAsync({
    type: "blob",
    compression: "DEFLATE",
    compressionOptions: {
      level: 6,
    },
  });

  this.downloadBlob(zipBlob, "split_result.zip");
}

浏览器下载统一通过 Blob 和临时 a 标签完成:

downloadBlob: function (blob, filename) {
  var url = URL.createObjectURL(blob);
  var link = document.createElement("a");

  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

整个 PDF 拆分功能的核心,就是把不同输入方式都转换成稳定的页码分组,再用 pdf-lib 复制页面生成新文档,最后根据结果数量决定直接下载还是打包下载。

最快的 JavaScript navmesh pathfinding3d 算法。

2026年5月2日 17:51

最快的 JavaScript 三维寻路库:pathfinding3d

好久不见,今天为大家带来的是,JavaScript目前最快的三维寻路库:pathfinding3d。性能是目前three-pathfindingthree-pathfinding-3d)的10-20倍 它不是仅限 Three.js 的插件,而是通用的 WASM 三维寻路引擎。只要你的 JavaScript 三维引擎能提供网格顶点与索引数据,就可以用本库构建导航区域、查询分组并搜索路径。

github:MrYang614/pathfinding3d: the fast navigation mesh pathfinding for 3d world. use rust and compile to wasm. supper for all 3d engine like:three.js babylonjs,playcanvas

特点

  • 极高性能:核心寻路管线由 Rust + WebAssembly 实现,性能约为 three-pathfinding-3d 的 10-20 倍量级。
  • 引擎无关:不限于 Three.js,可与 Babylon.js、PlayCanvas、Cesium、自研 WebGL/WebGPU 引擎及任意 JavaScript 三维场景配合使用。
  • 面向 3D NavMesh 流程:由三角网格数据创建区域,再通过分组、节点、A* 与漏斗通道生成平滑路径。
  • JavaScript 开销低:路径结果写入预分配的 Float32Array,减少对象分配与 GC 压力。
  • 前后端通用:通过 wasm-pack 打包,适用于 Web、Electron、Node.js 等 JavaScript 环境。

适用场景

  • 大型三维场景中的角色导航
  • Web 游戏、数字孪生、仿真、编辑器与可视化项目
  • 需要可复用寻路、又不想绑定 Three.js 的多引擎项目
  • 寻路查询需要比 three-pathfinding-3d 更快的项目

pathfinding3d 寻路算法与实现概览

本文从性能特征底层模块实现三维引擎兼容性三方面,说明 pathfinding3d 中 NavMesh 式三维寻路的算法结构与工程取舍。实现语言为 Rust,通过 WebAssembly 暴露给 JavaScript/TypeScript。


1. 整体管线

库采用经典的 导航网格(NavMesh) 工作流:将可走区域表示为三角面片及其邻接图,在图上做 A* 搜索得到多边形序列,再用 漏斗算法(String Pulling / Funnel) 把多边形通道拉直为空间折线。

flowchart LR
  subgraph build [构建阶段]
    Mesh[顶点 positions + 索引 indices]
    Weld[容差焊接顶点]
    Tri[三角面与邻接/Portal]
    Group[连通分量分组]
    Idx[GroupData + KD 树 + AABB]
    Mesh --> Weld --> Tri --> Group --> Idx
  end
  subgraph query [查询阶段]
    Pos[世界坐标]
    KD1[KD 最近邻 + 可选多边形判定]
    Astar[A* 在分组内图上搜索]
    Portals[Portal 序列]
    Funnel[3D 漏斗拉直]
    Out[Float32Array 路径点]
    Pos --> KD1 --> Astar --> Portals --> Funnel --> Out
  end
  Idx --> query

同一 Zone 内可能包含多个 Group(互不相连的三角面子图);get_group 决定点落在哪一组,find_path 仅在给定 group_id 内寻路。


2. 算法性能:为何快、快在哪里

2.1 执行环境与语言层

  • Rust + WASM:热点路径(网格构建、空间查询、A*、漏斗)在原生机器码或 WASM 中执行,避免纯 JavaScript 解释执行与频繁装箱的开销。项目 README 中与 three-pathfinding-3d 的对比属于同量级场景下的经验性描述;具体倍数随网格规模、图密度与硬件变化,应以实际基准测试为准。
  • 数值类型:内部大量使用 f64glam::DVec3)做几何与搜索,与 JS 侧 number 精度衔接自然;输出写入 Float32Array 时再做 f32 截断,减小返回路径的内存与带宽。

2.2 内存与垃圾回收

  • 预分配与复用:每个分组预先分配 AstarScratch(开放表、closed、g/h、父指针、touched 用于增量清空)与 PathScratch(Portal 缓冲、路径点、flat_points)。单次查询主要在这些缓冲上读写,避免每次 find_path 在堆上大量分配小对象。
  • A* 的 reset:通过 touched 只恢复本次搜索访问过的节点,在图较大但搜索范围局部时,比整表 memset 更省。
  • 启发式缓存h_score + h_seen 对每个节点到终点的欧氏距离只算一次,重复入堆时复用。

2.3 查询复杂度(定性)

环节 典型结构 说明
最近三角形 / 分组 三维 KD 树 + AABB 剪枝 最近邻平均接近 (O(\log n)),predicate 会过滤无效候选
A* 二叉堆 + 邻接表 与展开节点数相关;边数约为三角网格邻接规模
漏斗 线性于 Portal 数量 对每个 Portal 常数次方向与交点判断

3. 底层模块与具体实现

以下按源码模块对应说明(路径相对于仓库根目录 src/)。

3.1 builder.rs:从原始网格到 Zone

  1. 校验positions 长度为 3 的倍数,indices 为三角形索引三元组,且索引不越界。
  2. 容差焊接:用 tolerance 将顶点量化到整数格点 (x/tol, y/tol, z/tol),合并近似重合顶点,得到压缩后的 verticesremapped_indices
  3. 三角形对象:每个三角形记录 vertex_indices质心 centerneighboursportals(共享边上的两个顶点索引)。
  4. 邻接与 Portal:遍历每条无向边 HashMap<(min,max), tri_idx>,若同一条边被两个三角形使用则 bind_neighbour,双向记录邻接三角形 id 与共享边的顶点对。
  5. 分组(Group):对 group_id == -1 的三角形做 BFS(VecDeque)扩散,将连通分量标为 0..G-1,再按 group_id 聚合成 ZoneInput.groups

3.2 impls.rs:分组内图结构 GroupData

  • PolygonInput.id(构建时的三角形序号)映射到分组内的紧凑下标 id_to_index
  • neighbours_by_index 存储 NeighborLink { index, portal },供 A* 枚举邻居与后续取 相邻三角形之间的 Portal 边

3.3 pathfinding.rs:运行时索引与查询编排

  • GroupSpatialData:每个分组维护全体三角形 AABB、每个三角形的 AABB、以三角形 质心 为点集的 KD 树(项为分组内下标)。
  • 全局 node_tree:所有分组的 (group_idx, node_idx) 挂在同一棵 KD 树上,用于跨分组挑选最近三角形(如 get_group)。
  • compute_group
    • check_polygon == true:在最大距离平方阈值内,KD 搜索 + AABB + 到三角形平面距离 + 点是否在三角形内math 模块)。
    • 否则:最近质心 + 分组整体 AABB 约束。
  • get_closest_node_index:分组内 KD + AABB 距离剪枝;可选 is_vector_in_polygon(带 y 方向条带 + 三角形内测试)判定是否落在当前三角形上。
  • compute_path_points
    1. 起点、终点各求最近三角形下标;
    2. astar_search 得到中间三角形序列;
    3. 构造 Portal3 列表:起点到第一段若有合法 Portal 则加入;相邻三角形间用 portal_between_indices 取共享边两端世界坐标;最后以目标点闭合通道;
    4. judge_dir(叉积的 y 分量)统一 Portal 左右顺序;
    5. funnel3d_into 生成平滑折线到 path_scratch.points
    6. write_path_to_output 跳过第一个点(起点),将后续点写入调用方 Float32Array

3.4 astar.rs:分组内 A*

  • 状态BinaryHeapf = g + h 最大堆反转实现最小 fHeapNodef 相等时用 idx 打破平局,保证次序稳定。
  • 代价g 的增量为当前与邻居三角形 质心间距离平方之和。
  • 启发式h 为邻居三角形质心到 终点三角形质心欧氏距离(非平方),并对每个节点缓存。
  • 路径回溯parent 链从终点回到起点,再 reverse 得到从起点侧到终点侧的三角形序列(注意与 path 向量填充顺序一致)。

3.5 channel.rs:三维漏斗与辅助插点

  • funnel3d_into:在 Portal 序列上维护 apex、左右边界点与索引,用 judge_dir 判断“右转/左转”约束;必要时 insert 在一段 Portal 间按 线段最近点 在边上插值(distance_sq_segment_to_segment),并在 xz 平面上算交点参数 segment_fraction_xz,再 lerp 出三维点,减少拐角处路径贴边生硬的问题。
  • 退化:无 Portal 时路径退化为起点—终点直线。

3.6 math.rs:几何原语

  • 三角形内点:三边叉积的 y 分量 同号(与水平面 NavMesh 假设一致:可走面大致水平或判定主要依赖 xz 投影与 y 条带)。
  • is_vector_in_polygon:先限制查询点 y 在三角形 y 范围加 ±0.5,再调用 is_point_in_triangle
  • point_to_plane_distance:点到三角形所在平面的有符号距离,用于分组时“落在面上”的数值容差(如 0.01)。
  • distance_sq_segment_to_segment:两线段最近距离的平方及最近点,供漏斗 insert 使用。

3.7 kdtree.rs:三维 KD 树

  • 构建:按深度循环维度 x → y → z,对当前点集按该维排序,取中位点建节点,递归左右子树。
  • nearest_matching:标准 KD 最近邻遍历,维护 best_distance;仅在 distance < best_distance谓词为真时更新最优;利用 delta² < best_distance 决定是否搜索远侧子树。

3.8 utils.rs / lib.rs

  • Panic hook:改善 WASM 中 panic 的可读性(便于调试)。
  • 对外类型lib.rspub use pathfinding::PathfindingWasm,JS 通过 wasm-bindgen 调用。

4. 三维引擎兼容性

本库 不依赖任何渲染引擎对象(无 THREE.Mesh、无场景图),只要求调用方提供:

  • positions[x,y,z, ...]f32 扁平数组(与 WebGL 属性布局一致即可)。
  • indices:三角形索引 u32,每三个一组。

因此只要引擎能导出或拼接 世界空间 下的顶点与索引(Three.js、Babylon.js、PlayCanvas、Cesium、自研 WebGL/WebGPU 等),即可使用;坐标系与单位由数据决定,库内部不做左手/右手或 Y-up/Z-up 的强制转换。

集成时注意:

  1. 可走网格质量:非流形、重复面、过大容差会影响焊接与邻接;Disconnected 区域会落入不同 group_id,跨组需业务层处理(如传送或桥接网格)。
  2. “地面”假设judge_dir 与部分点在三角形内判定依赖 y 轴 与水平投影习惯;若可走面为任意朝向的陡坡,需在业务上评估是否适用或是否应预处理网格。
  3. 输出约定find_path 返回的点数对应 output 中写入的三元组个数;若缓冲区不足,返回值表示所需长度(见 README API 说明),需调用方扩容后重试。

5. 小结

pathfinding3dNavMesh 构建(焊接、邻接、连通分组)KD 树空间查询质心图上 A*带 Portal 的三维漏斗拉直 集中在 Rust/WASM 中,并通过 复用搜索缓冲区Float32Array 直写 控制 JS 侧开销,从而在浏览器与 Node 中提供通用的三维寻路后端;与具体三维引擎的耦合点仅有 网格顶点与索引的序列化格式


V8引擎精品漫游指南--Ignition篇(下 一) 动态执行前的事情

作者 一锤捌拾
2026年5月2日 00:03

二. Ignition解释器(下一)

1. 前文总结 和 运行期前置知识

这个系列文章,已经写了一少半了,现在终于到了动态执行阶段了。

我们首先需要梳理一下知识,这部分内容,相对独立,但是都算是比较重要的知识点。

  • 预编译的说法为什么不建议使用

    在我们平时看文章,看资料,甚至是看一些比较权威的文档时,预编译 这个术语非常常见。但是,在js中,预编译 是个伪术语,是一些教材教程在以前的js教学中,为了解释变量提升等一些问题,生造出来的一个词语,后来,只要是运行期以前的 甚至是在和运行期交织发生的一些动作流程,统统装进了 预编译 这个大口袋里。大部分人,也就不求甚解的接受并使用了这个说法。但是,这是一个不规范且容易引发歧义的词汇。在传统编译语言中,预处理、编译与执行通常有明确的时间边界;在现代 JavaScript 环境,这些阶段高度交织。规范(ECMAScript (ECMA-262))并不使用“预编译”一词,而是通过“执行上下文的创建阶段(creation / declaration instantiation)”来描述声明的注册与初始化。实际引擎(例如 V8)则采用惰性解析与按需编译:先做必要的解析与作用域分析,再由解释器生成字节码(如 Ignition)或在运行时将热点编译为机器码(由优化器完成)。

    对于js,可以分为如下四个宏观的阶段:

    词法分析:把源代码分成记号(tokens)。

    语法分析(Parsing):构建抽象语法树(AST),确定静态作用域结构。

    执行上下文创建阶段(Creation / Declaration Instantiation):为全局或每次函数调用登记标识符(函数声明整体被绑定;var 注册并初始化为 undefinedlet/const 注册但处于 TDZ)。这一步决定了变量可见性和提升行为,但不等于把所有代码预先编译成机器码。

    执行阶段:逐条执行语句;遇到函数调用重复执行上一 步。现代引擎会在此阶段对运行行为收集反馈,并按需触发优化编译。

  • 全局创建阶段和函数创建阶段的区别

    无论是全局还是函数,在代码真正执行前都会经历“创建阶段”(进行变量和函数声明的提升),但两者有本质区别:

    作用域范围:

    • 全局阶段:影响整个程序,声明的变量和函数最终挂载到全局环境(浏览器中为 window)。

    • 函数阶段:每调用一次函数,生成一个完全独立的执行上下文,仅对函数体内部有效,互不干扰。

    变量遮蔽(shadowing):

    • 在函数内部,如果存在与全局同名的变量,函数内的局部变量会“遮蔽”全局变量。即使全局变量在早期的全局阶段已经存在,函数内部在自己的创建阶段会优先登记局部标识符。
  • 四个宏观的阶段

    JavaScript 代码的完整生命周期分为以下四个阶段:

    1. 词法分析(Lexical Analysis)
    • 目的:将源代码字符串分解成一系列记号(Tokens)。
    • 内容:识别关键字、标识符、操作符、数字、字符串、注释等最小语法单元。
    2. 语法分析(Syntax Analysis / Parsing)
    • 目的:将记号序列转换成抽象语法树(AST)。
    • 内容:检查代码结构是否符合语法规则,构建反映代码静态结构的蓝图。
    3. 执行上下文创建阶段(Creation/Instantiation Phase)
    • 全局上下文创建
      • 创建全局对象(Global Object)。
      • 扫描全局代码:将函数声明整体提升;将 var 变量注册并初始化为 undefined;将 let/const 注册,但置于“暂时性死区(TDZ)”。
      • 建立全局词法环境,其外部引用为 null
      • 计算 this 绑定。
    • 函数上下文创建(每次调用时触发)
      • 确定外部环境引用(Outer Environment Reference),构建作用域链。
      • 创建局部词法环境,绑定形参与实参,创建 arguments 对象。
      • 扫描函数体,处理内部的变量和函数声明(规则同上)。
      • 根据调用规则(普通调用、方法调用、new 调用等)计算并保存当前函数的 this 值。
    4. 执行阶段
    • 逐条执行语句,完成真实的赋值操作和表达式求值。遇到函数调用时,重复步骤 3。

    • 主线程同步代码结束后,进入事件循环处理异步任务。无闭包引用的上下文将被垃圾回收。

  • 静态结构AST和动态运行执行阶段的关系

    这是理解 JS 闭包和作用域链最核心的关键。

    1. 逻辑结构(AST 阶段:静态分析)

    在语法分析结束后,AST 已经固化了代码的静态结构(Lexical Scope)。作用域的层级、变量的引用关系在这个阶段已经完全确定。

    • 注意:AST 仅确定作用域链的结构蓝图,它不包含任何运行时值或内存绑定。这也是我们在第一部分解析篇,和AST部分中,反复说过无数遍的。
    2. 物理实现(运行时阶段:动态绑定)

    具体的词法环境实例(Lexical Environment)是在代码执行阶段动态创建的

    • 函数对象的创建:函数声明(FunctionDeclaration)通常在执行上下文的创建阶段就被绑定为可调用的函数对象,而函数表达式(FunctionExpression)则是在运行时执行到表达式处时才生成函数对象。

    • 闭包的落地:虽然闭包的静态依赖关系可以从 AST 中推导出来,但真正的闭包(在堆内存中实际捕获并保存外部函数的词法环境)是在函数被执行并返回后,由运行时的执行上下文和作用域链动态构建的。

    AST 阶段就像是建筑设计图,明确了房间的布局(作用域)和走廊的连接关系(静态作用域链)。而运行时相当于实际建造,根据设计图动态分配水泥建材(内存),并让住户(变量值)真正住进去。

    闭包形成的动态实例:

    JavaScript

    function outer() {
      var a = 10;
      function inner() {
        console.log(a); // 引用了 outer 的变量 a
      }
      return inner;
    }
    var closureFunc = outer(); 
    closureFunc(); 
    
  1. 语法分析阶段:AST 记录了标识符 a 的引用关系,随后的作用域分析(Scope Analysis)会基于 AST 建立变量解析的静态链接。

  2. 执行 outer():创建新的执行上下文和词法环境(包含 a)。inner 函数被创建时,捕获当前词法环境并存入其 [[Environment]]

  3. 执行 closureFunc()inner 执行,虽然 outer 的上下文已销毁,但 inner 通过自身的 [[Environment]] 依然保留着对 outer 词法环境的物理引用,真正的闭包在此刻发挥作用。


  • 词法环境和作用域链

    这两个概念非常容易混淆:

    • 词法环境(单个节点):是一个存储变量和函数声明的具体环境。全局脚本开始、函数调用、进入块级作用域({})时都会实例化对应的词法环境。

    • 作用域链(链式结构):是由多个词法环境通过 Outer Reference(外部引用)串联而成的查找路径。

    如果把作用域链比作一面**“墙”,那么每一个词法环境就是砌成这面墙的“砖块”**。词法环境负责“存储变量”,作用域链负责提供“查找路径”。

    这里需要特别注意,前面 尤其是解析篇中 我们反复强调了 蓝图 这个说法,在ast生成以后,作用域已经形成,这里要注意,是结构的形成,我们可以知道,某个变量可以到哪里寻找,但是,这只是蓝图 ,并不是实例的形成。 真正的可操作的作用域/链,是在执行阶段动态创建的。


  • 执行上下文的模型

    一、 执行上下文的抽象模型

    在 ECMAScript 规范中,一个执行上下文(Execution Context)记录可以抽象为如下结构:

    JavaScript

      Execution Context Record = {
      
        LexicalEnvironment: {
          EnvironmentRecord: { ... },          // 当前词法环境中的绑定 (let/const/function/class)
          Outer: <reference to outer env>      // 外部环境引用
        },
        
        VariableEnvironment: {
          EnvironmentRecord: { ... },          // 专门存储 var 声明的绑定
          Outer: <reference to outer env>
        },
        
        ThisBinding: <the value of this>,      // 当前上下文的 this 值
      PrivateEnvironment: <optional record>  // 用于类的私有字段(#private)
      }
    

    环境记录的类型与功能:

  • DeclarativeEnvironmentRecord(声明性环境记录): 用于存放命名绑定(letconstfunction 等),并跟踪每个绑定的内部状态(如是否已初始化、是否可变)。let/const 的 TDZ(暂时性死区)正是通过在绑定创建后、初始化前,将该绑定底层标记为“未初始化(uninitialized)”来实现的。

  • ObjectEnvironmentRecord(对象环境记录): 将一个普通对象包装成环境记录。典型场景是全局环境(将 globalThis 作为绑定载体)或被废弃的 with 语句。它的查找是通过直接的对象属性访问来实现的。

  • FunctionEnvironmentRecord(函数环境记录): 声明性环境记录的特化版,专职负责管理函数的参数、arguments 对象,以及处理 thissuper 的绑定状态。

    二、 词法环境和变量环境的区分

    在函数初始执行时,LexicalEnvironment 和 VariableEnvironment 通常指向同一个环境记录实例。但规范特意将它们物理分离,是为了在“绑定创建阶段”区分不同声明的处理策略:

  • 历史和兼容: 在 ES5 及之前,声明以函数作用域为准(var)。ES6 引入了块级作用域(let/const)。规范通过 VariableEnvironment 负责 varLexicalEnvironment 负责块级声明,完美实现了旧行为与新特性的并存。

  • var 的处理(变量环境): var 声明会在 VariableEnvironment 上被创建并立刻初始化为 undefined。这就是为什么在声明前读取 var 变量会得到 undefined(即“变量提升”)。

  • let/const 的处理(词法环境): 它们在 LexicalEnvironment 上被创建,但并不初始化。在实际执行到声明语句之前,访问这些绑定会触发 TDZ,抛出 ReferenceError

    三、 上下文完整实例

我们通过一段经典代码,观察环境及闭包的情况:

JavaScript

  console.log(foo);  
  var foo = 10;

  function outer() {
    let a = 1;
    function inner() {
      console.log(a);
    }
    return inner;
  }

const closureFunc = outer();  
  closureFunc();     

1. 全局创建阶段

foo 注册到变量环境,初始为 undefinedouter 函数对象创建,其内部槽 [[Environment]](闭包的环境指针)指向当前的全局词法环境。

注意:ES6 后的全局环境是复合的,包含一个“全局声明性环境”(存 let/const)和一个“全局对象环境”(存 var 和全局函数,映射到 globalThis)。在 ES Modules 模式下,顶层绑定则由专属的 Module Environment Record 接管,不再使用 globalThis。

2. 执行全局代码

console.log(foo) 输出 undefined,因为 foovar 绑定已在创建阶段完成初始化。随后 foo 赋值为 10。

3. 调用 outer() 并进入其创建阶段

注册局部变量 a(处于 TDZ)。创建 inner 函数对象,将其 [[Environment]] 指向 outer 的词法环境。随后执行赋值 a = 1(解除 TDZ),并返回 inner 函数。

注意:此时如果在 a = 1 之前尝试读取 a,会立刻触发 TDZ 报错。

4. 调用 closureFunc()(即 inner)

创建 inner 的执行上下文。在其自身的词法环境中找不到 a,顺着 [[Environment]] 构成的作用域链,向外查找到 outer 环境中的 a,输出 1

闭包的真实情况:

inner[[Environment]] 保存的是对 outer 词法环境的引用,而不是当时绑定值的快照!闭包捕获的是“绑定本身”。因此,如果 outer 后续修改了 a 的值,inner 再次执行时读取到的必然是最新的修改值。这也解释了为什么在 for 循环中使用 var 创建闭包,所有闭包会共享同一个循环变量绑定(最终输出相同的值),而使用 let 则会为每次迭代创建独立的绑定环境。

补充内容:

This 绑定(ThisBinding)

this 的值并非由执行上下文自动决定为某个固定值,而是严格由调用方式在运行时动态决定:

  • 直接调用 (fn()):非严格模式指向全局对象,严格模式为 undefined
  • 方法调用 (obj.method()):指向调用者对象(基值 obj)。
  • 显式绑定 (call / apply / bind):由传入的第一个参数决定。
  • 构造调用 (new Fn()):指向内部新创建的实例对象。
  • 箭头函数:没有自己的 this,它会穿透当前上下文,从创建时的外层词法环境中继承 this(Lexical This)。因此箭头函数无法被 new,也不能被 bind 改变指向。

私有环境(PrivateEnvironment)

这是规范专为支持类私有成员(如 #x)引入的机制。在类定义阶段,私有标识符会被登记到私有环境中。访问时,引擎只在当前类的私有环境中查找对应绑定。对外表现为:无法通过 obj['#x'] 访问,也不会出现在 Object.keys 的枚举中。

优化与性能

现代 JavaScript 引擎对闭包和作用域链有极强的优化(例如 V8 的逃逸分析),闭包本身并不总是天然低效。但需要注意,如果无意中让闭包捕获了大型外部数据结构(或庞大的 DOM 节点),会导致这些环境记录的生命周期被强行延长,阻碍垃圾回收,从而造成内存泄漏。因为闭包会让被捕获的外部绑定“活得更久”,所以在高性能场景需谨慎管理引用。


  • 重要总结一

    前面我们讲了,js中,预编译是个伪术语,尽量不要使用。 那么,除了使用规范中的术语,我们在工程实现中,可以使用 编译期 这个术语。

    一段源码要想跑起来,只要经历了“词法分析 -> 语法分析 -> 生成 AST -> 生成某种中间代码(如字节码)”的过程,这个过程在计算机科学中就被标准的定义为**“编译(Compilation)”**。 既然 V8 引擎确确实实做了这些事情,那把它称为“编译期”是名正言顺的。

    但是需要注意:一是 传统语言的“编译期”和“运行期”可能相隔很长的时间(开发者在电脑上编译好,发给用户运行)。而 JS 的“编译期”和“运行期”是首尾相连、紧密贴合的。引擎通常在接收到代码后,立刻进行编译,随后立刻交由解释器执行。二是 在现代 V8 引擎中,纯粹的“编译期”通常指 Ignition 将 AST 转换为字节码的过程。但在“运行期”中,TurboFan 编译器依然会在后台将热点字节码再次编译成机器码。所以 JS 的“编译”行为基本上是贯穿了运行的始终。

  • 重要总结二

    在前面我们讲了上下文 讲了词法环境 环境记录 等等概念,很多朋友肯定会有疑问:

    这些所谓的上下文、环境记录,到底是完全虚构出来的抽象概念,还是在物理内存中真实存在的结构?

    关于这个问题,或者说 关于类似的问题,我们需要从两个方面来看,一是规范 二是实现,而这种思考方式,是我们从开篇就一直贯彻使用的。

    1. 规范

      前面列出的包含了 LexicalEnvironmentOuter 引用的对象结构,还有环境记录,还有之前的let的for循环等等等等,实际上是 ECMAScript 规范定义的一种抽象机制(Abstract Mechanism)。 规范委员会(TC39)只负责制定语义上的“规则条文”:他们规定了代码跑起来后,变量查找必须遵循什么顺序、闭包必须保留什么数据,但规范绝不干涉引擎在内存中必须使用何种底层数据结构来实现这些规则。

    2. 实现

      V8 引擎作为极致追求性能的“实现者”,通常不会在内存里一对一地去“照搬”或者 new 出规范中描述的那种深层次嵌套的庞大对象。相反,它会使用**栈帧(Stack Frame)、寄存器(Register)、堆上对象(Heap Object)**等极其底层的机制,来“实现/模拟/达到语义要求”并提供相同的行为表现。

    下面,我们从规范层和实现层来学习一下这几个概念

    1. 执行上下文 (Execution Context) 和 全局执行上下文
    • 【规范层:抽象级别 - 最高】
      • 规范定义:一个用来跟踪代码执行进度的“抽象记录(Abstract Record)”或“容器”。规范赋予了它词法环境、变量环境、This绑定等语义属性,这是纯粹的“规则文本”。
    • 【V8层:物理表现形式与载体】
      • 函数上下文的物理表现函数调用栈帧(Frame-like 结构)
      • 真实存在方式:当函数被调用时,V8 会在底层的调用栈(Call Stack)上开辟一块连续的内存空间(栈帧)。在 V8 内部,这对应着随着版本不断演进的 C++ 栈帧实现(如曾经的 StandardFrameJavaScriptFrame 等)。这块内存里压入了:返回地址、参数、接收者(this)、以及分配给局部变量的寄存器槽位。函数一 return,栈帧出栈,其物理状态瞬间回收。
      • 进阶(关于全局执行上下文):全局上下文的生命周期是跟随进程/页面的。它的物理实现并不是一个“永远压在栈底不弹出的常驻栈帧”。相反,全局相关的数据(全局对象 Global Object 与全局词法环境)通常**常驻于堆内存(Heap)**中。浏览器标签页存活时,这些堆结构就一直存在,依靠堆内存来维持全局语义。
    2. 词法环境 (Lexical Environment) 和 变量环境 (Variable Environment)
    • 【规范层:抽象级别 - 高】
      • 规范定义:一种用来定义标识符和变量值映射关系的嵌套结构(包含环境记录与外部引用)。规范特意区分词法环境和变量环境,是为了在语义上兼容 ES6 块级作用域(let/const)与老旧的函数级作用域(var)。
    • 【V8层:物理表现形式与载体】
      • 物理表现:引擎根本不会去创建一个名叫 Environment 的统一 C++ 对象。相反,V8 会对绑定进行极其精明的按需分流
        • 非逃逸(局部)绑定:被直接编译为栈帧上的寄存器/栈槽,访问极快。
        • 逃逸(闭包捕获)绑定:当绑定必须在当前栈帧销毁后继续存活时,才会被搬到堆内存的 Context 结构中。
      • 进阶(var 与 let/const 的精细差异):在底层物理分配时,虽然它们在函数内部都受“是否逃逸”规则的支配,但语义表现截然不同:全局的 var 往往直接映射为全局对象的属性(Property Cell),而全局的 let/const 则属于声明式记录;且 var 没有 TDZ 标记。引擎通过不同的底层操作指令来严格区分这两种语义。
    3. 环境记录 (Environment Record)

    这是反差最大的一个概念。在规范里它像个哈希表,但在 V8 底层,它被分化成了三种截然不同的物理形态:

    • 形态A:完全虚无化(针对 Declarative ER 中的非逃逸变量)
      • 物理载体无独立运行时查找载体。化身为编译器分配的寄存器/栈槽。
      • 解释:在编译/生成字节码时,引擎知道变量的固定位置,直接硬编码(如存入寄存器 r0)。执行时没有运行时的字符串查找,只有纯粹的内存/寄存器读写指令。
    • 形态B:堆内存槽位(针对 Declarative ER 中的逃逸变量/闭包)
      • 物理载体V8 Heap(堆内存)中的 Context / Slot 结构。
      • 解释:这是一个类似 FixedArray(固定数组)或包含 Cell 引用的结构。闭包变量以**固定的槽位索引(Slot Index)**存储。访问时通过“基地址 + 偏移量”极速拿取,而非哈希查找。
      • 进阶(惰性分配):V8 非常抠门内存。它不一定在 AST 解析完就立刻 new 出这个堆数组。通常在运行时或编译阶段,借助强大的逃逸分析(Escape Analysis),引擎会尽量延迟甚至消除这种堆分配,只有在无可避免(真正创建闭包引用)时才在堆上开辟空间。
    • 形态C:复杂的对象/字典结构(针对 Global ER / Object ER)
      • 物理载体全局对象(Global Object)或 Property Cell。
      • 解释:因为全局对象(如 window)的属性可以被动态增删,无法提前确定数组大小,引擎通常使用更通用的字典结构或 Property Cell 来存放,这在语义上最接近传统的哈希表。
    4. 外部环境引用 (Outer Reference) / 作用域链
    • 【规范层:抽象级别 - 低】
      • 规范定义:一个指向父级词法环境的引用指针。
    • 【V8层:物理表现形式与载体】
      • 物理表现:真实的 内存指针/引用
      • 真实存在方式:在上述堆内存的 Context 结构中,会保留一个指向父 Context 的指针(通常位于特定的槽位中)。当当前上下文查找未命中时,引擎会沿着这些真实的物理指针,按索引继续向外层查找,从而在物理内存中串联起一条真正的作用域链(Scope Chain)
    5. 函数的内部插槽 [[Environment]]
    • 【规范层:抽象级别 - 低】
      • 规范定义:函数对象身上的一个隐藏属性,保存创建该函数时的词法环境。
    • 【V8层:物理表现形式与载体】
      • 物理表现C++ 对象内部的真实字段
      • 真实存在方式:在 V8 的实现中,函数对象(例如 JSFunction 的实例)会包含一个专属的字段(在源码中常见的命名如 context_)。这个字段保存着指向创建时词法环境(堆上的 Context 对象)的内存引用,这就是闭包能够“记住”外部环境的物理铁证。
    6. TDZ (暂时性死区) 与 未初始化的物理实现
    • 【规范层:抽象级别 - 逻辑态】

      • 规范定义let/const 绑定已创建但未初始化,此时访问将抛出 ReferenceError
    • 【V8层:物理表现形式与载体】

      • 物理表现:特殊的 内部哨兵值(Sentinel Value)
      • 真实存在方式:为了实现 TDZ 语义,V8 会在相应的内存槽位(寄存器或 Context 槽中)放置一个内部定义的哨兵标记(例如常被称为 the_hole 的特殊 Tagged Value)。
      • 运行机制:当引擎的指令尝试读取该内存时,如果发现读出的是这个特殊的哨兵值,就会立刻触发 ReferenceError。一旦代码执行到了真实的赋值语句,真实的数据就会覆盖掉这个哨兵值,TDZ 随之在物理层面上被解除。
      • 这个会吹哨子的警卫,我们已经讲过无数次了。。。

在前面学习字节码生成的时候,我们使用了导演 场务 记录员 这个比喻,随着我们的学习深入,很有必要扩展一下我们的 片场宇宙 ,下面我们把片场宇宙的整体设定,以表格的形式固定下来,这个设定,应该足以支撑我们的后续学习了。而且 在记忆点,在准确性 等方面,也是挺合适的。 这是我的原创丫,保留版权。盗版会被追杀的。 嘿嘿嘿。。。

一、 基建与环境

片场比喻 V8 底层实体 核心职责与表现
大老板 / 制片人 Host Environment (宿主:Chrome/Node.js) 掌握生杀大权。负责出资建厂,并在一切准备就绪后扣动 Execution::Call 扳机,下达全场开机指令。
独立制片厂 Isolate 进程内的独立工业园区。拥有专属土地和主线程。不能擅自串门,所有跨厂通信须通过宿主提供的 IPC 桥接机制(如 postMessage / embedder bridge),以保证隔离策略与安全边界。
拍摄场域 Realm 对应一套完整的全局内置对象体系。有助解释不同脚本/模块之间的原型链与全局隔离等高级语义(如 iframe 之间的差异)。大多数情况下,Realm(规范概念) = Context(V8 物理实现)
逻辑摄影棚 Context 搭建在制片厂内的执行环境。提供基础道具(如当前的 window/global 实例)。同厂内可有多棚,互不串戏。
预制构件厂 mksnapshot (快照机制) 编译期打包好的引擎原生初始化对象与初始堆状态。开新棚时“拎包入住”。(注意:并不等同于把用户的运行时代码或业务脚本提前编译为机器码)
清道夫 / 场地清理队 GC (垃圾回收器) 分两队:**新生代突击队(Scavenge)**用复制算法把还在用的道具完整搬到新片场,旧片场一键清空;老生代重型拆迁队用 Mark-Sweep 清理废弃垃圾,并用 **Mark-Compact(标记压缩)**把还在用的别墅统一挪到地块前排,消除内存碎片。
道具仓库管理员 Object Factory 制片厂专属库管。负责统一创建、分配所有 JS 对象、字符串、数组等道具,确保所有出库道具严格符合定妆照标准。

二、 剧组班底与工作人员

片场比喻 V8 底层实体 核心职责与表现
原著编剧与审核员 Parser & Syntax Checker 拆解源代码并同步查错(如括号不匹配、非法语法)。剧本不合格直接打回,导演休想开工。
导演 BytecodeGenerator (字节码生成器) 掌控全局的大佬。拿着 AST 原稿,决定指令走向,画出最初的分镜头脚本。
场务 BytecodeRegisterAllocator 抠门的空间管理大师。编译期负责精打细算分配椅子(寄存器),算出“最高水位线”,打下 Frame Size 物理钢印。
记录员 / 老编辑 BytecodeArrayBuilder 手速如飞的记录员。自带“窥孔优化(Peephole)”职业病,听到导演喊了废话(如冗余存取)直接在脑子里抹掉。
无情的男一号 Ignition 解释器 极速执行机器与 V8 默认入口。哪怕特效师临时救场,全场的最终兜底权永远在男一号手里。
海关 / 双向安检员 JSEntry & CEntry Stub 驻守 C++ 与 JS 边界。砸下防爆门,并在 Entry Frame 中保存返回地址与调用约定,确保 C++ 与 JS 间的调用契约被完整维护,防止异常穿透。
后期特效师 TurboFan (优化编译器) 激进的赌徒。只接“跑热了”的戏份(执行次数超阈值),冷剧本绝不碰。 赌定演员的定妆照(Map)绝对不变。

三、 核心道具与约定

片场比喻 V8 底层实体 核心职责与表现
分镜头原稿 AST (抽象语法树) 导演看的分镜头原稿,上面画满了变量的作用域归属(住栈上还是住别墅)。
公共图纸 SharedFunctionInfo (SFI) 主要存放静态元数据的图纸(包含字节码与函数签名)。同一份图纸可供多个剧组实体(JSFunction)共用。 运行时会在上面挂载情报小本本。
活着的剧组实体 JSFunction (闭包对象) 运行期动态诞生的活物。体内缝合两根指针:一根指向公共图纸(SFI),一根持有出生地摄影棚的钥匙(Context 的引用)。
临时演员 / 龙套 Tagged Value (标记值) 所有 JS 值的统一物理载体。靠底层的 pointer-tagging / immediate-tag(指针标记机制)来区分小整数(Smi)与堆指针等不同表示形式。
唯一聚光灯 Accumulator (Acc) 舞台上的累加器。全场只有这一盏聚光灯,同一时间只能有一个值站在灯下,是所有字节码指令的核心操作锚点。
小板凳 / 休息椅 Registers (虚拟寄存器) 摆在聚光灯外围的椅子(r0, r1...)。用于存放局部变量或暂时退下阵来的中间计算结果。
豪华别墅 Heap Context Slot 为“逃逸(被闭包捕获)”的变量专门在富人区(堆内存)开辟的保留地。只要拿着钥匙的剧组还活着,别墅就不会被强拆。
情报小本本 Feedback Vector 解释器狂奔时动态更新的侦查记录。记录对象的形状与运行信息,为后期特效师(TurboFan)提供关键证据与优化线索。
定妆照 / 服装单 Hidden Class / Map 规定了演员的穿着打扮和口袋位置。注意:演员只要加减/修改一个属性(换件衣服),就必须当场换一张全新的定妆照(Map 迁移)。
特技替身 Inline Cache (IC) 分为:单态替身(只认一张定妆照,速度极快)、多态替身超态替身(定妆照太乱,替身直接罢工,只能走完整查找流程)。
吹哨的警卫 The Hole (哨兵值) 主要看守 let/const 的未初始化状态(TDZ)。(注意:除了暴躁的吹哨子警卫,它还有另外一种用途,在这个列表后面,会详细说明)。
场记板 Bytecode PC 记录当前执行的字节码偏移(哪条分镜头正在执行)。解释器、错误回溯与去优化恢复时靠它精准定位回退点。
制作日程 / 微任务队 Microtask / Job Queue 存放 Promise.then 的回调。主调用栈清空后,微任务队会被逐条调度执行,对事件循环的可观察顺序有直接影响。

四、 场地与关键动作

片场比喻 V8 底层实体 核心职责与表现
跨界防爆门 Entry Frame (入口帧) 砸在物理堆栈底部的厚重铁门。保存宿主调用约定与 C++ 物理现场,挡住异常穿透,保全宿主进程。
临时搭建的戏台 JS Stack Frame 函数 Call 时拔地而起的工作区。嵌套调用就是“戏台叠戏台”,杀青时严格按调用顺序从最上层挨个拆除。
界碑 与 戏台前沿线 FP (帧指针) & SP (栈指针) FP 往下看内务,往上看遗产。SP 是戏台前沿线,杀青时 SP=FP 瞬间收回前沿线,夷平整个戏台。
极速圈地 / 物理一刀 SP = SP - Frame_Size 解释器按图纸钢印数字,挥刀向下拉伸 SP,瞬间 O(1) 斩出戏台上所有虚拟寄存器(小板凳)的物理空间。
替身罢工 IC Miss (缓存未命中) 特技替身(IC)上场时,发现演员的定妆照和情报本里不一样,直接罢工。只能重新走查找流程,同时更新情报小本本。
现场无缝换角 OSR (On-Stack Replacement) 演极其漫长的循环戏时发生的现场换人。这是片场唯一能打破“杀青前不能换演员”规则的绝对特例。
安全绳 / 彩排录像 Deopt Metadata 特效师预留的回退通道。必要时借助它,将高度优化的机器码物理寄存器,精确还原回解释器的状态。
拍摄翻车与废片 Deoptimization (去优化) 激进特效遇到突变当场穿帮。拉拽安全绳,把控制权安全交还给解释器(Ignition),并直接把这段失效的机器码扔进垃圾桶废弃。
重拍预案 Lazy Deopt (懒去优化) 翻车后如果不致命,先标记当前特效失效,等这组长镜头(函数)平稳演完再回退,避免强行中断。


我们从开篇就一直强调, 一定要分清 规范 和 实现 的区别,在学习中, 也尽量以双视角甚至多视角来讲解。下面我们就以从解析篇到现在,已经出现很多次的 会吹哨子的警卫 这个知识点,来说明,双视角多视角的必要性。

对于数组 [1, , 3] 我们进行分析:

规范 / 编译期(AST 层,语法语义)

在语言/规范层面,[1, , 3] 中间的“空位”(elision)语义上就是**“该索引在对象上不存在”,不是 NullLiteral 也不是显式写出的 null。解析器/Parser 在生成 AST 时会以一种占位(elision)**的形式标记该位置;某些解析器实现把这个占位在 AST 的数组元素列表里表示为 null(仅作为实现细节的占位符),但这和源码中显式写的 nullNullLiteral)是不同的概念。简短检验(语义区别)如 1 in arrforEach 的行为,会把两者明显区分开来。

运行期 / 引擎实现(Heap 层,物理表示)

在实际堆布局里(例如 V8 的 FixedArray backing store),不能留“物理空洞”,因此引擎用一个**内部哨兵(sentinel)**填充该槽——通常称为 the_hole / the_hole_value

  • the_hole 不是 null、不是 undefined、也不是数值 0;它是 C++ 层面的内部标记/对象,脚本层不应直接依赖或可见它。
  • 读取槽时若遇到 the_hole,引擎会把该槽视为“缺失属性”,按属性查找/回退逻辑继续处理(最终由语义层返回 undefined)。
  • 出现 the_hole 会把数组的 elements-kind 从 packed 降到 holey(如 HOLEY_SMI_ELEMENTS),这改变了底层快速路径并通常带来性能成本(对后续访问产生长期影响)。

运行结果 / JS 语义层(表面行为)

对脚本可观察到的是:访问空位 arr[1] 返回 undefined,但这只是规范定义的回退值(因为属性不存在),并不意味着槽里真实存的是 undefined

示例

const holey = [1, , 3]; const undef = [1, undefined, 3];

console.log(1 in holey); // false — 索引不存在 console.log(1 in undef); // true — 索引存在,值为 undefined

holey.forEach(x => console.log(x)); // prints 1, 3 (跳过空槽) undef.forEach(x => console.log(x)); // prints 1, undefined, 3

最后,语义层、编译期 AST 表示与运行期物理表示是三套不同的“视角”:AST 用占位表示缺节点;运行期用 the_hole 填槽并影响优化;JS 层最终呈现的是规范定义的 undefined 回退。写代码和做性能优化时需要以规范语义判断行为,但以**引擎实现(hole → holey → 性能降级)**来评估性能的结果。


2. JS的运行场景

js的运行场景,需要两个刚性的核心需求。

强隔离:不同的JS代码运行环境必须互不干扰,比如浏览器里两个网站的代码不能互相篡改数据、一个页面崩溃不能带崩整个浏览器;

轻量隔离:在同一个大运行环境里,需要多个独立的小执行环境,但是又不能付出过高的性能和内存开销,比如同一个页面里的多个同站iframe,不需要重新启动一整套引擎实例。

为了能满足这两个要求,v8设计了两层隔离体系

Isolate负责底层物理级别的绝对隔离,Context负责同物理实例内的逻辑级执行环境隔离

下面我们分别学习。

一、Isolate

  1. 详细定义
  • 首先,V8本身是一套用C++编写的JS引擎库,它不是进程、也不是线程,而是一套可以被嵌入到程序中的代码执行能力。
  • 一个Isolate,就是V8引擎的一个完整、独立、可运行的副本实例。当你在一个操作系统进程中创建一个Isolate时,相当于你在进程的内存空间里,划出了一块完全独立的「专属运行领地」,初始化了一整套完整的JS运行所需的核心组件。
  • 通俗理解:操作系统是一座城市,进程是城市里的一个独立工业园区(有自己的水电、安保边界,和其他园区完全隔离),Isolate就是这个工业园区里,一个完全独立的「物理制片厂」。这个制片厂有自己的围墙、专属地皮、专属工作人员、专属仓库,和园区里其他制片厂完全物理隔绝,连大门都不互通。

这里必须纠正一个常见错误:Isolate不是进程,也不是线程。一个操作系统进程里,可以创建多个Isolate实例;一个Isolate实例,对应且仅对应一个主线程,同时可以有自己专属的辅助线程。

  1. 每一个Isolate都拥有一整套完全专属的运行资源,不会共享

(1)专属的堆内存(Heap)

  • 堆内存到底是什么? 通俗说,JS里所有的引用类型数据(对象、数组、函数、闭包、字符串、类实例等),实际的内容都存在堆内存里;我们代码里的变量,只是在栈内存里存了这个数据在堆里的内存地址。堆内存,就是JS代码运行的「数据仓库」。
  • 专属的核心含义:每个Isolate的堆内存,是操作系统分配的、完全独立的内存地址空间,和其他Isolate的堆内存完全割裂。
    • 内存地址完全不互通:A Isolate堆里的一个对象的内存地址,在B Isolate里完全无效,B Isolate根本无法读取、访问、修改这个地址里的内容,就像A制片厂的仓库地址,在B制片厂的系统里根本不认,连门都进不去。
    • 内存配额完全独立:每个Isolate都有自己独立的堆内存上限,A Isolate的堆内存用了多少、剩了多少,和B Isolate完全无关。
    • 内存生命周期完全独立:这个堆里的内存分配、释放,全由当前Isolate自己管理,其他Isolate无权干预。

(2)专属的垃圾回收器(GC)实例

  • GC是什么? GC全称Garbage Collection,垃圾回收。通俗说,就是引擎自动扫描堆内存,清理掉那些不再被使用的对象,释放内存空间的机制,避免内存泄漏和内存溢出。V8的GC有完整的分代回收策略(新生代、老生代),包含标记清除、标记压缩、增量标记等一整套流程。
  • 专属的核心含义:每个Isolate都有自己独立的、完整的GC全流程实例,和其他Isolate的GC完全互不干扰。
    • 回收范围完全独立:A Isolate的GC,只会扫描、清理自己的堆内存,绝对不会碰其他Isolate的堆,就像A制片厂的垃圾清运队,只会清理自己仓库的垃圾,绝不会跑到隔壁制片厂的仓库里干活。
    • 执行时机与影响范围:GC执行时会触发的「全停顿」(Stop-The-World),在JS/引擎语义层只会暂停当前Isolate的主线程,其他Isolate的代码执行不受影响。但需注意:如果embedder(如Chrome)在高层做了进程/线程绑定、或存在native共享资源,极端的native bug/内存分配压力仍可能影响整个进程/其它组件。

(3)线程模型:Isolate的进入限制与后台任务

  • Isolate的进入限制:一个Isolate在任意时刻只能被一个线程Enter并执行(需用Locker/Unlocker在多线程中同步)。这是V8的核心线程规则,Isolate本身不是线程安全的,必须通过排他锁保证同一时间只有一个线程访问,否则会直接崩溃。
  • 后台任务与线程调度:V8会使用后台worker/任务来做并发GC、并行标记或JIT编译等工作,这些后台线程/任务的调度与是否“为某个Isolate专属”由V8平台与embedder决定,不能简单的下结论说是为每个Isolate都创建一整套独占OS线程。
  1. Isolate之间的强隔离,是V8稳定性和安全性的底层基石

(1)完全不共享任何JS对象,跨Isolate无法直接传递对象引用

  • 底层逻辑:V8里的每一个JS对象,都有一个绑定所属Isolate的「隐藏类(Map)」,同时对象的实际数据存在所属Isolate的堆内存里。这个对象和它的隐藏类,只在所属的Isolate里有效,一旦脱离这个Isolate,就完全失去了意义。
  • 实际表现:你绝对无法把A Isolate里的一个对象,直接传给B Isolate使用。哪怕你通过C++代码把内存地址传过去,B Isolate也无法识别这个地址,更无法访问这个对象,强行操作会直接触发崩溃。
  • 跨Isolate数据传递的唯一方式:序列化+反序列化。比如浏览器里的跨Tab通信、Node.js里的Worker线程和主线程通信,用的「结构化克隆算法(Structured Clone)」,本质就是把A Isolate里的对象,转换成二进制数据流,再把这个数据流传给B Isolate,B Isolate在自己的堆里,重新生成一个一模一样的全新对象。注意:这里传递的不是原对象的引用,而是生成了一个完全独立的副本,两个对象后续的修改完全互不影响。

(2)OOM、崩溃的隔离边界

  • OOM(内存溢出)隔离:OOM通俗说就是,Isolate的堆内存使用量超过了系统给它分配的上限,装不下新的对象了,导致程序无法继续运行。在JS语义层与正常错误范围内,一个Isolate发生OOM,只会触发当前Isolate的内存超限,同进程里的其他Isolate的堆内存完全不受影响,依然可以正常运行。但需注意:在native内存越界、引擎bug或exploit的情况下,整体进程仍可能被破坏。
    • 实际场景:Chrome浏览器里,一个网站页面因为内存泄漏触发OOM崩溃,只会当前页面白屏,其他打开的Tab页面完全正常,就是因为每个站点的页面都运行在独立的Isolate(甚至独立进程)里。
  • 崩溃隔离:在JS语义层与正常错误范围内,一个Isolate里发生的运行时错误,只会触发当前Isolate的异常,不会污染同进程里其他Isolate的内存空间。但极端的native内存越界、未定义行为、内核/驱动异常或者V8自己的严重bug,仍可能影响整个进程。

二、Context

  1. 详细定义
  • 首先,JS是词法作用域(静态作用域)语言,代码的作用域在编写时就确定了,而所有作用域链的最顶端,就是全局执行环境。我们写的所有JS代码,最终都必须在一个全局执行环境里运行,所有的全局变量、全局函数,都挂载在这个环境的全局对象上。
  • 一个Context,就是V8里一个完整、独立的全局执行环境的实体,对应V8的C++类v8::Context。它是JS代码真正的「运行容器」——哪怕你创建了Isolate,没有Context,也无法执行任何JS代码。
  • 通俗理解:如果Isolate是独立的物理制片厂,Context就是这个制片厂里面,搭建的一个个独立的逻辑摄影棚。同一个制片厂(Isolate)里,可以搭建多个摄影棚(Context),每个摄影棚都有自己完整的布景、道具、演员阵容,拍摄的剧本完全独立;它们共享制片厂的地皮(堆内存)、垃圾清运队(GC)、核心拍摄团队(主线程),但每个棚的拍摄内容互不干扰,也不会窜棚。

这里我们需要理解这个设计的核心价值所在:Context是为了在同一个Isolate里,实现轻量级的全局环境隔离,避免重复创建Isolate带来的巨大性能和内存开销。创建一个新的Context,开销极小(只是创建一套新的全局环境);而创建一个新的Isolate,需要重新分配堆内存、初始化GC、初始化一整套引擎实例,开销是Context的成百上千倍。

  1. 每个Context都有一套完全独立的全局执行环境,是隔离的核心

(1)专属的、完全独立的全局对象

  • 全局对象是什么? 它是JS全局执行环境的根对象,所有的全局变量、全局函数都会作为它的属性存在。在浏览器环境里,全局对象是window/globalThis;在Node.js环境里,是global/globalThis;在自定义Context里,你可以完全自定义这个全局对象。
  • 专属独立的核心含义:每个Context的全局对象,都是一个全新的、独立的对象,和同Isolate里其他Context的全局对象完全没有关联。
    • 实际表现1:你在A Context里执行window.a = 123,给全局对象加了一个属性a,在同Isolate的B Context里,执行console.log(window.a),只会输出undefined——因为两个Context的window根本不是同一个对象,就像两个摄影棚的背景板,哪怕都叫「客厅布景」,也是两个完全独立的板子,你在A棚的背景板上写字,B棚的背景板上完全看不到。
    • 实际表现2:浏览器里,主页面和同站iframe的window对象,就是两个不同Context的全局对象。主页面的全局变量,iframe里默认完全访问不到,反之亦然,这就是Context隔离的最直观体现。

(2)内置原生对象

  • 内置原生对象是什么? 就是JS语言自带的、不需要我们手动引入的原生构造器和API,比如ArrayObjectFunctionStringNumberMathJSONPromiseRegExp等等,所有JS内置的语法相关的API,都属于这个范畴。
  • 准确表述(区分实现与语义)
    • ECMAScript语义层(JS开发者视角):每个Context都有自己的全局对象与内置构造器/原型(即一个Context的Array与另一个Context的Array在JS语义上是不同的),这就是iframe-to-parent instanceof出现false的根本原因。
    • V8实现层(引擎开发者视角):V8/Isolate会维护builtin的实现(engine code),但在ECMAScript语义上,内置对象是按Context/realm隔离的。
    • 前端高频踩坑案例:浏览器里,主页面(A Context)里拿到了同站iframe(B Context)里的一个数组arr,在主页面里执行console.log(arr instanceof Array),结果会返回false——因为主页面里的Array构造器,是A Context的内置对象;而iframe里的数组arr,它的原型是B Context里的Array.prototype。这两个Array构造器,在JS语义上是两个完全独立的函数对象,它们的原型对象也完全独立,所以instanceof判断会失败。

(3)Context的环境装配流程,以及自定义沙箱能力

一个Context的创建和环境装配,分为两个核心步骤,这也是它能实现自定义JS沙箱的核心原理:

第一定义全局对象模板:通过V8的ObjectTemplate(对象模板),预先定义全局对象可以拥有哪些属性、方法,哪些属性可读写、可配置、可枚举。你可以在这里决定,给这个Context注入哪些API,屏蔽哪些API。 第二初始化Context实例,完成环境装配:基于上面的模板,创建Context实例,V8会自动为这个Context初始化一整套完整的内置原生对象(语义层独立),同时把模板里定义的属性、方法挂载到全局对象上,最终生成一个完整的、可执行JS代码的全局执行环境。

  • 沙箱应用场景:很多低代码平台、在线代码编辑器、JS沙箱库(比如Node.js的vm模块、isolated-vm库),核心原理就是创建一个自定义Context,只给它注入允许的安全API,屏蔽掉fetchevaldocumentprocess等危险API,让用户的JS代码只能在这个受限的Context里运行,实现安全隔离。
  1. 同Isolate内多Context的运行规则、通信机制与实际场景

(1)浏览器Tab/iframe与Isolate/Context的映射

浏览器的Tab页与Isolate、Context的对应关系,受Chrome的站点隔离(Site Isolation) 机制影响,分为两种情况:

  1. 同站iframe:和主页面运行在同一个渲染进程、同一个Isolate里,主页面和iframe各自拥有独立的Context。
  2. 跨站iframe:Chrome会把它分配到独立的渲染进程中,拥有自己独立的Isolate。

重要说明:这是Chrome的site-isolation策略带来的常见映射;具体映射依赖浏览器的进程/线程模型与隔离策略,V8只提供Isolate/Context的能力,并不强制这种对应关系。

(2)同Isolate内多Context的核心运行规则

  1. 共享底层资源,隔离执行环境
    • 共享:同一个Isolate里的所有Context,共享Isolate的堆内存、GC实例、主线程、后台任务调度系统。
    • 隔离:每个Context的全局执行环境、全局对象、内置原生对象(语义层)完全独立,代码在哪个Context里执行,就默认使用哪个Context的全局环境。
  2. Context的切换规则:同一时间,主线程只能进入一个Context执行代码
    • V8里,要在某个Context里执行JS代码,必须先通过Context::Enter()进入这个Context,执行完成后,通过Context::Exit()退出。
    • 通俗类比:制片厂的拍摄团队,同一时间只能在一个摄影棚里拍戏,拍完这个棚的内容,要先退出这个棚,再进入另一个棚拍摄。
    • 核心优势:Context的切换开销极低,只是切换了当前的全局执行环境指针,不需要切换线程、堆内存等底层资源,比切换Isolate的开销小几个数量级。
  3. 词法作用域与Context的绑定规则 JS是词法作用域,函数的作用域链,是在函数创建时确定的,而不是执行时。这个规则和Context深度绑定:
    • 举个例子:你在主页面的A Context里,创建了一个函数fn,函数里写了console.log(window.a)。然后你把这个fn函数,传给同Isolate的iframe的B Context里执行。
    • 执行结果:fn里访问的window,依然是A Context的window,而不是B Context的window
    • 底层原因:函数创建时,它的作用域链就已经绑定了创建它的A Context的全局环境,哪怕你把它拿到B Context里执行,它的作用域链也不会改变,依然会从创建时的Context里查找变量。

(3)跨Context通信

  1. postMessage API:这是最常用、最安全的跨Context通信方式。底层原理是:V8允许在同Isolate的不同Context之间,传递结构化克隆的数据,或者可转移对象,同时浏览器会校验消息的来源、目标域名,防止恶意跨域访问。
  2. iframe.contentWindow 引用:同站iframe之间,可以通过contentWindow拿到对方Context的全局对象的有限引用,进而访问对方允许的属性、调用对方的方法。底层是V8暴露了跨Context的对象访问能力,同时浏览器会做严格的同域校验,跨域场景下会屏蔽绝大多数属性的访问。
  3. V8原生API的跨Context对象传递:在C++层面嵌入V8时,可以直接通过V8的API,把一个Context里的对象、函数,传递给另一个Context使用,因为它们在同一个堆里,对象引用是有效的。但V8依然会做上下文的安全校验,避免非法的跨Context访问。

三、容易理解错误的关键知识点

  1. 错误:Isolate就是进程/线程,Context就是线程

    • 正确:Isolate不是进程也不是线程,它是V8引擎的一个运行实例,一个进程里可以创建多个Isolate,一个Isolate对应一个主线程;Context更不是线程,它只是一个执行环境,多个Context共享Isolate的主线程。
  2. 错误:内置对象要么完全是per-Isolate,要么完全是per-Context

    • 正确:需区分实现层与语义层。实现层V8/Isolate保有builtin的实现代码;语义层每个Context拥有独立的全局对象与内置构造器/原型,这是instanceof在不同Context不等同的根源。
  3. 错误:iframe一定是一个独立的Context,且和主页面同Isolate

    • 正确:Chrome站点隔离机制下,跨站iframe会运行在独立的渲染进程、独立的Isolate里;只有同站iframe才是同Isolate下的独立Context。且这种映射是embedder策略,不是V8强制的。
  4. 错误:把函数传到另一个Context里执行,就会用这个Context的全局对象

    • 正确:JS是词法作用域,函数的作用域链在创建时就绑定了所属的Context,哪怕在另一个Context里执行,依然会使用创建时的Context的全局环境。
  5. 错误:Isolate的OOM/崩溃“绝对”不会波及其他Isolate

    • 正确:在JS语义层与正常错误范围内不会波及,但native内存越界、引擎bug或exploit仍可能影响整个进程。
  6. 错误:每个Isolate都必然拥有一组专属的OS后台线程

    • 正确:后台线程/任务的调度与是否“专属”由V8平台与embedder决定,不能轻易下结论,前面讲过的。
  7. 错误:64位系统下V8堆上限通常为4GB

    • 正确:堆上限与V8版本、embedder配置及运行时参数(如--max-old-space-size)有关;默认值在不同平台/版本间有较大差别,指针压缩会对最大堆规模引入工程限制(常见讨论在4GB左右),但不能将4GB作为统一默认值。

四、一段js代码的完整执行流程

我们看一段JS代码从初始化到执行的完整流程:

  1. 进程与Isolate初始化:操作系统启动浏览器渲染进程,进程内创建一个V8 Isolate实例,为它分配专属的堆内存、初始化专属的GC实例、启动主线程。
  2. Context创建与环境装配:在这个Isolate里,为页面主环境创建一个Context实例,初始化全局对象window,注入语义层独立的JS内置原生对象,同时挂载浏览器提供的documentlocationfetch等Web API,完成全局执行环境的装配。
  3. 进入Context执行代码:主线程Enter这个主Context,把页面的JS代码加载进来,进行预解析、编译成字节码,然后在这个Context的全局执行环境里逐行执行;代码里的全局变量挂载到当前Context的window上,调用的ArrayObject等API,都来自当前Context。
  4. 多Context场景处理:页面加载了一个同站iframe,浏览器在同一个Isolate里,为这个iframe创建一个全新的Context实例,初始化它自己的window对象、内置原生对象和独立的document对象;主线程Exit主Context,Enter这个iframe的Context,执行iframe里的JS代码,两个Context的全局环境完全隔离。
  5. 跨Context通信:主页面通过postMessage给iframe发消息,浏览器通过V8的跨Context通信机制,把消息数据传递给iframe的Context,触发iframe里的消息回调,回调在iframe的Context里执行。
  6. 资源销毁:页面关闭时,先销毁iframe的Context,再销毁主页面的Context,最后销毁Isolate实例,释放对应的堆内存和所有资源。

3. 快速启动的机制

一个可执行的 Context(逻辑摄影棚),必须完成全局对象、ECMAScript 规范内置原生对象的完整装配,才能承接 JS 代码的执行。但是,规范定义了上百个内置构造器、上千个内置方法,从 Array、Object 到 Promise、JSON,每一个都需要底层 C++ 代码从零创建、初始化、挂载原型链、编译字节码。

这套标准化的繁琐装配流程,每次新建 Isolate 或 Context 时都要完整重复执行一遍,这是 V8 引擎冷启动最大的性能瓶颈。在早期无快照的版本中,桌面端创建一个 Context 需要耗时 40ms 以上,中低端移动端更是需要 270ms 以上(这些是查阅资料,找到的历史观测的估算数值,具体耗时取决于硬件平台和测量方法)。这严重降低了冷启动的体验。

而现在, V8 用来打破这个瓶颈、实现开机时间数量级缩减的,就是类似于 预制化基建 的 mksnapshot 快照机制。

我们使用 制片厂-摄影棚 的比喻:如果说 Context 的内置对象初始化,是给每个新摄影棚从零搭建标准化的背景板和道具架,哪怕所有摄影棚的基础配置完全一致,也要一钉一板的重新施工;那么 mksnapshot 就是制片厂的预制构件工厂,提前在工厂里把所有标准化基建一次性搭建完成,拍下完整的状态快照存档。新建摄影棚时,直接把预制好的整套基建搬运进场、一键还原,瞬间达到开机拍摄的标准,省去了 99% 的施工时间。

一、定义

mksnapshot 是 V8 引擎源码编译阶段的一个中间可执行程序,也是 V8 冷启动优化的核心基础设施。它的核心逻辑就是:

把 JS 运行环境的重复初始化工作,从无数次的运行期提前到一次性的编译期;把运行时需要 CPU 逐行执行代码才能生成的堆内存状态,固化成编译期预制好的二进制快照,运行时直接内存还原即可。

  • 构建极简实例: 编译 V8 源码时,第一步会先编译出一个极简版的、最小可用的 V8 实例,这就是 mksnapshot 程序。(mksnapshot 的具体生成策略与 V8 版本和平台有关,早期与现在的实现细节会有差别,它会随引擎不断演进)。
  • 生成基建状态: 运行 mksnapshot,它会在内部创建一个临时的 Isolate 和 Context,完整执行一遍 ECMAScript 规范要求的所有内置对象初始化流程,生成一个完全可用、装配完毕的 JS 运行环境堆内存状态。
  • 拍下物理快照: mksnapshot 会把这个稳定的堆内存状态,序列化成一个二进制快照文件(snapshot_blob.bin)。mksnapshot 会把内置函数的元数据(如 SharedFunctionInfo)、Ignition 字节码以及部分底层的可序列化内建实现(code objects)一并打包进快照;而由运行时 JIT(如 TurboFan)针对业务代码动态生成的优化机器码通常是运行期产物,绝不会作为通用快照的一部分
  • 打包发版: 最后,这个快照文件会被转换成 C++ 常量数组,和 V8 的其他核心源码一起编译,最终打包进 Chrome、Node.js 等宿主程序的可执行文件中,随程序发布。

通俗来说,这就是餐饮行业的中央厨房预制菜模式(即星际著名的西贝模式~.~):中央厨房(编译期 mksnapshot)提前把菜做好、速冻锁鲜(序列化快照),配送到各个门店(用户的宿主程序)。门店不用再洗菜、切菜、点火,只需要微波炉加热(反序列化),瞬间就能出餐,彻底解决了每个门店都要重复备菜的效率问题。

二、底层全流程:快照的生成与反序列化的物理动作

我们将快照机制分解为编译期生成和运行期还原两个核心阶段,理解底层的内存操作。

(1)编译期:快照的生成与序列化全流程

这一步发生在 V8 引擎自身的编译构建阶段,对前端开发者和最终用户完全透明。

  • 第一步:预执行,完成虚拟世界的完整基建

    mksnapshot 启动后创建一个干净的临时 Isolate 和默认 Context,执行两大核心工作:

    1. 内置对象全量装配: 从零创建规范定义的所有内置构造器、原型对象、全局 API,完成原型链挂载和属性配置。

    2. 内置函数预编译: 对所有内置方法进行解析、编译,生成对应的 SharedFunctionInfo(公共图纸,下一章核心)和 Ignition 字节码,部分核心内置函数甚至直接编译成底层可序列化的 code objects 存入快照。

      至此,临时 Isolate 的堆内存里,已经有了一个无任何动态副作用的纯净 JS 运行环境。

  • 第二步:序列化与指针重定位(核心要点)

    堆内存里的对象通过指针互相引用,而指针存储的是绝对物理地址。下次新建 Isolate 时,堆的基地址完全不同,直接死板拷贝会导致指针全部失效。

    mksnapshot 的序列化是一套完整的「对象图谱持久化」流程:

    1. 遍历临时堆内存,梳理出所有对象(内置对象、字节码、隐藏类、常量等)的依赖关系图。
    2. 将所有对象的绝对内存指针,转换成基于快照基地址的相对偏移量。引用关系从「绝对地址指向」变成了「相对偏移指向」。
    3. 按照特定格式,将这些元数据、实际内容和偏移信息,压缩编码成连续的二进制数据块(快照 Blob)。
  • 第三步:嵌入可执行文件

    生成的快照 Blob 被转换为 C++ 巨型常量数组,随 V8 一起链接打包。当你安装 Chrome 或 Node.js 时,这个预制好的 JS 世界基建,就已经以物理数据的形式躺在二进制文件里了。

(2)运行期:快照的反序列化(开箱即用的基建还原)

这一步发生在浏览器或 Node.js 启动、新建 Isolate/Context 的瞬间。

  • 第一步:内存拷贝与指针重定向

    当宿主环境创建新 Isolate 时,V8 拒绝执行繁琐的初始化代码,而是干脆利落地执行两个物理动作:

    1. 拷贝: 在新 Isolate 的堆内存里开辟连续空间,把内置在程序里的快照二进制数据直接整块拷贝进去。
    2. 重定向: 执行一次极速遍历,把快照里所有的相对偏移量,加上当前 Isolate 堆的基地址,瞬间转换成当前堆里有效的绝对物理指针,缝合所有对象的引用关系。

    复杂度: 这两步操作本质上是大块内存拷贝加上对快照中所有引用的一次修正(Pointer Relocation)。其耗时随快照规模线性增长(即 O(n) 复杂度)。但由于 V8 使用了大块 memcpy、只读映射和按需反序列化等极致优化,体验上能做到非常低的延迟。在桌面端耗时不到 2ms 等毫秒级别(估算值),实现了数量级的性能跃升。

  • 第二步:环境挂载,完成最终装配

    快照只包含标准的 ECMAScript 基础状态。浏览器还需要 window/document,Node.js 还需要 global/process。

    此时,V8 只需基于快照还原出的干净环境,快速创建一个全局对象并挂载这些宿主专属 API,一个完整可用的 Context 瞬间拔地而起。

三、现代 V8 的进阶优化:从全量到精细化管控

早期的快照是全量反序列化的,哪怕 90% 的内置对象(如 WebAssembly 或高级正则库)用户根本用不到,也会完整塞进内存,造成极大浪费。现代 V8 通过三招将启动速度和内存占用做到了极致平衡。

(1)懒反序列化 (Lazy Deserialization):按需加载

这是 V8 彻底解决内存浪费的核心优化。

原理: 将完整的快照拆分成几十个独立的小块。启动时,仅反序列化最核心、最基础的极小一部分快照块。

按需触发: 当用户的 JS 代码第一次用到某个特定的内置对象时(比如第一次执行 new Promise()),V8 才会去反序列化对应的快照块并在堆里还原。完全用不到的对象,永远不会占据物理内存。

(2)只读堆快照 (Static Roots):多实例共享的公共基建

在多 Context 或多 Isolate 场景下(如 Chrome 的多个同站 Tab,或 Node.js 的 Worker 线程),每个实例都反序列化一份完全相同的内置对象,依然是内存冗余。

原理: 现代 V8 将快照中永远不会被修改的内容(如内置函数的字节码、隐藏类 Map、undefined/null 常量等),单独剥离成一个独立的只读快照 (Read-Only Snapshot)。

共享机制: 现代 V8 可以通过操作系统的内存映射 (mmap) 实现多个实例(通常指同一进程内的多个 Isolate)对这段物理内存的直接共享;至于能否跨进程共享,则依赖宿主(如 Chrome 的进程模型)如何使用共享内存或文件映射来达成。这使得多实例场景下的基础内存开销骤降 50% 以上。

(3)自定义快照 (Custom Snapshot):业务级冷启提速

mksnapshot 不仅能预制 V8 原生对象,还允许开发者把自己的业务代码和第三方库提前预制进去。

原理: 在应用的构建阶段,提前执行高频依赖库(如 React、Vue 或各种 Utils),生成自定义快照并打包。应用启动时直接反序列化,彻底免去了运行时的源码解析和编译时间。

战绩: VS Code、Figma 等重型 Electron 桌面应用,正是通过自定义快照,将冷启动时间砍掉了 30% 以上。(需要注意:某些系统用自定义快照确实能显著提速,但也要注意业务代码调试的复杂性与快照维护成本)。

四、使用和限制的问题:快照机制不能做什么?

快照是冷启动优化的核心关键,但是快照并不是万能的,它有严格的限制。

不能包含宿主相关的动态 API

快照只能包含与 ECMAScript 语义直接相关、且在编译期可确定为无副作用的内容。任何依赖宿主运行时信息的对象(如浏览器的 DOM 树、Node.js 的 process.pid、实时网络交互或打开的文件句柄等),都不应被写入快照,必须在运行时动态挂载。

不能包含有副作用或动态不确定的代码

预执行的代码必须是纯净无副作用的。不能包含 Math.random()、Date.now()、网络请求或文件读写。如果在编译期预制了当前时间,那用户运行时拿到的将永远是几个月前快照打包那一刻的过期时间。

跨版本不通用

快照与 V8 引擎版本是强绑定的。不同版本的 V8,其堆内存布局、对象隐藏类结构、序列化格式随时会变。跨版本使用快照会导致指针错乱,直接引发进程崩溃。

(说明:在前面的章节中提到不同 Isolate 之间完全不共享对象引用,这是准确的;但通过宿主环境提供的共享内存如 SharedArrayBuffer 或 native handle,依然可以实现跨环境的数据互通,这是独立于堆快照之外的特殊通道。)

五、快着急制的理解误区

  • **错误 :**快照就是简单的内存镜像直接映射。

    正确: 快照是经过序列化处理的对象依赖图。反序列化时必须经历严格的指针重定向计算,把相对偏移转为当前堆的绝对物理地址,绝非一句简单的 memcpy 就能搞定。

  • **错误 :**快照能把所有 JS 代码都提前预制,启动时无需执行。

    正确: 只有静态、无副作用的初始化代码有资格进入快照。动态的业务逻辑必须在运行时老老实实交给解释器执行。

  • **错误 :**自定义快照里塞的代码越多,启动越快。

    正确: 塞入过多低频代码会导致快照体积暴涨,极大地拖慢反序列化时的 I/O 读取和指针重定向耗时,反而得不偿失。

  • **错误 :**快照反序列化出来的对象,和运行时从零创建的对象有差异。

    正确: 两者在结构、行为、语义上完全一致,JS 代码完全感知不到任何区别 —— 唯一的差异就是创建速度快了几个数量级。反序列化出来的对象,同样可以正常修改、删除属性,正常调用方法,没有任何限制。

六、收工了:串联制片厂的生命周期

到这里,我们可以用完整的比喻将快照机制串联起来:

  • 工厂预制(编译期): 制片厂建厂前,先在预制构件厂(mksnapshot)把标准化的背景板、灯光系统搭建好,拍下状态快照存档。
  • 拎包入住(新建 Isolate): 接到新项目时,不再从零采购砖瓦,直接把预制好的基建一次性搬运到新厂区。
  • 按需装修(新建 Context): 在预制基建上,快速加装本次拍摄专属的道具(挂载宿主 API),瞬间开机。

快照机制的本质,就是把重复劳动一次性前置,用编译期的一次重度计算,替换掉运行时无数次的重复初始化。

同时,快照里已经预制好了所有内置函数的 SharedFunctionInfo(公共图纸)和预编译字节码。这正是连接「编译期」与「运行期」的终极纽带。

4. 空间的KPI

上面的快照,核心kpi是时间,而现在,我们讲一下空间,即v8在内存的使用中是如何的扣扣嗖嗖。

JavaScript 是一门到处都是回调函数、极度依赖闭包的语言。如果每一次 function() {} 的执行,引擎都要在内存里原封不动地把庞大的指令代码复制一份,那再大的运行内存也会被撑爆。

为了将内存压榨到极致,V8 对 JS 里最核心的实体------ 函数,进行了一次分解。这就是 V8 运行期精妙的内存设计:双子星模型(SharedFunctionInfo 与 JSFunction)


第一:SharedFunctionInfo (SFI):公共图纸

  • 属性: 编译期的纯静态产物。
  • 物理形态: 它是一个绝对的“死物”。一旦在编译期(或 mksnapshot 快照期)生成,就作为以静态元数据为主的长期对象存在,通常位于老生代或只读映射段。(注意:只要没有任何引用且 GC 判定可回收,SFI 也会被销毁;并且为了节省内存,它挂载的部分编译产物——如长期未使用的字节码或机器码——在运行时可被触发 Flush 刷新或丢弃)。
  • 跨环境共享: 同一份代码源码,即便运行在不同的摄影棚(Context / Realm)里,底层也可以共享同一份 SFI 图纸,因为它只描述静态特征,与具体的执行环境彻底分离。

核心内容: SFI 里面装载的全是与“单次执行状态无关”的元数据。它是一张详尽的公共建筑图纸:

  • BytecodeArray(分镜头剧本): 导演(字节码生成器)录制的完整字节码序列,是函数执行的绝对核心指令集。
  • FormalParameterCount(演员名额): 明确规定了这个剧本需要的形参个数,用于运行时的参数适配与溢出校验。
  • Expected Register Count(栈帧最高水位线): 这主要记录在 SFI 关联的 BytecodeArray 中,是生成器在编译期精打细算后,打上的那个决定性物理钢印!它明确记录了未来建组时,需要瞬间圈出多少个虚拟寄存器(r0, r1...),是运行时解释器 O(1) 极速圈出栈帧空间的唯一依据。
  • FeedbackMetadata(侦察兵的空白表格): 提前计算好这个函数里有多少个需要收集类型信息的插槽,规定了未来“情报小本本”的格式和页数。
  • Source Position Table(源码雷达): 指向字节码与源码行列号的映射表。运行时报错时,能精准定位到开发者写的具体是哪一行哪一列。
  • Flags(特性标记): 标注函数的核心特性(如“箭头函数”、“严格模式”、“async”等),直接指导解释器的微观执行逻辑。

为什么叫“Shared” (共享)?

想象一下,如果你写了一个高频触发的逻辑:for(let i=0; i<1000; i++) { function foo(){} }

在 JS 的语义层面,每一次循环都会创建一个全新的、相互独立的函数对象。难道 V8 要把 foo 的字节码编译 1000 次、在内存里存 1000 份重复的死代码吗?

肯定不会!V8 对内存的控制极其抠门。对于 foo 这个函数,内存里永远只有唯一的一张 SFI 图纸。那 1000 个循环创建出来的函数实体,都会通过内部指针共享这同一张图纸。这也是它名字里 Shared 的核心由来,它极大拯救了前端应用的堆内存。


第二: JSFunction:活着的剧组与闭包的肉身

  • 属性: 运行期的动态产物,它是有生命的,会随着代码执行而诞生,也会随着引用清零被垃圾回收。
  • 诞生的瞬间: 当 Ignition 解释器在运行期,真刀真枪地执行到 CreateClosure 这条字节码指令时,V8 才会在堆内存(通常是新生代 New Space,但具体的分配与提升行为会受 GC 与逃逸分析等优化影响)里 new 出一个真实的 JSFunction 对象。

核心动作(物理缝合): 这个新建的 JSFunction 本质上是一个“执行容器”。V8 会在它诞生的瞬间,做一次极其神圣的“缝合手术”,为其注入三大核心灵魂指针:

  • shared_ 指针(拿图纸): 死死地指向那张静态的 SFI 图纸,获取函数执行的所有静态指令与元数据。
  • context_ 指针(锁环境): 死死地抓住当前那一刻正在运行的上下文(Context 结构)。这是 V8 最核心的一步——物理锁定函数的词法作用域。
  • feedback_cell_ 指针(发笔记本的领取凭证): 注意!为了极致的节约内存,V8 在初期通常只会给 JSFunction 发一个 feedback_cell 的间接引用。真正的“情报小本本”(Feedback Vector)是按需、延迟分配的。一旦分配,它将负责记录函数专属的类型情报。情报猜测的准确度,将直接决定后期特效师(TurboFan)的优化质量;而错误的猜测,则会导致去优化(Deopt)的翻车惨剧。

第三:指针的力量

有很多初学者,甚至工作多年前端开发者,到处吐槽js如何的不堪,如何如何的难用,如何如何的是个缝合怪 指针如何如何的难理解难使用。。。js都默默承受着。

前端八股文里总是背诵:“JavaScript 采用词法作用域,函数的作用域在定义时决定,而非调用时决定”。很多初学者觉得这是一种语言规范的“玄学”,看不见摸不着,甚至经常和 this 的动态指向搞混。

但站在这对核心双子星面前,玄学荡然无存,只剩下冷冰冰的 C++ 指针与绝对确定的物理规则:

解释器在运行时,真正 Call 的永远是带有上下文的 JSFunction,而绝不是光秃秃的 SFI 图纸!

无论这个 JSFunction 被作为回调函数传到了多深的调用栈里,也无论它被 return 到了哪个毫无相干的外部环境去执行。只要它一启动,Ignition 解释器只会做两件固定的事:

  1. JSFunction 里掏出 shared_ 指针,拿到预编译好的字节码和预先算好的寄存器数量,在物理栈上瞬间砸出一个栈帧(Stack Frame),完成极速内存圈地。
  2. JSFunction 里掏出那个在它出生时就被缝合进去的 context_ 钥匙,把它作为当前函数查找外部变量的唯一基准点。所有越界的变量查找,都会顺着这把钥匙指向的堆内存链条(Context Chain)向上摸索。

我们用一个最经典的闭包例子,直观还原这个底层物理过程:

JavaScript

function outer() {
  let a = 1;
  // 解释器走到这里,执行 CreateClosure 字节码
  return function inner() { 
    console.log(a); 
  };
}
const fn = outer();
fn(); // 输出 1

底层的物理动作完全对应我们的规则:

  1. outer 执行时,解释器走到 inner 的函数声明处,触发 CreateClosure 指令,在堆内存中创建出 innerJSFunction 对象。
  2. 缝合瞬间完成: innershared_ 指针连上预编译好的 SFI 图纸;它的 context_ 指针,被 V8 强行绑定到了 outer 刚刚在堆上生成的那个包含了 a=1 的 Context 别墅上。
  3. fn 被 return 到了全局环境。此时,虽然 outer 的 C++ 物理调用栈帧已经被彻底销毁出栈,但是! fn 身上的 context_ 指针依然像个铁锚一样,牢牢抓着 outer 留在堆内存里的那个 Context 别墅,导致它无法被垃圾回收。
  4. fn() 被调用时,解释器毫不关心当前是在全局环境,它直接掏出 fn 肚子里的 context_ 钥匙,顺着指针一开门,精准拿到了 a=1,完成打印输出。

总结: 所谓“闭包”,所谓“出身决定命运的词法作用域”,在 V8 底层从来都不是什么虚无缥缈的玄学。它就是 JSFunction 对象内部,那个在 CreateClosure 执行瞬间被刻死、永远指向堆内存中某座特定 Context 别墅的 context_ 物理指针。


如果看过这系列文章的第一部分 解析篇 的朋友,可能会记得,我们在学习解析时,说过, 在预解析时,并不会生成AST,而是会生成一个占位符,并且和SFI相关联,那个时候的SFI,和这里的SFI,有神么区别吗?我们下面就详细的讲一下,把这个延续千年的恩怨给了结了。

先说结论:

它们在 C++ 的物理内存地址上,是 100% 绝对相同的同一个对象, 但是,它的内部状态和装载的数据,经历了一次从“空壳档案袋”到“满配图纸”的变化。

在 V8 的 C++ 源码中,这个过程被称为 Lazy Compilation(惰性编译)

我们就来回顾一下SFI的前世今生

阶段一:预解析阶段 “只有封面的空档案袋”(Uncompiled SFI)

当 V8 第一次拿到一长串 JS 源码,准备开机建厂时,为了极速启动,预解析器(Pre-parser)只会对没有立即执行的函数进行极其粗略的扫描。

此时,V8 会在堆内存里 new 出一个 SharedFunctionInfo 对象。但这时候的它,是一个半成品

这个“空档案袋”里装了什么?

  • 函数名: 比如叫 foo
  • 源码位置(Source Positions): 记录了这个函数在源码字符串里的起止位置(比如第 10 行到第 20 行)。
  • 演员名额(Formal Parameter Count): 扫一眼括号里有几个参数,比如 function foo(a, b) 就是 2。
  • 特性标记(Flags): 比如标记了这是否是一个严格模式的函数。

它缺少了什么最重要的东东?

  • 没有 AST(分镜头原稿): 预解析不生成 AST 树。
  • 没有 BytecodeArray(分镜头剧本): 导演还没开工,根本没有指令代码。
  • 没有 Frame Size 和 Feedback Metadata: 没编译,当然不知道需要多少寄存器和情报小本本。
  • 【非常关键】替身指针: 此时,SFI 内部本该指向机器码或字节码的那个执行指针,被临时指向了一个 V8 内置的 C++ 占位函数,叫做 CompileLazy(懒编译替身)

阶段二: 触发 CompileLazy

时间来到了运行期。代码里终于有一句 foo() 被调用了!

男一号 Ignition 解释器(或者更准确地说是执行环境)顺着 JSFunction 的指针,找到了这个 SFI 图纸。结果低头一看:“哎呀?剧本(字节码)呢?怎么是个叫 CompileLazy 的替身?”

此时,CompileLazy 被触发,V8 瞬间按下了暂停键,大喊一声:“导演,快写剧本,演员要上场了!”

阶段三: “满配的图纸”(Compiled SFI)

V8 立刻把 foo 函数的源码(根据 SFI 里记录的起止位置提取出来)重新扔给真正的 Parser 和 BytecodeGenerator(导演)。

生成了完整的 AST,接着生成了 BytecodeArray(字节码序列),并算出了 Frame Size(最高水位线)和 FeedbackMetadata(情报表格)。

v8的点睛之笔:

V8 不会去销毁那个旧的 SFI 然后创建一个新的!如果那样做,外面无数个指向旧 SFI 的闭包(JSFunction)全都会变成野指针而崩溃。

V8 的做法是:原位热更新(In-place Update)

它直接把刚刚生成好的 BytecodeArrayFrame SizeFeedbackMetadata“塞进”那个预解析阶段留下的旧档案袋里,并把那个指向 CompileLazy 的占位指针,替换成真正指向字节码执行入口的指针。


总结:SFI 的“前世今生”

我们用表格对比一下同一个 SFI 对象在两个阶段的状态:

属性 / 内容 预解析阶段 (Uncompiled SFI) 真正调用后 (Compiled SFI)
片场比喻 只有封面的空档案袋 装满指令的图纸
物理内存地址 0x1234abcd 0x1234abcd (同一个地址,原位更新)
源码起止位置 已有 (记录了从哪到哪) 保持不变
形参个数 (参数名额) 已有 (如 2) 保持不变
AST (分镜头原稿) 编译瞬间生成 (生成字节码后通常被丢弃)
BytecodeArray (剧本) 被填入完整的字节码序列
Frame Size (钢印) 被填入确切的虚拟寄存器数量
FeedbackMetadata 被填入情报小本本的格式规范
执行入口指针 指向内置的 CompileLazy 替身 指向真正的字节码入口代码

那么为什么不一开始就全部编译好呢?

因为前端网页有太多类似下面这种“写了但可能永远不执行”的代码(比如点击某个冷门按钮才会触发的回调):

JavaScript

document.getElementById('hidden-btn').addEventListener('click', function massiveFunction() {
    // 几千行极其复杂的逻辑
});

如果在网页加载时,V8 就把 massiveFunction 完整编译成字节码,不仅会严重拖慢网页的首屏显示速度,还会白白浪费大量的内存,尤其是手机内存。先建个“空档案袋(Uncompiled SFI)”占着坑位,等用户真的点下按钮时再“填补剧本”,这是 V8 在极速启动极致内存之间的平衡知道。

5. Script Function 和 Entry Frame

包装一切的 Script Function

当我们在 app.js 里写下第一行看起来自由的顶层全局代码时,比如:

JavaScript

var a = 1; 
console.log(a);

很多朋友可能会以为,这些代码就像吹散的蒲公英一样,直接散落在名为“全局”的空间里。

其实并不是那样,在 V8 的底层视角里,根本不允许存在“散落代码”。

为什么不允许?

因为 V8 的整个编译流水线(从 Parser 生成 AST,到 BytecodeGenerator 生成字节码),其唯一能识别的“根节点”和“工作单元”,必须是函数(Function)。AST 树必须有一个树根,字节码序列必须有一个归属容器。它们无法接受零散的游离语句。

因此,V8 编译器在解析 JS 文件时,必须玩一个偷天换日的障眼法:它悄悄地把这整个文件里的顶层代码,全部编译成一个“类似函数”的顶级代码对象(Top-level Code Object)。在引擎内部,你可以把它视为一个隐式Script Function(脚本函数)。它的核心元信息(代码物理起止位置、词法作用域、包含多少个内层闭包),会被极其严谨地打上钢印,记录在对应的 SharedFunctionInfo (SFI) 图纸及 Script 结构中。

我们以为自己写的是“全局代码”,但在引擎眼里,这不过是这个庞大匿名函数肚子里的“内部逻辑(函数体)”而已。

这个 Script Function 有 3 个特殊的底层性质:

  • 无显式函数名: 它是引擎内部的特权实体,JS 代码无法通过名字直接调用它。当你在浏览器控制台看到报错堆栈最底部的 (anonymous) 时,那往往就是它的物理真身。
  • this 指向: 在浏览器的传统 <script> 标签中,顶层 this 指向全局对象(window)。在 Node.js 普通文件(CommonJS 模块)中,顶层 this 绝对不等于 global 为什么?因为 Node.js 在把代码交给 V8 之前,在外部又暴力套了一层真实的字符串外衣:(function (exports, require, module, __filename, __dirname) { 你的代码 \n });。所以模块顶层的 this 实际上等同于 module.exports。在 ES 模块(type="module".mjs)中,由于模块规范要求默认处于严格语义(Strict Mode),顶层的 this 永远是 undefined
  • 作用域链起点为全局 Context: 它的作用域链起点,在当前 Isolate 里的全局 Context(逻辑摄影棚)上。这意味着,当你在顶层写下 var a = 1 时,引擎实际上是在这个隐式函数执行时,顺着这根被锁死的指针,找到了全局摄影棚,并把 a 这个道具摆在了大厅的正中央。

就像在我们的片场: 写了一堆零散的表演动作,制片厂绝不会让演员在马路上瞎演,它会强行给套上一个名叫《第一集:试播集》(Script Function)的剧集外壳。所有的全局动作,都不过是这一集里的剧情。

现在,剧本包装好了(Script Function),图纸(SFI)和实体(JSFunction)也都完美缝合了。但是,V8 引擎本质上只是一个被嵌入的 C++ 库,它绝对不会主动去给自己找活干。

真正掌握生杀大权、决定什么时候开机的,是宿主环境(Host Environment)——比如 Chrome 浏览器主进程,或者 Node.js 底层的 C++ 核心代码。

宿主环境,才是真正出资组建这一切的**“大老板 / 制片人”**。关于片场宇宙的设定,可以往上翻翻,复习一下。

不同的宿主环境,触发这声开机指令的场景也完全不同:

  • Chrome 浏览器: 当页面加载完 HTML 中的 <script> 标签、执行 eval 动态代码、或是调用 new Function 创建函数时。
  • Node.js: 当执行入口文件 node app.js 或是执行 REPL 环境中的输入代码时。(前面在讲this指向时讲过,在使用 require 加载 CommonJS 模块时,Node.js 还会给代码额外套上一层 function(exports, require, module...){} 的外衣,但剥开这层特定外衣,扔给 V8 执行的最底层机制依然同理)。

当物理片厂(Isolate)建好了,逻辑摄影棚(Context)也搭好了,大老板拿着那个包装好的 Script Function 走向 V8 引擎,重重地按下了那个跨越两个世界的底层 API 按钮:

v8::internal::Execution::Call

“都出来干活了,把整个脚本跑起来!”

随着这句 C++ 代码的执行,宿主程序正式向 V8 引擎下达了开机指令。

但这同时引出了一个问题:

C++ 大老板这一个命令出来,就意味着操作系统的物理 CPU 要从执行 C++ 编译出来的机器码,瞬间切换去执行 V8 解释器里的指令了。

万一里面有个死循环,或者爆出了一个致命的未捕获错误,会不会把大老板(Node.js 或浏览器进程)直接带着一起崩溃坠崖?

为了防止这种情况,在真正拔起第一个 JS 栈帧之前,V8 必须在悬崖底下铺上一张极其厚实的“防爆缓冲垫”。

这就是跨界防爆门——Entry Frame(入口帧)


大老板(C++ 宿主)扣动了 Execution::Call 的扳机,但操作系统的物理 CPU 并不会直接“瞬移”到 JavaScript 的代码里去执行。

因为这是两个不同的世界

C++ 代码编译出的机器码,严格遵循着操作系统底层的 应用程序二进制接口调用约定,它把极其重要的系统状态保存在 CPU 的物理寄存器里(比如 rbp 栈底指针、rsp 栈顶指针,以及各种非易失性寄存器)。

而 V8 的 Ignition 解释器,是完全不按 C++ 规则运行的野路子。一旦让它接管 CPU,它会在物理内存里疯狂圈地、读写累加器、频繁变动栈顶指针,它有自己的一套寄存器使用策略。如果直接让它冲进去,C++ 保存在物理寄存器里的核心数据瞬间就会被踩得稀巴烂。

等 JS 代码跑完,CPU 回头一看:我是谁?我在哪?C++ 的执行现场全没了。操作系统会直接报出 Segmentation fault(段错误),把整个进程当场干掉。

为了防止这种同归于尽的惨剧,在真正建立第一个 JS 栈帧之前,V8 必须在悬崖底下,铺上一张极其厚实的“防爆缓冲垫”。

C++ 与 JS 的物理界碑

在执行任何一句 JS 字节码之前,V8 会先执行一段小型汇编代码片段(Stub)——也就是 JSEntry Stub。这段极速的底层汇编跳板代码,会在操作系统的物理堆栈上,强行砸入一个极其特殊的栈帧——Entry Frame (入口帧)

它是横亘在 C++ 静态世界与 JS 动态世界之间的一道“气闸舱”:一边连接着 C++ 的物理寄存器规则,一边连接着 JS 的虚拟栈帧逻辑。

不仅如此,它还充当了两个世界之间的“海关”。 C++ 大老板调用时传递过来的参数,通常是一个 C++ 的数组指针(argv),JS 引擎是无法直接使用的。JSEntry Stub 会在建立 Entry Frame 的同时,负责把 C++ 数组里的参数一个个取出来,严格按照 JS 的调用约定(Calling Convention)物理压入栈中,完成数据的“跨界偷渡”。

作用:物理现场的绝对冻结

Entry Frame 砸入物理栈后的第一件事,就是封存历史

它会把 C++ 世界此刻所有关键的物理寄存器状态——包括 rbp/rsp 等栈指针,以及所有非易失性寄存器(用于保存 C++ 的局部变量和调用上下文)——原封不动地全部压入自己所在的这片栈内存中保存起来。

完成封存后,它才放心地给 Ignition 解释器放行:“去吧,尽情去折腾 CPU 寄存器吧,C++ 的老家我已经替你们锁好了。”

兜底保障:跨越生死的完美退场

Entry Frame 不仅负责把 C++ 安全地送进去,更负责把结果安全地接回来。这里有两种情况:

  • 常规杀青(正常返回): 当顶层的 JS 脚本(Script Function)正常执行到了最后一行 Return。控制流跳回 Entry Frame,它从容地从栈上把之前保存的物理寄存器数据塞回 CPU。指针一转,C++ 宿主程序就像什么都没发生过一样,拿着 JS 返回的结果继续往下跑。
  • 重大生产事故(未捕获异常): 这是它作为“防爆门”最伟大的时刻。假设你的 JS 代码里抛出了一个错误 throw new Error("Boom!"),并且没有被任何 try-catch 捕获。
    • V8 引擎的异常处理机制会开始疯狂地**“栈展开(Stack Unwinding)”**——它会沿着栈链向上回溯,残忍地一层一层撕毁所有的 JS 栈帧、释放对应的栈空间,试图寻找能处理错误的 Catch 块。
    • 当它撕毁了所有 JS 栈帧,一路倒退,最终重重地撞在 Entry Frame 这扇防爆门上时,撕毁动作会被强制逼停!
    • 此时,JSEntry Stub 会检查 Isolate 线程内部的 pending_exception(待处理异常)标志位。 一旦发现有致命错误,Entry Frame 会把这个致命的 Error 包装成一个安全的 C++ 可处理对象,通过宿主设置的 v8::TryCatch 机制传递出去,然后恢复 C++ 的寄存器现场,平稳地把错误交还给宿主大老板。

结果就是: 这就是为什么你的 Node.js 代码报错时,终端里只会优雅地打印出一段红色的 Error 堆栈字符串然后正常退出,而不是直接让整个操作系统进程崩溃的原因。

正是 Entry Frame 的默默扛下所有,才保全了宿主进程的体面。


伴随着 Entry Frame 稳稳扎入物理内存,两个世界的安全通道彻底打通。

大老板的参数已经静静地躺在栈上,等待被认领。

控制权,正式移交给 Ignition 解释器。这中间通常通过一个名为 InterpreterEntryTrampoline 的内置代码片段作为跳板,它是通往字节码世界的第一级台阶。

第一个真正的 JavaScript 栈帧,即将拔地而起!


补充内容 ------从解析篇到现在,时间太久了,我不确定有没有写过这部分相关的内容,只记得3月份的那篇提到过栈帧大小的事,多写总比少写好。

栈帧图纸的数字烙印

在 Entry Frame 铺好缓冲垫、控制权刚刚交接给 Ignition 解释器的这一瞬间,时间仿佛静止了。

在解释器准备大干一场、往物理内存里圈地建栈帧之前,我们必须先回答一个直击灵魂的底层问题:

解释器怎么知道这个即将开机的“剧组(栈帧)”,到底需要多大的占地面积?它怎么知道要准备几把“椅子(参数和局部变量)”?

难道解释器要在每次函数被调用时,先临时去把函数体里的代码从头到尾扫描一遍,数一数里面有几个 let、几个 var、需要多少个临时变量,然后再决定向操作系统申请多大的内存吗?

这是不可能的。

如果把这笔账留在运行期去算,那么每次函数调用的开销就会变成 O(N)(N 为代码复杂度)。对于那些在 requestAnimationFrame 里每秒执行 60 次,甚至在 for 循环里执行千万次的高频函数来说,这种运行时的扫描损耗是灾难性的。

V8 的底层思路是:永远不要在运行期,去做任何可以在编译期完成的事。

实际上,栈帧的大小和参数的数量,早在之前的编译阶段,就已经被精确计算出来,并且作为“死数据”死死地烙印在图纸上了。

(1)演员名额的核定:Formal Parameter Count(形参数量)

当 Parser(解析器)在编译期第一次扫过你的代码 function foo(a, b, c) 时,它就已经确定了这个剧组需要 3 个正式演员。

这个数字 3(即 FormalParameterCount)会被直接硬编码写入到这把函数的公共图纸——SharedFunctionInfo (SFI) 的元数据中。(注:除了这 3 个明面上的演员,引擎还会暗中加上 1 个隐形大佬——this 接收者,作为雷打不动的零号位参数,这个知识点,我记得前面在哪个地方讲过,好像在ignition上篇?)。

为什么必须记下这个数字?

因为 JS 是一门极其自由灵活的语言。你规定了 3 个参数,但大老板(调用者)执行时完全可能乱塞 5 个参数,或者只给 1 个参数。

在接下来的建组阶段,解释器必须拿着图纸上的这个标准数字 3,去和调用者实际压入栈的参数进行“对账(参数适配)”,以保证栈帧结构的绝对规整。

(2)场务的精打细算:最高水位线 (High-Water Mark)

参数数量决定了栈帧的“上半部分(参数区)”,而函数内部的局部变量和临时计算,决定了栈帧“下半部分(工作区)”的大小。

在上一篇中,我们讲过场务(BytecodeRegisterAllocator - 字节码寄存器分配器)。他在陪着导演生成字节码时,干了一件极其了不起的事:极限复用

  • 场务看到显式声明 let x,就分配一个常驻寄存器 r0
  • 看到一个复杂的加法运算 a + b,就分配一个临时寄存器 r1 暂存结果。一旦加法算完,r1 立刻被场务无情收回,借给下一行代码的乘法继续使用。

在整个 AST(抽象语法树)被遍历完、最后一条字节码生成完毕的杀青时刻,场务会翻开他的账本,统计出一个决定性的数据——最高水位线(Maximum Register Count):即在这个函数逻辑最复杂、嵌套最深的那个瞬间,同时最多需要用到多少个虚拟寄存器(这里的“同时用到”已经包含了显式局部变量和临时变量的最大并发数)。

假设场务算出来,最高水位线是 5 个寄存器。这个数字,就是作为不可篡改的物理钢印,被死死烙印在 BytecodeArray 对象头部的 frame_size

它记录的是“需要预留的寄存器槽位个数”,而不是直接的物理字节数。

我们通过两个例子来看下计算过程:

例一:基础运算的临时借用

JavaScript

function calc(a, b) {
  let x = 100;
  let y = (a + b) * x;
  return y;
}

那个极度抠门的场务(Register Allocator)在编译期推演:

  1. 遇到 let x = 100:分配常驻虚拟寄存器 r0

  2. 遇到 (a + b):借用临时寄存器 r1 存结果。

  3. 遇到 * x:将 r1r0 相乘,结果放入新的常驻寄存器 r2(代表变量 y)。

    推演结论: 此函数并发最高时,同时征用了 3 个虚拟寄存器。

例二 控制流分支的极限使用

很多前端以为:我声明了几个变量,就会占用几个坑位。 这样的理解是不正确的,看下面这段代码:

JavaScript

function process(type, val) {
  let result = 0; // 分配 r0
  if (type === 'A') {
    let tempA = val * 2; // 分配 r1
    result = tempA + 10;
  } else {
    let tempB = val / 2; // 场务极其抠门,直接复用 r1 !!
    result = tempB - 5;
  }
  return result;
}

在这个例子中,代码里明明声明了 resulttempAtempB 三个局部变量。

但场务在推演时发现:tempAtempB 存在于两个绝对互斥的 if/else 分支中,它们在物理时间线上永远不可能同时存活

因此,场务会极其冷酷地让 tempAtempB 共享同一个物理寄存器 r1

推演结论: 尽管声明了 3 个变量,但这个函数的最高水位线只有 2 个虚拟寄存器(r0 和 r1)。

场务会将这个极限压榨出来的最高水位线数字(如例一的 3,例二的 2),死死地写入 BytecodeArray 的头部元数据中。

极速物理圈地

注意,图纸上记载的只是“寄存器需求数量(Metadata)”,它并不是最终的物理字节数。

当男一号登场前一瞬,InterpreterEntryTrampoline 会极速读取图纸上的 Metadata(假设最高水位线是 3 个寄存器),然后在脑子里进行一次绝对精确的汇编级心算:

(注:以下推演为一个基于 64-bit x86 架构的理想化核心模型。在真实的 V8 引擎中,实际的物理内存布局会因不同的操作系统 ABI、CPU 架构(如 ARM)以及编译器的具体优化策略而有所差异,但这丝毫不影响我们理解其 O(1) 圈地的本质。)

  1. 计算工作区: 3 个寄存器 × 8 字节(64位系统指针大小) = 24 字节
  2. 叠加固定帧头(Fixed Header): 任何 JS 栈帧必须包含基建数据,通常包括:
    • Return Address(返回地址)
    • Previous Frame Pointer(指向上一个栈帧的 rbp,用于异常回溯)
    • Context Pointer(当前函数所在的逻辑摄影棚指针)
    • JSFunction Pointer(当前正在执行的双子星实体自身的指针) 这 4 个固定槽位,占了 4 × 8 = 32 字节
  3. 平台内存对齐: 操作系统通常要求栈内存在 16 字节边界对齐,以保证 CPU 缓存行读取效率。

最终心算结果: 24 (工作区) + 32 (固定头) = 56 字节。为了对齐 16 的倍数,最终向上补齐到 64 字节

算完这个绝对精确的数字后,InterpreterEntryTrampoline 对物理内存挥出那极速的 O(1) 一刀,它直接将物理 CPU 的栈顶指针(比如 x64 下的 rsp)向低地址狠狠拉伸 64 个字节:

sub rsp, 64

只用了一条毫无波澜的机器指令,全场所需的所有槽位、固定帧头、运行期空间,瞬间在物理内存中拔地而起!

三万字了,又要分篇了。下篇再见。

五一快乐。

从零实现 GIF 制作工具:LZW 压缩与 Median Cut 色彩量化

2026年5月1日 01:42

GIF 制作工具,原本以为直接用现成的库就完事了,结果发现纯前端实现更有意思。这篇文章聊聊 GIF 格式的核心技术:LZW 压缩和 Median Cut 色彩量化。
在这里插入图片描述

为什么 GIF 这么难搞?

GIF 格式诞生于 1987 年,那时候的设计理念跟现在完全不同。最大的坑在于:GIF 只支持 256 色。现在的图片动辄几百万色,要塞进 256 色的框框里,还得保持画质,这就是色彩量化的难题。

另一个坑是 LZW 压缩。GIF 用的 LZW 算法是专利保护的(2003 年过期),但更重要的是,LZW 的实现细节很容易踩坑。比如码表溢出怎么办?Clear Code 什么时候发?这些细节文档里写得含糊,得靠实验摸索。

Median Cut:把几百万色砍到 256 色

Median Cut 是经典的色彩量化算法,核心思想很简单:把颜色空间切成 256 个方块,每个方块取中心点作为代表色。

算法步骤

function medianCut(pixels, maxColors) {
  // 1. 统计所有颜色及其出现频率
  const colorMap = new Map()
  for (let i = 0; i < pixels.length; i += 4) {
    const key = `${pixels[i]},${pixels[i+1]},${pixels[i+2]}`
    colorMap.set(key, (colorMap.get(key) || 0) + 1)
  }
  
  // 2. 初始化:所有颜色放进一个桶
  const buckets = [{
    entries: Array.from(colorMap.entries()),
    rMin: 0, rMax: 255,
    gMin: 0, gMax: 255,
    bMin: 0, bMax: 255
  }]
  
  // 3. 反复切分,直到桶数达到 maxColors
  while (buckets.length < maxColors) {
    // 找范围最大的桶
    const bucket = findWidestBucket(buckets)
    if (!bucket) break
    
    // 沿最长边切分
    const [left, right] = splitBucket(bucket)
    buckets.splice(buckets.indexOf(bucket), 1, left, right)
  }
  
  // 4. 每个桶取加权平均色作为调色板
  return buckets.map(b => computeAverage(b))
}

关键细节

为什么要按出现频率加权? 因为颜色出现的次数越多,对视觉影响越大。如果一个像素只出现一次,它被量化错了也无所谓;但背景色要是偏了,整张图都难看。

切分策略的选择:标准 Median Cut 按颜色数量均分,但更好的做法是按像素数量均分。这样可以避免一个桶里塞了几百万像素,另一个桶只有几个像素的情况。

LZW 压缩:GIF 的灵魂

LZW 是一种字典编码算法,核心思想是用短编码代替重复出现的字符串。GIF 的 LZW 有几个特殊点:

1. 变长编码

LZW 的编码长度是动态增长的。初始码长是 minCodeSize + 1,随着字典增大,码长逐步增加到 12 位。一旦字典满了(4096 项),就发 Clear Code 重置字典。

function packBits(codes, minCodeSize) {
  const clearCode = 1 << minCodeSize
  const endCode = clearCode + 1
  
  let codeSize = minCodeSize + 1
  let nextCode = endCode + 1
  
  for (const code of codes) {
    // 把编码塞进位缓冲区
    bitBuffer |= (code << bitCount)
    bitCount += codeSize
    
    // 每凑够 8 位就输出一个字节
    while (bitCount >= 8) {
      bytes.push(bitBuffer & 0xFF)
      bitBuffer >>>= 8
      bitCount -= 8
    }
    
    // 字典满了,发 Clear Code
    if (nextCode >= 4096) {
      codes.push(clearCode)
      nextCode = endCode + 1
      codeSize = minCodeSize + 1
    }
    // 码长增长
    else if (nextCode >= (1 << codeSize) && codeSize < 12) {
      codeSize++
    }
  }
}

2. 字典构建

LZW 的字典是边压缩边构建的。每次输出一个编码,就把"当前编码 + 下一个像素"加入字典。

function lzwEncode(indexedPixels, minCodeSize) {
  const dict = new Map()
  let w = indexedPixels[0]
  
  for (let i = 1; i < indexedPixels.length; i++) {
    const k = indexedPixels[i]
    
    // w+k 在字典里,继续扩展
    if (dict.get(w)?.has(k)) {
      w = dict.get(w).get(k)
    }
    // w+k 不在字典里,输出 w,把 w+k 加入字典
    else {
      codes.push(w)
      if (!dict.has(w)) dict.set(w, new Map())
      dict.get(w).set(k, nextCode++)
      w = k
    }
  }
  
  codes.push(w)
  return codes
}

3. 性能优化

原始 LZW 实现用 w+k 作为字典键,字符串拼接很慢。优化方案是用嵌套 Map:第一层 Map 的键是前缀编码,第二层 Map 的键是后缀像素值。这样查找从 O(n) 降到 O(1)。

GIF 文件结构

GIF 文件是按块组织的,主要包含:

GIF89a Header
Logical Screen Descriptor
Netscape Application Extension (循环播放)
┌─ Graphics Control Extension (延迟、透明)
│  Image Descriptor
│  Local Color Table
│  LZW Image Data
└─ (重复每一帧)
GIF Trailer

延迟时间的坑

GIF 的延迟单位是 10 毫秒,不是 1 毫秒。而且很多播放器会强制最小延迟为 20ms(2 个单位),所以你设 10ms 实际播放可能是 20ms。

循环播放

GIF89a 标准本身不支持循环播放,是 Netscape 加的扩展。循环次数 0 表示无限循环,1 表示播放 1 次(总共播放 2 次)。这个坑我踩了好久。

实战经验

做 JsonKit 的 GIF 制作工具时,遇到几个性能问题:

色彩量化太慢:原始实现遍历所有像素找最近色,O(width × height × paletteSize)。优化方案是用 KD-Tree 或预先建立颜色查找表。

LZW 压缩太慢:字典查找是瓶颈。用嵌套 Map 替代字符串拼接后,速度提升了 10 倍。

内存爆炸:处理大图时,Canvas 的 getImageData 会返回巨大的数组。解决方案是分块处理,或者用 Web Worker 避免阻塞主线程。

最终效果

在这里插入图片描述

相关工具

总结

GIF 格式虽然古老,但背后的技术很有意思。LZW 压缩是早期无损压缩的经典算法,Median Cut 是色彩量化的基石。理解这些原理,不仅能写出更好的 GIF 工具,对理解现代图片格式(WebP、AVIF)也有帮助。

完整代码在 JsonKit 的 GIF Maker 工具里,欢迎试用。

邪修:Markdown加粗语法**本土化改造

作者 Yue栎廷
2026年4月30日 20:46

前言

AI时代,前端开发者在将大模型输出的Markdown文本解析成HTML渲染时,想必有遇到过加粗未生效的问题:

‌模型幻觉是模型因统计偏差**“无意识”生成错误内容,而模型欺骗是模型“有意识”**地隐瞒或歪曲真相,两者本质不同但常被混淆。

乍一看,心想:完蛋了,又写bug了。预期是 “无意识”“有意识” 被加粗,怎么变成中间内容加粗了??

别慌,其实这不是前端的问题,也不是Markdown解析器的问题,而是CommonMark的规定如此。

具体可以看看这篇文章:为什么掘金的 Markdown 加粗语法(……)有时候不生效?

这项规范在中文语境下很不友好,同理,韩文、日文一样。

比如,截图MDN上的这篇文章,其中的WebSocket()在英文时可以正常加粗展示,而中文时却展示出了 **WebSocket()**。这种Markdown加粗未按预期渲染的情况,在网络平台上常见,刚好掘金也是其中之一。(**叠甲:**没有说掘金不对,掘金这是合理的)

IMG_5898.HEIC

Markdown规范

主流的Markdown解析是基于CommonMarkGFM规范实现的。

其中CommonMark对于加粗或者斜体的定义:spec.commonmark.org/0.30/#right…

总结:定界符序列(一个或多个*或者_)的规则。

左侧定界符序列:

  1. 后面不能是空白;
  2. 后面是标点符号时,前面必须有空白或标点符号。

右侧定界符序列:

  1. 前面不能是空白;
  2. 前面是标点符号时,后面必须有空白或标点符号。

关于加粗规范的定义,以及为何在中英文会有所区别,在上面中提到的文章里写的很好,此处便不再赘述。

项目里用到的解析器是markedjs@14.1.2

当初遇到 ** 的加粗问题时,查看issue,发现众多人也有相同的困扰,仓库作者表示这是符合规则定义的,如果需要按个人预期结果解析可以自定义插件实现。

目之所及大部分是关于加粗的疑问:

截屏2026-04-29 15.14.34.png

随便贴一个issue:github.com/markedjs/ma…

如何解决?

既然明确了Markdown规则,那就按照正确的规则来书写文本。

1. 添加空白

两侧定界符外围各添加1个空格

今年收益率 99% 表现不俗,继续加油!

模型幻觉是模型因统计偏差 “无意识” 生成错误内容,而模型欺骗是模型 “有意识” 地隐瞒或歪曲真相,两者本质不同但常被混淆。

文本之间有明显的空格,对于严谨的产品方可能不会接受这种效果。

2. 添加标点符号

零宽字符:一类在视觉上不可见、不占用显示宽度,但在计算机底层实际存在并占用存储空间的Unicode控制字符。

比如:今年收益率&zwnj;**-45%**&zwnj;表现很俗,需改进!

今年收益率‌**-45%**‌表现很俗,需改进!

还有其它类型的零宽字符:

字符名称 英文全称 简称 Unicode 码位 HTML 实体名称 HTML 实体编号 主要用途
‌零宽空格‌ Zero Width Space ZWSP U+200B &zwsp; &#8203; 用于长单词内部的换行分隔,防止自动换行破坏布局;也可用于文本分析中的隐形分隔。
‌零宽非连接符‌ Zero Width Non-Joiner ZWNJ U+200C &zwnj; &#8204; 阻止字符间的连字效果。例如在波斯语或阿拉伯语中,强制两个本应连写的字符分开显示。
‌零宽连接符‌ Zero Width Joiner ZWJ U+200D &zwj; &#8205; 强制字符间产生连字效果。广泛用于Emoji组合(如👨‍👩‍👦家庭表情)及复杂文字系统的排版。
‌零宽无断空格‌ Zero Width No-Break Space ZWNBSP / BOM U+FEFF (无标准命名实体) &#65279; 通常用作字节顺序标记(BOM)。在文本中使用时,可防止在该位置换行,功能类似不间断空格但宽度为零。

以上方案对于熟知规则的人来讲,可以在编辑时解决加粗标识未生效的问题,比如使用掘金编辑器时。

那对于大模型输出的随机答案呢?即使可以在模型训练时额外要求,但是也难以避免模型输出,并且不是所有的企业都能自己训练模型,那这种情况出现在生产环境,用户看到的就是 ** 了,产品经理就来找前端了。(前端苦啊)

3. 正则匹配替换

由开发者处理,在文本经过Markdown解析器之前,为 ** 外围添加空格或零宽字符,再调用解析器输出渲染。

左侧 ** 替换为&zwnj;**,右侧 ** 替换为**&zwnj;

不过,正则匹配 ** 的规则该怎么写,需要考虑空格吗?需要考虑嵌套吗?会导致其它问题吗?

其实这里又回到了定界符的定义。Markdown解析器本质也是正则匹配,按照CommonMark规范书写正则,去匹配 ** 。

既然如此,那我们可以根据使用的Markdown解析器修改定界符的规则,使其满足中文语境。

邪修大法改造

markedjs作者说的,可以实现自定义插件去解析加粗 **

markedjs官方自定义插件文档:marked.js.org/using_pro#e…

结果一看源码里关于加粗这块的实现,真的很复杂,如果开发者自己写插件额外实现,可能会导致更多的潜在问题。

如果能在解析器的基础上(站在巨人的肩膀上),只将关于标点符号的规则调整一下,这样便可以很少的改动完成预期。

那就先看源码试试,找到源码里关于规则的定义:github.com/markedjs/ma…

截图是v14.1.2版本: markdown加粗.png

可以很快识别到.replace(/punct/g, _punctuation)这行代码的作用。

看看_punctuation的定义const _punctuation = '\\p{P}\\p{S}';。这类是正则表达式里用于匹配特定类别的unicode字符的模式,这些模式基于unicode字符属性,可以更精确地匹配不同类型的字符。

那这里的\\p{P}\\p{S}表示匹配所有的标点符号和空白字符,即要求定界符前后需要这样的字符限定。

按照本土化改造,我们期望在定界符前后有标点符号(%、+、—)时也满足定界符的定义,可以将\\p{P}模式删掉。为了不影响原有的变量,新增变量_punctuationCustom替换 ** 规则replace的地方:

截屏2026-04-29 16.10.58.png

如此便可以最小改动实现预期,既解决了标点符号问题,又不影响原有规则下空格分词实现嵌套的场景。

改造前:

截屏2026-04-30 16.31.35.png

改造后:

截屏2026-04-30 16.24.18.png

两种实操方案

  1. markedjs GitHub仓库找到你需要的版本,clone一份,修改源码后再打包使用。优点:可以使用最新的release包。

  2. 或者npm i marked@14.1.2,这个版本的npm包里有编译后未混淆的源码可以直接修改使用。

    为什么是这个版本?在我使用markedjs时,安装的恰好是这个版本,后续的版本产物混淆了,没办法直接修改js文件。

另一个较多使用的解析器是markdown-it,社区里提供了支持中文加粗规则的插件:markdown-it-cjk-friendly

最后

市面上多个Markdown编辑器、网页的表现也不一样。比如:

  • VScode:完全符合CommonMark规范。
  • Typora:有自己的解析规则,所见即所得,输入 ** 就会被解析为加粗,不考虑空格和标点符号,在嵌套场景下完全混乱了。
  • 各家App、网页:展示也会有所不同,处理方式也不同。

一个合适自己项目的方案就是最好的方案。

照片墙太死板?做一个会随风摇摆的绳串图片交互效果

作者 JYeontu
2026年4月30日 18:31

说在前面

大家平时做图片展示,很多都是卡片平铺、瀑布流、轮播图。 这次我们换个思路:把图片“挂”在一根绳子上,加上随风摆动的动态效果,支持拖拽拉扯回弹。

在线体验

codePen

codepen.io/yongtaozhen…

码上掘金

code.juejin.cn/pen/7634497…

image.png

关键代码

1、场景分层

Canvas 画绳子,DOM 放照片

  1. canvas#ropeCanvas:只负责画绳子。
  2. #photos:绝对定位的图片元素层。
  3. .controls:风力滑块控制区。
<canvas id="ropeCanvas"></canvas>
<div id="photos"></div>
<div class="controls">...</div>

这么拆的好处是:绳子可以高频重绘,图片继续保留 DOM 的 3D transform 和 pointer 交互能力,性能和开发体验都更稳。

2、绳子曲线

线性插值 + 抛物线下垂

绳子不是死直线,而是通过参数 t(0~1)取点:

function ropeAnchorPoint(t) {
  const lineX = lerp(ropeStart.x, ropeEnd.x, t);
  const lineY = lerp(ropeStart.y, ropeEnd.y, t);
  const arc = 4 * t * (1 - t);
  return {
    x: lineX + ropeSway * arc + dragX * (0.36 + 0.64 * arc),
    y: lineY + ropeSag * arc + dragY * (0.36 + 0.64 * arc),
  };
}

arc = 4t(1-t) 是关键,它在中点最大、两端最小,天然适合模拟“中间下垂、两端固定”的绳子形态。

3、照片摆动

弹簧阻尼模型做“钟摆感”

每张图都有自己的 angle(角度)和 velocity(角速度),每帧按受力更新:

const acc =
  -p.stiffness * Math.sin(p.angle - p.restAngle) -
  p.damping * p.velocity +
  scaledWind;
p.velocity += acc * dt;
p.angle += p.velocity * dt;

这里本质是“回复力 + 阻尼 + 风力扰动”。 不同图片还带随机 phasemass,所以摇摆不会完全同步,看起来就更像真实挂件。

4、拖拽联动

限制位移 + 弹性回归

拖拽不是直接把图片瞬移,而是把拖拽位移转成“绳子的外力输入”:

const len = Math.hypot(dx, dy);
const dragLimit = 110;
if (len > dragLimit) {
  const ratio = dragLimit / len;
  dx *= ratio;
  dy *= ratio;
}

然后再通过速度与阻尼平滑回弹:

dragVX += (dragTargetX - dragX) * dragK * dt;
dragVX *= Math.exp(-dragDamping * dt);
dragX += dragVX * dt;

5、视觉细节

绳子高光 + 穿绳遮挡 + 透视倾斜

这个效果需要注意一些细节:

  1. 绳子画两遍:粗深色主线 + 细浅色高光线。
  2. 每张图上方加 .rope-pass,并按切线角度旋转,制造“绳子穿过卡片孔位”的假象。
  3. 图片 transform 叠加 rotateZ + rotateY + rotateX,速度越大越有轻微俯仰感。
const tangent = ropeTangent(p.t);
const tangentAngle = Math.atan2(tangent.y, tangent.x);
p.passEl.style.transform = `translate(0, -50%) rotate(${tangentAngle}rad)`;

源码地址

gitee

gitee.com/zheng_yongt…

github

github.com/yongtaozhen…

🌟 觉得有帮助的可以点个 star~

🖊 有什么问题或错误可以指出,欢迎 pr~

📬 有什么想要实现的功能或想法可以联系我~

公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

告别割裂式学习:待办清单项目,一次性掌握数组、本地存储与事件委托

2026年4月30日 18:16

告别割裂式学习:待办清单项目,一次性掌握数组、本地存储与事件委托

你也许背过 mapfilter 的用法,也用过 localStorage,但一遇到真实项目就不知道怎么组合?
本文通过一个完整的待办清单应用,带你真正理解:数据驱动视图、状态持久化、如何优雅地操作数组。
重点:我会用最通俗的白话讲清楚「事件委托」——新手最头疼的概念之一。


为什么第二个项目必须是待办清单?

待办清单(TodoMVC)被称为“前端的力学题”。它看起来简单,却包含了现代 Web 应用的核心模式:

  • 数据模型:用数组存储对象,每个对象有 idtextcompleted
  • 增删改查(CRUD):添加、删除、更新完成状态
  • 数据持久化localStorage 保存数据,刷新页面不丢失
  • 事件委托:处理动态生成的 DOM 元素
  • 条件渲染:根据数据状态展示不同样式
  • 筛选过滤:全部/未完成/已完成

完成它之后,你会发现很多中大型项目的基础模块都长这样。


第一步:搭建界面(HTML + CSS)

我们先写好一个干净、现代的界面:输入框、添加按钮、待办列表、筛选栏和统计信息。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>待办清单|JavaScript 实战</title>
    <style>
        * { box-sizing: border-box; }
        body {
            background: #f1f5f9;
            font-family: system-ui, -apple-system, sans-serif;
            display: flex;
            justify-content: center;
            padding: 2rem;
        }
        .todo-app {
            max-width: 500px;
            width: 100%;
            background: white;
            border-radius: 1rem;
            box-shadow: 0 8px 20px rgba(0,0,0,0.1);
            padding: 1.5rem;
        }
        h1 { margin-top: 0; font-size: 1.8rem; color: #0f172a; }
        .add-form { display: flex; gap: 8px; margin-bottom: 1.5rem; }
        .add-form input {
            flex: 1; padding: 10px; border: 1px solid #cbd5e1;
            border-radius: 8px; font-size: 1rem;
        }
        .add-form button {
            background: #3b82f6; color: white; border: none;
            border-radius: 8px; padding: 0 16px; cursor: pointer;
        }
        .todo-list { list-style: none; padding: 0; margin: 0 0 1rem 0; }
        .todo-item {
            display: flex; align-items: center; gap: 12px;
            padding: 10px; border-bottom: 1px solid #e2e8f0;
        }
        .todo-item.completed span { text-decoration: line-through; color: #94a3b8; }
        .todo-item span { flex: 1; }
        .delete-btn {
            background: #ef4444; color: white; border: none;
            border-radius: 6px; padding: 4px 12px; cursor: pointer;
        }
        .filter-bar { display: flex; gap: 8px; margin-top: 1rem; }
        .filter-btn {
            background: #e2e8f0; border: none; border-radius: 20px;
            padding: 6px 12px; cursor: pointer;
        }
        .filter-btn.active { background: #3b82f6; color: white; }
        .stats { margin-top: 1rem; font-size: 0.9rem; color: #475569; text-align: center; }
    </style>
</head>
<body>
<div class="todo-app">
    <h1>✅ 待办清单</h1>
    <div class="add-form">
        <input type="text" id="todoInput" placeholder="写一个待办..." autocomplete="off">
        <button id="addBtn">添加</button>
    </div>

    <ul class="todo-list" id="todoList"></ul>

    <div class="filter-bar">
        <button class="filter-btn active" data-filter="all">全部</button>
        <button class="filter-btn" data-filter="active">未完成</button>
        <button class="filter-btn" data-filter="completed">已完成</button>
    </div>
    <div class="stats" id="stats"></div>
</div>

<script>
    // 所有 JavaScript 代码将放在这里
</script>
</body>
</html>

第二步:核心知识点拆解(重点:事件委托)

2.1 数据结构设计

每个待办项是一个对象:

{
  id: Date.now(),      // 唯一标识,不用下标避免删除错乱
  text: '学习JavaScript',
  completed: false
}

整个应用的数据就是一个数组 todos

2.2 从 localStorage 读取和保存

function loadTodos() {
  const stored = localStorage.getItem('todos');
  return stored ? JSON.parse(stored) : [];
}

function saveTodos(todos) {
  localStorage.setItem('todos', JSON.stringify(todos));
}

2.3 “数据 → 视图”的渲染函数

核心思想:只要数据 todos 变了,就彻底重新生成列表 HTML。不需要手动操作每一个 DOM 元素。

let currentFilter = 'all';

function render() {
  // 1. 根据筛选条件过滤
  let filtered = todos;
  if (currentFilter === 'active') {
    filtered = todos.filter(t => !t.completed);
  } else if (currentFilter === 'completed') {
    filtered = todos.filter(t => t.completed);
  }

  // 2. 生成 HTML 字符串
  const html = filtered.map(todo => `
    <li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
      <input type="checkbox" ${todo.completed ? 'checked' : ''}>
      <span>${escapeHtml(todo.text)}</span>
      <button class="delete-btn">删除</button>
    </li>
  `).join('');

  document.getElementById('todoList').innerHTML = html;

  // 3. 统计
  const total = todos.length;
  const completedCount = todos.filter(t => t.completed).length;
  document.getElementById('stats').innerHTML = `共 ${total} 项,已完成 ${completedCount} 项`;
}

2.4 🔥 事件委托(新手必看!)

问题:为什么需要事件委托?

我们所有的待办项(包括里面的“删除”按钮和复选框)都是通过 render() 动态生成的。如果直接写:

document.querySelector('.delete-btn').addEventListener('click', function() { ... })

这行代码执行时,页面上还没有 .delete-btn(因为列表是后来才渲染出来的),所以根本绑定不上。哪怕你写在 render() 之后,每次重新渲染旧 DOM 会被替换,之前绑定的监听器也会丢失。

传统解决方案:每次渲染后重新绑定事件——非常麻烦且容易出错。

解决方案:事件委托

利用 事件冒泡 机制:点击某个元素后,事件会一直向上传播到它的父元素、祖先元素。我们可以把监听器挂到已经存在的父容器上(比如 <ul id="todoList">),然后通过 e.target 判断实际点击的是哪个子元素,再做出相应的处理。

生活类比

  • 普通绑定 = 在每个员工身上安装一个专线电话(员工离职换人,电话就没了)。
  • 事件委托 = 只在部门经理那放一部总机,谁打来电话就通过分机号转给对应的人(员工换人,分机号不变,依然能找到)。
代码实现(逐行注释)
document.getElementById('todoList').addEventListener('click', (e) => {
  // e 是事件对象,e.target 是用户实际点击的最深层元素(可能是按钮、复选框、<span>等)
  const target = e.target;

  // 关键方法:closest('.todo-item')
  // 它会沿着祖先链向上查找,找到第一个匹配 '.todo-item' 选择器的元素。
  // 这样无论你点击的是按钮、复选框还是文字,都能拿到当前待办项所在的 <li>
  const li = target.closest('.todo-item');
  if (!li) return;   // 如果点击的不是待办项内部,直接忽略

  // 从 li 上读取 data-id 属性,这是我们在渲染时设置的
  const id = Number(li.dataset.id);

  // 判断点击的是“删除按钮”
  if (target.classList.contains('delete-btn')) {
    // 删除:过滤掉 id 匹配的项
    todos = todos.filter(t => t.id !== id);
    saveTodos(todos);
    render();   // 重新渲染列表
    return;
  }

  // 判断点击的是“复选框”(input type="checkbox")
  if (target.type === 'checkbox') {
    const todo = todos.find(t => t.id === id);
    if (todo) {
      todo.completed = target.checked;  // 根据复选框状态更新
      saveTodos(todos);
      render();
    }
  }
});

为什么这样就能工作?

  • 监听器挂载在 #todoList 上,而这个 <ul> 自始至终存在,不会被替换。
  • 每次点击,事件冒泡到 <ul>,我们检查 e.target,根据点击的元素类名或类型做出不同操作。
  • 对于新添加的待办项,不需要额外绑定任何事件——事件委托会自然处理。

这就是事件委托的威力:只需一个监听器,就能管理未来所有动态生成的子元素

2.5 添加待办

function addTodo() {
  const input = document.getElementById('todoInput');
  const text = input.value.trim();
  if (text === '') return;

  const newTodo = {
    id: Date.now(),
    text: text,
    completed: false
  };
  todos.push(newTodo);
  saveTodos(todos);
  input.value = '';
  render();
}

2.6 筛选功能

改变 currentFilter,然后重新调用 render() 即可。

function setFilter(filter) {
  currentFilter = filter;
  // 更新按钮高亮
  document.querySelectorAll('.filter-btn').forEach(btn => {
    if (btn.dataset.filter === filter) btn.classList.add('active');
    else btn.classList.remove('active');
  });
  render();
}

document.querySelectorAll('.filter-btn').forEach(btn => {
  btn.addEventListener('click', () => setFilter(btn.dataset.filter));
});

2.7 初始化

let todos = loadTodos();
currentFilter = 'all';
render();
// 绑定添加按钮和回车事件
document.getElementById('addBtn').addEventListener('click', addTodo);
document.getElementById('todoInput').addEventListener('keypress', (e) => {
  if (e.key === 'Enter') addTodo();
});
// 事件委托已经挂载在 ul 上了,不再需要额外绑定

第三步:完整代码(可以直接运行)

下面是把所有部分整合在一起,复制保存为 .html 即可体验。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>待办清单|JavaScript 实战</title>
    <style>
        * { box-sizing: border-box; }
        body {
            background: #f1f5f9;
            font-family: system-ui, -apple-system, sans-serif;
            display: flex;
            justify-content: center;
            padding: 2rem;
        }
        .todo-app {
            max-width: 500px;
            width: 100%;
            background: white;
            border-radius: 1rem;
            box-shadow: 0 8px 20px rgba(0,0,0,0.1);
            padding: 1.5rem;
        }
        h1 { margin-top: 0; font-size: 1.8rem; color: #0f172a; }
        .add-form { display: flex; gap: 8px; margin-bottom: 1.5rem; }
        .add-form input {
            flex: 1; padding: 10px; border: 1px solid #cbd5e1;
            border-radius: 8px; font-size: 1rem;
        }
        .add-form button {
            background: #3b82f6; color: white; border: none;
            border-radius: 8px; padding: 0 16px; cursor: pointer;
        }
        .todo-list { list-style: none; padding: 0; margin: 0 0 1rem 0; }
        .todo-item {
            display: flex; align-items: center; gap: 12px;
            padding: 10px; border-bottom: 1px solid #e2e8f0;
        }
        .todo-item.completed span { text-decoration: line-through; color: #94a3b8; }
        .todo-item span { flex: 1; }
        .delete-btn {
            background: #ef4444; color: white; border: none;
            border-radius: 6px; padding: 4px 12px; cursor: pointer;
        }
        .filter-bar { display: flex; gap: 8px; margin-top: 1rem; }
        .filter-btn {
            background: #e2e8f0; border: none; border-radius: 20px;
            padding: 6px 12px; cursor: pointer;
        }
        .filter-btn.active { background: #3b82f6; color: white; }
        .stats { margin-top: 1rem; font-size: 0.9rem; color: #475569; text-align: center; }
    </style>
</head>
<body>
<div class="todo-app">
    <h1>✅ 待办清单</h1>
    <div class="add-form">
        <input type="text" id="todoInput" placeholder="写一个待办..." autocomplete="off">
        <button id="addBtn">添加</button>
    </div>

    <ul class="todo-list" id="todoList"></ul>

    <div class="filter-bar">
        <button class="filter-btn active" data-filter="all">全部</button>
        <button class="filter-btn" data-filter="active">未完成</button>
        <button class="filter-btn" data-filter="completed">已完成</button>
    </div>
    <div class="stats" id="stats"></div>
</div>

<script>
    // ---------- 工具函数 ----------
    function escapeHtml(str) {
        if (!str) return '';
        return str.replace(/[&<>]/g, function(m) {
            if (m === '&') return '&amp;';
            if (m === '<') return '&lt;';
            if (m === '>') return '&gt;';
            return m;
        });
    }

    // ---------- 数据持久化 ----------
    function loadTodos() {
        const stored = localStorage.getItem('todos');
        return stored ? JSON.parse(stored) : [];
    }

    function saveTodos(todos) {
        localStorage.setItem('todos', JSON.stringify(todos));
    }

    // ---------- 全局状态 ----------
    let todos = loadTodos();
    let currentFilter = 'all';

    // ---------- 渲染(数据 → 视图) ----------
    function render() {
        // 过滤
        let filtered = todos;
        if (currentFilter === 'active') {
            filtered = todos.filter(t => !t.completed);
        } else if (currentFilter === 'completed') {
            filtered = todos.filter(t => t.completed);
        }

        // 生成 HTML
        const listHtml = filtered.map(todo => `
            <li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
                <input type="checkbox" ${todo.completed ? 'checked' : ''}>
                <span>${escapeHtml(todo.text)}</span>
                <button class="delete-btn">删除</button>
            </li>
        `).join('');

        document.getElementById('todoList').innerHTML = listHtml;

        // 统计
        const total = todos.length;
        const completedCount = todos.filter(t => t.completed).length;
        document.getElementById('stats').innerHTML = `共 ${total} 项,已完成 ${completedCount} 项`;
    }

    // ---------- 添加待办 ----------
    function addTodo() {
        const input = document.getElementById('todoInput');
        const text = input.value.trim();
        if (text === '') return;

        const newTodo = {
            id: Date.now(),
            text: text,
            completed: false
        };
        todos.push(newTodo);
        saveTodos(todos);
        input.value = '';
        render();
    }

    // ---------- 事件委托(核心) ----------
    function handleListClick(e) {
        const target = e.target;
        // 通过 closest 找到当前待办项所在的 <li>
        const li = target.closest('.todo-item');
        if (!li) return;
        const id = Number(li.dataset.id);

        // 删除按钮
        if (target.classList.contains('delete-btn')) {
            todos = todos.filter(t => t.id !== id);
            saveTodos(todos);
            render();
            return;
        }

        // 复选框(切换完成状态)
        if (target.type === 'checkbox') {
            const todo = todos.find(t => t.id === id);
            if (todo) {
                todo.completed = target.checked;
                saveTodos(todos);
                render();
            }
        }
    }

    // ---------- 筛选 ----------
    function setFilter(filter) {
        currentFilter = filter;
        document.querySelectorAll('.filter-btn').forEach(btn => {
            if (btn.dataset.filter === filter) {
                btn.classList.add('active');
            } else {
                btn.classList.remove('active');
            }
        });
        render();
    }

    // ---------- 初始化 ----------
    function init() {
        render();
        document.getElementById('addBtn').addEventListener('click', addTodo);
        document.getElementById('todoList').addEventListener('click', handleListClick);
        document.getElementById('todoInput').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') addTodo();
        });
        document.querySelectorAll('.filter-btn').forEach(btn => {
            btn.addEventListener('click', () => setFilter(btn.dataset.filter));
        });
    }

    init();
</script>
</body>
</html>

第四步:你从中学到了什么?

通过这个项目,你不再只是孤立地知道数组方法、事件监听、本地存储,而是真正理解了它们如何协作构建一个真实应用。

  • 数据驱动视图:我们修改 todos,然后重新 render(),而不是手忙脚乱地操作 DOM。这是 React/Vue 等框架的思想源头。
  • 不可变数据:删除时用 filter 返回新数组(虽然添加用了 push,但删除和更新都是不可变或可追踪的)。
  • 🔥 事件委托:用最简单的方式处理动态元素,不再为绑定事件发愁。
  • 本地存储:前端数据持久化的基础。

下一步你可以扩展什么?

  • 编辑待办:双击文字变成可编辑的输入框(使用 contenteditable)。
  • 清空已完成:添加一个按钮,一次删除所有 completedtrue 的项。
  • 拖拽排序:使用 drag-and-drop API 重新排序待办。
  • 数据导出/导入:把 todos 导出为 .json 文件,也可以从文件导入。

每完成一个扩展,你都会对 JavaScript 更加自信。

如果你已经完成了猜数字和待办清单,下一个项目(记账本)将帮你熟练掌握 reduce、日期格式化、图表库的使用。

用 wagmi v2 + WebSocket 硬磕 NFT 上架失败:一个前端开发者踩过的实时状态同步坑

作者 竹林818
2026年4月30日 18:01

背景:一个让我抓狂的 NFT 上架问题

去年秋天,我在帮一个 NFT 交易市场做前端重构。项目用的是 Next.js 14 App Router,合约是 OpenSea 兼容的 Seaport 协议,但前端完全不依赖 OpenSea SDK——因为我们自己改了上架逻辑,用了个简化版的 ListOrder 函数。用户上架 NFT 时,先调用合约的 approve,再调用 createOrder,然后前端得立刻显示这个 NFT 已经上架,状态从“可出售”变成“已挂单”。

问题来了:用户用 MetaMask 确认交易后,前端列表死活不更新。我一开始想,那简单,交易确认后重新 fetch 一下用户挂单列表不就行了?但我天真了——用户上架后,合约事件还没被索引服务(比如 The Graph)同步,直接调 RPC 查 userOrders 映射,返回的还是空数组。更糟的是,用户连续上架两个 NFT,第一个刚确认第二个又触发,状态全乱套。

当时项目排期紧,PM 每天问我“上架按钮点了怎么没反应”,我嘴上说“链上确认需要时间”,心里知道这锅不能全甩给区块链。我必须找到一个方案:用户钱包确认交易后,前端能实时监听到 OrderCreated 事件,然后自动刷新列表,而不是靠用户手动刷新页面。

问题分析:轮询为什么不行?

我的第一版方案很简单:用 ethers.jsprovider.on("block") 监听新区块,每出一个块就去查一次 userOrders。代码大概长这样:

// 第一版:轮询方案(已废弃)
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(MARKET_ADDRESS, MARKET_ABI, provider);

useEffect(() => {
  const handleBlock = async (blockNumber: number) => {
    const orders = await contract.getUserOrders(userAddress);
    setOrders(orders);
  };
  provider.on("block", handleBlock);
  return () => provider.off("block", handleBlock);
}, [userAddress]);

这个方案有几个致命问题:

  1. 延迟太高:以太坊平均出块时间 12 秒,用户上架后要等 12 秒才能看到变化。如果用户连着上架两个,第一个还没被索引第二个就触发了,列表会回滚到中间状态。
  2. RPC 调用次数爆炸:每个区块都调 RPC,如果用户页面开着不动,一小时调几百次,Infura 直接限流。我们项目用的 Alchemy 免费层,没几天就超量了。
  3. React 18 严格模式useEffect 在开发环境会执行两次,导致事件监听注册两次,然后清理函数只执行一次,最后监听器泄漏。我当时在控制台看到一堆 Event listener added 警告,查了半天才发现是严格模式的锅。

后来我试过用 setInterval 每 5 秒轮询,但用户体验更差——列表明明没变化,却在不停闪烁。而且用户钱包切换链时,旧的 provider 没清理,监听还在老链上跑,数据全错。

核心实现:从轮询到事件驱动的迁移

第一步:用 wagmi 的 useWatchContractEvent 替换轮询

我决定彻底放弃 ethers.js 的轮询方案,改用 wagmi v2 的事件监听。wagmi 的 useWatchContractEvent 本质上是对 eth_subscribe 的封装,通过 WebSocket 直接监听合约事件,不用自己管理 provider 和清理逻辑。

先安装 wagmi v2 和 viem:

npm install wagmi viem @tanstack/react-query

然后配置 wagmi 客户端。这里有个坑:wagmi v2 默认用 HTTP 传输,要启用 WebSocket 监听事件,必须显式指定 transports 为 WebSocket 地址。

// lib/wagmi.ts
import { createConfig, http, webSocket } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';

export const config = createConfig({
  chains: [mainnet, sepolia],
  transports: {
    [mainnet.id]: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
    [sepolia.id]: webSocket('wss://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'),
  },
});

注意这个细节:如果你同时需要 HTTP 请求(比如读合约状态),可以在 transports 里用 fallback

transports: {
  [mainnet.id]: fallback([
    webSocket('wss://...'),
    http('https://...'),
  ]),
}

这样 wagmi 会优先用 WebSocket,如果断开自动降级到 HTTP。我当时没加这个,结果 WebSocket 偶尔断连后,所有事件监听都失效了,页面直接卡死。

第二步:实现上架表单 + 实时监听

核心组件 ListNFTForm:用户选择 NFT,输入价格,点击上架。上架成功后,自动监听 OrderCreated 事件更新列表。

// components/ListNFTForm.tsx
'use client';
import { useState } from 'react';
import { useAccount, useWriteContract, useWatchContractEvent } from 'wagmi';
import { parseEther } from 'viem';
import { MARKET_ABI, MARKET_ADDRESS } from '@/lib/contract';

export default function ListNFTForm() {
  const { address } = useAccount();
  const [tokenId, setTokenId] = useState('');
  const [price, setPrice] = useState('');
  const [isPending, setIsPending] = useState(false);

  // 1. 写合约:上架 NFT
  const { writeContractAsync } = useWriteContract();

  const handleList = async () => {
    if (!tokenId || !price) return;
    setIsPending(true);
    try {
      const tx = await writeContractAsync({
        address: MARKET_ADDRESS,
        abi: MARKET_ABI,
        functionName: 'createOrder',
        args: [BigInt(tokenId), parseEther(price)],
      });
      // 这里不立刻刷新列表,等事件回调
      console.log('交易已发送:', tx);
    } catch (err) {
      console.error('上架失败:', err);
    } finally {
      setIsPending(false);
    }
  };

  // 2. 实时监听 OrderCreated 事件
  const [recentOrders, setRecentOrders] = useState<bigint[]>([]);

  useWatchContractEvent({
    address: MARKET_ADDRESS,
    abi: MARKET_ABI,
    eventName: 'OrderCreated',
    // 只监听当前用户的事件
    args: { seller: address },
    onLogs(logs) {
      // 这里有个坑:logs 可能包含多个事件,需要过滤
      const newTokenIds = logs
        .filter(log => log.args.seller === address)
        .map(log => log.args.tokenId);
      setRecentOrders(prev => [...new Set([...newTokenIds, ...prev])]);
    },
  });

  return (
    <div>
      <input value={tokenId} onChange={e => setTokenId(e.target.value)} placeholder="Token ID" />
      <input value={price} onChange={e => setPrice(e.target.value)} placeholder="价格 (ETH)" />
      <button onClick={handleList} disabled={isPending}>
        {isPending ? '上架中...' : '上架 NFT'}
      </button>
      <h3>最近上架的 NFT (ID):</h3>
      <ul>
        {recentOrders.map(id => <li key={id}>{id.toString()}</li>)}
      </ul>
    </div>
  );
}

这里有个坑useWatchContractEventargs 过滤参数,在 wagmi v2 中只支持精确匹配,不支持 undefined 表示“不过滤”。如果你写成 args: { seller: undefined },它会直接报错。所以要么不传 args(监听所有事件),要么传具体的地址。我当时传了 seller: address,但用户刚连接钱包时 addressundefined,导致监听器注册失败。后来我加了个条件:

const { address } = useAccount();
// 只有 address 存在时才注册监听
const enabled = !!address;
useWatchContractEvent({
  address: MARKET_ADDRESS,
  abi: MARKET_ABI,
  eventName: 'OrderCreated',
  args: enabled ? { seller: address } : undefined,
  onLogs(logs) { /* ... */ },
  enabled, // wagmi v2 支持这个选项
});

第三步:处理跨链和多钱包切换

NFT 市场通常支持多链。用户如果在 Sepolia 上架,然后切换到 Ethereum Mainnet,之前的事件监听必须清理。wagmi 的 useWatchContractEvent 会自动根据当前链切换,但有个问题:它不会清理旧链上的订阅。

我踩了个坑:用户在 Sepolia 上架了一个 NFT,然后切换到 Mainnet,Mainnet 上也监听到了 OrderCreated 事件——因为 WebSocket 连接还在老链上跑。后来发现是 wagmi 的 webSocket 传输在切换链时不会自动断开旧连接。

解决方案:在组件卸载或链切换时手动清理。但 wagmi v2 没有暴露 unwatch 方法,我只好用 useEffect 的清理函数配合 useChainId

import { useChainId } from 'wagmi';

export default function ListNFTForm() {
  const chainId = useChainId();

  // 每次链变化时,重新挂载组件
  useEffect(() => {
    // 清理旧状态
    setRecentOrders([]);
  }, [chainId]);

  // ... 其余代码
}

这个方案不完美,但至少能保证链切换后列表清空,不会显示错误的数据。更优雅的方案是用 wagmi 的 useSyncExternalStore 自己写一个订阅管理器,但对我们项目来说够用了。

第四步:处理交易确认和事件延迟

用户上架后,writeContractAsync 返回的是交易哈希,不是确认状态。useWatchContractEvent 在交易被打包进区块时就触发,但此时交易可能还没被最终确认(比如 L2 的即时确认 vs L1 的 12 秒)。如果列表在交易刚打包时就更新,用户看到 NFT 已经上架,但几秒后区块重组,事件消失,列表又变回未上架状态。

我当时的解决方案:在 onLogs 回调里不直接更新状态,而是先存到本地,等交易确认后再正式更新。但这样太复杂,而且 wagmi 的 useWaitForTransactionReceipt 可以解决这个问题。

最终方案:把事件监听和交易确认分开。用户上架后,用 useWaitForTransactionReceipt 等待交易确认,确认后再主动查一次列表。同时事件监听作为辅助,提前展示“上架中”的占位状态。

// 等待交易确认
const { data: receipt } = useWaitForTransactionReceipt({
  hash: txHash, // 从 writeContractAsync 返回的哈希
});

useEffect(() => {
  if (receipt) {
    // 交易已确认,重新查询列表
    refetchOrders();
  }
}, [receipt]);

这样用户体验更好:上架后立刻看到占位,交易确认后列表自动刷新,事件监听作为实时更新的补充。

完整代码:一个可运行的 NFT 上架模块

我把上述逻辑整合成一个完整的组件,可以直接复制到 Next.js 14 项目中运行。前提是你已经配好了 wagmi 客户端(参考前面的 lib/wagmi.ts)。

// components/NFTMarketplace.tsx
'use client';
import { useState, useEffect } from 'react';
import { useAccount, useChainId, useWriteContract, useWatchContractEvent, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
import { MARKET_ABI, MARKET_ADDRESS } from '@/lib/contract';

export default function NFTMarketplace() {
  const { address, isConnected } = useAccount();
  const chainId = useChainId();
  
  // 上架表单状态
  const [tokenId, setTokenId] = useState('');
  const [price, setPrice] = useState('');
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined);
  
  // 订单列表状态
  const [orders, setOrders] = useState<Array<{ tokenId: bigint; price: bigint; seller: string }>>([]);
  
  // 写合约
  const { writeContractAsync } = useWriteContract();
  
  // 等待交易确认
  const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
    hash: txHash,
  });
  
  // 交易确认后刷新列表
  useEffect(() => {
    if (isConfirmed) {
      setTxHash(undefined);
      // 这里可以调用 RPC 重新查询列表
      // 但为了演示,我们直接用事件数据
    }
  }, [isConfirmed]);
  
  // 实时监听 OrderCreated 事件
  const enabled = !!address && !!isConnected;
  useWatchContractEvent({
    address: MARKET_ADDRESS,
    abi: MARKET_ABI,
    eventName: 'OrderCreated',
    args: enabled ? { seller: address } : undefined,
    onLogs(logs) {
      const newOrders = logs.map(log => ({
        tokenId: log.args.tokenId as bigint,
        price: log.args.price as bigint,
        seller: log.args.seller as string,
      }));
      setOrders(prev => [...newOrders, ...prev]);
    },
    enabled,
  });
  
  // 上架处理
  const handleList = async () => {
    if (!tokenId || !price || !address) return;
    try {
      const hash = await writeContractAsync({
        address: MARKET_ADDRESS,
        abi: MARKET_ABI,
        functionName: 'createOrder',
        args: [BigInt(tokenId), parseEther(price)],
      });
      setTxHash(hash);
    } catch (err) {
      console.error('上架失败:', err);
    }
  };
  
  // 链切换时清空列表
  useEffect(() => {
    setOrders([]);
  }, [chainId]);
  
  if (!isConnected) return <div>请连接钱包</div>;
  
  return (
    <div style={{ padding: '20px' }}>
      <h2>NFT 上架</h2>
      <div>
        <input
          value={tokenId}
          onChange={e => setTokenId(e.target.value)}
          placeholder="Token ID"
          style={{ marginRight: '8px' }}
        />
        <input
          value={price}
          onChange={e => setPrice(e.target.value)}
          placeholder="价格 (ETH)"
          style={{ marginRight: '8px' }}
        />
        <button onClick={handleList} disabled={isConfirming}>
          {isConfirming ? '确认中...' : '上架'}
        </button>
      </div>
      
      <h3 style={{ marginTop: '30px' }}>我的挂单</h3>
      {orders.length === 0 ? (
        <p>暂无挂单</p>
      ) : (
        <ul>
          {orders.map((order, i) => (
            <li key={i}>
              Token #{order.tokenId.toString()} - {order.price.toString()} wei
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

这个组件可以直接放到 Next.js 14 的 app/page.tsx 里运行,前提是你已经配置好 wagmi provider 和合约地址。

踩坑记录

  1. WebSocket 连接泄漏:wagmi v2 的 useWatchContractEvent 在组件卸载时不会自动断开 WebSocket。我在开发环境频繁热更新,控制台看到 WebSocket connection to 'wss://...' failed。解决方式是在 lib/wagmi.ts 里用 fallback 确保 HTTP 降级,同时在组件里用 enabled 控制只在需要时监听。

  2. React 18 严格模式导致事件重复useWatchContractEvent 在严格模式下会注册两次监听,但 wagmi 内部做了去重,所以不会触发两次回调。但 onLogs 回调里的状态更新会触发两次渲染。我在 setOrders 里用了函数式更新 prev => [...newOrders, ...prev],避免了重复添加。

  3. 事件参数类型不匹配:合约事件定义 OrderCreated(uint256 tokenId, uint256 price, address seller),但 wagmi 的 log.args 返回的是 unknown 类型。我需要手动断言 log.args.tokenId as bigint。后来发现用 viemdecodeEventLog 可以更安全地解析。

  4. 链切换后事件监听不更新:用户从 Sepolia 切到 Mainnet,useWatchContractEvent 会重新注册,但 wagmi 的 webSocket 传输不会自动断开旧链的连接。我加了个 useEffect 监听 chainId,变化时清空列表,但更好的做法是用 wagmi 的 useDisconnect 手动清理。

小结

从 ethers.js 轮询到 wagmi 事件驱动,核心收获是:Web3 前端的状态同步,不要靠定时器,要用合约事件驱动。wagmi v2 的 useWatchContractEvent 封装了 WebSocket 订阅和清理逻辑,但要注意链切换、严格模式和类型断言这些细节。如果你想继续深挖,可以研究 wagmi 的 useSyncExternalStoreviemcreateEventFilter,实现更精细的事件过滤和批量处理。

❌
❌