阅读视图

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

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

本文将详细介绍如何在 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 构建流动性质押协议

前言

本文主要梳理流动性质押协议的理论知识,涵盖概念机制、核心功能、行业痛点及应用优劣势分析。同时,将借助 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:颠覆传统的前端开发框架,为什么它如此受欢迎?

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都值得你深入了解和尝试。

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

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 集成

从零构建 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 讨论。

nuxt配置代理 和 请求接口

一、安装

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:从截屏到可点词的实践笔记

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 的响应式原理

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 实现超丝滑打字机效果组件(可复用、高定制)

在前端开发中,打字机效果能极大提升页面的交互趣味性和视觉体验,比如 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 聊天、个人主页、产品介绍等需要打字机效果的场景,开箱即用!

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

前言

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

关于白屏

基本定义:白屏时间(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, ...) 过滤,避免负数。

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

高阶函数和泛型是函数式编程的核心,也是 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);
});

结语

类型体操的目的是让代码更安全、更清晰,而不是炫耀技术。我们应该从实际需求出发,选择最简单的解决方案。

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

深入浅出哈希表:原理、实现与实战应用

深入浅出哈希表:原理、实现与实战应用

哈希表(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)删除;

  • 高性能场景:优先选择拉链法(实现简单、不易出错)。

六、练习

图形编辑器:Figma 创建图形的命名方案

你好,我是瓜某。

前几天群里有人问了我一个有意思的问题,就是 Figma 创建图形,它的命名规则是怎样的?

这个问题挺有趣的,不妨展开说说。

绘制图形场景

Figma 中通过绘制创建图形,图形名称会给图形类型对应的前缀,加上一个空格符,以及一个正整数序号,如 "Rectangle 6""Vector 3"

绘制图形时,会找到它的图形类型,在当前画布中找到对应的最大序号。取这个序号 + 1,作为新的图形的序号。

整体的逻辑是:

  • 根据 type 拿到名字前缀 prefix。如矩形的类型是 "ROUNDED_RECTANGLE",映射拿到 "Rectangle"(这里可做国际化);
  • 基于 prefix遍历画布上所有节点,根据 某个规则 计算出这个 prefix 的最大序号 num;
  • 新图形的名字就是 prefix + ' ' + (num + 1)

绘制一个新的矩形,遍历当前图纸中的图形,匹配图形名为 /^Rectangle\s*(\d+)/ 的,拿到对应的序号,找出其中的最大值,在这个示例中是 3。然后我们加 1,得到 4,于是我们的新的矩形的 name 就是 "Rectangle 4" 正则 /^Rectangle\s*(\d+)/ 的匹配规则是:

  • 前面不能有空格;
  • 中间空格可以是 0 到多个;
  • 数字只取整数,数字后面可以有多余的东西(如 "Rectangle 2test" 是符合规则的,会拿到 2)。
const getNumFromName = (str, prefix) => {  const regex = new RegExp(`^${prefix}\\s*(\\d+)`);  const match = str.match(regex);  return match ? parseInt(match[1]) : undefined;}// 用法如下:getNumFromName('Rectangle 25', 'Rectangle') // 25getNumFromName(' Rectangle 25', 'Rectangle') // undefinedgetNumFromName('Rectangle  25.7', 'Rectangle') // 25getNumFromName('Rectangle 25hello', 'Rectangle') // 25

计算当前画布中特定前缀的最大序号:

const getNodesMaxNum = (nodes, prefix) => {   let maxNum = 0;  for (const node of nodes) {    const name = node.name;    const num = getNumFromName(name, prefix);    if (num != undefined)      maxNum = Math.max(maxNum, num);  }  return maxNum;}// 拿到新的图形名const num = getNodesMaxNum(getAllNodes(), 'Rectangle') + 1;const nodeName = 'Rectangle ' + num;

命名和画布中的图形的实际类型完全没关系,你可以给一个圆形图形命名为 Rectangle <num> ,这个 name 仍旧会参与计算。

如果觉得遍历画布的所有节点比较耗时,可以考虑缓存不同图形的最大序号,需要在新增图形、删除图形、更新图形名时做缓存的更新。

复制场景

Figma 中复制图形,也会对图形名进行重命名,逻辑和创建不太一样。 逻辑是:

  • 判断被复制图形的名字在 复制位置层级 下是否有同名图形,如果没有同名,不需要重命名;
  • 如果有同名,判断被复制图形名是否匹配 /^(.*)\s(\d+)/。如果不匹配,不重命名;如果匹配,取得前缀 prefix;
  • 执行前面绘制场景相同的逻辑,不过这次不用所有节点,只遍历复制位置层级 下的节点。

获取 prefix 的方法:

const getNamePrefix = (str) => {  const regex = /^(.*)\s\d+$/;  const match = str.match(regex);  return match ? match[1] : undefined;}

prefix 不要求是图形类型前缀,可以是任何字符。所以复制 "banana 2",也是会得到 "banana 3" 的。 有个地方要注意下,就是复制多个图形的时候,当给上一个图形计算好新的 name 后,下一个图形要把上一个图形的 name 也纳入运算,否则会出现相同的序号。

另外,如果复制了组,组下节点不会进行重命名,因为它们和复制位置不在一个层级了。

还有就是文本图形比较特殊,如果开启了 autoRename,图形名需要跟随文本内容,优先级更高,也不会重命名。

优缺点

Figma 给图形加序号的好处,是 **可以一定程度对图形进行标识。**此外递增的特性,也能清晰地感知到图形的创建时间顺序,尤其是复制大量相同图形时,能清晰识别出谁是复制出来的。

缺点是,不太优雅。但我们创建了大量的图形,会发现它们的名字会带上一段很长的没有意义的狗屁膏药一般的数字。

也有一些办法可以去掉后缀。

Figma 提供了一个批量重命名的能力,选中多个图形,然后按下 Command / Ctrl + R 可唤起然后按一定的规则改名。 另外也可以安装一个 Figma 插件,叫做 No Numbers,它是一个脚本,执行一下,就能将选中的图形或画布中所有图形名的数字后缀去掉。

www.figma.com/community/p…

方案二

还有种方案是 新建图形,图形名不带数字后缀的,比如 Adobe Illustractor、Affinity。

准确来说,它们创建的图形,是没有名字的。

