普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月24日首页

浏览器指纹管理:如何在 Electron 应用中实现多账号隔离

作者 云舟吖
2026年1月24日 16:53

本文将详细介绍如何在 Electron 应用中实现浏览器指纹管理,涵盖技术选型、架构设计、核心实现到踩坑经验。适合对浏览器自动化、指纹技术感兴趣的开发者阅读。

为什么需要浏览器指纹管理

在 RPA(Robotic Process Automation)自动化场景中,我们经常需要同时管理多个账号。然而,现代网站已经发展出越来越复杂的反爬虫和反自动化检测机制:

🔍 常见的检测手段

检测类型 检测方式 风险等级
浏览器指纹 Canvas、WebGL、音频指纹等跨会话追踪 🔴 高
自动化检测 navigator.webdriver、CDP 痕迹检测 🔴 高
账号关联 通过指纹识别同一设备上的多个账号 🔴 高
会话污染 多账号共享 Cookie/LocalStorage 🟡 中

💡 业务痛点

同一台电脑 + 多个账号 = 账号关联风险
         ↓
网站检测到相同指纹
         ↓
账号被封禁/限制

我们的目标很明确:在同一台电脑上运行多个具有完全独立指纹的浏览器实例,每个实例看起来就像来自不同的设备。


技术选型:为什么选择定制 Chromium

方案对比

方案 优点 缺点 结论
Puppeteer Stealth 易集成、轻量 指纹修改有限、易被检测 ❌ 不够彻底
Playwright 跨浏览器、功能丰富 无原生指纹修改能力 ❌ 能力不足
fingerprint-chromium 深度指纹修改、开源免费 需定制浏览器 ✅ 最佳选择
商业指纹浏览器 功能完善 成本高、依赖第三方 ❌ 成本考量

选择 fingerprint-chromium 的理由

fingerprint-chromium 是基于 Ungoogled Chromium 的定制版本,它在浏览器底层实现了指纹修改能力:

  1. Canvas/WebGL/音频指纹自动修改 - 无需 JavaScript 注入
  2. navigator.webdriver 自动隐藏 - 内置反自动化检测
  3. CDP 检测规避 - 调用 Runtime.enable 不触发检测
  4. 通过命令行参数配置 - 灵活的指纹定制能力

系统架构设计

整体架构

┌──────────────────────────────────────────────────────────────────────┐
│                           RPA 工作流引擎                               │
│                    (任务调度、步骤执行、状态管理)                       │
└──────────────────────────────────┬───────────────────────────────────┘
                                   │
                                   ▼
┌──────────────────────────────────────────────────────────────────────┐
│                          BrowserManager                              │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  • 浏览器实例生命周期管理                                          │  │
│  │  • 多实例协调与资源控制                                            │  │
│  │  • Storage State 持久化(登录状态保存)                            │  │
│  │  • 指纹配置解析与覆盖                                             │  │
│  └────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────┬───────────────────────────────────┘
                                   │
                                   ▼
┌──────────────────────────────────────────────────────────────────────┐
│                      LocalFingerprintService                         │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  • 指纹生成(基于种子的确定性算法)                                  │  │
│  │  • Chromium 进程管理                                             │  │
│  │  • CDP 连接管理                                                  │  │
│  │  • 端口动态分配                                                  │  │
│  └────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────┬───────────────────────────────────┘
                                   │
                                   ▼
┌──────────────────────────────────────────────────────────────────────┐
│                    Fingerprint Chromium (定制浏览器)                   │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  • 通过命令行参数接收指纹配置                                      │  │
│  │  • Canvas/WebGL/音频指纹自动修改                                  │  │
│  │  • navigator.webdriver 隐藏                                     │  │
│  │  • CDP 检测规避                                                 │  │
│  └────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────┘

核心模块职责

模块 职责 关键能力
BrowserManager 高层封装 生命周期管理、配置解析、会话持久化
LocalFingerprintService 核心实现 指纹生成、进程管理、端口分配
FingerprintConfigValidator 配置验证 参数校验、GPU/平台匹配、版本迁移

核心实现详解

1. 指纹种子机制

我们使用种子(Seed)机制确保指纹的确定性和一致性:

// 基于 profileId 生成确定性种子
const fingerprintSeed = Math.abs(hashCode(profileId)) % 2147483647

// 使用种子确定性选择各项配置
const fingerprint = {
  userAgent: userAgents[fingerprintSeed % userAgents.length],
  viewport: viewports[fingerprintSeed % viewports.length],
  language: languages[fingerprintSeed % languages.length],
  timezone: timezones[fingerprintSeed % timezones.length],
  gpu: gpuConfigs[fingerprintSeed % gpuConfigs.length],
  cores: coreOptions[fingerprintSeed % coreOptions.length]
}

优点

  • ✅ 相同 profileId 始终生成相同指纹
  • ✅ 支持自定义种子实现跨设备一致性
  • ✅ 可通过配置覆盖任意指纹项

2. 指纹覆盖机制

用户可以通过配置覆盖自动生成的任意指纹项:

// BrowserManager._extractFingerprintOverrides()
_extractFingerprintOverrides(rawConfig) {
  const cfg = this._normalizeFingerprintConfig(rawConfig)
  const overrides = { rawConfig: cfg }

  // User Agent 配置
  if (cfg.uaMode === 'custom' && cfg.userAgent?.trim()) {
    overrides.userAgent = cfg.userAgent.trim()
  }

  // 语言配置(非自动模式时生效)
  if (cfg.languageAuto === false && cfg.language?.trim()) {
    overrides.language = cfg.language.trim()
  }

  // 时区配置
  if (cfg.timezoneAuto === false && cfg.timezone?.trim()) {
    overrides.timezone = cfg.timezone.trim()
  }

  // GPU 配置覆盖
  if (cfg.gpuVendor || cfg.gpuRenderer) {
    overrides.gpuVendor = cfg.gpuVendor
    overrides.gpuRenderer = cfg.gpuRenderer
  }

  // CPU 核心数覆盖
  if (cfg.hardwareConcurrency > 0) {
    overrides.hardwareConcurrency = cfg.hardwareConcurrency
  }

  return overrides
}

3. 动态 User-Agent 生成

根据平台、品牌、版本动态生成一致的 User-Agent:

// LocalFingerprintService._generateUserAgent()
_generateUserAgent(platform, brand, brandVersion, platformVersion) {
  // Windows NT 版本映射
  const ntVersion = WINDOWS_NT_VERSION_MAP[platformVersion] || '10.0'

  // 基础 UA 模板
  let ua
  if (platform === 'windows') {
    ua = `Mozilla/5.0 (Windows NT ${ntVersion}; Win64; x64) ` +
         `AppleWebKit/537.36 (KHTML, like Gecko) ` +
         `Chrome/${brandVersion} Safari/537.36`
  } else if (platform === 'macos') {
    ua = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ` +
         `AppleWebKit/537.36 (KHTML, like Gecko) ` +
         `Chrome/${brandVersion} Safari/537.36`
  }

  // 添加品牌后缀
  if (brand === 'Edge') {
    ua += ` Edg/${brandVersion}`
  } else if (brand === 'Opera') {
    ua += ` OPR/${brandVersion}`
  }

  return ua
}

4. 命令行参数构建

最终生成的指纹通过命令行参数传递给 Chromium:

// LocalFingerprintService.launch()
const chromiumArgs = [
  // 调试端口
  `--remote-debugging-port=${debuggingPort}`,

  // 用户数据目录(实现会话隔离)
  `--user-data-dir=${userDataDir}`,

  // 指纹核心参数
  `--fingerprint=${fingerprintSeed}`,
  `--fingerprint-platform=${platform}`,
  `--fingerprint-platform-version=${platformVersion}`,
  `--fingerprint-brand=${brand}`,
  `--fingerprint-brand-version=${brandVersion}`,
  `--fingerprint-hardware-concurrency=${cores}`,
  `--fingerprint-gpu-vendor=${gpuVendor}`,
  `--fingerprint-gpu-renderer=${gpuRenderer}`,

  // 基本设置
  `--lang=${language}`,
  `--accept-lang=${acceptLanguage}`,
  `--timezone=${timezone}`,
  `--user-agent=${userAgent}`,

  // 窗口设置
  `--window-size=${viewport.width},${viewport.height}`,

  // 代理设置(可选)
  ...(proxyServer ? [`--proxy-server=${proxyServer}`] : [])
]

指纹配置能力矩阵

可配置的指纹类型

指纹类型 命令行参数 配置方式 说明
指纹种子 --fingerprint 自动/手动 核心参数,启用后大部分指纹功能生效
User Agent --user-agent 自动/手动 修改 navigator.userAgent 及相关 API
操作系统 --fingerprint-platform 手动 windows / linux / macos
平台版本 --fingerprint-platform-version 自动/手动 如 10.0 (Win10)、14.0 (macOS 14)
浏览器品牌 --fingerprint-brand 自动/手动 Chrome / Edge / Opera / Vivaldi / Brave
浏览器版本 --fingerprint-brand-version 自动/手动 如 139.0.0.0
CPU核心数 --fingerprint-hardware-concurrency 自动/手动 2 / 4 / 6 / 8 / 12 / 16
GPU厂商 --fingerprint-gpu-vendor 自动/手动 NVIDIA / AMD / Intel / Apple
GPU渲染器 --fingerprint-gpu-renderer 自动/手动 具体 GPU 型号
语言 --lang 自动/手动 zh-CN / en-US 等
时区 --timezone 自动/手动 Asia/Shanghai 等
代理服务器 --proxy-server 手动 支持 HTTP/SOCKS5 协议

自动处理的指纹

以下指纹由 fingerprint-chromium 自动处理,无需配置:

指纹类型 说明
Canvas 图像 自动修改 Canvas 2D 渲染输出
WebGL 图像 自动修改 WebGL 渲染输出
音频指纹 自动修改 AudioContext 输出
字体指纹 修改系统字体列表
ClientRects 修改元素边界矩形
WebRTC 修改 WebRTC 相关指纹

实战:从配置到启动的完整流程

流程图

┌─────────────┐    ┌─────────────────┐    ┌───────────────────┐
│  前端配置页   │───>│ BrowserManager  │───>│ LocalFingerprint  │
│  (Vue 组件)  │    │  .openBrowser() │    │   Service.launch()│
└─────────────┘    └────────┬────────┘    └─────────┬─────────┘
                           │                       │
                           ▼                       ▼
                  ┌─────────────────┐    ┌───────────────────┐
                  │ 解析指纹配置      │    │ 生成指纹参数        │
                  │ 提取覆盖值        │    │ 构建命令行参数      │
                  └────────┬────────┘    └─────────┬─────────┘
                           │                       │
                           ▼                       ▼
                  ┌─────────────────┐    ┌───────────────────┐
                  │ 加载 StorageState│   │ 启动 Chromium 进程  │
                  │ (恢复登录状态)    │    │ 等待端口就绪        │
                  └────────┬────────┘    └─────────┬─────────┘
                           │                       │
                           ▼                       ▼
                  ┌─────────────────────────────────────────┐
                  │     CDP 连接建立,返回 page 对象           │
                  └─────────────────────────────────────────┘

代码示例:打开浏览器

// 1. 调用 BrowserManager 打开浏览器
const { browser, context, page } = await browserManager.openBrowser({
  profileId: 'profile-1',
  fingerprintConfig: {
    os: 'win10',
    uaMode: 'auto',
    languageAuto: false,
    language: 'en-US',
    gpuAuto: false,
    gpuVendor: 'NVIDIA Corporation',
    gpuRenderer: 'NVIDIA GeForce RTX 3060'
  }
})

// 2. 使用页面
await page.goto('https://example.com')

// 3. 保存登录状态(可选)
await browserManager.saveStorageState('profile-1', browserId)

// 4. 关闭浏览器
await browserManager.closeBrowser(browserId)

踩坑记录与解决方案

踩坑 1:平台覆盖不生效

问题:配置 os: "win8" 后,浏览器仍使用本机平台 (macOS)。

原因分析

  • BrowserManager 未正确提取 platform 字段
  • LocalFingerprintService 只使用了 process.platform

解决方案

// 添加 os 到 platform 的映射
_mapPlatform(osCode) {
  const mapping = {
    win7: 'windows', win8: 'windows', win10: 'windows', win11: 'windows',
    mac13: 'macos', mac14: 'macos', mac15: 'macos',
    linux: 'linux'
  }
  return mapping[osCode] || process.platform
}

// 修改 launch() 中调用顺序,先计算 targetPlatform
const targetPlatform = fingerprintOverrides?.platform ||
  this._mapPlatform(fingerprintOverrides?.rawConfig?.os)

踩坑 2:User-Agent 与品牌不一致

问题:配置 Edge 品牌时,User-Agent 仍为 Chrome 格式。

原因generateFingerprint() 使用静态 User-Agent 列表,不考虑品牌配置。

解决方案:新增 _generateUserAgent() 方法,根据平台、品牌、版本动态生成。

踩坑 3:GPU 与平台不匹配

问题:用户可能配置不匹配的 GPU(如 Windows 平台配置 Apple GPU)。

解决方案:实现 GPU/平台自动修复:

// fingerprintConfigValidator.js
function autoFixGpuPlatformMismatch(config) {
  const platform = mapOsToPlatform(config.os)
  const compatibility = GPU_PLATFORM_COMPATIBILITY[platform]

  // 检查 GPU 厂商是否与平台兼容
  if (!compatibility.vendors.includes(config.gpuVendor)) {
    return {
      ...config,
      gpuVendor: compatibility.defaultGpu.vendor,
      gpuRenderer: compatibility.defaultGpu.renderer,
      _autoFixed: true,
      _fixReason: `GPU 厂商 "${config.gpuVendor}" 与 ${platform} 平台不匹配`
    }
  }

  return config
}

踩坑 4:代理认证问题

问题:带用户名密码的代理 URL 无法正常工作。

解决方案:实现代理 URL 解析和认证处理:

// proxyAuthHelper.js
function parseProxyUrl(proxyUrl) {
  const url = new URL(proxyUrl)
  return {
    server: `${url.protocol}//${url.host}`,
    protocol: url.protocol.replace(':', ''),
    host: url.hostname,
    port: url.port,
    username: decodeURIComponent(url.username),
    password: decodeURIComponent(url.password)
  }
}

// 使用 CDP 设置代理认证
async function setupProxyAuth(context, proxyConfig) {
  if (proxyConfig.username && proxyConfig.password) {
    await context.route('**/*', async (route) => {
      await route.continue({
        headers: {
          'Proxy-Authorization': `Basic ${btoa(
            `${proxyConfig.username}:${proxyConfig.password}`
          )}`
        }
      })
    })
  }
}

性能优化与最佳实践

1. 低内存模式

const LOW_MEMORY_CONFIG = {
  lowMemoryMode: true,
  mediaCacheSizeMB: 128,
  mediaCacheDiskSizeMB: 512,
  enableVideoOptimizations: false
}

// 禁用重量级视频管道标志
const HEAVY_VIDEO_PIPELINE_FLAGS = [
  '--enable-av1-decoder',
  '--enable-video-decoding-multiple-threads',
  '--enable-features=VaapiVideoDecoder,PlatformVideoDecoder'
]

2. 会话隔离最佳实践

// 每个实例使用独立的用户数据目录
const userDataDir = path.join(
  profilesDir,
  `profile-${profileId}-${uniqueId}`
)

// 登录数据白名单(仅持久化必要文件)
const LOGIN_WHITELIST = [
  'Default/Cookies',
  'Default/Login Data',
  'Default/Local Storage',
  'Default/Session Storage'
]

3. 指纹一致性检查

// 确保指纹参数之间的一致性
function validateFingerprintConsistency(config) {
  const warnings = []

  // Windows 平台应使用 Windows GPU
  if (config.platform === 'windows' &&
      config.gpuVendor === 'Apple Inc.') {
    warnings.push('Windows 平台不应使用 Apple GPU')
  }

  // 语言和时区应地理位置一致
  if (config.language === 'zh-CN' &&
      config.timezone === 'America/New_York') {
    warnings.push('语言和时区地理位置不一致')
  }

  return { valid: warnings.length === 0, warnings }
}

4. 指纹验证

// 快速验证指纹是否生效
async function quickVerify(page) {
  const result = await page.evaluate(() => ({
    webdriver: navigator.webdriver,
    platform: navigator.platform,
    userAgent: navigator.userAgent,
    hardwareConcurrency: navigator.hardwareConcurrency,
    languages: navigator.languages
  }))

  return {
    passed: result.webdriver === false,
    details: result
  }
}

总结

核心能力回顾

通过本文介绍的方案,我们实现了以下能力:

能力 说明
多指纹实例 同时运行多个具有独立指纹的浏览器
指纹自定义 支持自定义 UA、GPU、CPU、时区、平台等
会话隔离 每个实例独立的 Cookie 和 LocalStorage
指纹一致性 相同配置 ID 生成相同指纹
登录持久化 Storage State 机制保存登录状态
配置验证 自动验证和修复配置问题

技术要点总结

  1. 种子机制 是实现指纹确定性和一致性的关键
  2. 分层架构(BrowserManager → LocalFingerprintService → Chromium)便于维护和扩展
  3. 配置覆盖机制 允许用户灵活定制任意指纹项
  4. GPU/平台匹配验证 可以避免不合理的配置组合
  5. 代理认证处理 需要单独解析 URL 并设置认证头

延伸阅读

如果你对浏览器指纹技术感兴趣,可以进一步了解:

深入浅出 LSD:基于 Solidity 0.8.24 与 OpenZeppelin V5 构建流动性质押协议

作者 木西
2026年1月24日 16:46

前言

本文主要梳理流动性质押协议的理论知识,涵盖概念机制、核心功能、行业痛点及应用优劣势分析。同时,将借助 Solidity 0.8.24 与 OpenZeppelin V5,从 0 到 1 实现一个流动性质押协议,完整展示开发、测试与部署的全流程。

流动性质押(LSD)理论梳理

一、主要概念与核心机制

定义:PoS链中,用户质押ETH、SOL等原生资产至协议,智能合约即时发放1:1挂钩的LST(流动性质押代币),代表质押权益与收益,LST可在DeFi生态自由流转,实现“质押不锁仓”。

核心流程:用户存入资产→协议聚合资金并委托验证节点挖矿→发放LST(价值随收益增长)→用户可交易、使用LST或赎回原生资产(部分有锁定期/滑点)。

关键角色:协议层(智能合约管控资产与分配)、验证节点(保障网络安全与收益)、用户(获取LST兼顾流动性与收益)。

二、核心功能

  • 质押与流动性兼得,LST可参与DeFi实现收益叠加;
  • 降低门槛,小额资金可通过协议聚合参与,无需自建节点;
  • 资产复用增值,LST可借贷抵押、DEX提供流动性,或参与再质押提升年化;
  • 灵活退出,通过二级市场交易或协议赎回快速止损。

三、解决的行业痛点

痛点 传统质押 流动性质押解决方案
流动性缺失 资产长期锁定,无法再投资 LST可自由交易、借贷,释放流动性
高门槛 需大额资金+技术运维能力 一键质押,协议聚合小额资金,零技术要求
资本效率低 仅获基础质押收益,机会成本高 同一资产叠加质押与DeFi双重收益
退出困难 解锁周期长,难以及时止损 二级市场即时交易或快速赎回通道
中心化风险 节点运营商垄断,去中心化不足 多节点竞争,用户可自选节点,分散权力

四、行业应用场景

  1. 去中心化借贷:LST作为抵押品借款,实现“质押+借贷”杠杆;
  2. DEX流动性:提供LST/原生资产交易对,赚取手续费与挖矿奖励;
  3. 再质押:存入EigenLayer等平台,年化收益可从8%提升至15%-20%;
  4. 资产管理:构建低风险、高流动性理财组合;
  5. 衍生品生态:基于LST发行稳定币或开发期货、期权。

五、优势与劣势分析

优势:资本效率高,收益叠加;门槛低、操作便捷;退出灵活;助力网络去中心化。

劣势:存在智能合约安全风险;极端市场下LST可能与原生资产脱钩;头部协议有节点集中隐患;部分协议赎回受限;LST监管性质尚不明确。

智能合约开发、测试、部署

智能合约

流动性质押智能合约:它的核心逻辑是:用户质押 ETH,合约按 1:1 的比例铸造代币(stETH)给用户;当用户销毁 stETH 时,返还等量的 ETH

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/**
 * @title SimpleLiquidStaking
 * @dev 实现基础的 ETH 质押并获得 LSD 代币 (stETH)
 */
contract SimpleLiquidStaking is ERC20, Ownable, ReentrancyGuard {
    
    event Staked(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);

    // 初始化时设置代币名称和符号,并将所有权移交给部署者
    constructor() ERC20("Liquid Staked ETH", "stETH") Ownable(msg.sender) {}

    /**
     * @notice 用户质押 ETH,获得等额 stETH
     * @dev 使用 nonReentrant 防止重入攻击
     */
    function stake() external payable nonReentrant {
        require(msg.value > 0, "Amount must be greater than 0");
        
        // 1:1 铸造代币给用户
        _mint(msg.sender, msg.value);
        
        emit Staked(msg.sender, msg.value);
    }

    /**
     * @notice 用户销毁 stETH,取回等额 ETH
     * @param amount 想要提取的金额
     */
    function withdraw(uint256 amount) external nonReentrant {
        require(amount > 0, "Amount must be greater than 0");
        require(balanceOf(msg.sender) >= amount, "Insufficient stETH balance");

        // 先销毁用户的 stETH 凭证
        _burn(msg.sender, amount);
        
        // 发送 ETH 给用户
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "ETH transfer failed");

        emit Withdrawn(msg.sender, amount);
    }

    /**
     * @dev 允许合约接收 ETH (例如验证者节点的奖励返还)
     */
    receive() external payable {}
}

部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
import { parseUnits } from "viem";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer, investor] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  
  // 部署SimpleLiquidStaking合约
  const SimpleLiquidStakingArtifact = await artifacts.readArtifact("SimpleLiquidStaking");
  // 1. 部署合约并获取交易哈希
  const SimpleLiquidStakingHash = await deployer.deployContract({
    abi: SimpleLiquidStakingArtifact.abi,
    bytecode: SimpleLiquidStakingArtifact.bytecode,
    args: [],
  });
  const SimpleLiquidStakingReceipt = await publicClient.waitForTransactionReceipt({ 
     hash: SimpleLiquidStakingHash 
   });
   console.log("SimpleLiquidStaking合约地址:", SimpleLiquidStakingReceipt.contractAddress);
}

main().catch(console.error);

测试脚本

import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { parseEther } from 'viem';
// 注意:在 Hardhat 环境中通常通过 hre 获取 viem
import hre from "hardhat";

describe("SimpleLiquidStaking 测试", async function() {
    const { viem } = await hre.network.connect();
    let simpleLiquidStaking: any;
    let publicClient: any;
    let owner: any, user1: any;
    let deployerAddress: string;

    beforeEach(async function () {
        
        // 获取 clients
        publicClient = await viem.getPublicClient();
        [owner, user1] = await viem.getWalletClients();
        deployerAddress = owner.account.address;

        // 部署合约,注意 OpenZeppelin V5 的 Ownable 需要构造参数(可选,取决于你合约具体实现)
        // 如果你的合约构造函数是 constructor() ERC20(...) Ownable(msg.sender) {}
        simpleLiquidStaking = await viem.deployContract("SimpleLiquidStaking", []);
    });

    it("用户应该能够成功质押 ETH 并获得 stETH", async function () {
        const stakeAmount = parseEther("10");

        // 1. 执行质押操作
        const hash = await user1.writeContract({
            address: simpleLiquidStaking.address,
            abi: simpleLiquidStaking.abi,
            functionName: "stake",
            value: stakeAmount,
        });

        // 2. 等待交易确认
        await publicClient.waitForTransactionReceipt({ hash });

        // 3. 验证合约中的 ETH 余额
        const contractEthBalance = await publicClient.getBalance({
            address: simpleLiquidStaking.address,
        });
        assert.equal(contractEthBalance, stakeAmount, "合约收到的 ETH 数量不正确");

        // 4. 验证用户收到的 stETH 凭证数量
        const userStEthBalance = await simpleLiquidStaking.read.balanceOf([user1.account.address]);
        assert.equal(userStEthBalance, stakeAmount, "用户获得的 stETH 数量不正确");
    });

    it("用户应该能够通过销毁 stETH 赎回 ETH", async function () {
        const amount = parseEther("5");

        // 1. 先质押
        await user1.writeContract({
            address: simpleLiquidStaking.address,
            abi: simpleLiquidStaking.abi,
            functionName: "stake",
            value: amount,
        });

        const ethBalanceBefore = await publicClient.getBalance({ address: user1.account.address });

        // 2. 执行赎回
        const hash = await user1.writeContract({
            address: simpleLiquidStaking.address,
            abi: simpleLiquidStaking.abi,
            functionName: "withdraw",
            args: [amount],
        });
        const receipt = await publicClient.waitForTransactionReceipt({ hash });
        // console.log(receipt);
        // 3. 验证 stETH 是否已销毁
        const stEthBalanceAfter = await simpleLiquidStaking.read.balanceOf([user1.account.address]);
        assert.equal(stEthBalanceAfter, 0n, "stETH 应该已被销毁");

        // 4. 验证用户 ETH 余额是否增加 (考虑 Gas 费,余额应大于赎回前)
        const ethBalanceAfter = await publicClient.getBalance({ address: user1.account.address });
        assert.ok(ethBalanceAfter > ethBalanceBefore, "用户未收到赎回的 ETH");
    }); 
});

结语

至此,流动性质押协议的理论梳理与代码实现已全部落地。本文既夯实了理论认知,又通过Solidity 0.8.24与OpenZeppelin V5完成了从开发到部署的全流程实践,形成理论与实操的闭环。 本次落地案例为相关开发者提供了可参考的实践范式,也印证了流动性质押在区块链生态中的应用价值。未来可基于此进一步优化协议性能、应对行业挑战,助力DeFi生态持续迭代。

Next.js:颠覆传统的前端开发框架,为什么它如此受欢迎?

作者 北辰alk
2026年1月24日 16:21

Next.js:颠覆传统的前端开发框架,为什么它如此受欢迎?

image.png

一、Next.js到底是什么?

Next.js 是一个基于 React 的全栈框架,由Vercel公司开发并维护。它不是简单的UI库,而是一个完整的Web应用开发解决方案,让你能够轻松构建高性能的React应用程序。

简单来说,Next.js = React + 路由系统 + 服务端渲染 + 构建优化 + API路由 + 更多开箱即用的功能。

二、Next.js的八大核心特点

1. 服务端渲染(SSR)与静态站点生成(SSG)

这是Next.js最亮眼的特性之一!传统React应用只在客户端渲染,而Next.js支持:

  • • 服务端渲染:页面在服务器上生成HTML,然后发送给客户端
  • • 静态生成:构建时预渲染页面,适合内容变化不大的页面
  • • 增量静态再生:保持静态页面的优势,同时支持内容更新
// 简单的静态页面生成示例
export async function getStaticProps() {
  const data = await fetch('https://api.example.com/data');
  return {
    props: { data },
  };
}

2. 文件系统路由

不需要复杂配置,直接在pages目录下创建文件即可定义路由:

pages/
  index.js        →  /
  about.js        →  /about
  blog/
    [slug].js     →  /blog/:slug (动态路由)

3. API路由

无需单独的后端服务器,在pages/api目录下创建文件即可编写API接口:

// pages/api/user.js
export default function handler(req, res) {
  res.status(200).json({ name'John Doe' });
}

4. 内置CSS和Sass支持

支持CSS Modules、Styled JSX、Sass等,开箱即用,无需额外配置。

5. 自动代码分割

Next.js会自动将代码拆分成小块,只加载当前页面需要的代码,大幅提升首屏加载速度。

6. 图像优化组件

<Image />组件自动优化图片:

  • • 自动调整尺寸
  • • 转换为现代格式(WebP)
  • • 延迟加载
  • • 防止布局偏移

7. TypeScript原生支持

只需创建一个tsconfig.json文件,Next.js会自动配置TypeScript支持。

8. 快速刷新(Fast Refresh)

开发模式下,保存文件即可实时看到更改,保持组件状态不变。

三、Next.js的五大优势

1. 极致的性能优化

Next.js的预渲染策略显著改善了:

  • • 首屏加载时间:服务器返回完整的HTML,用户立即看到内容
  • • SEO优化:搜索引擎可以轻松抓取页面内容
  • • 核心Web指标:在LCP、FID、CLS等关键指标上表现优异

2. 开发体验极佳

  • • 零配置起步
  • • 丰富的插件生态系统
  • • 优秀的错误报告和调试工具
  • • 完整的文档和活跃的社区

3. 全栈能力

从前端到后端API,一个框架搞定所有:

  • • 前端页面渲染
  • • API接口开发
  • • 中间件处理
  • • 数据库连接

4. 强大的部署能力

与Vercel平台无缝集成:

  • • 一键部署
  • • 自动HTTPS
  • • 全球CDN
  • • 预览部署
  • • 自动性能优化

5. 企业级特性

  • • 国际化路由
  • • 按需分析
  • • 预览模式
  • • 中间件支持
  • • 安全头部自动配置

四、Next.js适合哪些项目?

✅ 适合场景

  • • 需要SEO优化的内容型网站(博客、新闻、电商)
  • • 需要良好性能的Web应用
  • • 需要服务端渲染的应用程序
  • • 全栈项目,前后端统一技术栈

❌ 不太适合

  • • 纯静态的无交互页面(简单的静态站点生成器可能更轻量)
  • • 超大型企业应用(可能需要更定制的架构)

五、快速开始Next.js

创建Next.js项目只需要一行命令:

npx create-next-app@latest my-app
cd my-app
npm run dev

几秒钟后,你的开发服务器就会在 http://localhost:3000 启动!

六、成功案例

许多知名公司都在使用Next.js:

  • • Twitch - 直播平台
  • • Netflix - 部分页面
  • • TikTok - 官网
  • • Notion - 文档工具
  • • Hulu - 视频流媒体

七、学习资源推荐

  1. 1. 官方文档(强烈推荐):nextjs.org/docs
  2. 2. Next.js学习课程:官方免费互动教程
  3. 3. GitHub示例:官方示例仓库
  4. 4. 社区:GitHub Discussions、Reddit、Discord

结语

Next.js正在改变我们构建Web应用的方式。它将React的灵活性与生产就绪的功能相结合,提供了一个既适合初学者入门,又能满足企业级需求的完整解决方案。

无论你是想提升现有项目的性能,还是开始一个新项目,Next.js都值得你深入了解和尝试。

小鹏汽车副总裁顾捷一行到访东安动力

2026年1月24日 16:00
据东安动力消息,近日,小鹏汽车副总裁顾捷率核心团队到访东安动力,东安动力副总经理赵兴天携研发、市场、质量、销售等部门负责人陪同座谈。双方围绕量产项目推进、下一代发动机技术开发等核心议题深入交流并达成共识。

Post List、mockjs与axios实战学习笔记

作者 UIUV
2026年1月24日 15:57

Post List、mockjs与axios实战学习笔记

在现代前端开发中,数据请求、模拟数据与状态管理是核心环节。本文基于React+Vite技术栈,结合实战代码,系统梳理Post List数据渲染、axios请求封装、mockjs模拟接口三大模块的相关知识,剖析其技术原理、实现逻辑与开发规范,为前端项目的数据层搭建提供参考。

一、整体技术背景与核心流程

本文实战场景为移动端React项目的首页帖子列表(Post List)功能,核心目标是实现“前端请求-模拟数据-状态管理-页面渲染”的完整闭环。在前后端分离架构下,后端接口开发往往滞后于前端页面开发,此时需通过mockjs模拟接口返回数据,同时借助axios封装统一的请求逻辑,再通过Zustand管理全局状态,最终将数据渲染至页面。

核心技术栈:React 18+Vite 5+TypeScript+axios+mockjs+Zustand+TailwindCSS,各技术分工如下:

  • axios:负责发起HTTP请求,处理请求拦截、响应拦截、错误捕获等逻辑;
  • mockjs:在开发环境模拟后端接口,生成随机测试数据,实现前端独立开发;
  • Zustand:轻量级全局状态管理库,存储帖子列表数据与加载方法,实现组件间数据共享;
  • Vite:通过插件集成mock服务,配置路径别名,优化开发体验;
  • TypeScript:定义接口类型(Post、User),实现类型安全,避免数据异常。

完整数据流转流程:页面加载时触发useEffect调用loadMore方法 → Zustand调用封装好的fetchPosts接口 → axios发起GET请求 → mockjs拦截请求并返回模拟数据 → axios接收响应并处理 → Zustand更新posts状态 → 页面从状态中读取数据并渲染。

二、axios:HTTP请求封装与实战

2.1 axios核心特性

axios是一款基于Promise的HTTP客户端,支持浏览器端与Node.js环境,具备以下核心优势:

  • 支持请求/响应拦截器,可统一处理请求头、token验证、错误提示等;
  • 自动转换JSON数据,无需手动解析响应体;
  • 支持取消请求、超时设置、请求重试等高级功能;
  • 兼容性良好,可适配不同浏览器与Node.js版本;
  • 支持TypeScript类型推导,与TS项目无缝集成。

2.2 基础配置与封装规范

在实际项目中,需对axios进行统一封装,避免重复代码,便于维护。核心封装要点包括:基础路径设置、请求头配置、错误统一处理、类型定义等。

2.2.1 基础配置实现

代码中对axios的基础配置如下,位于src/api/config.ts(或对应文件):

import axios from 'axios';
// 接口地址都以/api开始
axios.defaults.baseURL = 'http://localhost:5173/api'
// 生产环境可切换为真实后端地址
// axios.defaults.baseURL = 'http://douyin.com:5173/api'

export default axios;

关键配置说明:

  • baseURL:设置请求基础路径,后续请求URL可省略基础部分,简化代码。开发环境指向本地Vite服务(配合mockjs),生产环境切换为后端真实接口地址;
  • 可扩展配置:如设置超时时间(timeout: 5000)、默认请求头(headers: {'Content-Type': 'application/json'})等。

2.2.2 接口函数封装

针对具体业务接口,封装独立的请求函数,便于复用与维护。以帖子列表请求为例,代码位于src/api/posts.ts

import axios from './config';
import type { Post } from '@/types';

export const fetchPosts = async (
    page: number = 1,
    limit: number = 10,
) => {
    try {
        const response = await axios.get('/posts', {
            params: {
                page,
                limit,
            }
        });
        console.log('获取帖子列表成功', response);
        return response.data;
    } catch (error) {
        console.error('获取帖子列表失败', error);
        throw error;
    }
};

封装要点与最佳实践:

  • TypeScript类型约束:导入Post类型,明确返回数据结构,实现类型安全。参数page、limit设置默认值,避免调用时传参遗漏;
  • 异步处理:使用async/await语法,替代Promise.then(),代码更简洁易读;
  • 错误捕获:通过try/catch捕获请求异常,打印错误日志便于排查问题,同时通过throw error向上层抛出异常,由调用方决定后续处理(如提示用户);
  • 参数传递:GET请求通过params属性传递查询参数(page、limit),axios会自动将其拼接为URL查询字符串(如/api/posts?page=1&limit=10);POST请求可通过data属性传递请求体。

2.3 进阶扩展:请求/响应拦截器

实际项目中,需通过拦截器实现全局统一逻辑,例如请求时添加token、响应时统一处理错误状态码等。以下为常见拦截器配置示例,可集成到axios基础配置中:

// 请求拦截器
axios.interceptors.request.use(
    (config) => {
        // 给每个请求添加token
        const token = localStorage.getItem('token');
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    },
    (error) => {
        // 请求发起前的错误处理(如参数验证失败)
        return Promise.reject(error);
    }
);

// 响应拦截器
axios.interceptors.response.use(
    (response) => {
        // 统一处理响应数据,只返回data部分
        return response.data;
    },
    (error) => {
        // 统一处理错误状态码
        const status = error.response?.status;
        switch (status) {
            case 401:
                // 未授权,跳转登录页
                window.location.href = '/login';
                break;
            case 404:
                console.error('接口不存在');
                break;
            case 500:
                console.error('服务器内部错误');
                break;
        }
        return Promise.reject(error);
    }
);

拦截器的核心价值在于“集中处理”,减少重复代码,提升项目可维护性。

三、mockjs:前端模拟接口与测试数据生成

3.1 mockjs的核心作用

在前后端分离开发模式中,前端开发常依赖后端接口,但后端接口开发、联调往往需要一定时间。此时mockjs可实现以下功能:

  • 模拟后端接口,拦截前端请求,返回自定义模拟数据,使前端无需等待后端接口完成即可独立开发;
  • 生成大量随机测试数据,覆盖不同场景(如分页、异常状态),便于测试页面渲染效果;
  • 与真实接口格式一致,开发完成后只需切换baseURL即可无缝对接后端,无需修改业务代码。

3.2 Vite集成mock服务

Vite通过vite-plugin-mock插件集成mock服务,实现开发环境下的接口模拟。配置步骤如下:

3.2.1 安装依赖

npm install mockjs vite-plugin-mock --save-dev

3.2.2 Vite配置文件修改

vite.config.ts中配置mock服务,指定mock文件路径:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig({
  plugins: [
    react(), 
    tailwindcss(), 
    viteMockServe({
      mockPath: 'mock', // 指定mock文件存放目录
      localEnabled: true, // 开发环境启用mock
      prodEnabled: false, // 生产环境禁用mock
    })
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'), // 路径别名,简化导入
    }
  }
});

关键配置说明:

  • mockPath:指定mock配置文件的存放目录(本文为项目根目录下的mock文件夹);
  • localEnabled:控制开发环境是否启用mock服务,设为true即可在开发时使用模拟接口;
  • prodEnabled:生产环境需禁用mock,避免模拟数据干扰真实接口。

3.3 mockjs语法与接口模拟实现

3.3.1 mock文件结构规范

在mock目录下创建posts.js文件,用于定义帖子列表接口的模拟规则。mockjs的核心语法包括“数据模板定义”与“接口配置”两部分。

3.3.2 数据模板定义:生成随机测试数据

mockjs通过特定语法生成随机数据,支持中文、数字、日期、图片等多种类型,核心语法如下:

  • '属性名|规则': 值:定义数据生成规则,如'list|45': []表示生成长度为45的list数组;
  • 占位符@xxx:生成随机数据,如@ctitle(8,20)生成8-20字的中文标题,@integer(1,30)生成1-30的随机整数;
  • 自定义函数:可通过函数返回动态数据,如tags字段通过() => Mock.Random.pick(tags,2)从标签数组中随机选取2个。

帖子列表模拟数据生成代码:

import Mock from 'mockjs'
const tags = ['前端','后端','职场','AI','副业','面经','算法'];
const posts = Mock.mock({
    'list|45':[ // 生成45条帖子数据
        {
            title: '@ctitle(8,20)', // 中文标题(8-20字)
            brief: '@ctitle(20,100)', // 中文摘要(20-100字)
            totalComments: '@integer(1,30)', // 评论数(1-30)
            totalLikes: '@integer(0,500)', // 点赞数(0-500)
            publishedAt: '@datetime("yyyy-MM-dd HH:mm:ss")', // 发布时间
            user: {
                id: '@integer(1,100)',
                name: '@cname()', // 中文姓名
                avatar: '@image(300x200)' // 随机图片(300x200)
            },
            tags: () => Mock.Random.pick(tags,2), // 随机2个标签
            thumbnail: '@image(300x200)', // 缩略图
            pics: [
                '@image(300x200)',
                '@image(300x200)',
                '@image(300x200)',
            ],
            id: '@integer(1,1000000)', // 唯一ID
        }
    ]
}).list; // 提取list数组

3.3.3 接口配置:拦截请求并返回数据

通过配置url、method、response,实现对指定接口的拦截与响应。核心逻辑包括请求参数解析、分页处理、响应格式返回:

export default [
    {
        url: '/api/posts', // 匹配前端请求的URL(需与axios请求路径一致)
        method: 'get', // 请求方法(GET/POST等)
        // response函数:处理请求并返回响应数据
        response: ({ query }, res) => {
            console.log(query); // 打印请求参数(page、limit)
            const { page = '1' , limit = '10' } = query;
            // 将字符串参数转换为数字(前端传参可能为字符串,需处理类型)
            const currentPage = Number(page, 10);
            const size = parseInt(limit, 10);
            
            // 参数合法性校验
            if(isNaN(currentPage) || isNaN(size) || currentPage < 1 || size < 1){
                return {
                    code: 400,
                    msg: 'Invalid page or pageSize',
                    data: null
                };
            }

            // 分页逻辑计算
            const total = posts.length; // 总数据量
            const start = (currentPage - 1) * size; // 起始索引
            const end = start + size; // 结束索引
            const paginatedData = posts.slice(start, end); // 截取当前页数据

            // 返回响应结果(与后端接口格式一致)
            return {
                code: 200,
                msg: 'success',
                items: paginatedData,
                pagination: {
                    current: currentPage,
                    limit: size,
                    total,
                    totalPages: Math.ceil(total / size), // 总页数
                }
            };
        }
    }
];

接口模拟关键要点:

  • URL匹配:url需与axios请求的URL完全一致(含baseURL前缀),确保请求被正确拦截;
  • 参数处理:GET请求参数从query中获取,需注意类型转换(前端传参可能为字符串,需转为数字),同时进行合法性校验,返回对应错误信息;
  • 分页逻辑:通过slice方法截取当前页数据,计算总页数,返回分页信息,便于前端实现分页加载;
  • 响应格式统一:与后端约定好响应格式(code、msg、data/items、pagination),确保切换真实接口时无需修改前端逻辑。

3.4 mockjs进阶用法扩展

  • 多种请求方法支持:可配置POST、PUT、DELETE等方法的接口,POST请求参数从body中获取(({ body }, res) => {});
  • 动态数据生成:可根据请求参数动态生成数据,如根据用户ID返回对应用户的帖子;
  • 异常场景模拟:除了正常响应,还可模拟401(未授权)、404(接口不存在)、500(服务器错误)等状态,测试前端错误处理逻辑。