{  id: '1:2',  type: 'RECT',  name: undefined  // ...}

虽然没有名字,但有 type,在 UI 层可以基于这个 type 给出一个虚假的 “图形名”。

const getUIName = (node) => {  if (node.name != undefined) return node.name;  return getNameFromMap(node.type); // 从映射表里拿一个 name。}

Adobe Illustractor 的图层列表显示效果: 虽然丢掉了前面说的带数字后缀的优点。

但它的 实现上会更简单一些

此外没有名字也有一些优势:

  • 图形的类型发生变化,UI 层的名字也会发生变化,让用户能 感知到图形类型的改变
  • 切换语言,能 用对应的国际化文案去显示

不过如果用户手动设置了图形名字,让 name 不再是 undefined,上面这两项是不会生效的。

结尾

我是前端西瓜哥,关注我,学习更多图形编辑器知识。

冲上了 Hacker News 第 5 名,竟然是我的 Svelte 练手项目

今天早上醒来,发生了一件让我有点懵的事情。我前段时间为了学习 Svelte 而写的一个“练手项目”—— Zsweep,竟然冲上了 Hacker News (HN) 首页的第 5 名

(这是后面看到时的截图,最开始上了前5,可惜没有截屏😭) image.png

(此图为证)

image.png

(小站下午游戏时长暴涨100小时🤯) image.png

对于独立开发者来说,HN 的首页就像是“奥斯卡红毯”。看着自己写的代码被全球各地的极客讨论, 我想趁热打铁,在掘金复盘一下这个项目的开发思路技术栈选择,以及我为了让它“好玩”而死磕的一些技术细节

HN:news.ycombinator.com/item?id=466…

Repo: github.com/oug-t/zswee…

🎮 什么是 Zsweep?

简单来说,Zsweep 是一个 Vim 键位驱动的扫雷游戏

zsweep.com

它的灵感来源有两个:

  1. Monkeytype:我很喜欢 Monkeytype 那种极简、无广告、纯粹追求速度的打字体验。
  2. Vim/Neovim:作为一名开发者,我想把 h j k l 的肌肉记忆延伸到游戏里。

所以 Zsweep 的设计哲学就是:极简 UI + 极致手速 + 全键盘操作

image.png

🛠️ 技术栈:为什么选择 SvelteKit + Supabase?

作为一个全栈项目,我没有选择我最熟悉的 React,而是选择了 SvelteKit,搭配 Supabase

1. 前端:SvelteKit + Tailwind CSS

Svelte 真的太爽了。在这个项目里,我深刻体会到了“Write less code”的含义。

  • 状态管理:不需要复杂的 Context 或 Redux,Svelte 的响应式变量让处理游戏状态(比如计时器、剩余雷数、当前选中的格子)变得异常简单。
  • 动画:Svelte 内置的 transitionanimate 指令,让我几行代码就实现了“踩雷”时的屏幕震动和结算界面的数字跳动效果。
  • Vim 键位绑定:我写了一个全局的键盘监听器,配合 Svelte 的 store,实现了丝滑的光标移动体验。

2. 后端 & 数据库:Supabase

因为是独立开发,我不想花时间在配运维环境上。Supabase 提供了 PostgreSQL 数据库和开箱即用的 Auth(认证)服务。

  • 登录:直接集成了 GitHub 和 Google OAuth,几行配置就搞定。
  • 排行榜:利用 Postgres 的强大查询能力,我能很快算出全球排名。

💻 那些让我“掉头发”的技术细节

虽然是扫雷,但为了追求极致体验,我在数据处理上花了不少心思。

1. 核心算法:Mines/Min (扫雷效率)

传统的扫雷只看时间,但不同难度的雷数不一样。为了衡量玩家的真实水平,我参考了 Monkeytype 的 WPM (Words Per Minute),设计了 Mines/Min (每分钟扫雷数) 指标。

(也implement了3BV,但考虑到time mode,还需后续更新)

这里有个坑:如果是通过点击复位(重开)太快,可能会导致除以零或者时间极短的数据异常。 我在前端加了一个健壮的计算逻辑:

TypeScript

// 核心计算逻辑片段
if (timeTaken > 0) {
  const minesPerMin = parseFloat(((mines / timeTaken) * 60).toFixed(1));
  // 只有当成绩更优时才更新本地的最佳记录
  if (!calculatedBests[cat] || minesPerMin > calculatedBests[cat].value) {
    calculatedBests[cat] = {
      value: minesPerMin,
      date: g.created_at
    };
  }
}

2. 全球排行榜与“防作弊”

为了做 Leaderboard,我利用 Supabase 的 Foreign Key 把 game_results 表和 profiles 表关联起来。

刚才上线后发现一个小插曲:数据库里出现了一些 0秒 的通关记录(大概是调试时的残留数据,或者是 API 被人用 Postman 刷了)。

为了保证公平,我在后端查询时加了严格的过滤器,利用 SQL 直接过滤掉异常数据:

TypeScript

const { data } = await supabase
  .from('game_results')
  .select('time, profiles(username)')
  .eq('win', true)
  .gt('time', 0) // 过滤掉 0s 的异常数据
  .order('time', { ascending: true })
  .limit(50);

现在,排行榜终于干净了,还能显示“Your Rank”高亮自己的排名。(下一个PR,的ploy)

3. 用户体验细节

  • Glitch 风格:当踩雷失败时,我没有用普通的弹窗,而是写了一个 CSS Glitch(故障风)特效,配合 "FATAL_ERR" 的文案,更有极客感。
  • 热力图:参考 GitHub Contribution,我在个人主页做了一个扫雷热力图,记录玩家每天的活跃度。

🚀 总结与开源

这次冲上 Hacker News 第 5 名,给我最大的启示是:不要等到项目完美了才发布。

Zsweep 其实还有很多 Issues(比如之前的 Joined Date 显示 Invalid Date,刚刚才修好 😂),UI 也不够完美。但因为它解决了一个小痛点(想用 Vim 玩游戏),并且做得足够简单纯粹,就获得了很多开发者的喜爱。

目前项目完全开源,如果你对 Svelte、Vim 或者扫雷感兴趣,欢迎来 GitHub 给个 Star,或者提 PR 一起改进它!

如果你也喜欢 Vim 或者 Svelte,欢迎在评论区交流!

《Vue.js前端开发实战》学习笔记 第1章 初识Vue.js

Vue3 核心知识点读书笔记

一、Vue 核心原理与架构

1. MVVM 核心模式(核心架构)

Vue 基于 MVVM 模式设计,核心是实现视图与数据的解耦,三者关系如下:

模块 核心职责
Model 数据层,负责业务数据处理(纯数据,无视图交互逻辑)
View 视图层,即用户界面(仅展示内容,不处理数据逻辑)
ViewModel 桥梁层,连接 View 和 Model,包含两个核心能力: ✅ DOM Listeners:监听 View 中 DOM 变化,同步到 Model ✅ Data Bindings:监听 Model 中数据变化,同步到 View

关键:View 和 Model 不能直接通信,必须通过 ViewModel 中转,实现解耦。

2. Vue 核心特性(四大核心)

特性 具体说明 示例/应用场景
数据驱动视图 数据变化自动触发视图重新渲染,无需手动操作 DOM 修改变量值 → 页面自动更新
双向数据绑定 视图变化 ↔ 数据变化双向同步 表单输入框内容自动同步到数据变量
指令 分内置指令(Vue 自带)和自定义指令,以v-开头绑定到 DOM 元素 v-bind(单向绑定)、v-if(条件渲染)、v-for(列表渲染)
插件 支持扩展功能,配置简单 VueRouter(路由)、Pinia(状态管理)

二、Vue 版本与开发环境

1. Vue2 vs Vue3 核心差异

维度 Vue3 变化
新增功能 组合式(Composition)API、多根节点组件、底层渲染/响应式逻辑重构(性能提升)
废弃功能 过滤器(Filter)、$on()/$off()/$once() 实例方法
兼容性 兼容 Vue2 绝大多数 API,新项目推荐直接使用 Vue3

2. 开发环境准备(必装)

  1. 编辑器:VSCode → 安装「Vue (Official)」扩展(提供代码高亮、语法提示)
  2. 运行环境:Node.js(官网下载安装,为包管理工具提供基础)
  3. 包管理工具:npm/yarn(管理第三方依赖,支持一键安装/升级/卸载,避免手动下载解压)

三、Vite 创建 Vue3 项目(核心操作)

1. 项目创建命令(适配 npm10 版本)

# Yarn 方式(推荐)
yarn create vite hello-vite --template vue

# 交互提示处理(关键步骤,不要遗漏):
# 1. 提示 "Use rolldown-vite (Experimental)?" → 回车选 No(优先使用稳定版)
# 2. 提示 "Install with yarn and start now?" → 回车选 Yes(自动安装依赖并启动项目)

2. 手动创建命令(补充)

# npm 方式
npm create vite@latest
# yarn 方式
yarn create vite
# 后续需手动填写项目名称、选择框架(Vue)、选择变体(JavaScript)

四、Vue3 项目核心文件与目录

1. 项目目录结构(重点关注)

hello-vite/          # 项目根目录
├── node_modules/    # 第三方依赖包(自动生成)
├── dist/            # 构建产物(执行 yarn build 后生成,用于部署)
├── src/             # 源代码目录(开发核心)
│   ├── assets/      # 静态资源(图片、样式等)
│   ├── components/  # 自定义组件
│   ├── App.vue      # 根组件
│   ├── main.js      # 项目入口文件
│   └── style.css    # 全局样式
├── index.html       # 页面入口文件
└── package.json     # 项目配置(依赖、脚本命令)

2. 核心文件代码解析(带完整注释)

(1)index.html(页面入口)
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>hello-vite</title>
  </head>
  <body>
    <!-- Vue 实例挂载容器:被 main.js 中的 Vue 实例控制 -->
    <div id="app"></div>
    <!-- type="module":启用 ES6 模块化语法,引入项目入口文件 -->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
(2)src/main.js(项目入口,创建 Vue 实例)
// 从 Vue 中导入创建应用实例的核心函数
import { createApp } from 'vue'
// 导入全局样式文件
import './style.css'
// 导入根组件(App.vue)
import App from './App.vue'

// 方式1:简洁写法(创建实例 + 挂载到 #app 容器)
createApp(App).mount('#app')

// 方式2:分步写法(更易理解,效果一致)
// const app = createApp(App) // 创建 Vue 应用实例
// app.mount('#app') // 挂载实例(仅可调用一次)
(3)src/App.vue(根组件,单文件组件核心)
<!-- script setup:Vue3 组合式 API 语法糖,简化组件编写 -->
<script setup>
// 导入子组件(HelloWorld.vue)
import HelloWorld from './components/HelloWorld.vue'
</script>

<!-- template:组件模板结构(视图部分) -->
<template>
  <div>
    <a href="https://vite.dev" target="_blank">
      <img src="/vite.svg" class="logo" alt="Vite logo" />
    </a>
    <a href="https://vuejs.org/" target="_blank">
      <img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
    </a>
  </div>
  <!-- 使用子组件,传递 msg 属性 -->
  <HelloWorld msg="Vite + Vue" />
</template>

<!-- style scoped:样式仅作用于当前组件(通过 Hash 隔离,不影响子组件) -->
<style scoped>
.logo {
  height: 6em;
  padding: 1.5em;
  will-change: filter;
  transition: filter 300ms;
}
.logo:hover {
  filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
  filter: drop-shadow(0 0 2em #42b883aa);
}
</style>

五、核心知识点总结

1. 核心原理

  • Vue 基于 MVVM 模式,通过 ViewModel 实现视图与数据的双向驱动,核心是「数据驱动视图」,无需手动操作 DOM;
  • 双向数据绑定是 Vue 核心特性,表单场景下可自动同步视图与数据。

2. 项目开发

  • Vue3 推荐使用 Vite 创建项目(比 VueCLI 更快),npm10 版本下优先用 yarn create vite 项目名 --template vue 命令;
  • 项目核心文件:index.html(页面入口)→ main.js(创建 Vue 实例)→ App.vue(根组件),三者构成项目基础骨架。

3. 关键注意点

  • mount() 方法仅可调用一次,挂载目标可以是 DOM 元素或 CSS 选择器(#app/.app);
  • <style scoped> 样式仅作用于当前组件,避免样式污染;
  • Vue3 废弃了过滤器、$on/$off/$once 等功能,开发时需避开。

开箱即用的 HarmonyOS 通用脚手架

HarmonyKit 是一个基于 ArkTS + ArkUI 的 HarmonyOS 快速开发框架,内置网络、分页、数据库、状态管理、导航、屏幕适配等常用能力,支持深色模式、国际化、多端适配,欢迎一起学习交流。

项目亮点

  • 开箱即用:内置网络、分页、数据库、状态管理等基础能力,无需重复搭建
  • 完整示例:每个功能模块都提供可运行的示例代码,快速上手
  • 模块化架构:清晰的分层设计,参考官方最佳实践
  • 现代化技术栈:ArkTS + ArkUI + V2 状态管理,拥抱最新技术
  • 屏幕适配:完整支持手机/折叠屏/平板多种设备形态
  • 深色模式:完整支持浅色/深色主题动态切换
  • 国际化支持:支持中英文语言切换
  • 在线文档:与代码同步的详细文档,便于学习和定制

如果项目对你有帮助,请给个 Star 支持 ⭐ 这对我来说很重要,能给我带来长期更新维护的动力!

项目地址GitHub | Gitee 在线文档harmony.dusksnow.top

项目预览

💡 说明:框架提供了多种示例页面,展示各项能力的使用方式,支持手机、折叠屏、平板等多种设备形态。

📱 手机

手机端预览

📱 折叠屏

折叠屏预览

📱 平板

平板预览

架构设计

HarmonyKit 采用模块化分层架构,参考官方最佳实践,从上到下分为三层:

业务层(Feature)

  • 功能模块:按业务域拆分(认证、用户、示例等)
  • MVVM 模式:View + ViewModel 分离,生命周期统一管理
  • 页面入口:每个页面提供独立的 @Builder 入口

核心层(Core)

  • base:基础父类与基础能力封装(BaseViewModel、BaseNetWorkViewModel、BaseNetWorkListViewModel)
  • data:统一数据入口,封装仓库层,聚合网络、数据库与本地存储
  • database:本地数据库能力(IBest-ORM)
  • datastore:轻量级本地存储
  • designsystem:设计系统与全局样式规范(颜色、间距、字体)
  • ibestui:IBest UI 组件库的封装与适配层
  • model:数据模型与实体定义
  • navigation:导航能力与路由基础设施
  • network:网络请求基础能力(DataSource、拦截器)
  • result:统一结果封装与错误处理(RequestHelper)
  • state:全局状态管理(V2 状态管理)
  • ui:通用 UI 组件(BaseNetWorkView、BaseNetWorkListView、RefreshLayout)
  • util:工具类(ToastUtils、LogUtils)

入口层(Entry)

  • 应用入口:应用启动流程与路由注册
  • 适配器:窗口适配器(安全区、断点监听)

依赖关系

  • feature 依赖 core:业务模块可使用 core 的基础能力
  • feature 不直接依赖 network / database:业务模块通过 data 模块内的仓库作为统一数据入口
  • data 聚合底层能力data 负责整合 networkdatabasedatastore
  • 模块间不互相依赖:feature 之间保持隔离,避免交叉依赖

架构图

架构图来自华为官方文档:分层架构设计

技术栈

类别 技术选型 说明
编程语言 ArkTS HarmonyOS NEXT 主流语言
UI 框架 ArkUI 声明式 UI 框架
架构模式 MVVM View + ViewModel 分离
状态管理 V2(ObservedV2/AppStorageV2) 新版状态管理能力
数据库 IBest-ORM 本地数据库能力
组件库 IBest-UI-V2 业务组件库与基础控件封装

核心能力

1. 网络请求封装

基于 Axios 的网络请求封装,采用 DataSource → Repository → ViewModel 三层架构,框架自动处理错误、loading、toast 等通用逻辑。

功能特性:

  • 统一管理 Axios 实例与基础配置(baseUrl、timeout、headers)
  • 自动处理 Token 注入与刷新
  • 自动错误处理与 Toast 提示
  • 自动日志拦截
  • 类型安全的数据源接口

使用案例:

@ObservedV2
export default class NetworkRequestViewModel extends BaseViewModel {
  private repository: GoodsRepository = new GoodsRepository();

  // 只需传递仓库方法,框架自动处理错误
  requestGoodsDetail() {
    RequestHelper.repository<Goods>(this.repository.getGoodsInfo("1"))
      .loading(true)  // 请求过程显示 loading
      .toast(true)   // 业务失败自动弹出 toast
      .execute()
      .then((data: Goods): void => {
        // 只需处理成功逻辑
        console.log("商品详情:", data);
      });
  }
}

单次请求场景(自动处理加载、错误、成功状态):

// ViewModel 继承 BaseNetWorkViewModel
@ObservedV2
export default class NetworkDemoViewModel extends BaseNetWorkViewModel<Goods> {
  private repository: GoodsRepository = new GoodsRepository();

  // 只需实现这一个方法
  protected requestRepository(): Promise<NetworkResponse<Goods>> {
    return this.repository.getGoodsInfo("1");
  }
}

// 页面使用 BaseNetWorkView 自动处理状态
@ComponentV2
export struct NetworkDemoPage {
  @Local
  private vm: NetworkDemoViewModel = new NetworkDemoViewModel();

  build() {
    AppNavDestination({
      title: "网络请求示例",
      viewModel: this.vm
    }) {
      // 框架自动处理加载、错误、成功状态、重试逻辑
      BaseNetWorkView({
        uiState: this.vm.uiState,
        onRetry: (): void => this.vm.retryRequest(),
        content: (): void => this.NetworkDemoContent()
      });
    }
  }

  // 请求成功后渲染内容
  @Builder
  private NetworkDemoContent() {
    Column() {
      Text(this.vm.data?.title ?? "")
      Text(this.vm.data?.subTitle ?? "")
    }
  }
}

具体实现代码,请参考:网络请求文档 | 结果处理文档

2. 分页列表封装

统一封装分页列表的加载、刷新、空态、错误处理,开发者只需关心数据请求和列表渲染。

功能特性:

  • 自动处理加载中、成功、失败、空数据四种状态
  • 自动处理下拉刷新与上拉加载更多
  • 自动管理分页参数(currentPage、pageSize)
  • 自动判断是否还有更多数据
  • 支持自定义加载、错误、空态视图

ViewModel 实现:

@ObservedV2
export default class NetworkListDemoViewModel extends BaseNetWorkListViewModel<Goods> {
  private repository: GoodsRepository = new GoodsRepository();

  // 只需实现这一个方法
  protected requestListData(): Promise<NetworkResponse<NetworkPageData<Goods>>> {
    const request: GoodsSearchRequest = new GoodsSearchRequest();
    request.page = this.currentPage;  // 框架自动管理页码
    request.size = this.pageSize;     // 框架自动管理每页数量
    return this.repository.getGoodsPage(request);
  }
}

页面使用:

@ComponentV2
export struct NetworkListDemoPage {
  @Local
  private vm: NetworkListDemoViewModel = new NetworkListDemoViewModel();
  private listScroller: Scroller = new Scroller();

  build() {
    AppNavDestination({
      title: "分页列表示例",
      viewModel: this.vm
    }) {
      // 框架自动处理加载、错误、空态
      BaseNetWorkListView({
        uiState: this.vm.uiState,
        onRetry: (): void => this.vm.retryRequest(),
        content: (): void => this.NetworkListDemoContent()
      });
    }
  }

  @Builder
  private NetworkListDemoContent() {
    // 框架自动处理下拉刷新和上拉加载
    RefreshLayout({
      scroller: this.listScroller,
      loading: this.vm.isLoading,
      isEnableSlideUp: this.vm.isEnableSlideUp,
      onRefresh: (direction): void => this.vm.onRefreshDirection(direction)
    }) {
      List({ space: 12, scroller: this.listScroller }) {
        ForEach(this.vm.listData, (item: Goods) => {
          ListItem() {
            Text(item.title);
          }
        }, (item: Goods) => `${item.id}`);
      }
      .width("100%")
      .height("100%");
    }
  }
}

使用步骤:

  1. ViewModel 继承 BaseNetWorkListViewModel<T> 并实现 requestListData()
  2. 页面使用 BaseNetWorkListView 包住列表内容
  3. 列表容器用 RefreshLayout 处理下拉刷新和上拉加载

具体实现代码,请参考:分页列表文档

3. 状态管理

基于 V2 状态管理(AppStorageV2/PersistenceV2)实现全局状态共享。

功能特性:

  • 类型安全的全局状态定义
  • 响应式更新(@Trace 自动触发 UI 更新)
  • 支持持久化存储(PersistenceV2)
  • 多页面状态共享
  • 统一状态管理入口

状态定义:

// 定义状态类
@ObservedV2
export class DemoCounterState {
  @Trace count: number = 0;  // @Trace 标记的字段会自动触发 UI 更新

  increment(): void {
    this.count++;
  }
}

// 获取全局状态实例
export function getDemoCounterState(): DemoCounterState {
  return AppStorageV2.connect<DemoCounterState>(
    DemoCounterState,
    "demo_counter_state",
    () => new DemoCounterState()
  )!;
}

使用案例:

@ObservedV2
export default class StateManagementViewModel extends BaseViewModel {
  @Trace
  counterState: DemoCounterState = getDemoCounterState();  // 获取全局状态

  increment(): void {
    this.counterState.increment();  // 调用状态方法,UI 自动更新
  }
}

具体实现代码,请参考:状态管理文档

4. 导航管理

统一管理路由注册、页面跳转、参数传递与结果回传。

功能特性:

  • 类型安全的路由参数与返回结果
  • 模块化路由注册(RouteGraph)
  • 统一导航服务(NavigationService)
  • 支持带参跳转与结果回传
  • 支持登录拦截与路由守卫

路由定义:

// 路由常量
export const DemoRoutes = {
  NavigationWithArgs: "demo/navigation-with-args",
  NavigationResult: "demo/navigation-result"
};

// 参数定义
export interface DemoGoodsParam {
  goodsId: number;
  goodsName: Resource;
}

// 返回结果定义
export interface DemoResult {
  title: string;
  description: string;
}

Navigator 封装(推荐):

export class DemoNavigator {
  // 带参跳转
  static toNavigationWithArgs(goodsId: number, goodsName: Resource): void {
    const params: DemoGoodsParam = { goodsId, goodsName };
    navigateTo(DemoRoutes.NavigationWithArgs, params);
  }

  // 结果回传
  static toNavigationResult(): Promise<DemoResult | undefined> {
    return navigateToForResult<DemoResult>(DemoRoutes.NavigationResult);
  }
}

使用案例:

// 发起方:跳转并等待结果
@ObservedV2
export default class CallerViewModel extends BaseViewModel {
  openResultPage(): void {
    DemoNavigator.toNavigationResult()
      .then((result?: DemoResult): void => {
        if (result) {
          console.info("返回结果:", result.title);
        }
      });
  }
}

// 目标页:获取参数
@ObservedV2
export default class TargetViewModel extends BaseViewModel {
  routeParams: DemoGoodsParam = getRouteParams<DemoGoodsParam>(DemoRoutes.NavigationWithArgs);
}

// 目标页:返回结果
@ObservedV2
export default class ResultViewModel extends BaseViewModel {
  submitResult(): void {
    const result: DemoResult = {
      title: "标题",
      description: "说明"
    };
    navigateBackWithResult(result);
  }
}

具体实现代码,请参考:导航管理文档

5. 屏幕适配

完整的屏幕适配方案,支持手机、折叠屏、平板等多种设备形态。

功能特性:

  • 断点适配(XS/SM/MD/LG 四档断点)
  • 响应式工具函数 bp()
  • 安全区自动适配
  • 全局断点状态监听

断点规则:

断点 说明 最大宽度(vp)
XS 超小屏 320
SM 小屏 600
MD 中屏 840
LG 大屏 Infinity

网格布局适配:

import { bp } from "state";

@Builder
private GridSection(): void {
  Grid() {
    // ... 数据循环
  }
  // 小屏2列,中屏3列,大屏4列
  .columnsTemplate(bp({ sm: "1fr 1fr", md: "1fr 1fr 1fr", lg: "1fr 1fr 1fr 1fr" }))
  .width("100%");
}

文本适配:

import { bp } from "state";

@Builder
private TextAdaptSection(): void {
  Text("大屏适配示例文字")
    .fontSize(bp({ sm: 16, md: 20, lg: 28 }));
}

Tab 栏大屏位置调整:

import { BreakpointState, getBreakpointState } from "state";

@ComponentV2
export struct MainPage {
  @Local
  private breakpointState: BreakpointState = getBreakpointState();

  @Builder
  private MainContent(): void {
    Tabs({
      // 大屏时 Tab 栏在左侧,小屏时在底部
      barPosition: this.breakpointState.isLG() ? BarPosition.Start : BarPosition.End,
    }) {
      // ... 页面内容
    }
    .barWidth(this.breakpointState.isLG() ? 96 : "100%")
    .vertical(this.breakpointState.isLG());
  }
}

安全区适配:

框架默认使用全局安全区进行页面内边距避让,AppNavDestination 已自动处理。

@ComponentV2
export struct SafeAreaDemoPage {
  @Local
  private vm: SafeAreaDemoViewModel = new SafeAreaDemoViewModel();

  build(): void {
    // AppNavDestination 默认启用安全区
    AppNavDestination({
      title: "安全区示例",
      viewModel: this.vm
    }) {
      Text("内容会自动避让安全区");
    };
  }
}

如需自定义安全区:

AppNavDestination({
  title: "自定义安全区",
  viewModel: this.vm,
  paddingValue: { top: 0, left: 0, right: 0, bottom: 0 }
}) {
  Text("自定义内边距");
}

具体实现代码,请参考:屏幕适配文档 | 安全区文档

6. 数据库封装

基于 IBest-ORM 的本地数据库能力,采用 DataSource → Repository 架构,业务层通过 Repository 访问数据库。

实体定义:

@Table({ name: "demo_items" })
export class DemoEntity {
  @PrimaryKey({ autoIncrement: true })
  id?: number;

  @Column({ type: ColumnType.TEXT })
  title?: string;

  @CreatedAt()
  createdAt?: string;
}

使用案例:

@ObservedV2
export default class DatabaseViewModel extends BaseViewModel {
  private demoRepository: DemoRepository = new DemoRepository();

  // 保存记录
  async save(): Promise<void> {
    await this.demoRepository.createDemo(this.titleInput, this.descInput);
  }

  // 获取所有记录
  async fetchList(): Promise<void> {
    const list = await this.demoRepository.getAll();
    // 处理列表数据
  }
}

具体实现代码,请参考:数据库文档 | IBest-ORM 文档

7. 本地存储

基于 Preferences 的轻量级本地存储,采用 DataSource → Repository 架构,业务层通过 Repository 访问存储数据。

使用案例:

@ObservedV2
export default class LocalStorageViewModel extends BaseViewModel {
  private repository: AccountStoreRepository = new AccountStoreRepository();

  // 保存账号
  async saveAccount(): Promise<void> {
    await this.repository.saveAccount(this.accountInput);
  }

  // 读取账号
  async loadAccount(): Promise<void> {
    this.storedAccount = await this.repository.loadAccount();
  }
}

具体实现代码,请参考:本地存储文档

工具类

框架内置了常用的工具类,避免业务层重复造轮子:

  • ToastUtils:Toast 提示封装
  • ContextUtil:统一获取应用/窗口上下文
  • PreferencesUtil:轻量级本地存储封装
  • PermissionUtils:权限申请封装

具体使用方法,请参考:工具类文档

项目结构

AppScope/               # 应用配置
entry/                  # 应用入口模块
core/                   # 核心模块
│   ├── base/           # 基类
│   ├── data/           # 数据层
│   ├── database/       # 数据库
│   ├── datastore/      # 数据存储
│   ├── designsystem/   # 设计系统
│   ├── ibestui/        # IBest UI 组件库
│   ├── model/          # 数据模型
│   ├── navigation/     # 导航
│   ├── network/        # 网络层
│   ├── result/         # 结果处理
│   ├── state/          # 全局状态
│   ├── ui/             # UI 组件
│   └── util/           # 工具类
feature/                # 功能模块
│   ├── auth/           # 认证模块
│   ├── demo/           # 示例模块
│   ├── main/           # 主模块
│   └── user/           # 用户模块

相关资源:

相关项目:

HarmonyKit 源于 青商城(HarmonyOS) 的实践,如果你想查看完整的电商业务实现:

如果这个项目对你有帮助,请给个 ⭐ Star 支持!

npm install 核心流程

npm install 核心流程

作为前端开发,npm install 天天用,但这行简单的命令背后,npm 其实按固定流程把依赖安装的事安排得明明白白!不用深究底层原理,这篇文章用最直白的话讲清核心步骤,看完秒懂,轻松解决日常安装依赖的小问题~

第一步:先找配置,定好安装规则

执行 npm install 后,npm 第一步不下载,先查找项目和系统的配置文件(比如.npmrc),确定这些关键信息:

  • 依赖从哪下载(镜像源,比如国内常用的淘宝镜像)
  • 下载的包存在哪(缓存目录,避免重复下载)
  • 安装到哪个路径(默认项目根目录的node_modules

简单说,就是先“定规矩”,再开始干活~

第二步:核心分支判断!有没有package-lock.json?

这是整个安装流程的关键分叉口,npm 会先检查项目根目录有没有package-lock.json文件(依赖版本快照,记录上一次安装的精确依赖信息),分两种情况处理,核心都是为了保证版本一致、提升安装速度

情况1:有package-lock.json文件

  1. 先校验版本一致性 检查lock文件里的依赖版本,是否符合package.json里的版本范围(比如package.json^2.0.0,lock文件里2.1.0、2.2.0都算符合)。 符合:按lock文件的精确版本继续; 不符合:忽略旧lock文件,按package.json重新处理。

  2. 拉取包信息,构建并扁平化依赖树 按lock文件的信息,从镜像源获取依赖的元数据,接着构建依赖树(项目依赖的包是一级依赖,包又依赖的包是二级依赖,以此类推)。 关键操作扁平化处理:把能共享的子依赖提升到node_modules根目录,避免层级过深、重复安装,省空间又快!

  3. 缓存判断,安装依赖+更新lock文件

    • 有缓存:直接把缓存里的包解压到node_modules,不用重新下载;
    • 无缓存:从镜像源下载包→检查文件完整性(防止损坏)→存入缓存(下次用)→解压到node_modules; 最后更新lock文件,保证快照最新。

情况2:没有package-lock.json文件

没有lock文件就简单了,直接按package.json来,步骤少了版本校验,其余和上面一致: 拉取远程包信息→构建并扁平化依赖树→缓存判断(有则解压,无则下载+存缓存)→解压到node_modules→生成全新的lock文件,为下一次安装留好精确版本快照。

核心流程一句话总结

输入 npm install → 查找并加载配置文件(.npmrc 等)
→ 检查项目根目录是否有 package-lock.json?
  → 是 → 校验 lock 文件与 package.json 版本是否一致?
    → 一致 → 拉取远程包信息 → 构建依赖树(扁平化)→ 检查缓存?
      → 有 → 解压缓存到 node_modules → 更新 lock 文件
      → 无 → 下载依赖 → 校验完整性 → 存入缓存 → 解压到 node_modules → 更新 lock 文件
    → 不一致 → 按 package.json 重新拉取包信息 → 构建依赖树(扁平化)→ 缓存判断与安装 → 生成/更新 lock 文件
  → 否 → 拉取远程包信息(基于 package.json)→ 构建依赖树(扁平化)→ 缓存判断与安装 → 生成 lock 文件
→ 安装完成 

npm install 核心流程.png

日常开发

1. 缓存超有用,出问题清一在这里插入图片描述

下 缓存是npm提速的关键,第一次下载的包会存起来,后续安装直接复用。如果遇到安装报错、包损坏,执行npm cache clean --force强制清缓存,重新安装大概率解决。

2. package-lock.json别随便删/改

这个文件是团队协作、生产环境的“版本保障”,删了重新安装可能导致依赖版本变化,项目出问题。真要改版本,先改package.json,再重新npm install自动更新lock文件。

从小白到大佬:TypeScript 的逆袭之路!

大家好,我是一个写了多年 TypeScript 的前端开发老鸟。今天,我要和大家分享一些关于 TypeScript 的最佳实践。这些经验都是我在无数次踩坑后总结出来的,希望能让你少走弯路,直接成为 TypeScript 的大佬。当然,分享的过程也不会枯燥无味,毕竟编程嘛,开心最重要!

TypeScript 是什么鬼?

如果你是第一次听说 TypeScript,可能会有点懵:“TypeScript?这名字听起来好高大上,但到底是啥?”

简单来说,TypeScript 是 JavaScript 的超集,它在 JS 的基础上增加了静态类型检查。这就像在你写代码的时候,有一个贴心的“保姆”在旁边提醒你:“嘿,这个变量类型不对哦!”或者“你确定这个函数会返回一个数字吗?”有了 TypeScript,你就可以在写代码时减少很多低级错误。

不过,TypeScript 也有一个“坑爹”的地方——它太严格了!刚开始用的时候,你可能会觉得自己被绑手绑脚。但慢慢地,你会发现,这种“严格”其实是对你的爱啊!它能帮你写出更健壮、更安全的代码。


入门必备:TypeScript 的核心概念

在正式开始之前,我们先来了解几个基础概念。如果你已经是老手,可以直接跳过这一节(但我猜你不会,因为我的文章这么有趣)。

1. 类型(Type)

TypeScript 最核心的就是类型。它会让你的代码变得更加清晰,比如:

let age: number = 25; // age 是一个数字
let name: string = '小明'; // name 是一个字符串
let isHappy: boolean = true; // isHappy 是一个布尔值

看起来是不是很简单?但如果你写成这样:

let age: number = '25'; // 报错!字符串不能赋值给数字类型

TypeScript 会立刻跳出来:“兄弟,你是不是写错了?”这就是它的魅力——帮你在开发阶段就发现问题,而不是等到线上出 bug 再被产品经理追着打。

2. 接口(Interface)

接口是 TypeScript 的另一大杀手锏。它可以用来定义对象的结构,比如:

interface Person {
  name: string;
  age: number;
}

const student: Person = {
  name: '小红',
  age: 18,
};

如果你少写了某个属性,比如 age,TypeScript 会立刻报警:“喂,你是不是忘了啥?”

3. 泛型(Generics)

泛型听起来很高深,但其实很好理解。它就像一个“模板”,可以让你的代码更加灵活,就像你去买衣服,商家告诉你“这衣服是均码的,胖瘦高矮都能穿”,这就是泛型的核心思想。例如:

function getArray<T>(items: T[]): T[] {
  return items;
}

const numberArray = getArray<number>([1, 2, 3]);
const stringArray = getArray<string>(['a', 'b', 'c']);

是不是感觉很厉害?用泛型,你可以写出更加通用的代码。


实战技巧:如何用好 TypeScript?

了解了基础概念后,我们来聊聊一些实用的技巧。这些都是我踩过无数坑后总结出来的,希望对你有帮助。

1. 永远开启严格模式

TypeScript 有一个选项叫 strict,默认是关闭的。但我建议你一定要打开!为什么呢?因为严格模式会让 TypeScript 更加“挑剔”,从而帮你发现更多潜在问题。

tsconfig.json 中设置:

{
  "compilerOptions": {
    "strict": true
  }
}

刚开始可能会有点痛苦,因为 TypeScript 会疯狂地指出你的错误。但相信我,这种痛苦是值得的。久而久之,你会发现自己的代码质量提升了一大截!

2. 善用 any,但不要滥用

any 是 TypeScript 中的“万金油”类型,它可以接受任何值,比如:

let something: any = 'hello';
something = 42; // 完全没问题!

虽然 any 用起来很爽,但千万不要滥用!因为一旦用了 any,TypeScript 的类型检查就失效了。正确的姿势是:只在万不得已时才用 any,尽量用更具体的类型替代它

3. 学会使用类型推断

TypeScript 的一个优点是,它会自动推断变量的类型。例如:

let count = 10; // TS 自动推断 count 是 number 类型

所以,很多时候你不需要显式地写出类型声明。这样不仅减少了代码量,还能让代码看起来更简洁。

4. 使用联合类型和类型保护

有时候,一个变量可能有多种类型。比如:

function printId(id: number | string) {
  if (typeof id === 'string') {
    console.log('ID 是字符串:' + id.toUpperCase());
  } else {
    console.log('ID 是数字:' + id.toFixed(2));
  }
}

这叫做联合类型,而通过 typeof 判断类型的过程叫做类型保护。它能让你的代码更加安全和灵活。


TypeScript 的内置类型:让你少写 100 行代码

接下来,我们聊聊 TypeScript 自带的一些“黑科技”——内置类型。它们就像超市里的速食食品,直接拿来用,省时省力。以下是几个常用的“明星选手”:

1. Required<T>:让你的属性变得“必填”

有时候,你定义了一个对象,但某些属性是可选的(用 ? 标记)。突然有一天,老板说:“不行!这些属性必须全都有!”这时候你就可以用 Required<T>

interface User {
  name?: string;
  age?: number;
}

const user1: Required<User> = {
  name: "小明",
  age: 18,
}; // 如果少填一个属性,TS 就会报错

Required<T> 的作用就是把所有可选属性变成必填属性。老板再也不会在代码审查时拍桌子了!


2. Omit<T, K>:精确剪裁对象

假如你有一个大对象,但有些属性你不想要(比如老板要求的数据统计字段),这时候 Omit<T, K> 就派上用场了。

interface User {
  id: number;
  name: string;
  password: string;
}

type PublicUser = Omit<User, "password">;

const user2: PublicUser = {
  id: 1,
  name: "小红",
}; // password 被成功“剪掉”!

Omit<T, K> 的作用就是从对象中“剔除”某些属性。想象一下,你在剪菜叶,把不想要的部分丢掉,只留下精华部分。


3. Record<K, T>:快速生成对象类型

你有没有遇到过这种情况:需要定义一个对象,但键和值的类型都要严格控制?这时候 Record<K, T> 就是你的救星!

type Role = "admin" | "user" | "guest";

const permissions: Record<Role, string[]> = {
  admin: ["create", "read", "update", "delete"],
  user: ["read", "update"],
  guest: ["read"],
};

Record<K, T> 的意思是:“我有一堆键(K),每个键对应的值都是某种类型(T)。”简直就是批量生产对象类型的神器!


4. Partial<T>:让你的属性变得“随意”

Required<T> 相反,Partial<T> 可以把每个属性变成可选的。适合那种“随便填点啥”的场景。

interface User {
  name: string;
  age: number;
}

const user3: Partial<User> = {
  name: "小刚",
}; // age 可以不写

Partial<T> 的存在,就像一碗加了水的粥——稀释了约束,但更灵活!


5. Pick<T, K>:精准挑选

如果 Omit 是剪掉不要的东西,那 Pick 就是挑出想要的东西。比如,你只关心用户的名字和年龄,不在乎其他字段:

interface User {
  id: number;
  name: string;
  age: number;
}

type UserInfo = Pick<User, "name" | "age">;

const user4: UserInfo = {
  name: "小强",
  age: 25,
};

Pick<T, K> 的作用就是从对象中精准挑出你需要的字段。就像点菜一样,只点自己爱吃的,不浪费!


常见坑:如何避免被 TypeScript“坑”?

虽然 TypeScript 很强大,但它也有不少坑等着你跳。以下是几个常见的“雷区”,希望你能绕开。

坑 1:类型太复杂

有时候,为了追求完美,我们可能会写出非常复杂的类型定义,比如:

type ComplexType = { a: string } & { b: number } & { c: boolean };

这种类型虽然看起来很酷,但维护起来非常麻烦!所以,尽量保持简单,别给自己挖坑。

坑 2:忽略类型定义文件

当你使用第三方库时,如果没有为它们提供类型定义文件(.d.ts),TypeScript 就无法进行类型检查。这时候,你可以去 DefinitelyTyped 找找看有没有对应的定义文件。如果没有,就自己动手丰衣足食吧!

坑 3:误用类型断言

类型断言是告诉 TypeScript:“相信我,我知道这个变量的真实类型。”比如:

let someValue: any = 'hello';
let strLength: number = (someValue as string).length;

虽然类型断言很好用,但滥用它可能会导致灾难性的后果!所以,在使用之前,一定要确保你的断言是正确的。


总结

TypeScript 就像一把双刃剑,用得好,它能帮你写出更优雅、更安全的代码;用得不好,它也能让你抓狂到想砸键盘。但不管怎么说,学习 TypeScript 是一个非常值得投入时间和精力的事情。

最后,我送大家一句话:“学 TypeScript 就像谈恋爱,一开始觉得麻烦,但时间久了就离不开了。”

希望这篇文章能帮到想入门 TypeScript 的同学。如果你还有其他问题,欢迎留言,我们一起交流、一起成长!

工程化落地:利用 TS/ESLint 自动化构建 AI 权限围墙

前言

在上一篇方案篇中,我们构思了“AI 逻辑沙盒”的双层宏契约:通过 define 与 apply 模式,将 AI 的破坏半径锁死在受限的环境中。

但架构设计如果不落实为自动化工具,就只是纸上谈兵。在 2026 年的开发环境下,我们追求的是 “开发态极高压约束,运行态零开销脱离” 。今天,我们进入深水区,探讨如何利用 TypeScript Compiler API、ESLint 定制规则以及编译时宏处理,将这套逻辑打造成一套闭环的自动化准入体系。


一、 自动化基石:从“宏声明”到“规则映射”

手动维护每个 AI 文件夹的配置是不可持续的。我们的目标是:架构师修改一行 TS 类型定义,工程环境自动完成“布防”。

  1. 静态扫描器 (The Scanner)

我们需要编写一个 Node.js 脚本(利用 ts-morph 或 SWC 的解析能力),专门扫描宿主侧的权限声明。

  • 核心逻辑

    1. 识别 defineEnvContext<T> 调用的位置。
    2. 静态解析泛型 T 中的属性。例如,如果 T 是 Pick<Window, 'addEventListener' | 'removeEventListener'>,脚本将提取出 window 及其成对的授权属性。这种成对授权是必须的,它确保了 AI 具备清理副作用的能力,从根源规避内存泄露。
    3. 解析 defineImportContext<T> 中定义的外部模块映射路径。
  1. 配置自动生成 (The Generator)

脚本扫描完成后,会立即在对应的 AI 逻辑沙盒 文件夹下生成/更新两个关键的“围墙”文件:

  • 生成 .eslintrc.js
    必须设置 root: true。这是为了彻底切断父级目录中可能存在的“宽松规则”干扰,确保沙盒规则的纯净。

    javascript

    // /src/ai_modules/xxx/.eslintrc.js
    module.exports = {
      root: true, // 核心:断绝父级规则合并,建立独立“法律体系”
      env: {
        browser: false, // 禁用默认环境,防止隐式逃逸
        es2022: true
      },
      rules: {
        "no-undef": "error", // 配合抹除 DOM 库,禁止直接访问全局变量
        // 这里的 "error" 是等级(Severity),确保任何违规代码都无法通过编译
        "no-restricted-globals": ["error", "window", "document", "location", "localStorage"], 
        "no-restricted-syntax": [
          "error",
          {
            // 仅允许从我们的宏解构出的变量,禁止绕过宏直接调用
            "selector": "VariableDeclarator[init.callee.name='applyEnvContext'] > ObjectPattern > Property",
            "message": "解构变量未在宿主 define 宏中授权。"
          }
        ]
      }
    };
    

    请谨慎使用此类代码。

  • 生成 tsconfig.json
    通过 compilerOptions.paths 将全局类型重定向到我们生成的受限 .d.ts 定义,确保 AI 编写时 IDE 提示仅包含被 Pick 出来的安全属性。


二、 编译时魔法:宏的“彻底消失术”

宏(Macros)的本质是“开发态的严格约束,生产态的纯净幻觉”。在构建阶段,我们需要通过编译器插件进行物理处理。

1. applyEnvContext 的运行时脱除

这是方案最优雅之处:由于宿主环境天然存在全局对象,我们只需要在编译时把宏“删掉”即可。

  • 转换前(AI 源码)
    const { window } = applyEnvContext<GlobalApi>();
  • 转换后(产物代码)
    const { window } = globalThis;(或直接物理移除,让变量引用回退到原生全局访问)。
  • 工程意义:开发态通过宏实现解构变量赋予受限类型以通过审计;构建态则让其消失,保证产物零开销。

2. applyImportContext 的逻辑提升

与环境宏不同,第三方模块需要真实的引入逻辑。

  • 处理逻辑:编译器扫描该宏,根据映射关系在文件顶部插入 import debounce from 'lodash/debounce',随后删除原始宏调用行。

三、 防御性审计:如何防止 AI “逃逸”?

AI 可能会尝试利用先验知识,通过 window['loc' + 'ation'] 这种手段绕过静态拦截。为此,我们在 AI 逻辑沙盒 内实施  “零信任审计”

  1. 禁止成员表达式动态访问:通过 ESLint 拦截 MemberExpression 的计算属性访问,强制要求 API 调用必须是静态可见的。
  2. 强制单一入口:禁止任何非宏声明的外部 import。所有依赖必须通过 applyImportContext 申请。
  3. 二次编译校验:在构建插件中,我们会对解构的属性进行二次核对。如果 AI 试图解构一个未经 define 授权的属性,构建将直接阻断

四、 架构总结:将权力关进“基建”的笼子

至此,我们构建了一套完整的 AI 原生工程治理流水线

  1. 架构师(人) :在宿主层通过 define 宏拨发微小的、经过 Pick 裁剪的权限。
  2. 自动化脚本:将权限实时映射为沙盒内部的 root: true 的 ESLint 规则与 TSConfig
  3. AI(执行者) :在受限的 AI 逻辑沙盒 内,通过 apply 宏行使能力。
  4. 编译器(拦截者) :在构建时校验并抹除所有宏逻辑,产出纯净代码。

在这种架构下,人担责的压力被降到了最低。  你不再需要死磕业务逻辑,只需要审计那几行“契约”。例如: “AI 申请了 addEventListener 但没有申请 removeEventListener,这是否会引入内存风险?”  这种基于权限边界的审计,才是真正的高效。


结语

「AI 逻辑沙盒」不仅是一套工具,它代表了我们在 2026 年对防御性编程的终极实践。

我们正处在一个分水岭:一边是 Vibe Coding 带来的生产力狂欢,一边是工程严谨性的崩塌。而这套方案试图在两者之间架起一座桥梁:用最硬核的基建,去拥抱最不确定的 AI 生产力。

感谢阅读系列文章《AI 原生工程:逻辑沙盒与零信任代码治理》。这场关于 AI 准入的革命,才刚刚开始。

❌