四、Post List:数据状态管理与页面渲染

4.1 TypeScript类型定义

为实现类型安全,需定义Post、User接口,明确数据结构。代码位于src/types/index.ts

export interface User{
    id: number;
    name: string;
    avatar?: string; // 可选属性(?表示)
}

export interface Post{
    id: number;
    title: string;
    brief: string; // 简介
    publishedAt: string; // 发布时间
    totalLikes?: number; // 点赞数(可选)
    totalComments?: number; // 评论数(可选)
    user: User; // 关联User接口
    tags: string[]; // 标签数组
    thumbnail?: string; // 缩略图(可选)
    pics?: string[]; // 图片数组(可选)
}

类型定义要点:

  • 必填属性直接定义类型,可选属性添加?
  • 关联接口(如Post中的user属性关联User),实现数据结构的嵌套约束;
  • 所有接口类型需与mock数据、后端接口返回数据保持一致,避免类型不匹配错误。

4.2 Zustand全局状态管理

Zustand是一款轻量级状态管理库,相比Redux更简洁,无需Provider包裹,适合中小型项目。本文用其存储帖子列表数据、轮播图数据及加载方法。

4.2.1 状态定义与实现

代码位于src/store/home.ts

import { create } from "zustand";
import type { SlideData } from "@/components/SlideShow";
import type { Post } from "@/types";
import { fetchPosts } from "@/api/posts";

// 定义状态接口
interface HomeState {
    banners: SlideData[]; // 轮播图数据
    posts: Post[]; // 帖子列表数据
    loadMore: () => Promise<void>; // 加载更多方法(分页加载)
}

// 创建状态管理实例
export const useHomeStore = create<HomeState>((set) => ({
    // 初始轮播图数据
    banners: [{
      id: 1,
      title: "React 生态系统",
      image: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?q=80&w=2070&auto=format&fit=crop",
    },
    {
      id: 2,
      title: "移动端开发最佳实践",
      image: "https://img.36krcdn.com/hsossms/20260114/v2_1ddcc36679304d3390dd9b8545eaa57f@5091053@ai_oswg1012730oswg1053oswg495_img_png~tplv-1marlgjv7f-ai-v3:600:400:600:400:q70.jpg?x-oss-process=image/format,webp",
    },
    {
      id: 3,
      title: "百度上线七猫漫剧,打的什么主意?",
      image: "https://img.36krcdn.com/hsossms/20260114/v2_8dc528b02ded4f73b29b7c1019f8963a@5091053@ai_oswg1137571oswg1053oswg495_img_png~tplv-1marlgjv7f-ai-v3:600:400:600:400:q70.jpg?x-oss-process=image/format,webp",
    }],
    // 初始帖子列表为空
    posts: [],
    // 加载更多方法(异步)
    loadMore: async () => {
      const { items } = await fetchPosts();
      // 更新状态:将新获取的帖子数据追加到posts中(分页加载逻辑)
      set((state) => ({ posts: [...state.posts, ...items] }));
      console.log(items);
    }
}));

状态管理核心逻辑:

  • 状态接口定义:通过HomeState接口约束状态的结构与类型,确保状态数据合规;
  • 初始状态:banners设置初始轮播图数据,posts初始为空数组;
  • 异步方法:loadMore为异步方法,调用fetchPosts获取帖子数据,通过set方法更新状态。set方法支持函数参数,可获取当前状态,实现数据追加(分页加载核心逻辑)。

4.3 页面渲染与数据联动

首页组件(src/pages/Home.tsx)从状态中读取数据,渲染轮播图、帖子列表,并在组件加载时触发数据加载。

import { useEffect } from "react";
import Header from "@/components/Header";
import SlideShow from "@/components/SlideShow";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useHomeStore } from "@/store/home";

export default function Home() {
    // 从状态中解构数据与方法
    const { banners, posts, loadMore } = useHomeStore();
    
    // 组件挂载时触发加载更多(获取第一页数据)
    useEffect(() => {
        loadMore();
    }, []); // 空依赖数组,仅在组件挂载时执行一次
    
    return ( 
        <>
        <Header title="首页" showBackButton={true} />
        <div className="p-4 space-y-4 ">
            {/* 轮播图组件,传入banners数据 */}
            <SlideShow slides={banners} />
            {/* 欢迎卡片 */}
            <Card>
                <CardHeader>
                    <CardTitle>欢迎来到React Mobile</CardTitle>
                </CardHeader>
                <CardContent>
                    <p className="text-muted-foreground">这是内容区域</p>
                </CardContent>
            </Card>
            {/* 帖子列表网格布局 */}
            <div className="grid grid-cols-2 gap-4">
                {/* 遍历posts数据渲染帖子卡片(当前为占位,可替换为真实帖子组件) */}
                {posts.map((post) => (
                    <div key={post.id} className="h-32 bg-white rounded-lg shadow-sm flex items-center justify-center border ">
                        {post.title}
                    </div>
                ))}
                {/* 原占位数据,可删除,替换为真实数据渲染 */}
                {/* {[1,2,...,25].map((i,index) => (...))} */}
            </div>
        </div>
        </>
    );
}

页面渲染关键要点:

  • 状态订阅:通过useHomeStore钩子订阅状态,当posts、banners发生变化时,组件会自动重新渲染;
  • 数据加载时机:通过useEffect在组件挂载时调用loadMore,获取第一页帖子数据;
  • 列表渲染:使用map遍历posts数组渲染列表,需指定唯一key(post.id),避免React渲染警告。原代码中的占位数据可替换为真实帖子组件,展示帖子标题、缩略图等信息;
  • 样式与布局:通过TailwindCSS实现网格布局(grid-cols-2)、间距控制(space-y-4、gap-4),适配移动端展示。

五、核心技术整合与实战总结

5.1 技术整合关键点

  • 接口一致性:mock数据格式、TypeScript接口、后端接口文档三者必须保持一致,这是实现“无缝切换”的核心前提;
  • 分层设计:请求层(axios)、模拟数据层(mockjs)、状态层(Zustand)、视图层(页面组件)分层清晰,便于维护与扩展;
  • 类型安全:全程使用TypeScript定义类型,从请求参数、响应数据到状态管理、组件Props,避免数据异常导致的bug;
  • 开发效率:mockjs使前端独立开发,无需依赖后端,Vite插件集成简化配置,Zustand减少状态管理冗余代码,整体提升开发效率。

5.2 常见问题与解决方案

  • mock请求拦截失败:检查mock文件路径是否与vite.config.ts中mockPath配置一致,URL是否与axios请求路径完全匹配,确保localEnabled设为true;
  • 类型不匹配错误:检查TypeScript接口定义与mock数据、响应数据是否一致,确保可选属性、嵌套结构正确;
  • 分页逻辑异常:确认page、limit参数类型转换正确,分页计算公式(start = (currentPage-1)*size)无误,slice方法截取范围正确;
  • 状态更新后组件不渲染:确保通过Zustand的set方法更新状态,且组件正确订阅状态(使用useHomeStore钩子解构数据)。

5.3 生产环境部署注意事项

  • 切换axios的baseURL为后端真实接口地址,禁用mock服务(prodEnabled: false);
  • 完善错误处理逻辑,添加用户可感知的错误提示(如Toast组件),替代控制台打印;
  • 优化请求性能,如添加请求缓存、防抖节流(针对下拉加载更多)、超时重连等;
  • 校验后端接口返回数据,处理异常状态码,确保生产环境数据稳定性。

六、扩展学习与进阶方向

  • axios进阶:学习请求取消(如页面卸载时取消未完成请求)、请求重试、上传下载进度监控等高级功能;
  • mockjs扩展:使用mockjs结合JSON5语法编写更复杂的模拟规则,集成mock数据持久化(如localStorage);
  • 状态管理深化:学习Zustand的中间件(如日志、持久化),对比Redux、Pinia等状态管理库的适用场景;
  • 分页与无限滚动:基于当前分页逻辑,实现下拉加载更多、上拉刷新功能,集成第三方组件(如react-infinite-scroll-component);
  • 接口联调与测试:学习使用Postman、Swagger等工具测试后端接口,实现前端与后端的高效联调。

本文通过实战代码拆解,系统讲解了Post List功能开发中axios、mockjs的核心用法及状态管理、页面渲染的完整流程。掌握这些知识,可快速搭建前端项目的数据层架构,实现前后端分离模式下的高效开发。在实际项目中,需结合业务需求灵活扩展,不断优化代码质量与用户体验。

GitHub Issues 集成

作者 newbe36524
2026年1月24日 15:55

从零构建 GitHub Issues 集成:HagiCode 的前端直连实践

本文记录了在 HagiCode 平台中集成 GitHub Issues 的全过程。我们将探讨如何通过"前端直连 + 后端最小化"的架构,在保持后端轻量的同时,实现安全的 OAuth 认证与高效的 Issues 同步。

背景:为什么要集成 GitHub?

HagiCode 作为一个 AI 辅助开发平台,核心价值在于连接想法与实现。但在实际使用中,我们发现用户在 HagiCode 中完成了 Proposal(提案)后,往往需要手动将内容复制到 GitHub Issues 中进行项目跟踪。

这带来了几个明显的痛点:

  1. 工作流割裂:用户需要在两个系统之间来回切换,体验不仅不流畅,还容易导致关键信息在复制粘贴的过程中丢失。
  2. 协作不便:团队其他成员习惯在 GitHub 上查看任务,无法直接看到 HagiCode 中的提案进展。
  3. 重复劳动:每当提案更新,就要人工去 GitHub 更新对应的 Issue,增加不必要的维护成本。

为了解决这个问题,我们决定引入 GitHub Issues Integration 功能,打通 HagiCode 会话与 GitHub 仓库的连接,实现"一键同步"。

关于 HagiCode

嘿,介绍一下我们正在做的东西

我们正在开发 HagiCode —— 一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能 —— AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷 —— 多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣 —— 游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看~


技术选型:前端直连 vs 后端代理

在设计集成方案时,摆在我们面前的有两条路:传统的"后端代理模式"和更激进的"前端直连模式"。

方案对比

在传统的后端代理模式中,前端所有的请求都要先经过我们的后端,再由后端去调用 GitHub API。这虽然逻辑集中,但给后端带来了不小的负担:

  1. 后端臃肿:需要编写专门的 GitHub API 客户端封装,还要处理 OAuth 的复杂状态机。
  2. Token 风险:用户的 GitHub Token 必须存储在后端数据库中,虽然可以加密,但毕竟增加了安全风险面。
  3. 开发成本:需要数据库迁移来存储 Token,还需要维护一套额外的同步服务。

前端直连模式则要轻量得多。在这个方案中,我们只利用后端来处理最敏感的"密钥交换"环节(OAuth callback),获取到 Token 后,直接存在浏览器的 localStorage 里。后续创建 Issue、更新评论等操作,直接由前端发 HTTP 请求到 GitHub。

对比维度 后端代理模式 前端直连模式
后端复杂度 需要完整的 OAuth 服务和 GitHub API 客户端 仅需一个 OAuth 回调端点
Token 管理 需加密存储在数据库,有泄露风险 存储在浏览器,仅用户自己可见
实施成本 需数据库迁移、多服务开发 主要是前端工作量
用户体验 逻辑统一,但服务器延迟可能稍高 响应极快,直接与 GitHub 交互

考虑到我们要的是快速集成和最小化后端改动,最终我们采用了"前端直连模式"。这就像给浏览器发了一张"临时通行证",拿到证之后,浏览器就可以自己去 GitHub 办事了,不需要每次都找后端管理员批准。


核心设计:数据流与安全

在确定架构后,我们需要设计具体的数据流。整个同步流程的核心在于如何安全地获取 Token 并高效地利用它。

整体架构图

整个系统可以抽象为三个角色:浏览器(前端)、HagiCode 后端、GitHub。

+--------------+        +--------------+        +--------------+
|  前端 React  |        |    后端      |        |    GitHub    |
|              |        |   ASP.NET    |        |    REST API  |
|  +--------+  |        |              |        |              |
|  |  OAuth |--+--------> /callback    |        |              |
|  |  流程  |  |        |              |        |              |
|  +--------+  |        |              |        |              |
|              |        |              |        |              |
|  +--------+  |        |  +--------+  |        |  +--------+  |
|  |GitHub  |  +------------>Session |  +----------> Issues |  |
|  |API     |  |        |  |Metadata|  |        |  |        |  |
|  |直连    |  |        |  +--------+  |        |  +--------+  |
|  +--------+  |        |              |        |              |
+--------------+        +--------------+        +--------------+

关键点在于:只有 OAuth 的一小步(获取 code 换 token)需要经过后端,之后的粗活累活(创建 Issue)都是前端直接跟 GitHub 打交道。

同步数据流详解

当用户点击 HagiCode 界面上的"Sync to GitHub"按钮时,会发生一系列复杂的动作:

用户点击 "Sync to GitHub"
         │
         ▼
1. 前端检查 localStorage 获取 GitHub Token
         │
         ▼
2. 格式化 Issue 内容(将 Proposal 转换为 Markdown)
         │
         ▼
3. 前端直接调用 GitHub API 创建/更新 Issue
         │
         ▼
4. 调用 HagiCode 后端 API 更新 Session.metadata (存储 Issue URL 等信息)
         │
         ▼
5. 后端通过 SignalR 广播 SessionUpdated 事件
         │
         ▼
6. 前端接收事件,更新 UI 显示"已同步"状态

安全设计

安全问题始终是集成第三方服务的重中之重。我们做了以下考量:

  1. 防 CSRF 攻击:在 OAuth 跳转时,生成随机的 state 参数并存入 sessionStorage。回调时严格验证 state,防止请求被伪造。
  2. Token 存储隔离:Token 仅存储在浏览器的 localStorage 中,利用同源策略(Same-Origin Policy),只有 HagiCode 的脚本才能读取,避免了服务器端数据库泄露波及用户。
  3. 错误边界:针对 GitHub API 常见的错误(如 401 Token 过期、422 验证失败、429 速率限制),设计了专门的错误处理逻辑,给用户以友好的提示。

实践:代码实现细节

纸上得来终觉浅,咱们来看看具体的代码是怎么实现的。

1. 后端最小化改动

后端只需要做两件事:存储同步信息、处理 OAuth 回调。

数据库变更 我们只需要在 Sessions 表增加一个 Metadata 列,用来存储 JSON 格式的扩展信息。

-- 添加 metadata 列到 Sessions 表
ALTER TABLE "Sessions" ADD COLUMN "Metadata" text NULL;

实体与 DTO 定义

// src/HagiCode.DomainServices.Contracts/Entities/Session.cs
public class Session : AuditedAggregateRoot<SessionId>
{
    // ... 其他属性 ...

    /// <summary>
    /// JSON metadata for storing extension data like GitHub integration
    /// </summary>
    public string? Metadata { get; set; }
}

// DTO 定义,方便前端序列化
public class GitHubIssueMetadata
{
    public required string Owner { get; set; }
    public required string Repo { get; set; }
    public int IssueNumber { get; set; }
    public required string IssueUrl { get; set; }
    public DateTime SyncedAt { get; set; }
    public string LastSyncStatus { get; set; } = "success";
}

public class SessionMetadata
{
    public GitHubIssueMetadata? GitHubIssue { get; set; }
}

2. 前端 OAuth 流程

这是连接的入口。我们使用标准的 Authorization Code Flow。

// src/HagiCode.Client/src/services/githubOAuth.ts

// 生成授权 URL 并跳转
export async function generateAuthUrl(): Promise<string> {
  const state = generateRandomString(); // 生成防 CSRF 的随机串
  sessionStorage.setItem('hagicode_github_state', state);
  
  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: window.location.origin + '/settings?tab=github&oauth=callback',
    scope: ['repo', 'public_repo'].join(' '),
    state: state,
  });
  
  return `https://github.com/login/oauth/authorize?${params.toString()}`;
}

// 在回调页面处理 Code 换取 Token
export async function exchangeCodeForToken(code: string, state: string): Promise<GitHubToken> {
  // 1. 验证 State 防止 CSRF
  const savedState = sessionStorage.getItem('hagicode_github_state');
  if (state !== savedState) throw new Error('Invalid state parameter');

  // 2. 调用后端 API 进行 Token 交换
  // 注意:这里必须经过后端,因为需要 ClientSecret,不能暴露在前端
  const response = await fetch('/api/GitHubOAuth/callback', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, state, redirectUri: window.location.origin + '/settings?tab=github&oauth=callback' }),
  });

  if (!response.ok) throw new Error('Failed to exchange token');
  
  const token = await response.json();
  
  // 3. 存入 LocalStorage
  saveToken(token);
  return token;
}

3. GitHub API 客户端封装

有了 Token 之后,我们就需要一个强有力的工具来调 GitHub API。

// src/HagiCode.Client/src/services/githubApiClient.ts

const GITHUB_API_BASE = 'https://api.github.com';

// 核心请求封装
async function githubApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
  const token = localStorage.getItem('gh_token');
  if (!token) throw new Error('Not connected to GitHub');
  
  const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github.v3+json', // 指定 API 版本
    },
  });
  
  // 错误处理逻辑
  if (!response.ok) {
    if (response.status === 401) throw new Error('GitHub Token 失效,请重新连接');
    if (response.status === 403) throw new Error('无权访问该仓库或超出速率限制');
    if (response.status === 422) throw new Error('Issue 验证失败,可能标题重复');
    throw new Error(`GitHub API Error: ${response.statusText}`);
  }
  
  return response.json();
}

// 创建 Issue
export async function createIssue(owner: string, repo: string, data: { title: string, body: string, labels: string[] }) {
  return githubApi(`/repos/${owner}/${repo}/issues`, {
    method: 'POST',
    body: JSON.stringify(data),
  });
}

4. 内容格式化与同步

最后一步,就是把 HagiCode 的 Session 数据转换成 GitHub Issue 的格式。这有点像"翻译"工作。

// 将 Session 对象转换为 Markdown 字符串
function formatIssueForSession(session: Session): string {
  let content = `# ${session.title}\n\n`;
  content += `**> HagiCode Session:** #${session.code}\n`;
  content += `**> Status:** ${session.status}\n\n`;
  content += `## Description\n\n${session.description || 'No description provided.'}\n\n`;
  
  // 如果是 Proposal 类型,添加额外字段
  if (session.type === 'proposal') {
    content += `## Chief Complaint\n\n${session.chiefComplaint || ''}\n\n`;
    // 添加一个深链接,方便从 GitHub 跳回 HagiCode
    content += `---\n\n**[View in HagiCode](hagicode://sessions/${session.id})**\n`;
  }
  
  return content;
}

// 点击同步按钮的主逻辑
const handleSync = async (session: Session) => {
  try {
    const repoInfo = parseRepositoryFromUrl(session.repoUrl); // 解析仓库 URL
    if (!repoInfo) throw new Error('Invalid repository URL');

    toast.loading('正在同步到 GitHub...');
    
    // 1. 格式化内容
    const issueBody = formatIssueForSession(session);
    
    // 2. 调用 API
    const issue = await githubApiClient.createIssue(repoInfo.owner, repoInfo.repo, {
      title: `[HagiCode] ${session.title}`,
      body: issueBody,
      labels: ['hagicode', 'proposal', `status:${session.status}`],
    });
    
    // 3. 更新 Session Metadata (保存 Issue 链接)
    await SessionsService.patchApiSessionsSessionId(session.id, {
      metadata: {
        githubIssue: {
          owner: repoInfo.owner,
          repo: repoInfo.repo,
          issueNumber: issue.number,
          issueUrl: issue.html_url,
          syncedAt: new Date().toISOString(),
        }
      }
    });

    toast.success('同步成功!');
  } catch (err) {
    console.error(err);
    toast.error('同步失败,请检查 Token 或网络');
  }
};

总结与展望

通过这套"前端直连"方案,我们用最少的后端代码实现了 GitHub Issues 的无缝集成。

收获

  1. 开发效率高:后端改动极小,主要是数据库加一个字段和一个简单的 OAuth 回调接口,大部分逻辑都在前端完成。
  2. 安全性好:Token 不经过服务器数据库,降低了泄露风险。
  3. 用户体验佳:直接从前端发起请求,响应速度快,不需要经过后端中转。

注意事项

在实际部署时,有几个坑大家要注意:

  • OAuth App 设置:记得在 GitHub OAuth App 设置里填正确的 Authorization callback URL(通常是 http://localhost:3000/settings?tab=github&oauth=callback)。
  • 速率限制:GitHub API 对未认证请求限制较严,但用 Token 后通常足够(5000次/小时)。
  • URL 解析:用户输入的 Repo URL 千奇百怪,记得正则要匹配 .git 后缀、SSH 格式等情况。

后续增强

目前的功能还是单向同步(HagiCode -> GitHub)。未来我们计划通过 GitHub Webhooks 实现双向同步,比如在 GitHub 里关闭 Issue,HagiCode 这边的会话状态也能自动更新。这需要我们在后端暴露一个 Webhook 接收端点,这也是下一步要做的有趣工作。

希望这篇文章能给你的第三方集成开发带来一点灵感!如果有问题,欢迎在 HagiCode GitHub 上提 Issue 讨论。

下周解禁股出炉 12股解禁市值超1亿元

2026年1月24日 15:45
下周(1月26日至1月30日期间),将有29股有限售股解禁,合计解禁股份超7亿股,以最新收盘价计算(下同),总解禁市值超400亿元。有12股解禁市值超1亿元,其中海博思创解禁市值超200亿元,福斯达、益方生物-U解禁市值均超40亿元。

nuxt配置代理 和 请求接口

作者 江湖文人
2026年1月24日 15:45
一、安装

npm install @nuxtjs/axios @nuxtjs/proxy -S


// nuxt.config.js
{
  ...
  modules: [
    '@nuxtjs/axios',
    '@nuxtjs/proxy'
  ],
  axios: {
    // 是否可以跨域
    proxy: true,
    // retry: { retries: 3 },
    // baseUrl: process.env._ENV == 'production' ? 'xxx' : 'xxx'
  },
  proxy: {
    '/api': {
      target: 'http://localhost:4000',
      pathRewrite: {
        '^/api': ''
      }
    }
  }
}

image.png

登录&获取个人信息

axiaos在项目中一定会进行二次封装的。封装的内容可能有所不同。

登录 我的 新闻

点击登录,它就跳转到登录页,那在登录页我就可以输入账号或者密码。当然,在正儿八经的项目里面,可能会有扫码登录或者验证码等等。所以我们先做个简易版的,先把逻辑走通先。就是用户名和密码登录。如果说它输入内容登录上了以后,就需要把token记录上。

存储之后,如果说他已经登录了,那点击到我的,它可以展示这个登录账号的一个头像或者昵称等等信息。如果说没有登录,点到我的,它会跳转到登录页,

当然新闻的话呢,是不需要登录的。就是登录没邓旒都可以进入到新闻页。

/pages/index.vue

<template>
  <div>
    <nuxt-link to="/login">登录</nuxt-link>
    <nuxt-link to="/my">我的</nuxt-link>
    <nuxt-link to="/news">新闻</nuxt-link>
  </div>
</template>

<script>
export default {
  name: 'IndexPage'
}
</script>

登录页

// login.vue
<template>
  <div>
    <h1>登录页</h1>
    <input v-model="userName" />
    <input v-model="userPwd" />
    <button @click="login">登录</button>
  </div>
</template>

<script>
export default {
  return {
      userName: '',
      userPwd: ''
  },
  methods: {
    login() {
    }
  }
}
</script>

然后安装包

cnpm install @nuxtjs/axios @nuxtjs/proxy -Ss

到config里面去配置:

// nuxt.config.js

{
  ...
  modules: [
    '@nuxtjs/axios',
    '@nuxtjs/proxy'
  ],
  axios: {
    proxy: true
  },
  // 代理的地址
  proxy: {
    '/api': {
      // 一般这里填域名 
      target: 'http://testapi.xxx.cn/'
    }
  }
}

然后就可以写接口了:

// login.vue
<template>
  <div>
    <h1>登录页</h1>
    <input v-model="userName" />
    <input v-model="userPwd" />
    <button @click="login">登录</button>
  </div>
</template>

<script>
export default {
  return {
      userName: '',
      userPwd: ''
  },
  methods: {
    login() {
      this.$axios({
        url: '/api/u/loginByJson',
        method: 'post'
      })
    }
  }
}
</script>

这里需要qs一下:

cnpm install qs -S
// login.vue
<template>
  <div>
    <h1>登录页</h1>
    <input v-model="userName" />
    <input v-model="userPwd" />
    <button @click="login">登录</button>
  </div>
</template>

<script>
export default {
  return {
      userName: '',
      userPwd: ''
  },
  methods: {
    login() {
      let data = qs.stringify({
        username:  this.userName,
        password: this.userPwd
      })
      this.$axios({
        url: '/api/u/loginByJson',
        method: 'post',
        data
      }).then(res => {
        console.log(res)
      })
    }
  }
}
</script>

qs用来处理url查询字符串的序列化和解析。

解析

将url查询字符串转为js对象:

import qs from 'qs';

const query = '?name=张三&age=21';
const result = qs.parse(query, { ignoreQueryPrefix: true });

// 结果:{ name: '张三', age: '21' }

序列化

将js对象转换为url查询字符串

const obj = {
  name: '张三',
  age: 21,
  hobbies: ['1', '2']
}
const queryString = qs.stringify(obj);
// 结果 'name=张三&age=21$hobbies=1$hobbies=2'
// 嵌套对象
qs.stringify({ a: { b: { c: 'value' } } });
// 结果:'a[b][c]=value'

// 支持数组索引
qs.stringify({ a: [1, 2, 3] });
// 结果:'a[0]=1&a[1]=2&a[2]=3'
qs.stringify(obj, {
  encode: true,      // 是否编码(默认true)
  arrayFormat: 'brackets',  // 数组格式:indices/brackets/repeat
  skipNulls: true,   // 跳过null值
  allowDots: true,   // 使用点号表示嵌套:a.b.c=value
});

在请求中,为什么还要用一下qs.stringify()为了将对象正确地格式化为application/x-www-form-urlencoded格式。

解决axios默认行为问题。

axios默认情况下:

  • 当data是对象时,会自动序列化为application/json(json格式)。

  • 有些后端接口要求application/x-www-form-urlencoded格式。

qs.stringfy({
  username: '张三',
  password: '123'
})

// 结果 'username=%E5%BC%A0%E4%B8%89&password=123456'

请求头: Content-Type: application/x-www-form-urlencoded

image.png

登录成功后有一个token:

然后就需要把token给存储起来。下一步做的事就是如果我登录成功了,肯定要把token存进去,存到vuex里面,并且做到持久化存储。

点击我的,会显示个人信息在里边。获取个人信息。获取个人信息,需要把token携带过去。就有这样一个参数。这个时候就要针对axios进行二次封装了。

那么封装的内容就要在plugins里面。

// nuxt.config.ts

{
  ...
  plugins: [
    '~/plugins/axios'
  ],
  ...
}

plugins/axios.js

// axios.js
export default ({ $axios, store }) => {
  // Request拦截器,设置token
  $axios.onRequest((config) => {
    
  })
  $axios.onRequest((error) => {
    
  })
  $axios.onResponse((response) => {
    return response.data;
  })
}

二次封装封装的点特别多,比如说我们可以封装它的一个request拦截器去设置一个token

image.png

在store下面设置vuex:

export const state = {
  token: ''
}

// 因为要做服务端,服务端因为没有localstorage\cookie等,所以需要安装 cnpm install cookie-universal-nuxt -S
export const mutations = {
  setToken(state, token) {
    state.token = token
    this.$cookies.set('token', token);
  },
  getToken(state) {
    // 这里用中间件读取到token,然后在这个方法里面去设置token
    state.token = this.$cookies.get('token');
  }
}

并且要把cookie-universal-nuxt配置到modules里面:

// nuxt.config.js
{
  ...
  modules: [
   'cookie-universal-nuxt'
  ]
}

设置完成后,回到任何一个地方,它都有。然后我们需要认证守卫。

在这里我们需要把中间件创建一下,先在根目录创建middleware文件夹,然后在这下面创建一个auth.js文件,

在my页面做middleware:

// my.vue
<template>
  <div>
    <h1>我的</h1>
  </div>
</template>

<script>
export default{
  middleware: 'auth'
}
</scirpt>

middlewaremy页面生效。这是我们读取出来的store

// middleware/auth.js
export default ({ store }) => {
  console.log(store.state);
}

然后获取token的值,我们已经持久化存到cookie了,可以commit去提交那个getToken

// middleware/auth.js
export default ({ store }) => {
  store.commit('getToken');
  console.log(store.state);
}

然后就可以读取得到token了。

如果没有token的,就让他跳转到登录页:

// middleware/auth.js
export default ({ store, redirect }) => {
  store.commit('getToken');
  console.log(store.state);
  if (!store.state.token) {
    redirect('/login')
  }
}
// login.vue

methods: {
  ...mapMutations(['setToken']),
  login() {
    let data = qs.stringify({
      username: this.userName,
      password: this.userPwd
    });
    
    this.$axios({
      url: '/api/u/loginByJson',
      method: 'post',
      data
    }).then(res => {
      this.setToken(res.data.accessToken)
      this.$router.push({
        name: 'index'
      })
    })
  }
}

如果没有登录,那么点击我的就会进入登录页,登录也填入账号和密码,点击登录,就会请求登录接口,然后获得token,token就会设置到cookie里面,再点击我的,就会可以查看自己的信息了,然后state.token会在中间件里又把本地cookie里面的token设置回去给它(完成持久性)。

做完之后,在我的页面,想要获得到个人信息,用户信息,所以在my.vue就可以做文章了:

// my.vue

<template>
  <div>
    <h1>我的</h1>
  </div>
</template>

<script type="text/javascript">
export default {
  middleware: 'auth',
  async asyncData({ $axios }) {
    let res = await $axios.get('/api/member/getInfo');
    console.log(res);
  }
}
</script>

请求的axios要全局封装,一定要带token:

// axios.js
export default ({ $axios }) => {
  // Request拦截器,设置token
  $axios.onRequest((config) => {
    config.headers['Authorization'] = store.state.token;
  })
  // Error拦截器:出现错误的时候被调用
  $axios.onRequest((error) => {
    consolelog(222, error);
  })
  $axios.onResponse((response) => {
    console.log(333, response)
    return response.data
  })
}

在 macOS 上做 OCR:从截屏到可点词的实践笔记

作者 叶落阁主
2026年1月24日 15:38

Screenshot 2026-01-24 at 15.37.26.png

前言

最近在写一个 macOS 平台的快速翻译软件 SnapTra Translator,核心体验是“按住快捷键,把鼠标悬停在文字上就能看到翻译气泡”。这背后离不开 OCR:先把屏幕上一小块区域截下来,再用 Vision 把文字识别出来,最后根据光标位置选中最接近的单词。

这篇文章把我在项目里的实践整理成一份“可落地”的 macOS OCR 指南:既讲思路,也给关键代码和避坑点。

你能得到什么

  • 一个 macOS OCR 的完整链路:截屏 → 识别 → 选词 → 调试
  • 可直接复用的关键代码段(Vision + ScreenCaptureKit)
  • 真实项目里的设计取舍与坑位

为什么选择 Vision + ScreenCaptureKit

在 macOS 上做 OCR,本质是:

  1. 拿到屏幕图像(需要屏幕录制权限)
  2. 把图像喂给 Vision 的文本识别
  3. 根据识别结果的 bounding box 做交互(比如“光标附近取词”)

Vision 是系统级 OCR,稳定、轻量、无需额外模型;ScreenCaptureKit 是更现代的截屏方式,能更好控制采样区域与性能。

SnapTra 的 OCR 链路概览

我在 SnapTra Translator 里采用了“光标周围小范围截屏”的策略:

  • 每次翻译只截取鼠标附近一块固定大小区域(默认 520x140)
  • OCR 结果返回每行文本,再进一步切成更细的单词 token
  • 将 token 的 bounding box 与鼠标位置比对,选中最接近的词

这套链路让它能做到“点哪翻哪”,而不是整屏 OCR。

关键实现:截屏

下面是项目里截取光标周围画面的核心逻辑,使用 ScreenCaptureKit 获取一张 CGImage

final class ScreenCaptureService {
    let captureSize = CGSize(width: 520, height: 140)

    func captureAroundCursor() async -> (image: CGImage, region: CaptureRegion)? {
        let mouseLocation = NSEvent.mouseLocation
        guard let screen = NSScreen.screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }) else {
            return nil
        }
        let rectInScreen = captureRect(for: mouseLocation, in: screen.frame, size: captureSize)
        let cgRect = convertToDisplayLocalCoordinates(rectInScreen, screen: screen)

        let display = try await getDisplay(for: displayID)
        let filter = SCContentFilter(display: display, excludingWindows: [])
        let configuration = makeConfiguration(for: cgRect, scaleFactor: screen.backingScaleFactor)
        let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
        return (image, CaptureRegion(rect: rectInScreen, screen: screen, displayID: displayID, scaleFactor: scaleFactor))
    }
}

这段代码的重点是:只抓一个小矩形区域,不做整屏截图,性能会好很多。

关键实现:OCR 识别

Vision 的识别部分非常直接:

final class OCRService {
    func recognizeWords(in image: CGImage, language: String) async throws -> [RecognizedWord] {
        try await Task.detached(priority: .userInitiated) {
            let request = VNRecognizeTextRequest()
            request.recognitionLevel = .accurate
            request.usesLanguageCorrection = true
            if #available(macOS 13.0, *) {
                request.revision = VNRecognizeTextRequestRevision3
                request.automaticallyDetectsLanguage = true
            } else {
                request.recognitionLanguages = [language]
            }

            let handler = VNImageRequestHandler(cgImage: image)
            try handler.perform([request])
            return OCRService.extractWords(from: request.results ?? [])
        }.value
    }
}

几个小点:

  • recognitionLevel = .accurate 适合需要高精度的翻译场景
  • 开启 usesLanguageCorrection 能提升英文识别的可读性
  • 在 macOS 13+ 用 automaticallyDetectsLanguage,可以更自然地识别混合文本

关键实现:从行到“词”

Vision 返回的通常是“文本行”,但翻译场景需要更细粒度。

SnapTra 里我做了两步:

  1. 先按英文字符拆分 token
  2. 对 CamelCase 做进一步切分(比如 ApplePayApple + Pay

同时,我没有直接使用 Vision 的 boundingBox(for:),因为它对自定义分词并不稳定,而是用“字符比例”计算 box,保证稳定性。

// 始终使用字符比例计算边界框,确保稳定性
// Vision 的 boundingBox(for:) 对自定义分词(CamelCase)支持不稳定
private static func boundingBoxByCharacterRatio(_ textBox: CGRect, text: String, for range: Range<String.Index>) -> CGRect? {
    let totalCount = text.count
    let startOffset = text.distance(from: text.startIndex, to: range.lowerBound)
    let endOffset = text.distance(from: text.startIndex, to: range.upperBound)
    let startFraction = CGFloat(startOffset) / CGFloat(totalCount)
    let endFraction = CGFloat(endOffset) / CGFloat(totalCount)
    let x = textBox.minX + textBox.width * startFraction
    let width = textBox.width * (endFraction - startFraction)
    return CGRect(x: x, y: textBox.minY, width: width, height: textBox.height)
}

如何“点词”:用鼠标位置选中最相关的词

OCR 的输出是多个单词加 bounding box。为了实现“鼠标指哪翻哪”,我做了两件事:

  • 只保留 bounding box 包含鼠标位置的词
  • 如果有多个候选,取中心点离鼠标最近的

这样可以在密集文本里也保持稳定体验。

权限与系统设置:屏幕录制是前置条件

只要涉及屏幕截图,就需要 Screen Recording 权限。项目里通过 CGPreflightScreenCaptureAccess() 判断当前权限,并提供快捷跳转系统设置:

func requestAndOpenScreenRecording() {
    CGRequestScreenCaptureAccess()
    openPrivacyPane(anchor: "Privacy_ScreenCapture")
}

体验上,我会在权限状态变化后自动刷新,并在未授权时提示用户。

调试手段:把 OCR 区域画出来

“看不到”是 OCR 调试最大的阻力。SnapTra 里做了一个 Debug OCR Region 开关:

  • 红框显示当前截屏区域
  • 绿框显示每个识别出来的单词边界

这可以直观观察“截屏区域是否对齐”、“识别结果是否偏移”,非常推荐在早期就加上。

常见坑位与建议

  1. 不要整屏 OCR:性能会很差,体验会卡
  2. 识别结果的坐标系是归一化坐标,转到屏幕坐标要注意 y 轴翻转
  3. 语言设置不要死板:混合语言场景很常见
  4. 分词策略比你想象的重要:翻译工具对词粒度很敏感

落地小结

如果你要做一个“屏幕取词 + 翻译”的 macOS 工具,推荐的最小链路是:

  1. 用 ScreenCaptureKit 截取鼠标附近小区域
  2. 用 Vision 识别文本
  3. 自己做分词与 box 细化
  4. 用鼠标位置选词
  5. 加一个 Debug OCR Region 视图,调试效率直接翻倍

最后

我在 SnapTra Translator 里踩过的坑、做过的取舍,都尽量写在上面了。OCR 看似简单,但真正落地到“手感很好”的产品,细节真的很多。

如果你也在做 macOS OCR 或翻译工具,欢迎交流想法。

Vue2 的响应式原理

2026年1月24日 15:38

Vue2 的响应式原理

1.pngVue2 生命周期.png

Object.defineProperty

Object.defineProperty(obj, prop, descriptor)
  obj:必需。要定义或修改的属性的对象。
  prop:必需。要定义或修改的属性的属性名。
  descriptor:必需。要定义或修改的属性的描述符。

存取器 getter/setter

var obj = {}
var value = 'hello'

Object.defineProperty(obj, 'key', {
  // 当获取 obj['key'] 值的时候触发该函数。
  get: function() {
    return value
  },
  // 当设置 obj['key'] 值的时候触发该函数。
  set: function(newValue) {
    value = newValue
  }
})

注意:不要在 getter 中获取该属性的值,也不要在 setter 中设置该属性的值,否则会发生栈溢出。

实现数据代理和劫持

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
      // 数据劫持逻辑。
      let value = data[keys[i]]
      Object.defineProperty(data, keys[i], {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
          console.log(`获取了 data 的 ${keys[i]} 值。`)
          return value
        },
        set: function reactiveSetter(newValue) {
          console.log(`设置了 data 的 ${keys[i]} 值。`)
          value = newValue
        }
      })
    }
  }
}

实现数据代理和递归劫持

首先将数据递归劫持逻辑抽离到 observe 工厂函数中;然后新定义一个 Observer 类,为后续的工作做铺垫。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  new Observer(data)
}

// TODO:数组的观察逻辑暂时还没实现。
class Observer {
  constructor(data) {
    this.walk(data)
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  // 深度优先遍历。
  observe(value)

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log(`获取了 ${key} 值。`)
      return value
    },
    set: function reactiveSetter(newValue) {
      console.log(`设置了 ${key} 值。`)
      observe(newValue)
      value = newValue
    }
  })
}

实现 watch 监听

下面是 Vue 中的 watch 选项与 $watch 方法的实现原理。(暂时只实现了对 vm.$options.data 对象的第一层属性的监听。)

每个响应式属性都有一个属于自己的“筐”。在该响应式属性被其他回调函数依赖的时候,Vue 会通过这个“筐”的 depend 方法把这些回调函数添加到这个“筐”的 subs 属性中。在该响应式属性的值发生变化的时候,Vue 会通过这个“筐”的 notify 方法把这个“筐”的 subs 属性中的这些回调函数取出来全部执行。

在 Vue 中,“筐”被抽象成了 Dep 实例,回调函数被包装成了 Watcher 实例。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  new Observer(data)
}

// TODO:数组的观察逻辑暂时还没实现。
class Observer {
  constructor(data) {
    this.walk(data)
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  observe(value)

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      observe(newValue)
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.run()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
class Watcher {
  constructor(vm, exp, cb) {
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.get()
  }

  get() {
    Dep.target = this
    this.vm[this.exp]
    Dep.target = null
  }

  run() {
    this.cb.call(this.vm)
  }
}

在 Vue 中:1、被包装成 Watcher 实例的回调函数是被异步调用的;2、在该回调函数被异步调用之后和实际执行之前的这个过程中,如果触发该回调函数的响应式属性的值又被修改了,那么这些后续的修改操作将无法再次触发该回调函数的调用。所以 Watcher 类的实现原理,实际如下代码所示:

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
class Watcher {
  constructor(vm, exp, cb) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.get()
  }

  get() {
    Dep.target = this
    this.vm[this.exp]
    Dep.target = null
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

仍然存在的问题

至此,基本实现了 Vue 中基于发布订阅的 watch 监听逻辑。但目前仍然存在以下问题:1、对象的新增属性没有被添加数据劫持逻辑;2、数组元素的数据劫持逻辑还存在问题。因此在对对象的新增属性和数组元素添加监听逻辑时也会存在问题。

实现 $set 方法

在 Vue 中,如果响应式属性的值是一个对象(包括数组),那么在该响应式属性上就会被挂载一个 _ ob _ 属性,该 _ ob _ 属性的值是一个 Observer 实例,该 Observer 实例的 dep 属性的值是一个 Dep 实例,该 Dep 实例是和 defineReactive 方法的闭包中的 Dep 实例不同的与该响应式属性绑定的另外一个“筐”。

当响应式属性的值是一个对象(包括数组)时,Vue 会把触发该响应式属性的 getter 的 watchers 额外收集一份在该响应式属性的 _ ob _ 属性的 dep 属性的 subs 属性中。这样开发者就可以通过代码命令式地去触发这个响应式属性的 watchers 了。

2.png

$set 方法的实现思路基本如下:

1、在创建 Observer 对象的实例去观察响应式属性时,同时也创建一个 Dep 对象的实例。先将该 Dep 对象的实例挂载到该 Observer 对象的实例上,然后把该 Observer 对象的实例挂载到它自己观察的响应式属性上。

2、当响应式属性的 getter 被触发时,把与该响应式属性绑定的“筐”的 depend 方法调用一遍。响应式属性的值为对象或数组时,有两个筐;响应式属性的值不为对象和数组时,有一个筐。

3、当用户调用 $set 方法时,如果 target 为对象,则 Vue 先调用 defineReactive 方法把设置的属性也定义为响应式,然后调用 target._ ob _.dep.notify 方法触发 target 的 watchers。(target 为数组的情况暂时未实现。)

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  // TODO:暂时只实现了 target 为对象的情况,target 为数组的情况还未实现。
  $set(target, key, value) {
    defineReactive(target, key, value)
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

// TODO:数组的观察逻辑暂时还没实现。
class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    this.walk(data)
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.run()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
class Watcher {
  constructor(vm, exp, cb) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.get()
  }

  get() {
    Dep.target = this
    this.vm[this.exp]
    Dep.target = null
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

实现数组方法的重写

Vue 对数组的处理思路基本如下:

1、对数组本身不使用 Object.defineProperty 方法进行数据劫持,对数组元素依次使用 observe 方法进行数据观察。因此,数组元素不具有响应性,数组元素的属性仍然具有响应性

2、对数组的 push、pop、shift、unshift、splice、sort、reverse 实例方法进行重写。在这些重写的实例方法中,Vue 先调用数组的原始同名实例方法,然后再调用 this._ ob _.dep.notify 方法去触发该数组的 watchers。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arratProto = Array.prototype
      observe(value)
      arratProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.run()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
class Watcher {
  constructor(vm, exp, cb) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.get()
  }

  get() {
    Dep.target = this
    this.vm[this.exp]
    Dep.target = null
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

实现 computed 计算属性

Vue 中 computed 计算属性的特性:

1、计算属性不存在于 data 选项中,因此计算属性需要单独进行初始化。

2、计算属性的值是一个函数运行之后的返回值。

3、计算属性的值“只能取,不能存”,即计算属性的 setter 无效。

4、计算属性所依赖的响应式属性的值,一旦发生变化,便会引起该计算属性的值,一同发生变化。

5、计算属性是惰性的:计算属性所依赖的响应式属性的值发生变化时,不会立即引起该计算属性的值一同发生变化,而是等到该计算属性的值被获取时才会使得 Vue 对它的值进行重新计算。

6、计算属性是缓存的:如果计算属性所依赖的响应式属性的值没有发生变化,即使多次获取该计算属性的值,Vue 也不会对该计算属性的值进行重新计算。

注:对于计算属性 A 依赖计算属性 B 的情况,下面的代码好像已经实现了,但还需进一步的测试验证。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initComputed()
    this.initWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initComputed() {
    const computed = this.$options.computed
    if (computed) {
      const keys = Object.keys(computed)
      for (let i = 0; i < keys.length; i++) {
        const watcher = new Watcher(this, computed[keys[i]], function() {}, { lazy: true })
        Object.defineProperty(this, keys[i], {
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            if (watcher.dirty) {
              watcher.get()
              watcher.dirty = false
            }
            watcher.deps.forEach(dep => {
              dep.depend()
            })
            return watcher.value
          },
          set: function computedSetter() {
            console.warn('请不要给计算属性赋值!')
          }
        })
      }
    }
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arratProto = Array.prototype
      observe(value)
      arratProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  addSub(watcher) {
    if (this.subs.indexOf(watcher) !== -1) {
      return
    }
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
const targetStack = []
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.deps = []
    this.value = null
    this.lazy = this.dirty = !!options.lazy
    if (!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    targetStack.push(this)
    if (typeof this.exp === 'function') {
      this.value = this.exp.call(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    targetStack.pop()
    if (targetStack.length > 0) {
      Dep.target = targetStack[targetStack.length - 1]
    } else {
      Dep.target = null
    }
  }

  addDep(dep) {
    if (this.deps.indexOf(dep) !== -1) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this)
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      // 更新 this.value(watcher.value) 的值。
      this.get()
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

Vue 模板响应式更新的原理

Vue 对模板的响应式更新,就是如同代码中的 initRenderWatch 方法这样做的。在 Vue 中,响应式更新模板的 watcher 被称为 render watcher,该 watcher 的求值函数比代码中的 initRenderWatch 方法中的 watcher 的求值函数复杂的多。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initComputed()
    this.initWatch()
    this.initRenderWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initComputed() {
    const computed = this.$options.computed
    if (computed) {
      const keys = Object.keys(computed)
      for (let i = 0; i < keys.length; i++) {
        const watcher = new Watcher(this, computed[keys[i]], function() {}, { lazy: true })
        Object.defineProperty(this, keys[i], {
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            if (watcher.dirty) {
              watcher.get()
              watcher.dirty = false
            }
            watcher.deps.forEach(dep => {
              dep.depend()
            })
            return watcher.value
          },
          set: function computedSetter() {
            console.warn('请不要给计算属性赋值!')
          }
        })
      }
    }
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  initRenderWatch() {
    new Watcher(
      this,
      () => {
        document.querySelector('#app').innerHTML = `<p>${this.name}</p>`
      },
      () => {}
    )
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arrayProto = Array.prototype
      observe(value)
      arrayProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  addSub(watcher) {
    if (this.subs.indexOf(watcher) !== -1) {
      return
    }
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
const targetStack = []
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.deps = []
    this.value = null
    this.lazy = this.dirty = !!options.lazy
    if (!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    targetStack.push(this)
    if (typeof this.exp === 'function') {
      this.value = this.exp.call(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    targetStack.pop()
    if (targetStack.length > 0) {
      Dep.target = targetStack[targetStack.length - 1]
    } else {
      Dep.target = null
    }
  }

  addDep(dep) {
    if (this.deps.indexOf(dep) !== -1) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this)
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      // 更新 this.value(watcher.value) 的值。
      this.get()
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

Vue 对模板响应式更新的处理思路基本如下:

1、**模板编译:**如果传入了 template 或者 DOM,那么在 Vue 实例化的过程中,Vue 首先会把模板字符串转化成渲染函数(vm.$options.render)。

2、**虚拟 DOM:**Vue 借助这个渲染函数去响应式更新模板的时候,如果 Vue 直接去操作 DOM,那么会极大的消耗浏览器的性能。于是 Vue 引入 Virtual-DOM (虚拟 DOM),借助它来实现对 DOM 的按需更新。

实现模板编译

如果传入了 template 或者 DOM,那么在 Vue 实例化的过程中,Vue 首先会把模板字符串转化成渲染函数(vm.$options.render),这个过程就是 Vue 的模板编译

Vue 模板编译的整体逻辑主要分为三个步骤:1、解析器:模板字符串转换成 AST。2、**优化器:**对 AST 进行静态节点标记。主要是为了优化渲染性能。(这里不做介绍)3、**代码生成器:**将 AST 转换成 render 函数。

5.png

AST

AST,即抽象语法树,是源代码语法结构的抽象表示。JS AST 在线生成

Vue 中 AST 的代码示例如下:

{
  children: [{…}], // 叶子节点没有 children 属性。
  parent: {}, // 根节点的 parent 属性的值为 undefined。
  tag: "div", // 元素节点的专属属性。
  type: 1, // 1:元素节点。2:带变量的文本节点。3:纯文本节点。
  expression:'"姓名:" + _s(name)', // 文本节点的专属属性。如果 type 值是 3,则 expression 值为 ''。
  text:'姓名:{{name}}' // 文本节点的专属属性。text 值为文本节点编译前的字符串。
}

解析器(parser)

源代码被解析成 AST 的过程一般包含两个步骤:词法分析和语法分析。

6.png

Vue 中的解析器对模板字符串进行解析时,是每产生一个 token 便会立即对该 token 进行处理,即词法分析和语法分析同时进行,或者说没有词法分析只有语法分析。

下面以最单纯的 HTML 模板为例,阐述 Vue 中的解析器将模板字符串转换成 AST 的原理。(v-model、v-bind、v-if、v-for、@click 以及 HTML 中的单标签元素、DOM 属性、HTML 注释等情况都不予以考虑。)

**解析思路:**以 < 为标识符,代表开始标签或结束标签。使用栈结构去维护当前模板被解析到的层级。如果是开始标签,代表 AST 的层级 push 了一层;如果是结束标签,代表 AST 的层级 pop 了一层。

function parse(template) {
  let root = null
  let currentParent
  const stack = []

  // 跳过空白字符串。
  template = template.trim()

  while (template) {
    const ltIndex = template.indexOf('<')
    // ltIndex === -1 的情况不会出现。
    // ltIndex > 0 标签前面有文本节点。
    // ltIndex === 0 && template[ltIndex + 1] !== '/' 开始标签。
    // ltIndex === 0 && template[ltIndex + 1] === '/' 结束标签。
    if (ltIndex > 0) {
      // type:1-元素节点;2-带变量的文本节点;3-纯文本节点。
      const text = template.slice(0, ltIndex)
      const element = parseText(text)
      element.parent = currentParent
      if (!currentParent.children) {
        currentParent.children = []
      }
      currentParent.children.push(element)
      template = template.slice(ltIndex)
    } else if (ltIndex === 0 && template[ltIndex + 1] !== '/') {
      const gtIndex = template.indexOf('>')
      const element = {
        parent: currentParent, // 根节点的 parent 属性值为 undefined。
        tag: template.slice(ltIndex + 1, gtIndex), // 只考虑开始标签中没有任何属性的情况。
        type: 1
      }
      if (currentParent) {
        if (!currentParent.children) {
          currentParent.children = []
        }
        currentParent.children.push(element)
      } else {
        root = element
      }
      currentParent = element
      stack.push(element)
      template = template.slice(gtIndex + 1)
    } else if (ltIndex === 0 && template[ltIndex + 1] === '/') {
      const gtIndex = template.indexOf('>')
      // parse 函数执行完毕后 stack 值被设置为 []。
      stack.pop()
      // parse 函数执行完毕后 currentParent 值被设置为 undefined。
      currentParent = stack[stack.length - 1]
      // parse 函数执行完毕后 template 值被设置为 ''。
      template = template.slice(gtIndex + 1)
    }
  }

  return root
}

// 以 {{ 和 }} 为标识符,把 text 中的 '姓名:{{name}}' 转换成 '"姓名:" + _s(name)'。
function parseText(text) {
  const originText = text
  const tokens = []
  // type:2-带变量的文本节点;3-纯文本节点。
  let type = 3

  while (text) {
    const start = text.indexOf('{{')
    const end = text.indexOf('}}')
    if (start !== -1) {
      type = 2
      if (start > 0) {
        tokens.push(JSON.stringify(text.slice(0, start)))
      }
      const exp = text.slice(start + 2, end)
      tokens.push(`_s(${exp})`)
      text = text.slice(end + 2)
    } else {
      tokens.push(JSON.stringify(text))
      text = ''
    }
  }

  const element = {
    parent: null,
    type,
    expression: type === 2 ? tokens.join(' + ') : '',
    text: originText
  }

  return element
}

render

本小结生成的 render 函数的函数体字符串是这样的:

'with (this) { return _c("div", {}, [_c("p", {}, [_v("姓名:" + _s(name))])]) }'。

其中 _c 函数的第三个参数的空值是 [],不是 undefined。

代码生成器(codegenerator)

**生成思路:**1、遍历 AST,对 AST 中的每个节点进行处理。2、遇到元素节点生成 _c("标签名", 属性对象, 后代数组) 格式的字符串。(后代数组为空时为 [],而不是 undefined。)3、遇到纯文本节点生成 _v("文本字符串") 格式的字符串。4、遇到带变量的文本节点生成 _v(_s(变量名)) 格式的字符串。5、为了让字符串中的变量能够在 render 函数中被正常取值,在遍历完 AST 后, 将生成的字符串整体外包一层 with(this)。6、将经过 with(this) 包装处理后的字符串作为函数体,生成一个 render 函数,并将这个 render 函数挂载到 vm.$options 上。

// 将 AST 转换为 render 函数的函数体的字符串。
function generate(ast) {
  const code = genNode(ast)
  return {
    render: `with (this) { return ${code} }`
  }
}

// 转换节点。
function genNode(node) {
  if (node.type === 1) {
    return genElement(node)
  } else {
    return genText(node)
  }
}

// 转换元素节点。
function genElement(node) {
  const children = genChildren(node)
  // children 的空值是 '[]',不是 'undefined'。
  const code = `_c(${JSON.stringify(node.tag)}, {}, ${children})`
  return code
}

// 转换文本节点。
function genText(node) {
  if (node.type === 2) {
    return `_v(${node.expression})`
  } else if (node.type === 3) {
    return `_v(${JSON.stringify(node.text)})`
  }
}

// 转换元素节点的子节点列表中的子节点。
function genChildren(node) {
  // node.children 的空值为 undefined。
  if (node.children) {
    return '[' + node.children.map(child => genNode(child)).join(', ') + ']'
  } else {
    return '[]'
  }
}
class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initComputed()
    this.initWatch()
    this.initRenderFunction()
    // this.initRenderWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initComputed() {
    const computed = this.$options.computed
    if (computed) {
      const keys = Object.keys(computed)
      for (let i = 0; i < keys.length; i++) {
        const watcher = new Watcher(this, computed[keys[i]], function() {}, { lazy: true })
        Object.defineProperty(this, keys[i], {
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            if (watcher.dirty) {
              watcher.get()
              watcher.dirty = false
            }
            watcher.deps.forEach(dep => {
              dep.depend()
            })
            return watcher.value
          },
          set: function computedSetter() {
            console.warn('请不要给计算属性赋值!')
          }
        })
      }
    }
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  initRenderFunction() {
    let template
    if (this.$options.template) {
      template = this.$options.template
    } else {
      template = document.querySelector(this.$options.el).outerHtml
    }
    const ast = parse(template)
    const code = generate(ast).render
    // eslint-disable-next-line no-new-func
    this.$options.render = new Function(code)
  }

  initRenderWatch() {
    new Watcher(
      this,
      () => {
        document.querySelector('#app').innerHTML = `<p>${this.name}</p>`
      },
      () => {}
    )
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arratProto = Array.prototype
      observe(value)
      arratProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  addSub(watcher) {
    if (this.subs.indexOf(watcher) !== -1) {
      return
    }
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
const targetStack = []
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.deps = []
    this.value = null
    this.lazy = this.dirty = !!options.lazy
    if (!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    targetStack.push(this)
    if (typeof this.exp === 'function') {
      this.value = this.exp.call(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    targetStack.pop()
    if (targetStack.length > 0) {
      Dep.target = targetStack[targetStack.length - 1]
    } else {
      Dep.target = null
    }
  }

  addDep(dep) {
    if (this.deps.indexOf(dep) !== -1) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this)
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      // 更新 this.value(watcher.value) 的值。
      this.get()
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

实现虚拟 DOM

什么是虚拟 DOM?

真实 DOM。

<ul>
  <li>1</li>
  <li>2</li>
</ul>

虚拟 DOM。

{
  tag: 'ul',
  attrs: {},
  children: [
    {
      tag: 'li',
      attrs: {},
      children: [
        {
          tag: null,
          attrs: {},
          children: [], // children 的空值为 []。
          text: '1'
        }
      ]
    },
    ......
  ]
}

虚拟 DOM 有什么用?

1、**性能优化:**当数据发生变化时,Vue 会先在内存中构建虚拟 DOM 树,然后通过比较新旧虚拟 DOM 树的差异,最终只更新必要的部分到真实 DOM 树中。虚拟 DOM 的使用减少了 Vue 操作真实 DOM 的次数,从而提高了 Vue 渲染页面的性能。

2、**跨平台能力:**虚拟 DOM 是一个与平台无关的抽象层,它的使用使得 Vue 可以在浏览器、移动端和服务端(例如服务端渲染时)等多个环境中运行。

由渲染函数生成虚拟 DOM

定义一个简单的 VNode 类,并实现渲染函数中的 _c、_v、_s 函数。然后运行 vm.$options.render.call(vm) 即可得到虚拟 DOM。

class VNode {
  constructor(tag, attrs, children, text) {
    this.tag = tag
    this.attrs = attrs
    this.children = children // children 的空值为 []。
    this.text = text
    this.elm = undefined
  }
}

class Vue {
  ......

  _c(tag, attrs, children) {
    return new VNode(tag, attrs, children)
  }

  _v(text) {
    return new VNode(undefind, undefind, undefind, text)
  }

  _s(value) {
    if (value === null || value === undefined) {
      return ''
    } else if (typeof value === 'object') {
      return JSON.stringify(value)
    } else {
      return String(value)
    }
  }

  ......
}

实现 Diff 和 Patch

在 Vue2 中,Diff 和 Patch 是虚拟 DOM 算法的两个关键步骤:1、**Diff(差异计算):**Diff 是指将新旧虚拟 DOM 树进行比较,进而找出它们之间的差异;2、**Patch(补丁应用):**Patch 是指将这些差异映射到真实 DOM 树上,使得真实 DOM 树与新的虚拟 DOM 树保持一致。

通过 Diff 和 Patch 的配合,Vue 可以凭借较少次数的真实 DOM 操作来实现高效地页面更新。

注意,Vue2 中的虚拟 DOM 算法是基于全量比较的,即每次页面更新都会对整个虚拟 DOM 树进行比较,这在大型应用中可能会导致性能问题。为了解决这个问题,Vue3 引入了基于静态分析的编译优化,使用了更高效的增量更新算法。

class Vue {
  constructor(options) {
    this.$options = options
    this.$el = undefined
    this._vnode = undefined
    this._watcher = undefined
    this.initData()
    this.initComputed()
    this.initWatch()
    this.initRenderFunction()
    this.$mount(options.el)
  }

  ......

  initRenderFunction() {
    let template
    if (this.$options.template) {
      template = this.$options.template
    } else {
      template = document.querySelector(this.$options.el).outerHtml
    }
    const ast = parse(template)
    const code = generate(ast).render
    // eslint-disable-next-line no-new-func
    this.$options.render = new Function(code)
  }

  ......

  $mount(el) {
    this.$el = document.querySelector(el)
    this._watcher = new Watcher(
      this,
      () => {
        this._update(this.$options.render.call(this))
      },
      () => {}
    )
  }

  ......

  _update(vnode) {
    if (this._vnode) {
      patch(this._vnode, vnode)
    } else {
      patch(this.$el, vnode)
    }
    this._vnode = vnode
  }

  ......
}

Vue 对虚拟 DOM 进行 patch 的逻辑基于 snabbdom 算法。patch 函数接受两个参数:旧的虚拟 DOM 和新的虚拟 DOM。(以下代码不考虑节点的属性和节点的 key。)

function patch(oldVnode, newVnode) {
  const el = oldVnode.elm
  const parent = el.parentNode

  const isRealElement = oldVnode.nodeType
  if (isRealElement) {
    parent.replaceChild(createElement(newVnode), oldVnode)
    return
  }

  if (!newVnode) {
    parent.removeChild(el)
  } else if (isChange(newVnode, oldVnode)) {
    newVnode.elm = createElement(newVnode)
    parent.replaceChild(newVnode.elm, el)
  } else if (!isChange(newVnode, oldVnode)) {
    // 渲染性能的提升就在这里。
    newVnode.elm = el
    const newLength = newVnode.children.length
    const oldLength = oldVnode.children.length
    for (let i = 0; i < newLength || i < oldLength; i++) {
      if (i >= oldLength) {
        el.appendChild(createElement(newVnode.children[i]))
      } else {
        patch(oldVnode.children[i], newVnode.children[i])
      }
    }
  }
}

// 由虚拟 DOM 创建真实 DOM。
function createElement(vnode) {
  // 文本节点。
  if (!vnode.tag) {
    const el = document.createTextNode(vnode.text)
    vnode.elm = el
    return el
  }

  // 元素节点。
  const el = document.createElement(vnode.tag)
  vnode.elm = el
  // 在父子真实 DOM 之间建立关系。
  vnode.children.map(createElement).forEach(subEl => {
    el.appendChild(subEl)
  })
  return el
}

// 判断新的虚拟 DOM 相对于旧的虚拟 DOM 是否发生了变化。
function isChange(newVnode, oldVnode) {
  return newVnode.tag !== oldVnode.tag || newVnode.text !== oldVnode.text
}

虚拟 DOM 代码总结

class Vue {
  constructor(options) {
    this.$options = options
    this.$el = undefined
    this._vnode = undefined
    this._watcher = undefined
    this.initData()
    this.initComputed()
    this.initWatch()
    this.initRenderFunction()
    this.$mount(options.el)
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initComputed() {
    const computed = this.$options.computed
    if (computed) {
      const keys = Object.keys(computed)
      for (let i = 0; i < keys.length; i++) {
        const watcher = new Watcher(this, computed[keys[i]], function() {}, { lazy: true })
        Object.defineProperty(this, keys[i], {
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            if (watcher.dirty) {
              watcher.get()
              watcher.dirty = false
            }
            watcher.deps.forEach(dep => {
              dep.depend()
            })
            return watcher.value
          },
          set: function computedSetter() {
            console.warn('请不要给计算属性赋值!')
          }
        })
      }
    }
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  initRenderFunction() {
    let template
    if (this.$options.template) {
      template = this.$options.template
    } else {
      template = document.querySelector(this.$options.el).outerHtml
    }
    const ast = parse(template)
    const code = generate(ast).render
    // eslint-disable-next-line no-new-func
    this.$options.render = new Function(code)
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arrayProto = Array.prototype
      observe(value)
      arrayProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }

  $mount(el) {
    this.$el = document.querySelector(el)
    this._watcher = new Watcher(
      this,
      () => {
        this._update(this.$options.render.call(this))
      },
      () => {}
    )
  }

  _update(vnode) {
    if (this._vnode) {
      patch(this._vnode, vnode)
    } else {
      patch(this.$el, vnode)
    }
    this._vnode = vnode
  }

  _c(tag, attrs, children) {
    return new VNode(tag, attrs, children)
  }

  _v(text) {
    return new VNode(undefind, undefind, undefind, text)
  }

  _s(value) {
    if (value === null || value === undefined) {
      return ''
    } else if (typeof value === 'object') {
      return JSON.stringify(value)
    } else {
      return String(value)
    }
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  addSub(watcher) {
    if (this.subs.indexOf(watcher) !== -1) {
      return
    }
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
const targetStack = []
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.deps = []
    this.value = null
    this.lazy = this.dirty = !!options.lazy
    if (!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    targetStack.push(this)
    if (typeof this.exp === 'function') {
      this.value = this.exp.call(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    targetStack.pop()
    if (targetStack.length > 0) {
      Dep.target = targetStack[targetStack.length - 1]
    } else {
      Dep.target = null
    }
  }

  addDep(dep) {
    if (this.deps.indexOf(dep) !== -1) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this)
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      // 更新 this.value(watcher.value) 的值。
      this.get()
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

function parse(template) {
  let root = null
  let currentParent
  const stack = []

  // 跳过空白字符串。
  template = template.trim()

  while (template) {
    const ltIndex = template.indexOf('<')
    // ltIndex === -1 的情况不会出现。
    // ltIndex > 0 标签前面有文本节点。
    // ltIndex === 0 && template[ltIndex + 1] !== '/' 开始标签。
    // ltIndex === 0 && template[ltIndex + 1] === '/' 结束标签。
    if (ltIndex > 0) {
      // type:1-元素节点;2-带变量的文本节点;3-纯文本节点。
      const text = template.slice(0, ltIndex)
      const element = parseText(text)
      element.parent = currentParent
      if (!currentParent.children) {
        currentParent.children = []
      }
      currentParent.children.push(element)
      template = template.slice(ltIndex)
    } else if (ltIndex === 0 && template[ltIndex + 1] !== '/') {
      const gtIndex = template.indexOf('>')
      const element = {
        parent: currentParent, // 根节点的 parent 属性值为 undefined。
        tag: template.slice(ltIndex + 1, gtIndex), // 只考虑开始标签中没有任何属性的情况。
        type: 1
      }
      if (currentParent) {
        if (!currentParent.children) {
          currentParent.children = []
        }
        currentParent.children.push(element)
      } else {
        root = element
      }
      currentParent = element
      stack.push(element)
      template = template.slice(gtIndex + 1)
    } else if (ltIndex === 0 && template[ltIndex + 1] === '/') {
      const gtIndex = template.indexOf('>')
      // parse 函数执行完毕后 stack 值被设置为 []。
      stack.pop()
      // parse 函数执行完毕后 currentParent 值被设置为 undefined。
      currentParent = stack[stack.length - 1]
      // parse 函数执行完毕后 template 值被设置为 ''。
      template = template.slice(gtIndex + 1)
    }
  }

  return root
}

// 以 {{ 和 }} 为标识符,把 text 中的 '姓名:{{name}}' 转换成 '"姓名:" + _s(name)'。
function parseText(text) {
  const originText = text
  const tokens = []
  // type:2-带变量的文本节点;3-纯文本节点。
  let type = 3

  while (text) {
    const start = text.indexOf('{{')
    const end = text.indexOf('}}')
    if (start !== -1) {
      type = 2
      if (start > 0) {
        tokens.push(JSON.stringify(text.slice(0, start)))
      }
      const exp = text.slice(start + 2, end)
      tokens.push(`_s(${exp})`)
      text = text.slice(end + 2)
    } else {
      tokens.push(JSON.stringify(text))
      text = ''
    }
  }

  const element = {
    parent: null,
    type,
    expression: type === 2 ? tokens.join(' + ') : '',
    text: originText
  }

  return element
}

// 将 AST 转换为 render 函数的函数体的字符串。
function generate(ast) {
  const code = genNode(ast)
  return {
    render: `with (this) { return ${code} }`
  }
}

// 转换节点。
function genNode(node) {
  if (node.type === 1) {
    return genElement(node)
  } else {
    return genText(node)
  }
}

// 转换元素节点。
function genElement(node) {
  const children = genChildren(node)
  // children 的空值是 '[]',不是 'undefined'。
  const code = `_c(${JSON.stringify(node.tag)}, {}, ${children})`
  return code
}

// 转换文本节点。
function genText(node) {
  if (node.type === 2) {
    return `_v(${node.expression})`
  } else if (node.type === 3) {
    return `_v(${JSON.stringify(node.text)})`
  }
}

// 转换元素节点的子节点列表中的子节点。
function genChildren(node) {
  // node.children 的空值为 undefined。
  if (node.children) {
    return '[' + node.children.map(child => genNode(child)).join(', ') + ']'
  } else {
    return '[]'
  }
}

class VNode {
  constructor(tag, attrs, children, text) {
    this.tag = tag
    this.attrs = attrs
    this.children = children // children 的空值为 []。
    this.text = text
    this.elm = undefined
  }
}

function patch(oldVnode, newVnode) {
  const el = oldVnode.elm
  const parent = el.parentNode

  const isRealElement = oldVnode.nodeType
  if (isRealElement) {
    parent.replaceChild(createElement(newVnode), oldVnode)
    return
  }

  if (!newVnode) {
    parent.removeChild(el)
  } else if (isChange(newVnode, oldVnode)) {
    newVnode.elm = createElement(newVnode)
    parent.replaceChild(newVnode.elm, el)
  } else if (!isChange(newVnode, oldVnode)) {
    // 渲染性能的提升就在这里。
    newVnode.elm = el
    const newLength = newVnode.children.length
    const oldLength = oldVnode.children.length
    for (let i = 0; i < newLength || i < oldLength; i++) {
      if (i >= oldLength) {
        el.appendChild(createElement(newVnode.children[i]))
      } else {
        patch(oldVnode.children[i], newVnode.children[i])
      }
    }
  }
}

// 由虚拟 DOM 创建真实 DOM。
function createElement(vnode) {
  // 文本节点。
  if (!vnode.tag) {
    const el = document.createTextNode(vnode.text)
    vnode.elm = el
    return el
  }

  // 元素节点。
  const el = document.createElement(vnode.tag)
  vnode.elm = el
  // 在父子真实 DOM 之间建立关系。
  vnode.children.map(createElement).forEach(subEl => {
    el.appendChild(subEl)
  })
  return el
}

// 判断新的虚拟 DOM 相对于旧的虚拟 DOM 是否发生了变化。
function isChange(newVnode, oldVnode) {
  return newVnode.tag !== oldVnode.tag || newVnode.text !== oldVnode.text
}

🔥 Vue3 实现超丝滑打字机效果组件(可复用、高定制)

作者 小马_xiaoen
2026年1月24日 15:04

在前端开发中,打字机效果能极大提升页面的交互趣味性和视觉体验,比如 AI 聊天回复、个性化介绍页等场景都非常适用。本文将分享一个基于 Vue3 + Composition API 开发的高性能、高定制化打字机组件,支持打字/删除循环、光标闪烁、自定义样式等核心功能,且代码结构清晰、易于扩展。

🎯 组件特性

  • ✅ 自定义打字速度、删除速度、循环延迟
  • ✅ 支持光标显示/隐藏、闪烁效果开关
  • ✅ 打字完成后自动删除(可选)+ 循环播放
  • ✅ 完全自定义样式(字体、颜色、大小等)
  • ✅ 暴露控制方法(开始/暂停),支持手动干预
  • ✅ 无第三方依赖,纯原生 Vue3 实现
  • ✅ 性能优化:组件卸载自动清理定时器,避免内存泄漏 在这里插入图片描述

📝 完整组件代码

<template>
  <div class="typewriter-container" :style="fontsConStyle">
    <!-- 打字文本 - 逐字符渲染 -->
    <span class="typewriter-text">
      <span 
        v-for="(char, index) in displayedText" 
        :key="index" 
        class="character"
        :data-index="index"
      >
        {{ char }}
      </span>
    </span>
    
    <!-- 光标 - 精准控制显示/闪烁 -->
    <span 
      v-if="showCursor && isCursorVisible"
      class="cursor"
      :class="{ 'blink': showBlinkCursor }"
      aria-hidden="true"
    />
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch, watchEffect } from "vue";

// 组件 Props 定义(带完整校验)
const props = defineProps({
  // 要显示的文本内容
  text: {
    type: String,
    required: true,
    default: ""
  },
  // 打字速度(ms/字符)
  speed: {
    type: Number,
    default: 80,
    validator: (value) => value > 0
  },
  // 是否显示光标
  showCursor: {
    type: Boolean,
    default: true
  },
  // 光标是否闪烁
  blinkCursor: {
    type: Boolean,
    default: true
  },
  // 是否自动开始打字
  autoStart: {
    type: Boolean,
    default: true
  },
  // 是否循环播放
  loop: {
    type: Boolean,
    default: false
  },
  // 循环延迟(打字完成后等待时间)
  loopDelay: {
    type: Number,
    default: 1000,
    validator: (value) => value >= 0
  },
  // 容器样式(自定义字体、颜色等)
  fontsConStyle: {
    type: Object,
    default: () => ({
      fontSize: "2rem",
      fontFamily: "'Courier New', monospace",
      color: "#333",
      lineHeight: "1.5"
    })
  },
  // 是否开启删除效果
  deleteEffect: {
    type: Boolean,
    default: false
  },
  // 删除速度(ms/字符)
  deleteSpeed: {
    type: Number,
    default: 30,
    validator: (value) => value > 0
  },
  // 字符入场动画开关
  charAnimation: {
    type: Boolean,
    default: true
  }
});

// 响应式状态
const displayedText = ref("");    // 当前显示的文本
const currentIndex = ref(0);      // 当前字符索引
const isPlaying = ref(false);     // 是否正在播放
const isDeleting = ref(false);    // 是否正在删除
const isCursorVisible = ref(true);// 光标是否显示

// 定时器标识(用于清理)
let intervalId = null;
let timeoutId = null;
let cursorTimer = null;

// 计算属性:控制光标闪烁状态
const showBlinkCursor = computed(() => {
  return props.blinkCursor && 
         !isDeleting.value &&
         (displayedText.value.length === props.text.length || displayedText.value.length === 0);
});

// 工具函数:清除所有定时器
const clearAllTimers = () => {
  if (intervalId) clearInterval(intervalId);
  if (timeoutId) clearTimeout(timeoutId);
  if (cursorTimer) clearInterval(cursorTimer);
  intervalId = null;
  timeoutId = null;
  cursorTimer = null;
};

// 核心方法:开始打字
const startTyping = () => {
  // 重置状态
  clearAllTimers();
  isPlaying.value = true;
  isDeleting.value = false;
  currentIndex.value = 0;
  displayedText.value = "";
  isCursorVisible.value = true;
  
  // 打字逻辑
  intervalId = setInterval(() => {
    if (currentIndex.value < props.text.length) {
      displayedText.value = props.text.substring(0, currentIndex.value + 1);
      currentIndex.value++;
    } else {
      // 打字完成
      clearInterval(intervalId);
      isPlaying.value = false;
      
      // 循环逻辑
      if (props.loop) {
        if (props.deleteEffect) {
          timeoutId = setTimeout(startDeleting, props.loopDelay);
        } else {
          timeoutId = setTimeout(startTyping, props.loopDelay);
        }
      }
    }
  }, props.speed);
};

// 核心方法:开始删除
const startDeleting = () => {
  clearAllTimers();
  isDeleting.value = true;
  
  intervalId = setInterval(() => {
    if (currentIndex.value > 0) {
      currentIndex.value--;
      displayedText.value = props.text.substring(0, currentIndex.value);
    } else {
      // 删除完成
      clearInterval(intervalId);
      isDeleting.value = false;
      
      // 循环打字
      if (props.loop) {
        timeoutId = setTimeout(startTyping, props.loopDelay);
      } else {
        isCursorVisible.value = false; // 非循环模式下删除完成隐藏光标
      }
    }
  }, props.deleteSpeed);
};

// 监听文本变化:自动重启打字(适配动态文本场景)
watch(() => props.text, (newText) => {
  if (newText && props.autoStart) {
    startTyping();
  }
}, { immediate: true });

// 初始化光标闪烁(非闪烁模式下固定显示)
watchEffect(() => {
  if (props.showCursor && !cursorTimer) {
    cursorTimer = setInterval(() => {
      if (!showBlinkCursor.value) {
        isCursorVisible.value = true;
      } else {
        isCursorVisible.value = !isCursorVisible.value;
      }
    }, 500);
  }
});

// 组件挂载:自动开始打字
onMounted(() => {
  if (props.autoStart && props.text) {
    startTyping();
  }
});

// 组件卸载:清理所有定时器(避免内存泄漏)
onBeforeUnmount(() => {
  clearAllTimers();
});

// 暴露组件方法(供父组件调用)
defineExpose({
  start: startTyping,        // 手动开始
  pause: clearAllTimers,     // 暂停
  restart: () => {           // 重启
    clearAllTimers();
    startTyping();
  },
  isPlaying,                 // 当前播放状态
  isDeleting                 // 当前删除状态
});
</script>

<style scoped>
/* 容器样式 - 适配行内/块级显示 */
.typewriter-container {
  display: inline-flex;
  align-items: center;
  position: relative;
  font-size: inherit;
  line-height: inherit;
  font-family: inherit;
  white-space: pre-wrap; /* 支持换行符 */
  word-break: break-all; /* 防止长文本溢出 */
}

/* 文本容器 */
.typewriter-text {
  display: inline;
  font-size: inherit;
  line-height: inherit;
  font-family: inherit;
  color: inherit;
}

/* 字符样式 - 入场动画 */
.character {
  display: inline-block;
  animation: typeIn 0.1s ease-out forwards;
  opacity: 0;
}

/* 字符入场动画 */
@keyframes typeIn {
  0% {
    transform: translateY(5px);
    opacity: 0;
  }
  100% {
    transform: translateY(0);
    opacity: 1;
  }
}

/* 光标样式 - 垂直居中优化 */
.cursor {
  display: inline-block;
  width: 2px;
  height: 1.2em; /* 匹配字体高度 */
  background-color: currentColor;
  margin-left: 2px;
  vertical-align: middle;
  position: relative;
  top: 0;
  opacity: 1;
}

/* 光标闪烁动画 */
.cursor.blink {
  animation: blink 1s infinite step-end;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* 禁用字符动画的样式 */
:deep(.character) {
  animation: none !important;
  opacity: 1 !important;
}
</style>

🚀 核心优化点说明

1. 功能增强

  • 新增 charAnimation 属性:可开关字符入场动画,适配不同场景
  • 支持动态文本:监听 text 属性变化,文本更新自动重启打字
  • 优化光标逻辑:非闪烁模式下光标固定显示,避免闪烁干扰
  • 新增 restart 方法:支持手动重启打字效果
  • 文本换行支持:添加 white-space: pre-wrap,兼容带换行符的文本

2. 性能优化

  • 定时器统一管理:所有定时器集中清理,避免内存泄漏
  • 减少不必要渲染:通过 watchEffect 精准控制光标定时器创建/销毁
  • 样式优化:使用 currentColor 继承文本颜色,光标颜色与文本一致
  • 边界处理:添加 word-break: break-all,防止长文本溢出

3. 代码健壮性

  • 完善 Prop 校验:所有数值类型添加范围校验,避免非法值
  • 状态重置:每次开始打字前重置所有状态,避免多轮执行冲突
  • 注释完善:关键逻辑添加注释,提升代码可读性

📖 使用示例

基础使用

<template>
  <Typewriter 
    text="Hello Vue3! 这是一个超丝滑的打字机效果组件✨"
    speed="50"
  />
</template>

<script setup>
import Typewriter from './components/Typewriter.vue';
</script>

高级使用(循环+删除效果)

<template>
  <div>
    <Typewriter 
      ref="typewriterRef"
      text="Vue3 打字机组件 | 支持循环删除 | 自定义样式"
      :speed="60"
      :deleteSpeed="40"
      :loop="true"
      :deleteEffect="true"
      :loopDelay="1500"
      :fontsConStyle="{
        fontSize: '1.5rem',
        color: '#409eff',
        fontFamily: '微软雅黑'
      }"
    />
    <button @click="handleRestart">重启打字</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import Typewriter from './components/Typewriter.vue';

const typewriterRef = ref();

// 手动重启打字
const handleRestart = () => {
  typewriterRef.value.restart();
};
</script>

🎨 样式自定义说明

属性 说明 默认值
fontSize 字体大小 2rem
fontFamily 字体 'Courier New', monospace
color 文本颜色 #333
lineHeight 行高 1.5

你可以通过 fontsConStyle 属性完全自定义组件样式,例如:

fontsConStyle: {
  fontSize: "18px",
  color: "#e6a23c",
  fontWeight: "bold",
  background: "#f5f7fa",
  padding: "10px 15px",
  borderRadius: "8px"
}

🛠️ 扩展方向

  1. 自定义字符动画:通过 Prop 传入动画类名,支持不同的字符入场效果
  2. 分段打字:支持数组形式的文本,分段打字+间隔
  3. 速度渐变:实现打字速度由快到慢/由慢到快的效果
  4. 暂停/继续:扩展暂停后继续打字的功能(记录当前索引)
  5. 结合 AI 流式响应:对接 AI 接口的流式返回,实时更新打字文本

📌 总结

这个打字机组件基于 Vue3 Composition API 开发,具备高复用性、高定制性的特点,核心优化点如下:

  1. 完善的定时器管理,避免内存泄漏
  2. 精准的状态控制,支持打字/删除/循环全流程
  3. 灵活的样式自定义,适配不同业务场景
  4. 暴露控制方法,支持父组件手动干预

组件可直接集成到 Vue3 项目中,适用于 AI 聊天、个人主页、产品介绍等需要打字机效果的场景,开箱即用!

39度 澳网露天球场比赛因高温暂停

2026年1月24日 15:30
根据气象信息显示,澳大利亚网球公开赛举办墨尔本的气温达到39摄氏度,近地面的高温更是让人难以忍受。受高温影响,包括中国选手王欣瑜参加的比赛在内,所有露天球场内的比赛均已暂停。

周鸿祎:2026年全世界至少会出现100亿个智能体,百亿级公司在AI领域都算小公司

2026年1月24日 15:15
在2026崇礼论坛上,360集团创始人周鸿祎表示,“我来太舞小镇和大家目的一样,主要是来滑雪的,另外我也希望能在这实现一个梦想,我滑雪很多年了,但水平一直很一般,希望能学会上下身分离的技巧。”周鸿祎表示,“我觉得2026年,不说中国,全世界至少会出现100亿个智能体,百亿级公司在AI领域都算小公司了。为什么这么说?因为大模型本身还在不断进化,之前大模型基本就是聊天机器人的状态,干不了实际的活,只有升级成智能体,才能真正在各行各业落地。”

首屏加载统计的几个问题梳理

2026年1月24日 15:11

前言

前端性能优化方面离不开白屏的问题,今天只是侧重复习下白屏的计算方式与系统性的记录方式。关于白屏的性能优化方面有很多,大家可以先看看下面这位老哥写的性能优化的几种方式

关于白屏

基本定义:白屏时间(FP)是用户从发起请求到浏览器首次渲染出像素(结束白屏)的耗时。

所以按照上面的定义,白屏会经历以下几个阶段:

  • DNS解析:浏览器将域名解析为IP地址。

  • 建立TCP连接:浏览器与服务器建立TCP连接(三次握手)。

  • 发起HTTP请求:浏览器向服务器发送HTTP请求。

  • 服务器响应:服务器处理请求并返回响应数据。

  • 浏览器解析HTML:浏览器解析HTML文档并构建DOM树。

  • 浏览器渲染页面:浏览器根据DOM树和CSSOM树生成渲染树,并开始渲染页面。

  • 页面展示html元素:浏览器首次将页面内容渲染到屏幕上。

关于白屏的合理计算

白屏的计算始于浏览器,所以计算的依据也是浏览器提供,浏览器 Performance API 中提供了计算依据。

一、Performance API 是什么?

简单来说,Performance API 是浏览器提供的一套原生接口,专门用于精确测量、监控和分析网页的性能数据(比如页面加载时间、资源加载耗时、自定义代码执行耗时等),是前端性能优化的核心工具之一。

二、利用Performance API提供的方法:performance.timing 常规的计算方式如下:

下面是 performance.timing 里最常用的时间戳,以及它们的含义:

时间戳属性 含义
navigationStart 页面开始导航的时间(比如用户输入网址回车、点击链接),是整个流程的起点
domainLookupStart 开始 DNS 域名解析的时间
domainLookupEnd DNS 域名解析完成的时间
connectStart 开始建立 TCP 连接的时间
connectEnd TCP 连接建立完成的时间(如果是 HTTPS,包含 SSL 握手)
requestStart 浏览器向服务器发送请求的时间
responseStart 浏览器收到服务器第一个字节响应的时间(首字节时间,TTFB)
responseEnd 浏览器接收完服务器响应数据的时间
domContentLoadedEventEnd DOM 解析完成,且所有 DOMContentLoaded 事件回调执行完毕的时间
loadEventEnd 页面 load 事件触发且所有回调执行完毕的时间(页面完全加载完成)

三、简单用法:计算各阶段耗时

通过计算不同时间戳的差值,利用时间差得出页面加载各阶段的耗时,简单用法如下: (基于performance.timing):

性能指标 计算方式(相对耗时) 含义
DNS 解析耗时 domainLookupEnd - domainLookupStart 域名解析的总耗时
TCP 连接耗时 connectEnd - connectStart 建立 TCP 握手的耗时
首字节耗时 (TTFB) responseStart - navigationStart 从导航到服务器返回首字节
页面加载完成 loadEventEnd - navigationStart 整个页面加载完成的总耗时
DOM 解析完成 domContentLoadedEventEnd - navigationStart DOM 树构建完成的耗时

代码示例如下:

// 获取 timing 对象
const timing = performance.timing;

// 1. DNS解析耗时
const dnsTime = timing.domainLookupEnd - timing.domainLookupStart;
// 2. TCP连接耗时(含HTTPS握手)
const tcpTime = timing.connectEnd - timing.connectStart;
// 3. 首字节时间(TTFB):请求发送到收到第一个响应字节的时间
const ttfb = timing.responseStart - timing.requestStart;
// 4. 白屏时间:导航开始到首字节返回的时间(核心体验指标)
const blankScreenTime = timing.responseStart - timing.navigationStart;
// 5. DOM解析完成耗时
const domParseTime = timing.domContentLoadedEventEnd - timing.responseEnd;
// 6. 页面完全加载总耗时
const totalLoadTime = timing.loadEventEnd - timing.navigationStart;

console.log({
  DNS解析耗时: `${dnsTime}ms`,
  TCP连接耗时: `${tcpTime}ms`,
  首字节时间(TTFB): `${ttfb}ms`,
  白屏时间: `${blankScreenTime}ms`,
  DOM解析耗时: `${domParseTime}ms`,
  页面总加载耗时: `${totalLoadTime}ms`
});

当然还有新版本的计算方式:performance.getEntriesByType('navigation')

比如这样:

const navEntry = performance.getEntriesByType('navigation')[0]; 
// 等价于 timing 的核心计算
const dnsTime = navEntry.domainLookupEnd - navEntry.domainLookupStart; 
const ttfb = navEntry.responseStart - navEntry.requestStart; 
const totalLoadTime = navEntry.loadEventEnd - navEntry.navigationStart;

navigationStart触发时机

navigationStart 的时间戳在浏览器接收到导航指令的瞬间被记录,具体分场景:

  1. 普通跳转(点击链接、输入 URL 回车):浏览器开始发起请求前的瞬间;
  2. 页面刷新:浏览器清空当前页面、准备重新请求资源的瞬间;
  3. 前进 / 后退(浏览器缓存):浏览器开始从缓存加载页面的瞬间。

常规本地测试可以这样:

// main.js 
import { createApp } from 'vue'
import App from './App.vue'

function calculatePerformance() {
  // 先判断浏览器是否支持 Performance API
  if (!window.performance || !window.performance.timing) {
    console.warn('当前浏览器不支持 Performance.timing API');
    return;
  }

  const timing = performance.timing;
  // 核心:先判断 loadEventEnd 是否已完成(值大于 0)
  if (timing.loadEventEnd === 0) {
    console.warn('页面 load 事件还未完成,暂无法统计完整性能数据');
    // 退而求其次,统计已完成的阶段(比如 DOM 解析完成)
    const partialTotalTime = timing.domContentLoadedEventEnd - timing.navigationStart;
    console.log('已完成的性能数据(非完整):', {
      白屏时间: `${timing.responseStart - timing.navigationStart}ms`,
      DOM解析完成耗时: `${timing.domContentLoadedEventEnd - timing.responseEnd}ms`,
      截至DOM完成总耗时: `${partialTotalTime}ms`
    });
    return;
  }

  // 计算各阶段耗时(增加异常值过滤)
  const dnsTime = Math.max(0, timing.domainLookupEnd - timing.domainLookupStart);
  const tcpTime = Math.max(0, timing.connectEnd - timing.connectStart);
  const ttfb = Math.max(0, timing.responseStart - timing.requestStart);
  const blankScreenTime = Math.max(0, timing.responseStart - timing.navigationStart);
  const domParseTime = Math.max(0, timing.domContentLoadedEventEnd - timing.responseEnd);
  const totalLoadTime = Math.max(0, timing.loadEventEnd - timing.navigationStart);

  console.log('完整性能统计数据:', {
    DNS解析耗时: `${dnsTime}ms${dnsTime === 0 ? '(DNS缓存命中)' : ''}`,
    TCP连接耗时: `${tcpTime}ms`,
    首字节时间(TTFB): `${ttfb}ms`,
    白屏时间: `${blankScreenTime}ms`,
    DOM解析完成耗时: `${domParseTime}ms`,
    页面总加载耗时: `${totalLoadTime}ms`
  });
}

// 方案1:优先等待 load 事件(最准确)
if (document.readyState === 'complete') {
  // 页面已经加载完成,直接执行
  calculatePerformance();
} else {
  // 页面还在加载,监听 load 事件
  window.addEventListener('load', calculatePerformance);
}

// 初始化 Vue 应用(放在统计逻辑之后不影响,因为统计已异步等待 load 事件)
const app = createApp(App)
app.mount('#app')

路由切换时的简单处理

// Vue3 + Vue Router 路由切换埋点
import { useRouter } from 'vue-router';
const router = useRouter();

router.afterEach((to, from) => {
  const startTime = Date.now();
  const threshold = 2000;
  // 路由切换后,延迟阈值时间检测首屏
  setTimeout(() => {
    const keyNode = document.querySelector(`#${to.name}-container`); // 路由对应容器
    const isBlank = !keyNode || keyNode.offsetHeight === 0;
    report({
      isBlank,
      type: 'route-change', // 标记是路由切换首屏
      from: from.path,
      to: to.path,
      duration: Date.now() - startTime
    });
  }, threshold);
});

从用户的感知角度进行计算

responseStart - navigationStart传统白屏时间计算方式,但从用户体验角度,更精准的白屏结束标志是「首次绘制(FP)」或「首次内容绘制(FCP)」,这两个指标可以通过 performance.getEntriesByType('paint') 获取,比仅用 responseStart 更贴合实际视觉体验:

// 获取更精准的白屏时间(FP/FCP)
function getAccurateBlankScreenTime() {
  // 兼容处理:先判断是否支持 Paint Timing API
  if (!window.performance || !window.performance.getEntriesByType) {
    // 降级使用传统方式
    const timing = performance.timing;
    return Math.max(0, timing.responseStart - timing.navigationStart);
  }

  // 获取 Paint 类型的性能指标
  const paintEntries = performance.getEntriesByType('paint');
  let fpTime = 0; // 首次绘制(First Paint)
  let fcpTime = 0; // 首次内容绘制(First Contentful Paint)

  for (const entry of paintEntries) {
    if (entry.name === 'first-paint') {
      fpTime = entry.startTime;
    } else if (entry.name === 'first-contentful-paint') {
      fcpTime = entry.startTime;
    }
  }

  // 优先级:FCP > FP > 传统方式
  if (fcpTime) return fcpTime;
  if (fpTime) return fpTime;
  
  // 最终降级
  const timing = performance.timing;
  return Math.max(0, timing.responseStart - timing.navigationStart);
}

区别:

  • responseStart:服务器返回第一个字节的时间(仅代表数据开始传输,页面未必渲染);
  • first-paint (FP):浏览器首次渲染像素(哪怕是背景色,结束纯黑 / 纯白屏);
  • first-contentful-paint (FCP):浏览器首次渲染有意义的内容(文字、图片、按钮等),更贴近用户感知的「白屏结束」。

完整的常规性能统计工具示例

function getPagePerformance() {
  // 基础校验
  if (!window.performance) {
    console.warn('当前浏览器不支持 Performance API');
    return null;
  }

  // 1. 初始化基础数据
  const timing = performance.timing;
  const navEntry = performance.getEntriesByType('navigation')[0] || {};
  const paintEntries = performance.getEntriesByType('paint') || [];

  // 2. 核心指标计算(兼容新旧API)
  const performanceData = {
    // 基础导航时间(兼容 navEntry 和 timing)
    navigationStart: navEntry.navigationStart || timing.navigationStart || 0,
    
    // DNS 解析耗时
    dnsTime: Math.max(0, 
      (navEntry.domainLookupEnd || timing.domainLookupEnd) - 
      (navEntry.domainLookupStart || timing.domainLookupStart)
    ),

    // TCP 连接耗时(含HTTPS握手)
    tcpTime: Math.max(0,
      (navEntry.connectEnd || timing.connectEnd) -
      (navEntry.connectStart || timing.connectStart)
    ),

    // 首字节时间 TTFB
    ttfb: Math.max(0,
      (navEntry.responseStart || timing.responseStart) -
      (navEntry.requestStart || timing.requestStart)
    ),

    // 传统白屏时间(兼容)
    blankScreenTime_legacy: Math.max(0,
      (navEntry.responseStart || timing.responseStart) -
      (navEntry.navigationStart || timing.navigationStart)
    ),

    // 精准白屏时间(FP/FCP)
    firstPaint: 0, // 首次绘制
    firstContentfulPaint: 0, // 首次内容绘制

    // DOM 解析耗时
    domParseTime: Math.max(0,
      timing.domContentLoadedEventEnd - timing.responseEnd
    ),

    // 页面总加载耗时
    totalLoadTime: Math.max(0,
      (navEntry.loadEventEnd || timing.loadEventEnd) -
      (navEntry.navigationStart || timing.navigationStart)
    )
  };

  // 3. 补充 FP/FCP 数据
  paintEntries.forEach(entry => {
    if (entry.name === 'first-paint') {
      performanceData.firstPaint = entry.startTime;
    } else if (entry.name === 'first-contentful-paint') {
      performanceData.firstContentfulPaint = entry.startTime;
    }
  });

  // 4. 最终推荐的白屏时间(优先级:FCP > FP > 传统方式),适配性选择
  performanceData.blankScreenTime = performanceData.firstContentfulPaint || 
                                    performanceData.firstPaint || 
                                    performanceData.blankScreenTime_legacy;

  // 5. 补充友好的格式化数据
  performanceData.formatted = {
    dnsTime: `${performanceData.dnsTime}ms${performanceData.dnsTime === 0 ? '(DNS缓存命中)' : ''}`,
    tcpTime: `${performanceData.tcpTime}ms`,
    ttfb: `${performanceData.ttfb}ms`,
    blankScreenTime: `${performanceData.blankScreenTime.toFixed(2)}ms`, // 保留两位小数
    domParseTime: `${performanceData.domParseTime}ms`,
    totalLoadTime: `${performanceData.totalLoadTime}ms`
  };

  return performanceData;
}

//  初始化性能统计(确保在页面加载完成后执行)
function initPerformanceMonitor() {
  // 监听页面加载完成事件
  function handleLoad() {
    const perfData = getPagePerformance();
    if (perfData) {
      console.log('页面性能统计数据:', perfData.formatted);
      // 可选:上报性能数据到后端/监控平台
      // reportPerformanceToServer(perfData);
    }
  }

  // 页面已加载完成则直接执行,否则监听 load 事件
  if (document.readyState === 'complete') {
    setTimeout(handleLoad, 0); // 微任务延迟,确保所有资源加载完毕
  } else {
    window.addEventListener('load', handleLoad);
    // 兜底:如果 load 事件迟迟不触发,5秒后强制统计
    setTimeout(handleLoad, 5000);
  }
}

// ========== 业务集成示例(Vue/React 通用) ==========
// Vue 项目:在 main.js 中调用
// import { createApp } from 'vue'
// import App from './App.vue'

// // 先初始化性能监控
// initPerformanceMonitor();

// // 再挂载应用
// createApp(App).mount('#app');

// React 项目:在 index.js 中调用
// import React from 'react';
// import ReactDOM from 'react-dom/client';
// import App from './App';

// // 先初始化性能监控
// initPerformanceMonitor();

// // 再渲染应用
// const root = ReactDOM.createRoot(document.getElementById('root'));
// root.render(<App />);

注意

  1. 跨域资源的性能数据:如果页面加载了跨域的 JS/CSS/ 图片,默认情况下 performance 无法获取这些资源的详细耗时,需要在服务端配置 Timing-Allow-Origin 响应头。

  2. SPA 应用的适配:单页应用的路由跳转不会触发 navigationStart,需要手动标记路由切换的开始时间,结合 performance.mark() 自定义统计:

    // SPA 路由切换时标记开始时间
    function markRouteStart(routeName) {
      performance.mark(`route_${routeName}_start`);
    }
    
    // 路由渲染完成后计算耗时
    function calculateRouteTime(routeName) {
      performance.mark(`route_${routeName}_end`);
      const measure = performance.measure(`route_${routeName}_duration`, 
        `route_${routeName}_start`, 
        `route_${routeName}_end`);
      console.log(`${routeName} 路由渲染耗时:`, measure.duration);
    }
    

关于DOM 树构建完成的耗时问题:

DOM 检测可能存在 “DOM 存在但样式异常导致不可见” 的误判(如 z-index: -1、背景色与内容色一致),大厂会在核心页面增加 Canvas 视觉检测 作为兜底:

  1. 在阈值时间后,用 html2canvas 对首屏区域截图。

  2. 计算截图的 像素灰度方差

    • 白屏时,像素值趋于一致,方差趋近于 0;
    • 非白屏时,像素值差异大,方差高于阈值(如 50)。
  3. 双重验证:只有 DOM 检测和视觉检测均判定为 “白屏”,才计入白屏次数。

总结

  1. 白屏时间计算:优先使用 first-contentful-paint (FCP),降级使用 first-paint (FP),最终兜底用 responseStart - navigationStart,更贴合用户实际感知;
  2. 性能统计时机:必须在 load 事件触发后执行,或判断 document.readyState === 'complete',否则数据不完整;
  3. 兼容性处理:兼顾新旧 Performance API(timinggetEntriesByType),并对异常值做 Math.max(0, ...) 过滤,避免负数。

马国家网络安全局局长梅加特·祖海里:马来西亚将持续深化与中国在AI与网络安全领域合

2026年1月24日 15:00
围绕马中在AI与网络安全领域的合作和治理等议题,马来西亚国家网络安全局局长梅加特·祖海里·宾·梅加特·塔杰丁23日在接受记者专访时表示,长期以来,中国在前沿技术领域积累了丰富经验,一直是马方在能力建设方面的重要合作伙伴。马来西亚高度重视并将持续深化与中国在相关领域的合作。

高阶函数与泛型函数的类型体操

作者 wuhen_n
2026年1月24日 14:57

高阶函数和泛型是函数式编程的核心,也是 TypeScript 类型系统最强大的部分。掌握这部分内容以后,我们会发现 TypeScript 不仅能检查类型,还能推导类型、组合类型,甚至进行类型运算。

泛型参数约束:给类型参数加"限制条件"

基础泛型约束:确保类型具有某些特性

泛型约束就像给类型参数加上"限制条件",告诉TypeScript:"这个类型参数 T,必须满足某些条件"。这样我们就能在函数体内安全地使用 T 的特定属性。

// 基础约束:T必须具有length属性
interface HasLength {
  length: number;  // 定义了一个接口,要求有length属性
}

// T extends HasLength 表示:T必须有length属性
function logLength<T extends HasLength>(item: T): void {
  // 因为T一定有length,所以可以安全访问
  console.log(`长度: ${item.length}`);
}

// ✅ 正确使用
logLength("hello");       // string有length属性
logLength([1, 2, 3]);     // 数组有length属性
logLength({ length: 5 }); // 对象有length属性

// ❌ 错误使用
// logLength(42);           // number没有length属性 - 编译错误!
// logLength(true);         // boolean没有length属性 - 编译错误!

多个约束:必须同时满足多个条件

interface HasId {
  id: number;
}

interface HasName {
  name: string;
}

// T extends HasId & HasName 表示:T必须同时满足HasId和HasName的要求
function processItem<T extends HasId & HasName>(item: T): string {
  // 这里可以安全访问id和name
  return `${item.id}: ${item.name}`;
}

// ✅ 必须同时有id和name
processItem({ id: 1, name: "zhangsan", age: 25 }); // ✅ 有id和name,age额外属性不影响
// processItem({ id: 1 }); // ❌ 缺少name - 编译错误!
// processItem({ name: "lisi" }); // ❌ 缺少id - 编译错误!

注:约束只在编译时检查,以确保类型安全。

keyof:更精确的约束

keyof T 可以获取类型T的所有键(属性名)的联合类型,比如:keyof {name: string, age: number}就是 name: string | age: number ,这样可以实现完全类型安全的属性访问。

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  // K extends keyof T 表示:K必须是T的键之一
  return obj[key];
}

const person = { name: "zhangsan", age: 25, email: "zhangsan@example.com" };

// ✅ 正确:访问存在的属性
getProperty(person, "name");  // 返回string类型
getProperty(person, "age");   // 返回number类型

// ❌ 错误:访问不存在的属性
// getProperty(person, "address"); // 编译错误:address不是person的键

条件类型约束

条件类型类似于三元表达式,只是适用于类型:T extends U ? X : Y 。如果T可以赋值给U,则类型为X,否则为Y。

type ExtractStringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

函数作为参数的类型推导

函数参数的类型推导机制

在 TypeScript 中,当把一个函数作为参数传给另一个函数时,TypeScript 会根据上下文自动推导参数类型。

function mapArray<T, U>(
  array: T[],  // 第一个参数是T类型的数组
  callback: (item: T, index: number) => U  // 第二个参数是回调函数
): U[] {  // 返回U类型的数组
  return array.map(callback);
}

const numbers = [1, 2, 3];
const strings = mapArray(numbers, (n, i) => {
  // n自动推导为number,i自动推导为number
  return `数字${n}在位置${i}`;
});

上述代码中,TypeScript 的推导过程如下:

  1. 看到 numbersnumber[],所以推导出 T = number
  2. 在回调函数中,n 自动推导为 numberi 自动推导为 number
  3. 回调函数的返回值类型是 string,所以推导出 U = string
  4. 因此,整个函数返回值类型是 string[]

TypeScript可以根据上下文自动推导类型,推导过程是双向的:既可以从左到右推导,也可以从右到左推导。具体的泛型参数 TU 会在调用时被具体化。

高阶函数的类型参数推断

本节开始之前,我们先要明白高阶函数是什么?高阶函数本质就是接受 函数 作为 参数,或 返回函数 作为 结果 的函数。

function compose<A, B, C>(
  f: (b: B) => C,      // 接受B返回C的函数
  g: (a: A) => B       // 接受A返回B的函数
): (a: A) => C {       // 返回接受A返回C的函数
  return (a: A) => f(g(a));
}

// 使用:TypeScript自动推导所有类型
const addOne = (x: number) => x + 1;      // number => number
const toString = (x: number) => x.toString(); // number => string

const addOneThenToString = compose(toString, addOne);
// addOneThenToString类型: (x: number) => string

const result = addOneThenToString(5); // "6"

上述代码中,TypeScript 的推导过程如下:

  1. const toString = (x: number) => x.toString(); 接收 number 类型,并返回 string 类型,所以 compose() 函数中 f: (b: B) => CBnumberCstring
  2. const addOne = (x: number) => x + 1; 接收 number 类型,并返回 number 类型,所以 compose() 函数中 g: (a: A) => B AnumberBnumber
  3. 所以最终的推导结果为:A = number, B = number, C = string

柯里化函数的类型定义

什么是柯里化函数

柯里化(Currying)函数 其实就是把接受多个参数的函数变成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。简单来说就是把 f(a, b, c) 变成 f(a)(b)(c) 的函数。

type Curried<A, B, R> = (a: A) => (b: B) => R;

function curry<A, B, R>(fn: (a: A, b: B) => R): Currie2<A, B, R> {
  return (a: A) => (b: B) => fn(a, b);
}

const add = (x: number, y: number) => x + y;
const curriedAdd = curry(add);

// 分步调用
const add5 = curriedAdd(5);   // 返回新函数:接受一个数,加上5
const result = add5(3);       // 8

// 也可以连续调用
const result2 = curriedAdd(5)(3); // 8

自动柯里化:处理任意数量参数

在 TypeScript 中,我们可以用递归类型处理任意数量的参数:判断形参 Args 是否可以进行分解,分解为第一个参数 First 和 剩余参数 Rest。如果可以,则继续采用递归的方式,对剩余参数 Rest 进一步分解,直至无法分解,即只剩下一个参数。

type Curried<Args extends any[], R> = 
  Args extends [infer First, ...infer Rest]
    ? (arg: First) => Curried<Rest, R>  // 返回接受First的函数,继续处理Rest
    : R;  // 没有参数了,直接返回结果

我们来看一个简单的代码实例:

function curry<Args extends any[], R>(
  fn: (...args: Args) => R
): Curried<Args, R> {
  return function curried(...args: any[]): any {
    // 如果参数数量够了,就调用原函数
    if (args.length >= fn.length) {
      return fn(...args as any);
    }
    // 否则返回新函数,继续收集参数
    return (...moreArgs: any[]) => curried(...args, ...moreArgs);
  } as any;  // 类型断言,因为递归类型比较复杂
}

// 使用:可以柯里化任意参数的函数
const multiply = (a: number, b: number, c: number) => a * b * c;
const curriedMultiply = curry(multiply);

// 可以分任意步调用
const multiplyBy2 = curriedMultiply(2);      // 返回:接受两个参数的函数
const multiplyBy2And3 = multiplyBy2(3);      // 返回:接受一个参数的函数
const finalResult = multiplyBy2And3(4);      // 24

// 也可以连续调用
const finalResult2 = curriedMultiply(2)(3)(4); // 24

函数组合的类型体操

管道(Pipe)操作

所谓 管道,就是把多个函数连接起来,用前一个函数的输出作为后一个函数的输入。

管道定义

function pipe<F extends [(...args: any[]) => any, ...Array<(arg: any) => any>]>(
  ...functions: F
): (...args: Parameters<F[0]>) => ReturnType<F[F['length'] - 1]> {
  return (...args: Parameters<F[0]>) => {
    let result = functions[0](...args);  // 执行第一个函数
    for (let i = 1; i < functions.length; i++) {
      result = functions[i](result);  // 用前一个结果调用下一个函数
    }
    return result;
  } as any;
}

上述代码中,F是函数数组,第一个函数接受任意参数,后续函数接受前一个函数的返回值。

管道使用

const add = (x: number) => x + 1;
const multiply = (x: number) => x * 2;
const toString = (x: number) => x.toString();

// 创建处理管道
const process = pipe(add, multiply, toString);
const result = process(5);
console.log(result); // "12"

上述代码的输出结果是 "12" ,我们用直观一点的理解就是:process(5) 相当于 toString(multiply(add(5)))

组合(Compose)操作

组合 操作正好与 管道 操作相反,它是从右到左组合函数。

组合定义

function compose<Functions extends [(arg: any) => any, ...Array<(arg: any) => any>]>(
  ...functions: Functions
): (arg: any) => ReturnType<Functions[0]> {
  return (arg: any) => {
    let result = arg;
    // 从右向左执行
    for (let i = functions.length - 1; i >= 0; i--) {
      result = functions[i](result);
    }
    return result;
  } as any;
}

组合使用

const toUpper = (s: string) => s.toUpperCase();
const addExclamation = (s: string) => s + "!";
const repeat = (s: string) => s + s;

// 从右到左组合:repeat → addExclamation → toUpper
const shout = compose(toUpper, addExclamation, repeat);
const shouted = shout("hello"); 
console.log(shouted); // "HELLOHELLO!"

上述代码的输出结果是:"HELLOHELLO!" 其执行过程如下:

  • shout("hello") 函数会先调用 repeat() 函数,输出结果是:"hellohello"
  • 接着再调用 addExclamation() 函数,输出结果是:"hellohello!"
  • 最后再调用 toUpper() 函数,输出结果是:"HELLOHELLO!"

异步函数组合

当函数返回Promise时,我们需要特殊的组合方式。

异步管道的简单实现

async function asyncPipe<Functions extends Array<(arg: any) => any>>(
  ...functions: Functions
): (input: any) => Promise<any> {
  return async (input: any) => {
    let result = await Promise.resolve(input);  // 处理可能是Promise的输入
    for (const fn of functions) {
      result = await Promise.resolve(fn(result));  // 等待每个函数完成
    }
    return result;
  };
}

异步管道的简单实例

const fetchUser = async (id: number) => {
  console.log(`获取用户${id}...`);
  return { id, name: "User" + id };
};

const processUser = (user: any) => {
  console.log("处理用户...");
  return { ...user, processed: true };
};

const saveUser = async (user: any) => {
  console.log("保存用户...");
  return user;
};

// 创建异步管道:fetchUser → processUser → saveUser
const userPipeline = asyncPipe(fetchUser, processUser, saveUser);

// 执行管道
userPipeline(1).then(user => {
  console.log("最终用户:", user);
});

结语

类型体操的目的是让代码更安全、更清晰,而不是炫耀技术。我们应该从实际需求出发,选择最简单的解决方案。

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

世界首台10千伏供电智能采煤机在陕西下线

2026年1月24日 14:45
1月23日,陕煤重装集团西安煤矿机械有限公司联合国能神东煤炭集团有限责任公司,自主研制的10千伏供电智能采煤机在西安下线,并顺利通过出厂评议。“世界首台10千伏供电智能采煤机,首次实现了10千伏电压应用于煤矿井下移动设备,为进一步提升我国采煤机装机功率提供了技术支撑。”中国工程院院士、江西理工大学校长葛世荣代表专家组宣读了评议意见。

深入浅出哈希表:原理、实现与实战应用

作者 颜酱
2026年1月24日 14:40

深入浅出哈希表:原理、实现与实战应用

哈希表(Hash Table)是编程中最常用的高效数据结构之一,几乎所有编程语言的标准库都提供了哈希表的实现(如 JavaScript 的 Map/Object、Java 的 HashMap、Python 的 dict)。它以 O(1) 平均时间复杂度 支持增删查改操作,是解决“快速键值映射”问题的首选方案。本文将从底层原理出发,拆解哈希表的核心设计,实现两种解决哈希冲突的方案,并结合实战案例(RandomizedCollection)展示哈希表的灵活应用。

hash_x.png

一、哈希表的核心原理:数组+哈希函数

哈希表的本质是用数组实现的键值映射——通过一个“哈希函数”将任意类型的 key 转化为数组的合法索引,从而借助数组 O(1) 的随机访问特性实现高效操作。

1.1 基本结构(伪代码)


class MyHashMap {
    constructor() {
        // 底层存储数组
        this.table = new Array(1000).fill(null);
    }

    // 增/改:key→索引→数组赋值
    put(key, value) {
        const index = this.hash(key);
        this.table[index] = value;
    }

    // 查:key→索引→数组取值
    get(key) {
        const index = this.hash(key);
        return this.table[index];
    }

    // 删:key→索引→数组置空
    remove(key) {
        const index = this.hash(key);
        this.table[index] = null;
    }

    // 核心:哈希函数(key→合法索引)
    hash(key) {
        // 1. 计算key的哈希值(保证相同key返回相同值)
        let h = key.toString().charCodeAt(0);
        // 2. 保证哈希值非负(位运算效率高于算术运算)
        h = h & 0x7fffffff;
        // 3. 映射到数组合法索引(取模)
        return h % this.table.length;
    }
}

1.2 哈希函数的核心要求

哈希函数是哈希表的“灵魂”,必须满足三个条件:

  1. 确定性:相同 key 必须返回相同索引;

  2. 高效性:计算复杂度为 O(1)(否则哈希表整体性能退化);

  3. 均匀性:尽可能让不同 key 映射到不同索引(减少冲突)。

二、哈希冲突:不可避免的问题与解决方案

由于哈希函数是“无穷空间→有限空间”的映射,哈希冲突(不同 key 映射到同一索引)是必然存在的。解决冲突的核心方案有两种:拉链法(主流)和 线性探查法

2.1 拉链法:数组+链表(简单易实现)

核心思路

数组的每个位置存储一个链表,当发生哈希冲突时,将冲突的键值对追加到链表尾部。

  • 增删查改:先通过哈希函数找到数组索引,再操作对应链表;

  • 优势:实现简单、支持高负载因子(链表可无限延伸);

  • 劣势:链表遍历有轻微性能损耗(但平均仍为 O(1))。

完整实现(JavaScript)

// 链表节点:存储key-value
class HashNode {
    constructor(key, val) {
        this.key = key;
        this.val = val;
        this.next = null;
    }
}

class HashTableChaining {
    constructor(capacity = 10) {
        this.capacity = capacity; // 数组初始容量
        this.size = 0; // 实际存储的键值对数量
        this.loadFactor = 0.75; // 负载因子(触发扩容的阈值)
        this.table = new Array(capacity).fill(null); // 底层数组
    }

    // 哈希函数:key→索引
    hash(key) {
        let h = typeof key === 'number' ? key : key.toString().charCodeAt(0);
        h = h & 0x7fffffff; // 保证非负
        return h % this.capacity;
    }

    // 扩容:解决哈希冲突频繁的问题
    resize() {
        const oldTable = this.table;
        this.capacity *= 2; // 容量翻倍
        this.table = new Array(this.capacity).fill(null);
        this.size = 0;

        // 重新哈希并迁移数据
        for (let node of oldTable) {
            while (node) {
                this.put(node.key, node.val);
                node = node.next;
            }
        }
    }

    // 增/改
    put(key, val) {
        // 达到负载因子,先扩容
        if (this.size / this.capacity >= this.loadFactor) {
            this.resize();
        }

        const index = this.hash(key);
        // 链表为空,直接新建节点
        if (!this.table[index]) {
            this.table[index] = new HashNode(key, val);
            this.size++;
            return;
        }

        // 链表非空:遍历查找(存在则修改,不存在则追加)
        let curr = this.table[index];
        while (curr) {
            if (curr.key === key) {
                curr.val = val; // 存在,修改值
                return;
            }
            if (!curr.next) {
                curr.next = new HashNode(key, val); // 不存在,追加到尾部
                this.size++;
                return;
            }
            curr = curr.next;
        }
    }

    // 查
    get(key) {
        const index = this.hash(key);
        let curr = this.table[index];
        while (curr) {
            if (curr.key === key) {
                return curr.val;
            }
            curr = curr.next;
        }
        return null; // 未找到
    }

    // 删
    remove(key) {
        const index = this.hash(key);
        let curr = this.table[index];
        let prev = null;

        while (curr) {
            if (curr.key === key) {
                // 找到节点:删除(分头部/中间节点)
                if (prev) {
                    prev.next = curr.next;
                } else {
                    this.table[index] = curr.next;
                }
                this.size--;
                return true;
            }
            prev = curr;
            curr = curr.next;
        }
        return false; // 未找到
    }
}

// 测试拉链法哈希表
const ht = new HashTableChaining();
ht.put("a", 1);
ht.put("b", 2);
ht.put("c", 3);
console.log(ht.get("a")); // 1
ht.remove("b");
console.log(ht.get("b")); // null

2.2 线性探查法:开放寻址(复杂但无链表开销)

核心思路

不使用链表,当发生哈希冲突时,向后遍历数组找空位(到数组末尾则绕回头部):

  • 插入:找到空位后直接存入;

  • 查询:从哈希索引开始遍历,直到找到目标或空位;

  • 删除:不能直接置空(会中断查询),需用占位符标记(如 DELETED)。

关键难点
  1. 环形数组:遍历到数组末尾时需绕回头部;

  2. 删除逻辑:用占位符替代直接置空,避免查询中断。

完整实现(JavaScript)

class HashTableProbing {
    constructor(capacity = 10) {
        this.capacity = capacity;
        this.size = 0;
        this.loadFactor = 0.75;
        this.table = new Array(capacity).fill(null);
        this.DELETED = Symbol('deleted'); // 占位符:标记已删除
    }

    // 哈希函数
    hash(key) {
        let h = typeof key === 'number' ? key : key.toString().charCodeAt(0);
        h = h & 0x7fffffff;
        return h % this.capacity;
    }

    // 扩容
    resize() {
        const oldTable = this.table;
        this.capacity *= 2;
        this.table = new Array(this.capacity).fill(null);
        this.size = 0;

        // 迁移数据(跳过占位符)
        for (let entry of oldTable) {
            if (entry && entry !== this.DELETED) {
                this.put(entry.key, entry.val);
            }
        }
    }

    // 查找key的索引(核心:处理冲突+占位符)
    findIndex(key) {
        let index = this.hash(key);
        let start = index;

        // 环形遍历:直到找到目标/空位/遍历完
        while (this.table[index] !== null) {
            // 找到目标key
            if (this.table[index] !== this.DELETED && this.table[index].key === key) {
                return index;
            }
            // 绕回头部
            index = (index + 1) % this.capacity;
            // 遍历完一圈仍未找到
            if (index === start) {
                return -1;
            }
        }
        return index; // 返回空位索引
    }

    // 增/改
    put(key, val) {
        if (this.size / this.capacity >= this.loadFactor) {
            this.resize();
        }

        const index = this.findIndex(key);
        // 未找到:插入新值
        if (index === -1 || this.table[index] === null || this.table[index] === this.DELETED) {
            this.table[index] = { key, val };
            this.size++;
            return;
        }
        // 找到:修改值
        this.table[index].val = val;
    }

    // 查
    get(key) {
        const index = this.findIndex(key);
        if (index === -1 || this.table[index] === null || this.table[index] === this.DELETED) {
            return null;
        }
        return this.table[index].val;
    }

    // 删:用占位符标记
    remove(key) {
        const index = this.findIndex(key);
        if (index === -1 || this.table[index] === null || this.table[index] === this.DELETED) {
            return false;
        }
        this.table[index] = this.DELETED;
        this.size--;
        return true;
    }
}

// 测试线性探查法哈希表
const htProbe = new HashTableProbing();
htProbe.put(1, 10);
htProbe.put(11, 20); // 哈希冲突(1%10=1,11%10=1)
console.log(htProbe.get(11)); // 20
htProbe.remove(1);
console.log(htProbe.get(1)); // null

三、哈希表的进阶特性

3.1 负载因子与扩容

负载因子 = 已存储元素数 / 数组容量,是哈希表扩容的核心依据(通常设为 0.75):

  • 负载因子过高:哈希冲突频繁,性能退化;

  • 负载因子过低:数组空间浪费;

  • 扩容逻辑:容量翻倍,重新哈希并迁移所有数据(保证后续冲突减少)。

3.2 有序哈希表:哈希链表(LinkedHashMap)

标准哈希表的遍历顺序是无序的,若需保留插入顺序,可结合哈希表+双向链表实现:

  • 哈希表:快速查找节点(O(1));

  • 双向链表:维护插入顺序(删除节点 O(1))。


// 双向链表节点
class LinkedNode {
    constructor(key, val) {
        this.key = key;
        this.val = val;
        this.prev = null;
        this.next = null;
    }
}

class LinkedHashMap {
    constructor() {
        // 哨兵节点:简化链表操作
        this.head = new LinkedNode(null, null);
        this.tail = new LinkedNode(null, null);
        this.head.next = this.tail;
        this.tail.prev = this.head;
        this.map = new Map(); // 哈希表:key→节点
    }

    // 新增节点到链表尾部
    addLast(node) {
        const prev = this.tail.prev;
        prev.next = node;
        node.prev = prev;
        node.next = this.tail;
        this.tail.prev = node;
    }

    // 移除链表节点
    removeNode(node) {
        const prev = node.prev;
        const next = node.next;
        prev.next = next;
        next.prev = prev;
    }

    // 增/改
    put(key, val) {
        if (this.map.has(key)) {
            const node = this.map.get(key);
            node.val = val;
            return;
        }
        const newNode = new LinkedNode(key, val);
        this.addLast(newNode);
        this.map.set(key, newNode);
    }

    // 查
    get(key) {
        return this.map.has(key) ? this.map.get(key).val : null;
    }

    // 删
    remove(key) {
        if (!this.map.has(key)) return;
        const node = this.map.get(key);
        this.removeNode(node);
        this.map.delete(key);
    }

    // 按插入顺序遍历key
    keys() {
        const res = [];
        let curr = this.head.next;
        while (curr !== this.tail) {
            res.push(curr.key);
            curr = curr.next;
        }
        return res;
    }
}

// 测试有序哈希表
const lhm = new LinkedHashMap();
lhm.put("a", 1);
lhm.put("b", 2);
lhm.put("c", 3);
console.log(lhm.keys()); // ["a", "b", "c"]
lhm.remove("b");
console.log(lhm.keys()); // ["a", "c"]

3.3 支持随机访问的哈希表

若需哈希表支持“随机返回键”且元素不重复(如 MyArrayHashMap),可结合数组+哈希表实现,核心是用数组存储键值对、哈希表映射键与下标,兼顾 O(1) 增删查与随机访问。

  • 数组:存储 Node 实例(含 key 和 val),支持 O(1) 随机访问,保证随机返回键的等概率性;

  • 哈希表:key→元素在数组中的下标(一一对应,因元素不重复),支持 O(1) 定位元素,规避数组遍历开销。



// 键值对节点:封装key和val
class Node {
    constructor(key, val) {
        this.key = key;
        this.val = val;
    }
}

class MyArrayHashMap {
    constructor() {
        // 哈希表:存储key与对应在数组中的下标,实现O(1)定位
        this.map = new Map();
        // 数组:存储Node实例,支持O(1)随机访问
        this.arr = [];
    }

    /**
     * 按key查询值
     * @param {*} key - 要查询的键
     * @return {*} 对应的值(不存在返回null)
     */
    get(key) {
        if (!this.map.has(key)) {
            return null;
        }
        // 哈希表取下标,数组直接访问
        return this.arr[this.map.get(key)].val;
    }

    /**
     * 增/改键值对(元素不重复,已存在则修改值)
     * @param {*} key - 键
     * @param {*} val - 值
     */
    put(key, val) {
        if (this.containsKey(key)) {
            // 已存在:通过下标修改对应节点的值
            let i = this.map.get(key);
            this.arr[i].val = val;
            return;
        }
        // 新增:数组尾部添加节点,哈希表记录下标
        this.arr.push(new Node(key, val));
        this.map.set(key, this.arr.length - 1);
    }

    /**
     * 按key删除键值对
     * @param {*} key - 要删除的键
     */
    remove(key) {
        if (!this.map.has(key)) {
            return;
        }

        const index = this.map.get(key); // 待删除元素下标
        const node = this.arr[index]; // 待删除节点
        const lastIndex = this.arr.length - 1;
        const lastNode = this.arr[lastIndex]; // 数组最后一个节点

        // 1. 交换待删除节点与最后一个节点位置(避免数组移位,保证O(1))
        this.arr[index] = lastNode;
        this.arr[lastIndex] = node;

        // 2. 更新最后一个节点在哈希表中的下标映射
        this.map.set(lastNode.key, index);

        // 3. 数组删除最后一个元素(O(1)操作)
        this.arr.pop();

        // 4. 哈希表删除待删除节点的key
        this.map.delete(node.key);
    }

    /**
     * 随机返回一个键(等概率)
     * @return {*} 随机键
     */
    randomKey() {
        const n = this.arr.length;
        if (n === 0) return null; // 边界处理:空表返回null
        const randomIndex = Math.floor(Math.random() * n);
        return this.arr[randomIndex].key;
    }

    /**
     * 判断key是否存在
     * @param {*} key - 要判断的键
     * @return {boolean} 存在返回true,否则false
     */
    containsKey(key) {
        return this.map.has(key);
    }

    /**
     * 获取键值对数量
     * @return {number} 数量
     */
    size() {
        return this.map.size;
    }
}

// 测试(验证不重复特性、增删查及随机访问)
let map = new MyArrayHashMap();
map.put(1, 1);
map.put(2, 2);
map.put(3, 3);
map.put(4, 4);
map.put(5, 5);

console.log(map.get(1)); // 1(查询正常)
console.log(map.randomKey()); // 随机返回1-5中的一个键

map.remove(4); // 删除key=4的键值对
console.log(map.randomKey()); // 随机返回1-3、5中的一个键
console.log(map.randomKey()); // 再次随机,无重复元素干扰

四、哈希表的关键注意事项

4.1 不要混淆“Map接口”和“HashMap实现”

  • Map 是接口(抽象定义),不保证时间复杂度;

  • HashMap 是实现(哈希表),平均 O(1);TreeMap 是实现(红黑树),O(logN)。

4.2 key必须是不可变类型

若 key 是可变类型(如数组、对象),修改后哈希值会变化,导致无法找到原数据,甚至内存泄漏。

4.3 拉链法 vs 线性探查法

特性 拉链法 线性探查法
实现难度 简单 复杂(需处理环形/占位符)
负载因子 无上限(链表可延伸) 通常≤0.75(否则性能差)
性能 平均O(1)(链表遍历) 平均O(1)(缓存友好)
空间利用率 较低(链表节点开销) 较高(无额外节点)

五、总结

哈希表的核心是“数组+哈希函数”,解决冲突的两大方案各有优劣(拉链法是主流)。实际开发中,需根据场景选择不同的哈希表变体:

  • 普通键值映射:用标准哈希表(如 Map);

  • 有序遍历:用哈希链表(LinkedHashMap);

  • 随机访问+元素不重复:用数组+普通哈希表组合(如 ArrayHashMap),通过节点交换实现O(1)删除;

  • 高性能场景:优先选择拉链法(实现简单、不易出错)。

六、练习

❌
❌