阅读视图

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

你不知道的 TypeScript:联合类型与分布式条件类型

在 TypeScript 中,联合类型是非常常用的类型工具,但联合类型在条件类型中的分布式特性,估计会困扰很多人,因为它的行为非常的…不直观。

所以本文尽量用简单的语言和丰富的例子来让大家彻底搞懂联合类型与分布式条件类型,搞不懂也别打我。

联合类型

联合类型(Union Types) 表示一个值可以是几种类型之一。用竖线 | 来分隔每个类型,例如 string | number 表示一个值可以是 stringnumber

联合类型的基础用法:

// 表示类型可以为 string 或 number
type StringOrNumber = string | number;

let value: StringOrNumber;
value = "hello";  // ✅
value = 42;       // ✅
value = true;     // ❌ 不能将类型“boolean”分配给类型“StringOrNumber”

// 多个类型的联合
type ID = string | number | symbol;

// 字面量类型的联合
type Status = "pending" | "success" | "error";
type StyleType = 1 | 2 | 3;

分布式条件类型

什么是条件类型?

条件类型(Conditional Types)的语法类似于 JavaScript 中的三元运算符:

SomeType extends OtherType ? TrueType : FalseType;

它表示:当 extends 左边的类型 SomeType 可以赋值给右侧的类型 OtherType 时,返回 TrueType,否则返回 FalseType

用法示例:

type A = string extends 'string' ? true : false // false
type B = 'string' extends string ? true : false // true
type C = number extends number ? boolean : string // boolean

实际应用中,条件类型常配合泛型一起使用:

// 判断一个类型是否为字符串类型
type IsString<T> = T extends string ? true : false;
type A = IsString<string>;   // true
type B = IsString<number>;   // false

// 提取函数的返回类型
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type R = MyReturnType<() => string>;  // string

什么是分布式条件类型?

When conditional types act on a generic type, they become distributive when given a union type.

分布式条件类型(Distributive Conditional Types) 是指:当条件类型作用于泛型类型时,如果该泛型类型为联合类型,则条件类型将具有 分布性

简单来说,就是条件类型会分别对联合类型的每个成员进行判断和处理,然后将所有结果重新组合成一个联合类型。我愿意把这理解为 TypeScript 类型系统中的 "forEach"

假设我们有下面的类型 ToArray,可以看到它会把联合类型泛型当做一个整体处理。

type ToArray<T> = T[]
type Result = ToArray<string | number>; // (string | number)[]

而如果我们使用条件类型,可以看到此时联合类型的每一个成员会分别应用于条件类型。

type ToArray<T> = T extends any ? T[] : never;

// 当 T 是联合类型时
type Result = ToArray<string | number>; // string[] | number[]

// 分布过程:
// ToArray<string | number>
// =>  ToArray<string> | ToArray<number>
// => (string extends any ? string[] : never) | (number extends any ? number[] : never)
// => string[] | number[]

这里 T extends any 几乎永远为真(除了 Tnever 的特殊情况),其主要作用是触发分布式条件类型。我们也可以写成 T extends unknownT extends T 等形式来保证条件为真。

但是必须要注意一点:extends 的左边必须为单独的 T

阻止分布式行为

在某些场景下,我们希望在条件类型中使用联合类型,但不希望触发分布式行为,此时可以用方括号 []extends 关键字两侧的类型包裹起来。

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// 此时 ArrOfStrOrNum 不再是一个联合类型
type ArrOfStrOrNum = ToArrayNonDist<string | number>; // (string | number)[]

我们将 extends 左边单独的类型参数(比如前面的 T)称为 Naked Type (裸类型,一般指没有被包装的类型)。只有 Naked Type 才会触发分布式条件类型,当它不再是 Naked Type 就不会触发分布式。

事实上,任何形式的包装都可以阻止分布式,例如:

  • [T] extends [any] - 用元组包装
  • Array<T> extends Array<any> - 用泛型包装
  • { value: T } extends { value: any } - 用对象类型包装

只要 extends 左边不是单独的联合类型,就不会触发分布式行为。

分布式条件类型简单实例

Exclude

实现 TypeScript 内置的 Exclude<T, U> 类型:从联合类型 T 中排除 U 中的类型,来构造一个新的类型。

利用分布式条件类型的特性,依次判断联合类型 T 的每个成员是否可以赋值给 U。如果可以赋值,则返回 never(将其从结果中剔除);否则返回该成员本身(将其保留)。

type MyExclude<T, U> = T extends U ? never : T;

type T1 = MyExclude<"a" | "b" | "c", "a">; // "b" | "c"
type T2 = MyExclude<string | number | boolean, string>; // number | boolean

Extract

实现 TypeScript 内置的 Extract<T, U> 类型:从联合类型 T 中提取 U 中的类型,来构造一个新的类型。

Exclude 的逻辑完全相反:如果可以赋值给 U,则保留该成员;否则返回 never 将其剔除。

type MyExtract<T, U> = T extends U ? T : never;

type T3 = MyExtract<"a" | "b" | "c", "a" | "b">; // "a" | "b"
type T4 = MyExtract<string | number | boolean, number | boolean>; // number | boolean

Flatten

把联合类型中的每个数组元素都展平。

type Flatten<T> = T extends (infer U)[] ? U : T;

type Nested = string[] | number[];
type Flattened = Flatten<Nested>; // string | number

实战:实现 Permutation(全排列)

ok,学完 1+1,接下来我们开始学微积分了,笑:)

Type Challenges - Permutation

实现联合类型的全排列,将联合类型转换成所有可能的全排列数组的联合类型。

type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A']

这是一道典型的分布式条件类型应用题。(谁会知道我为了这道醋包了这篇饺子)。

全排列问题的核心思路是递归(建议先理解 JavaScript 中的全排列实现)。

'A' | 'B' | 'C' 为例,递归的思路是:

  1. 枚举第一个位置的元素(ABC
  2. 对剩余元素递归求全排列
  3. 将当前元素与剩余元素的全排列组合

具体来说:

  • 当第一个元素为 A 时,剩余元素为 B | C,递归计算 Permutation<'B' | 'C'>
  • 当第一个元素为 B 时,剩余元素为 A | C,递归计算 Permutation<'A' | 'C'>
  • 当第一个元素为 C 时,剩余元素为 A | B,递归计算 Permutation<'A' | 'B'>

这里的重点是:利用分布式条件类型会自动遍历联合类型的每个成员的特点,枚举首元素

难点在于:如何获取"除当前元素外的剩余元素"?我们需要一个额外的泛型参数 U 来保存完整的联合类型。当分布式条件类型遍历时,T 是当前元素,U 是完整的联合类型,此时使用 Exclude<U, T> 就能得到剩余元素。

初步实现如下:

type Permutation<T, U = T> = 
  T extends any
  ? [T, ...Permutation<Exclude<U, T>>]
  : []

type R = Permutation<'A' | 'B' | 'C'>;

不幸的是,上面的实现会得到 never。原因是递归缺少终止条件

当递归到最后,T 会变成空的联合类型(即 never),但我们没有特殊处理终止条件,只能得到 never

我们需要添加一个终止条件判断:当 T 为空集时,不需要再处理直接返回 []

判断空集的条件是 [T] extends [never]。这里将 T 包裹在元组中,阻止了分布式行为,使其变成普通的类型检查。只有当 T 为空集时,这个条件才为真。

正确答案:

/**
 * 生成联合类型的所有排列组合
 */
type Permutation<T, U = T> = 
  [T] extends [never]
    ? []
    : T extends U
      ? [T, ...Permutation<Exclude<U, T>>]
      : never;

type P1 = Permutation<"a" | "b" | "c">;
// => ["a", "b", "c"] | ["a", "c", "b"] | ["b", "a", "c"] | ["b", "c", "a"] | ["c", "a", "b"] | ["c", "b", "a"]

参考资源

一个开箱即用的鸿蒙异步任务同步队列——JSyncQueue

一、简介

在鸿蒙应用开发中,异步任务的顺序执行是一个常见需求。当多个异步任务需要按照特定顺序执行时,如果不加控制,可能会导致执行顺序混乱。

项目地址:github.com/zincPower/J…

JSyncQueue 提供了一个简洁的解决方案:

  • 顺序执行保证:所有任务严格按照入队顺序执行,即使任务内部有异步操作也能保证顺序
  • 双模式支持:支持 "立即执行" 和 "延时执行",满足不同场景需求
  • 双任务模式:支持 "Message 消息模式" 和 "Runnable 闭包模式"
  • 任务取消和管理:可随时取消指定任务或清空整个队列
  • 任务结果:通过 getResult() 获取任务执行结果,支持 then/catch/finally
  • 可继承扩展:通过继承 JSyncQueue 并重写 onHandleMessage 方法,实现自定义消息处理逻辑

项目架构如下图所示:

二、安装

ohpm install jsyncqueue

三、快速开始

3-1、基础使用

可以直接使用 JSyncQueue 无需继承,但仅支持 Runnable 模式(post/postDelay)。

import { JSyncQueue } from 'jsyncqueue'

// 创建队列
const queue = new JSyncQueue("MyQueue")

// 添加任务
queue.post(async (taskId) => {
  // 执行异步操作
  const result = await someAsyncOperation()
  return result
}).getResult().then((result) => {
  console.log(`任务完成: ${result}`)
}).catch((error) => {
  console.error(`任务失败: ${error}`)
})

3-2、继承使用

继承 JSyncQueue 后,既可以使用 Message 模式(sendMessage/sendMessageDelay)处理消息,也可以使用 Runnable 模式(post/postDelay)执行闭包。

import { JSyncQueue, Message, Any } from 'jsyncqueue'

class MyQueue extends JSyncQueue {
  async onHandleMessage(message: Message, taskId: number): Promise<Any> {
    switch (message.what) {
      case "say_hello":
        const name = message.data["name"]
        return `你好,${name}!`
      default:
        return undefined
    }
  }
}

// 使用自定义队列
const queue = new MyQueue("MyQueue")
queue.sendMessage({
  what: "say_hello",
  data: { name: "小明" }
}).getResult().then((result) => {
  console.log(result) // 输出: 你好,小明!
})

四、核心概念

4-1、“立即执行” 和 “延时执行”

方法 说明
post(runnable) 立即将闭包加入队列执行
postDelay(runnable, delay) 延时指定毫秒后将闭包加入队列执行
sendMessage(message) 立即将消息加入队列执行
sendMessageDelay(message, delay) 延时指定毫秒后将消息加入队列执行

4-2、“Message 模式” 和 “Runnable 模式”

Runnable 模式:直接传入一个闭包函数,适合简单的一次性任务。

queue.post(async (taskId) => {
  // 直接在闭包中编写执行逻辑
  return "任务结果"
})

Message 模式:发送消息到队列,由 onHandleMessage 方法处理,适合需要集中管理业务逻辑的场景。

queue.sendMessage({
  what: "action_type",
  data: { key: "value" }
})

注意:直接使用 JSyncQueue 实例时,Message 模式的消息不会被处理(onHandleMessage 默认返回 undefined)。需要继承 JSyncQueue 并重写 onHandleMessage 方法才能处理消息。

五、API 文档

5-1、JSyncQueue 类

构造函数

constructor(queueName: string)

创建一个同步队列实例。

  • queueName: 队列名称,用于标识和调试

方法

方法 参数 返回值 说明
post(runnable) runnable: (taskId: number) => Promise<Any> Task 立即执行闭包
postDelay(runnable, delay) runnable: (taskId: number) => Promise<Any>, delay: number Task 延时执行闭包,delay 单位为毫秒
sendMessage(message) message: Message Task 立即发送消息
sendMessageDelay(message, delay) message: Message, delay: number Task 延时发送消息,delay 单位为毫秒
cancel(taskId) taskId: number void 取消指定任务
clear() - void 清空队列中所有等待的任务
dumpInfo() - string 获取队列调试信息
onHandleMessage(message, taskId) message: Message, taskId: number Promise<Any> 消息处理方法,子类可重写

属性

属性 类型 说明
queueName string 队列名称(只读)
length number 当前队列中的任务数量(只读)

5-2、Message 接口

interface Message {
  what: string   // 消息类型
  data: Any      // 消息数据
}

5-3、Task 接口

interface Task {
  cancel(): void                  // 取消任务
  getResult(): Promise<Any>       // 获取任务结果
  getTaskId(): number            // 获取任务 ID
}

5-4、异常类型

JSyncQueueCancelException

任务被取消时抛出的异常。

interface JSyncQueueCancelException {
  message: string
}

JSyncQueueException

队列内部错误时抛出的异常。

interface JSyncQueueException {
  message: string
}

六、使用示例

6-1、直接使用 JSyncQueue + post()

适用于简单场景,直接使用闭包处理任务。

import { JSyncQueue } from 'jsyncqueue'

const queue = new JSyncQueue("SimpleQueue")

// 添加多个任务,它们会按顺序执行
for (let i = 0; i < 5; i++) {
  queue.post(async (taskId) => {
    console.log(`开始执行任务 ${i}`)
    // 模拟异步操作
    await new Promise(resolve => setTimeout(resolve, 100))
    console.log(`完成任务 ${i}`)
    return `结果 ${i}`
  }).getResult().then((result) => {
    console.log(`任务 ${i} 返回: ${result}`)
  })
}

6-2、继承 JSyncQueue 自定义队列

适用于需要集中管理业务逻辑的场景,继承后同样支持 Runnable 模式。

import { JSyncQueue, Message, Any } from 'jsyncqueue'

class UserQueue extends JSyncQueue {
  private userCount = 0

  async onHandleMessage(message: Message, taskId: number): Promise<Any> {
    switch (message.what) {
      case "register":
        this.userCount++
        const name = message.data["name"]
        // 模拟异步注册操作
        await this.simulateAsyncOperation()
        return `用户 ${name} 注册成功,当前用户数: ${this.userCount}`

      case "login":
        const username = message.data["username"]
        await this.simulateAsyncOperation()
        return `用户 ${username} 登录成功`

      default:
        return undefined
    }
  }

  private async simulateAsyncOperation() {
    return new Promise(resolve => setTimeout(resolve, 100))
  }
}

// 使用
const userQueue = new UserQueue("UserQueue")

userQueue.sendMessage({
  what: "register",
  data: { name: "张三" }
}).getResult().then(console.log)

userQueue.sendMessage({
  what: "login",
  data: { username: "张三" }
}).getResult().then(console.log)

// 继承后同样可以使用 post()
userQueue.post(async (taskId) => {
  console.log("执行自定义闭包任务")
  return "闭包任务完成"
}).getResult().then(console.log)

6-3、延时执行示例

import { JSyncQueue } from 'jsyncqueue'

const queue = new JSyncQueue("DelayQueue")

// 延时 1 秒后执行
queue.postDelay(async (taskId) => {
  console.log("延时任务执行了")
  return "延时任务结果"
}, 1000).getResult().then((result) => {
  console.log(`延时任务返回: ${result}`)
})

// 延时发送消息(需要继承实现 onHandleMessage)
queue.sendMessageDelay({
  what: "delayed_action",
  data: { info: "延时消息" }
}, 2000)

6-4、任务取消示例

import { JSyncQueue, Task, JSyncQueueCancelException } from 'jsyncqueue'

const queue = new JSyncQueue("CancelQueue")

// 添加任务并保存引用
const task: Task = queue.post(async (taskId) => {
  console.log("任务开始执行")
  await new Promise(resolve => setTimeout(resolve, 5000))
  return "任务完成"
})

// 监听任务结果
task.getResult().then((result) => {
  console.log(`任务成功: ${result}`)
}).catch((error: JSyncQueueCancelException) => {
  console.log(`任务被取消: ${error.message}`)
})

// 取消任务(两种方式)
task.cancel()                    // 方式1:通过 Task 对象取消
// queue.cancel(task.getTaskId()) // 方式2:通过队列和任务ID取消

// 清空所有任务
// queue.clear()

6-5、混合使用示例

Message 和 Runnable 可以混合使用,它们都会按入队顺序执行。

import { JSyncQueue, Message, Any } from 'jsyncqueue'

class MixedQueue extends JSyncQueue {
  async onHandleMessage(message: Message, taskId: number): Promise<Any> {
    console.log(`处理消息: ${message.what}`)
    return `消息 ${message.what} 处理完成`
  }
}

const queue = new MixedQueue("MixedQueue")

// 混合添加任务
queue.post(async () => {
  console.log("Runnable 1")
  return "R1"
})

queue.sendMessage({ what: "msg1", data: null })

queue.post(async () => {
  console.log("Runnable 2")
  return "R2"
})

queue.sendMessage({ what: "msg2", data: null })

// 执行顺序:Runnable 1 -> msg1 -> Runnable 2 -> msg2

七、作者简介

掘金:juejin.im/user/5c3033…

csdn:blog.csdn.net/weixin_3762…

公众号:微信搜索 "江澎涌"

Windows 系统中 fnm 安装与配置指南

Windows 系统中 fnm 安装与配置指南

本文档介绍如何在 Windows 系统中安装 fnm (Fast Node Manager) 并配置 CMD、PowerShell 和 PowerShell 7 终端以自动加载 Node.js 环境。

前提条件

  • Windows 10 或更高版本
  • 已安装 WinGet(Windows 包管理器)

安装 fnm

使用 WinGet 安装 fnm:

winget install Schniz.fnm

配置终端

1. 配置 PowerShell 7

PowerShell 7 使用独立的配置文件路径。需要创建以下配置文件:

# 创建 PowerShell 7 配置目录
New-Item -ItemType Directory -Path "$env:USERPROFILE\Documents\PowerShell" -Force

# 创建配置文件
New-Item -ItemType File -Path "$env:USERPROFILE\Documents\PowerShell\Microsoft.PowerShell_profile.ps1" -Force

在配置文件中添加以下内容:

fnm env --use-on-cd | Out-String | Invoke-Expression

可以通过以下命令一次性完成:

New-Item -ItemType Directory -Path 'C:\Users\leehoo\Documents\PowerShell' -Force
Set-Content -Path 'C:\Users\leehoo\Documents\PowerShell\Microsoft.PowerShell_profile.ps1' -Value 'fnm env --use-on-cd | Out-String | Invoke-Expression'

2. 配置 Windows PowerShell

Windows PowerShell 使用以下配置文件路径:

C:\Users\leehoo\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1

如果文件不存在,创建它并添加以下内容:

fnm env --use-on-cd | Out-String | Invoke-Expression

3. 配置 CMD

CMD 没有配置文件机制,需要通过注册表设置 AutoRun。

步骤 1:生成 fnm 初始化脚本
fnm env --use-on-cd --shell cmd | ForEach-Object { '@' + $_ } | Out-File -FilePath "$env:APPDATA\fnm\fnm-init.cmd" -Encoding ASCII
步骤 2:设置注册表 AutoRun
reg add 'HKCU\Software\Microsoft\Command Processor' /v AutoRun /t REG_SZ /d "call $env:APPDATA\fnm\fnm-init.cmd" /f

验证配置

PowerShell 7 / Windows PowerShell

重新打开 PowerShell 终端,运行:

node --version
npm --version

CMD

重新打开 CMD 终端,运行:

node -v
npm -v

常用 fnm 命令

# 列出已安装的 Node.js 版本
fnm list

# 安装 Node.js 版本
fnm install 20.10.0

# 切换 Node.js 版本
fnm use 20.10.0

# 设置默认版本
fnm default 20.10.0

# 卸载版本
fnm uninstall 20.10.0

注意事项

  1. 每次修改配置后,需要重新打开终端才能生效
  2. --use-on-cd 选项会在切换目录时自动检测并使用对应的 Node.js 版本(基于 .nvmrc 或 package.json)
  3. CMD 的 AutoRun 设置会影响所有 CMD 窗口
  4. 如果需要移除 CMD 的 AutoRun 设置,运行:
reg delete 'HKCU\Software\Microsoft\Command Processor' /v AutoRun /f

配置文件位置总结

终端 配置文件路径
PowerShell 7 C:\Users\leehoo\Documents\PowerShell\Microsoft.PowerShell_profile.ps1
Windows PowerShell C:\Users\leehoo\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1
CMD 注册表:HKEY_CURRENT_USER\Software\Microsoft\Command Processor\AutoRun
fnm 初始化脚本 C:\Users\leehoo\AppData\Roaming\fnm\fnm-init.cmd

故障排除

CMD 中显示命令输出

如果 CMD 启动时显示 SET 命令,确保 fnm-init.cmd 文件每行都以 @ 开头:

@SET PATH=...
@SET FNM_MULTISHELL_PATH=...

Node.js 命令不可用

  1. 确认 fnm 已正确安装:fnm --version
  2. 确认已安装 Node.js 版本:fnm list
  3. 检查配置文件路径是否正确
  4. 重新打开终端

PowerShell 配置不生效

  1. 检查配置文件是否存在
  2. 运行 $PROFILE 查看当前配置文件路径
  3. 手动加载配置:. $PROFILE

开源一个 markdown-it 插件:让你的博客支持 GitHub 用户悬浮卡片

开源一个 markdown-it 插件:让你的博客支持 GitHub 用户悬浮卡片

前言

在写技术博客或文档时,我们经常需要提到 GitHub 上的开发者,比如「这个方案参考了 @antfu 的实现」。但读者看到这个 @ 时,往往需要点击跳转才能了解这个人是谁。

能不能像 GitHub 那样,鼠标悬停就能看到用户信息呢?

于是我开发了这个插件:markdown-it-github-mention-card

效果预览

在 Markdown 中写入:

这个项目的灵感来自 {@antfu} 的开源作品。

渲染后,鼠标悬停在链接上,就会出现一个精美的用户信息卡片,包含:

  • 用户头像
  • 用户名和简介
  • 地理位置、公司信息
  • 粉丝数、关注数、公开仓库数

无需跳转页面,信息一目了然。

特性

  • 语法简洁{@username} 即可,支持自定义显示文本
  • 悬浮卡片:调用 GitHub API 实时获取用户信息
  • 暗色模式:自动适配 .dark 主题类
  • SSR/SSG 友好:完美支持 VitePress、Nuxt、Next.js 等框架
  • TypeScript:完整的类型定义
  • 轻量无依赖:核心代码仅依赖 markdown-it

安装

# npm
npm install markdown-it-github-mention-card

# pnpm
pnpm add markdown-it-github-mention-card

# yarn
yarn add markdown-it-github-mention-card

基础使用

import MarkdownIt from 'markdown-it'
import MarkdownItGitHubMentionCard, { initHoverCard } from 'markdown-it-github-mention-card'

const md = new MarkdownIt()
md.use(MarkdownItGitHubMentionCard, {
  // 可选:提高 GitHub API 速率限制
  githubToken: 'YOUR_GITHUB_TOKEN',
})

// 渲染 Markdown
const html = md.render('{@antfu} 是 Vue 核心团队成员')
document.querySelector('#app').innerHTML = html

// 初始化悬浮卡片(浏览器端调用)
initHoverCard()

语法说明

插件支持三种写法:

语法 说明 示例
{@username} 基础用法 {@antfu} → 显示 "antfu"
{@username|显示文本} 自定义文本 {@antfu|Anthony Fu} → 显示 "Anthony Fu"
{@username|显示文本|链接} 自定义链接 {@antfu|博客|https://antfu.me}

SSR/SSG 场景最佳实践

在服务端渲染场景下,构建时和浏览器端环境分离,推荐以下方式:

服务端(构建时):

import MarkdownIt from 'markdown-it'
import MarkdownItGitHubMentionCard from 'markdown-it-github-mention-card'

const md = new MarkdownIt()
md.use(MarkdownItGitHubMentionCard)
// 服务端不传 token,避免暴露

const html = md.render('{@antfu}')

客户端(浏览器):

import { initHoverCard } from 'markdown-it-github-mention-card'

// VitePress / Vite
initHoverCard(import.meta.env.VITE_GITHUB_TOKEN)

// Next.js
initHoverCard(process.env.NEXT_PUBLIC_GITHUB_TOKEN)

暗色模式

插件内置暗色模式支持,当页面或父元素包含 .dark 类时,卡片自动切换暗色主题:

<html class="dark">
  <!-- 卡片自动适配暗色 -->
</html>

与 VitePress、Tailwind CSS 等主流方案完美兼容。

在 VitePress 中使用

// .vitepress/config.ts
import MarkdownItGitHubMentionCard from 'markdown-it-github-mention-card'

export default {
  markdown: {
    config: (md) => {
      md.use(MarkdownItGitHubMentionCard)
    }
  }
}
// .vitepress/theme/index.ts
import { initHoverCard } from 'markdown-it-github-mention-card'
import { onMounted } from 'vue'

export default {
  enhanceApp() {
    if (typeof window !== 'undefined') {
      onMounted(() => {
        initHoverCard(import.meta.env.VITE_GITHUB_TOKEN)
      })
    }
  }
}

实现原理

简单介绍一下核心实现:

  1. Markdown 解析:通过 markdown-it 的 inline rule,匹配 {@...} 语法,生成带有 data-github-user 属性的 <a> 标签

  2. 悬浮卡片initHoverCard() 在浏览器端监听鼠标事件,当悬停在目标链接上时:

    • 调用 GitHub REST API 获取用户信息
    • 动态创建卡片 DOM 并定位显示
    • 内置请求缓存,避免重复请求
  3. 样式注入:首次调用时自动注入 CSS,支持亮/暗两套主题

为什么不用 GitHub 官方的 Hover Card?

GitHub 的 Hover Card 仅在 github.com 域名下生效,且需要登录状态。本插件:

  • 可在任意网站使用
  • 无需用户登录 GitHub
  • 可自定义样式和行为
  • 适配各种 SSR/SSG 框架

项目地址

写在最后

这是我开源的一个小工具,希望能帮助到有类似需求的同学。

如果觉得有用,欢迎:

  • 给项目点个 ⭐ Star
  • 提 Issue 反馈问题或建议
  • 提 PR 一起完善

有任何问题欢迎在评论区交流讨论!

🔥 在浏览器地址栏输入 URL 后,页面是怎么一步步显示出来的?


这是一个前端面试 100% 会被问到的问题
但也是一个90% 的人答不完整的问题

你可能会说:

  • “DNS 解析”
  • “请求 HTML”
  • “解析 DOM”
  • “渲染页面”

👉 但如果继续追问:

  • CSS 为什么会阻塞渲染?
  • JS 为什么会卡住页面?
  • 回流和重绘到底差在哪?
  • 浏览器内核到底在干嘛?

很多人就开始“凭感觉回答了”。

这篇文章,我会用尽量通俗、不堆术语的方式,带你完整走一遍:

从你敲下回车,到页面真正出现在屏幕上,中间到底发生了什么?


一、先给结论:浏览器做了哪几件大事?

不讲细节,先给你一条完整主线👇

输入 URL → 页面展示,大致分 9 步:

  1. 解析 URL(域名 / IP)
  2. DNS 解析(域名 → IP)
  3. 向服务器请求 HTML(通常是 index.html)
  4. 解析 HTML,生成 DOM Tree
  5. 解析 CSS,生成 CSSOM Tree
  6. DOM + CSSOM → Render Tree
  7. Layout(计算位置和大小)
  8. Paint(绘制像素)
  9. Composite(图层合成,GPU 加速)

你现在只需要记住一句话:

浏览器做的事情,本质上就是:
把“代码”一步步变成“像素”。

后面我们逐个拆。


二、URL、域名、IP、DNS:浏览器是怎么找到服务器的?

1️⃣ IP 是什么?

一句话:

IP 地址 = 服务器在互联网上的门牌号

比如:
101.34.243.124

  • 公网 IP 在整个互联网中是唯一的
  • 只要你知道 IP,就能直接访问服务器

2️⃣ 那为什么还要域名?

因为 IP:

  • 难记
  • 不符合人类直觉

所以就有了:

  • google.com
  • baidu.com
  • juejin.cn

👉 域名,本质上就是 IP 的“别名”


3️⃣ DNS 到底在干嘛?

DNS 只干一件事:

把「好记的域名」翻译成「真实的 IP 地址」

流程非常简单:

你输入 juejin.cn
↓
DNS 查询
↓
得到一个 IP
↓
浏览器去这个 IP 对应的服务器请求资源

4️⃣ 公网 IP 和私有 IP 的区别

  • 公网 IP

    • 全网唯一
    • 能被外部访问
  • 私有 IP

    • 只在局域网内有效
    • 学校 / 公司 / 家庭常见


三、为什么浏览器一上来就请求 index.html?

你有没有想过一个问题:

我明明只输入了域名,
为什么服务器知道要返回 index.html?

原因很简单:

  • 浏览器访问服务器后
  • 默认请求一个入口文件
  • 这个文件几乎永远叫:index.html

所以你会发现:

  • Vue / React 项目最终都会打包出 index.html
  • 服务器部署的,其实是一堆静态资源
  • HTML 是一切渲染的起点

四、浏览器内核到底是什么?为什么老爱被问?

很多人会说:

  • Chrome 是 Blink 内核
  • Firefox 是 Gecko
  • Safari 是 WebKit

内核到底是啥?

一句话解释:

浏览器内核 = 负责解析 HTML / CSS / JS,并把页面渲染出来的核心模块

也叫:渲染引擎(Rendering Engine)

常见关系👇

浏览器 内核
Chrome / Edge / Opera Blink
Safari WebKit
Firefox Gecko
IE Trident(已淘汰)


五、浏览器是如何一步步把页面“画”出来的?

这一部分是整个问题的核心

1️⃣ 解析 HTML → DOM Tree

  • HTML 会被拆成一个个标签
  • 标签会被转换成节点
  • 最终形成一棵 DOM 树

👉 DOM 树描述的是:页面的结构


2️⃣ 解析 CSS → CSSOM Tree

  • 遇到 <link>,浏览器会下载 CSS
  • CSS 会被解析成 CSSOM 树

⚠️ 重点来了:

CSS 不会阻塞 DOM 的解析
会阻塞页面的渲染


3️⃣ DOM + CSSOM → Render Tree

  • Render Tree 只包含需要显示的节点
  • display: none 的元素不会进入渲染树

👉 Render Tree 描述的是:页面真正要画什么


4️⃣ Layout:计算位置和大小

Layout 阶段,浏览器会计算:
每个元素在哪?多大?


5️⃣ Paint:真正开始画了

把布局结果,转换为屏幕上的像素


6️⃣ Composite:图层合成(性能关键)

  • 页面会被拆成多个图层
  • GPU 参与合成
  • transform / opacity / video 等会创建新图层

👉 合理使用能提升性能,滥用会吃内存


六、回流 & 重绘:为什么页面会卡?

🔁 回流(Reflow)

一句话:

元素的位置或尺寸发生变化

常见触发场景:

  • 改 width / height
  • 改 position / display
  • DOM 结构变化
  • 读取布局信息(如 getComputedStyle

⚠️ 回流一定会触发重绘


🎨 重绘(Repaint)

一句话:

只改外观,不改布局

例如:

  • color
  • background-color
  • box-shadow
  • opacity

👉 成本比回流小得多


🚀 常见性能优化建议

  • 一次性修改样式(class / cssText)
  • 减少 DOM 操作
  • 避免频繁读取布局信息
  • 合理使用 position: absolute / fixed
  • 谨慎创建合成层

七、最后用一句话总结

浏览器渲染的本质就是:
HTML → DOM → CSSOM → Render Tree → Layout → Paint → Composite

如果你真正理解了这条链路:

  • 白屏问题
  • 页面卡顿
  • 动画掉帧
  • script / link 阻塞
  • 回流 & 重绘优化

都会变得非常清晰


下篇预告

这篇我们讲的是:

浏览器 & 渲染引擎

但还有一个主角没登场:

👉 JavaScript 引擎(V8)

下篇会聊:

  • JS 是谁执行的?
  • JS 为什么会阻塞渲染?
  • 浏览器内核和 JS 引擎的关系?

Elpis 项目 Webpack 配置详解

Elpis 项目 Webpack 配置详解

本指南详细介绍了 Elpis 项目的 Webpack 5 构建系统设置,涵盖了从基础配置到生产环境优化的核心逻辑。

🏗️ 1. 核心架构

项目采用 分层配置 模式,通过 webpack-merge 组合配置:

webpack.base.js: 基础配置,包含入口、解析路径、公用 Loader 和插件。

webpack.prod.js: 生产环境配置,侧重于性能优化、代码分割和资源压缩。

webpack.dev.js: 开发环境配置,侧重于开发体验、热更新和调试支持。

📦 2. 基础配置说明 (webpack.base.js)

2.1 动态多入口 (Multi-Entry)

项目通过 glob 自动扫描 app/pages/ */entry. .js:

自动化: 每增加一个页面只需按照命名规范创建入口文件,Webpack 会自动识别。

模板关联: 利用 HtmlWebpackPlugin 为每个入口生成对应的 .tpl 模板文件。

2.2 核心 Loader 规则

Vue 支持: 使用 vue-loader 处理 .vue 文件。

JS 转译: babel-loader 处理 ES6+ 语法,范围锁定在 ./app/pages 以提升速度。

样式处理:

支持 css 和 less。

生产环境下样式会被提取到独立文件,开发环境下通过 style-loader 内联。

资产模块 (Asset Modules):

使用 Webpack 5 的 asset 类型(替代 url-loader)。

内联策略: 小于 8KB 的图片自动转为 Base64 以减少 HTTP 请求。

2.3 解析与快捷路径 (Resolve)

别名配置: @pages: app/pages

@common: app/pages/common

@widgets: app/pages/widgets

@store: app/pages/store

🚀 3. 生产环境优化 (webpack.prod.js)

3.1 构建加速 (Speed)

多进程打包 (HappyPack): 利用多核 CPU 并行处理 JS 和 CSS 转译。

代码压缩: TerserWebpackPlugin 开启并行压缩,并移除生产环境的 console.log。

3.2 资源优化 (Asset Optimization)

代码分割 (Code Splitting):

vendor: 独立打包第三方依赖(axios, lodash 等)。

common: 提取被多次引用的业务公共模块。

runtimeChunk: 提取 Webpack 运行时代码,确保长效缓存。

CSS 提取: 使用 MiniCssExtractPlugin 提取样式,配合 CSSMinimizerPlugin 进行压缩。

3.3 目录清理

配置了 CleanWebpackPlugin,保证每次 build 产物目录都是干净的。

🌗 4. 开发环境 vs 生产环境深度对比

特性 开发环境 (Development) 生产环境 (Production)

核心目标 极致的开发体验与调试效率 极致的性能加载与线上稳定性

Mode mode: "development" (不压缩,原始名) mode: "production" (Tree Shaking,代码混淆)

Source Map eval-cheap-module-source-map (代码映射,方便定位) 通常禁用或使用独立文件 (保护源码,减小体积)

HMR 热更新 开启。代码保存即生效,不刷新页面且保持状态 关闭。代码全量打包,生成版本化静态资源

样式处理 内联注入 ,构建速度快 提取独立 CSS 文件,并行下载并利用缓存

文件指纹 通常不带 Hash,或仅带简单的 contenthash 强缓存策略:[chunkhash:8] 确保版本管理稳定

调试信息 保留所有的 console.log 和注释 自动清理:Terser 插件移除所有调试输出

网络请求 指向本地 DevServer (http://localhost:9002) 指向 CDN 或生产静态资源路径

🛠️ 5. 开发环境配置 (webpack.dev.js) 深度解析

该文件是开发阶段的核心,其核心逻辑在于建立浏览器与本地服务器的通信实时链路。

5.1 核心代码逻辑拆解
  1. 动态注入 HMR 客户端
Object.keys(baseConfig.entry).forEach((v) => {
  if (v !== "vendor") {
    const { HOST, PORT, HMR_PATH, TIMEOUT } = DEV_SERVER_CONFIG;
    baseConfig.entry[v] = [
      baseConfig.entry[v],
      // 关键:向每个业务入口注入 HMR 运行时的客户端代码
      `webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}?timeout=${TIMEOUT}&reload=true`,
    ];
  }
});

为什么要做这一步? Webpack 默认打包出来的 JS 是静态的。为了实现热更新,我们需要在浏览器里运行一段“监听代码”,这段代码通过 EventSource 连接到开发服务器。注入后,浏览器就知道如何接收服务器发来的更新信号。

  1. 调试利器:Source Map

devtool: "eval-cheap-module-source-map", 原理:eval 将每个模块包裹在 eval 字符串中,构建极快;cheap 忽略列信息只保留行信息;module 负责把 Loader(如 vue-loader)处理前的源代码映射出来。 效果:你在浏览器 F12 看到的是原汁原味的 .vue 文件,而不是编译后的 JS。

  1. HMR 核心插件
plugins: [
  new HotModuleReplacementPlugin(), // 开启 HMR API
]

这个插件会在全局注入 module.hot 对象。只有有了这个对象,vue-loader 等程序才能调用 module.hot.accept() 接口来实现局部替换。

🔥 6. 热更新 (HMR) 核心原理深度阐述

HMR 的核心不是“刷新”,而是“补丁式替换”。

6.1 底层通信流程

Server 端 (监视者): Webpack 以 watch 模式启动。当你保存文件,Webpack 重新编译。

它不会生成新的大文件,而是生成两个小补丁:一个 [hash].hot-update.json(描述哪些模块变了)和一个 [chunk].[hash].hot-update.js(具体的变化代码)。

Middleware (快递员): webpack-hot-middleware 通过一条长连接(EventSource/WebSocket)推送一个消息给浏览器:“新版本 Hash 是 XXX”。

Client 端 (接收者): 浏览器里的 HMR Runtime 收到 Hash。

比对:发现和当前 Hash 不同。

下载清单:先下 .json 文件确认有哪些模块更新了。

载入补丁:动态创建

Runtime (施工员):

查找接口:Webpack 运行环境会查找代码中是否定义了 module.hot.accept。

代码替换:如果定义了(Vue 和 React 的 Loader 都会自动帮你加上),它会把内存中旧的模块定义删掉,换成补丁里的新定义,并重新执行该模块。

🍕 7. 代码分割

代码分割是 Webpack 优化中最关键的一环。核心目标是:不要让用户一次性下载一个超大的 JS 文件,而是将其拆分成多个小文件,按需加载或利用并发下载。

7.1 为什么要进行代码分割?

利用并发下载:浏览器可以同时下载多个小文件,比下载一个大文件快。

缓存:将几乎不动的第三方库(Vue, Lodash)和经常变动的业务代码分开。当你改了业务代码,用户只需要重新下载几 KB 的业务包,而几十 MB 的第三方库依

然使用浏览器缓存。

按需加载:只加载当前页面需要的代码,减少首屏负担。

7.2 项目中的 SplitChunks 配置拆解

在 webpack.base.js 的 optimization 中,配置了三个关键部分:

  1. vendor (第三方库)

配置: test: /[/]node_modules[/]/ 作用: 专门把从 node_modules 引入的所有库打包进 vendor.js。 策略: 这些库(如 Element Plus, Axios)版本通常是固定的,适合设置强缓存(Max-Age 一年)。

  1. common (公用业务代码)

配置: minChunks: 2, minSize: 1 作用: 如果你写了一个 utils.js 工具函数,且被两个以上的页面入口 import 了,它就会被自动提取到 common.js 中。 优点: 防止重复打包。如果没有这一步,每个页面的 bundle 里都会包含一份一模一样的工具函数。

  1. runtimeChunk: true (运行时代码)

作用: 这会生成一个类似 runtime~main.js 的微型文件。 深度原理解析: Webpack 打包时会给每个模块分配 ID。模块 A 引用模块 B 时,内部记录的是 ID。 问题: 如果不提取 runtime,模块 B 变了,模块 A 里的 ID 映射也会变,导致模块 A 的 Hash 也失效。 解决: 提取 runtime 后,所有的模块映射关系都存在这个小文件里。哪怕业务代码变了,只要模块依赖关系没变,其他文件的 Hash 就能保持稳定。

7.3 代码分割示意图 (前后对比)

打包方式 产物结构 浏览器行为

不分割 main.js (2MB) 改一行代码,用户得重下 2MB。

分割后 vendor.js (1.8MB) + common.js (100KB) + page.js (10KB) 改一行代码,用户只重下 10KB。

⚡ 8. 建议优化方向

更换现代化 Loader: 将 HappyPack 迁移至 thread-loader。

TypeScript:为 JavaScript 注入类型安全的工程化力量

JavaScript 以其灵活、动态的特性成为 Web 开发的基石,但这种“自由”在大型项目中往往演变为隐患。当函数参数类型不明、对象结构随意扩展、变量用途模糊不清时,代码便如同没有护栏的悬崖——看似畅通无阻,实则危机四伏。TypeScript(TS)作为 JavaScript 的超集,通过引入静态类型系统,在保留 JS 灵活性的同时,为开发者构建起一道坚固的质量防线。

弱类型的代价:隐藏在“简单”背后的陷阱

JavaScript 是动态弱类型语言,变量类型在运行时才确定。这使得以下代码合法却危险:

function add(a, b) {
  return a + b;
}
const result = add(10, '10'); // "1010"(字符串拼接)

开发者本意是数值相加,却因传入字符串导致隐式类型转换,结果出乎意料。这类“二义性”错误在小型脚本中或许无伤大雅,但在复杂业务逻辑中,可能引发难以追踪的 bug。

TypeScript 的解法:编译期类型检查

TypeScript 通过类型注解,在代码编写和编译阶段就捕获潜在错误:

function addTs(a: number, b: number): number {
  return a + b;
}
const result2 = addTs(10, 10); // 正确
// addTs(10, '10'); // 编译报错:Argument of type 'string' is not assignable to parameter of type 'number'.

类型签名 a: number 明确约束了参数类型,编辑器会立即提示错误,无需等到运行时才发现问题。这种“早发现、早修复”的机制,极大提升了代码健壮性。

基础类型与类型推导

TS 提供丰富的内置类型,并支持类型自动推导:

let a: number = 10;
let b: string = 'hello';
let arr: number[] = [1, 2, 3];
let user: [number, string, boolean] = [1, '张三', true]; // 元组

即使省略显式注解,TS 也能根据初始值推断类型:

let count = 100; // 自动推导为 number
// count = '100'; // 错误!不能将 string 赋值给 number

这种“写得少,检查多”的体验,让开发者既能享受简洁语法,又不失类型安全。

接口与自定义类型:描述复杂结构

对于对象,TS 提供 interfacetype 来定义结构契约:

interface IUser {
  name: string;
  age: number;
  readonly id: number; // 只读属性
  hobby?: string;      // 可选属性
}

let user3: IUser = {
  name: '张三',
  age: 10,
  id: 1001
};
// user3.id = 1002; // 错误!id 是只读的

接口清晰表达了对象应具备的字段及其约束,不仅防止非法赋值,还为 IDE 提供精准的智能提示和文档查看能力。

联合类型与泛型:应对多样性与复用

TS 支持联合类型处理多可能性:

type ID = string | number;
let id: ID = 1001;
id = 'user_001'; // 合法

而泛型则实现类型级别的参数化,提升组件复用性:

let arr2: Array<string> = ['a', 'b', 'c'];
// 或简写为 string[]

泛型让函数、类、接口能适用于多种类型,同时保持类型安全,是构建通用库的核心工具。

安全的未知类型:unknown vs any

面对不确定的类型,TS 提供 unknown 作为更安全的替代方案:

let bb: unknown = 10;
bb = 'hello';
// bb.toUpperCase(); // 错误!需先类型检查
if (typeof bb === 'string') {
  console.log(bb.toUpperCase()); // 安全调用
}

相比之下,any 会完全绕过类型检查,虽可作为迁移旧代码的“救命稻草”,但应尽量避免在新项目中使用。

工程价值:不止于防错

TypeScript 的优势远超错误预防:

  • 智能提示:输入对象属性时自动补全;
  • 重构安全:重命名变量或函数时,所有引用同步更新;
  • 代码导航:一键跳转到类型定义或实现;
  • 文档内嵌:类型本身就是最好的文档;
  • 垃圾清理:未使用的变量、导入会高亮提示。

这些特性显著提升开发效率,尤其在团队协作和长期维护中,价值倍增。

用 TypeScript + React Hooks 构建一个健壮的 Todo 应用

最近我用 React 和 TypeScript 从零写了一个 Todo 应用,整个过程让我深刻体会到:类型系统不是束缚,而是保护。它让组件之间的协作更清晰,状态管理更可靠,连 localStorage 的读写都变得安全可控。下面分享我是怎么一步步搭建这个小项目的。

核心思路:状态集中 + 类型约束

我把所有 todo 数据和操作逻辑封装在一个自定义 Hook useTodos 里,这样组件只需要“消费”状态和方法,不用关心实现细节。同时,用 TypeScript 接口明确约定数据结构,避免传错参数或访问不存在的属性。

首先定义 Todo 的结构:

// types/todo.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

这个接口就像一份契约——任何地方使用 Todo 数据,都必须包含这三个字段,且类型固定。

自定义 Hook:useTodos

这是整个应用的核心。它用 useState 管理状态,并通过 useEffect 同步到 localStorage:

// hooks/useTodos.ts
export function useTodos() {
  const [todos, setTodos] = useState<Todo[]>(() => 
    getStorage<Todo[]>(STORAGE_KEY, [])
  );

  useEffect(() => {
    setStorage<Todo[]>(STORAGE_KEY, todos);
  }, [todos]);

  const addTodo = (title: string) => {
    const newTodo: Todo = {
      id: +new Date(),
      title,
      completed: false
    };
    setTodos([...todos, newTodo]);
  };

  // toggleTodo 和 removeTodo 略...

  return { todos, addTodo, toggleTodo, removeTodo };
}

注意这里用了泛型函数 getStorage<T>setStorage<T>,确保读写 localStorage 时类型安全:

// utils/storages.ts
export function getStorage<T>(key: string, defaultValue: T): T {
  const item = localStorage.getItem(key);
  return item ? JSON.parse(item) : defaultValue;
}

这样,即使从 localStorage 读出来的数据是字符串,TS 也能正确推断为 Todo[] 类型。

组件通信:靠 Props 接口对齐

父组件 App 只负责组合,不处理逻辑:

// App.tsx
export default function App() {
  const { todos, addTodo, toggleTodo, removeTodo } = useTodos();
  return (
    <div>
      <h1>TodoList</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList todos={todos} onToggle={toggleTodo} onRemove={removeTodo} />
    </div>
  );
}

子组件通过 Props 接口明确声明自己需要什么:

// components/TodoList.tsx
interface Props {
  todos: Todo[];
  onToggle: (id: number) => void;
  onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onToggle={onToggle} onRemove={onRemove} />
      ))}
    </ul>
  );
};

这样,如果我在 App 里不小心传了错误类型的 onToggle,TypeScript 会立刻报错,而不是等到运行时才发现按钮点不动。

单个 Todo 项:细节处理

TodoItem 中,根据 completed 状态动态设置样式:

<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
  {todo.title}
</span>

因为 todo 的类型是 Todo,所以 todo.completed 一定是布尔值,不会出现 undefined 导致样式异常。

输入框:防错处理

TodoInput 还做了空值校验:

const handleAdd = () => {
  if (!value.trim()) return; // 防止添加空任务
  onAdd(value);
  setValue('');
};

配合 TS 的 string 类型,确保传给 onAdd 的一定是字符串,不会意外传入数字或 null。

总结:为什么值得用 TS?

  • 提前暴露问题:写代码时就知道哪里传参错了;
  • 自动文档:鼠标悬停就能看到函数签名和字段说明;
  • 重构安全:改一个接口,所有用到的地方都会提示更新;
  • 团队协作友好:别人看你的组件,一眼就知道要传什么。

这个 Todo 应用虽然简单,但用 TS 写完后,感觉整个项目“稳”了很多。以后再做大项目,我肯定会首选 TypeScript —— 它不是增加负担,而是帮我们写出更干净、更可靠的代码。

Vue使用<Suspense/>实现图片加载组件

什么是Suspense?

SuspenseVue的内置组件,能让你管理组件加载时的等待、出错和最终渲染逻辑。

主要配合异步组件async/await 版的 setup() 使用。

它提供两个插槽:

  • default(默认插槽):要渲染的异步内容
  • fallback:等待异步内容加载时显示的兜底内容

话不多说,上代码:

第一步,实现图片异步组件

<template>
    <img 
    :src="props.src" 
    :alt="props.alt" 
    :style="{ 
      width: setWidth, 
      height: setHeight, 
      borderRadius: rounded, 
      objectFit: 'cover'
    }"
    />
</template>
// =================================== 此处为计算属性逻辑 ================================
<script setup lang="ts">
import { computed, ref } from "vue";

// 图片形状可选项
type ShapeOption = "circle" | "square" | "roundRect";

interface ILazyImage {
  src: string;
  width:string|number;
  height:string|number;
  delay?:number;//延迟执行?
  timeout?:number;//超时时间?
  shape?:ShapeOption;//图片形状
  alt?:string;
}

const props = defineProps<ILazyImage>();

//计算超时
const timeoutSet = computed(() => {
    let time = Number(props.timeout) || 5;
    return time * 1000;
});

//计算延迟
const delayTime = computed(() => {
    let time = Number(props.delay) || 0.1;
    return time * 1000;
});


//计算宽度
const setWidth = computed(() => {
    const val = parseFloat(props.width)
    if(!val) return '100px'
    return val  + "px";
});

//计算高度
const setHeight = computed(() => {
    const val = parseFloat(props.Height)
    if(!val) return '100px'
    return val  + "px";
});

// 计算圆角
const rounded = computed(() => {
    switch (props.shape) {
        case "circle":
            return "50%";
        case "square":
            return "0";
        case "roundRect":
            return "10px";
        default:
            return "10px";
    }
});

//========================实现异步加载图片==================================

const loadImage = (src: string) => {
    return new Promise((resolve, reject) => {
    // 创建一个AbortController,用于取消请求
        const controller = new AbortController();
        const signal = controller.signal;

        const timeoutId = setTimeout(() => {
        // 超时时取消请求
            controller.abort();
            reject(new Error("图片加载超时"));
        }, timeoutSet.value);

        setTimeout(() => {
            const image = new Image();
            image.src = src;
            image.onload = () => {
                clearTimeout(timeoutId);
                resolve(src);
            };
            image.onerror = () => {
                clearTimeout(timeoutId);
                reject(new Error("图片加载失败"));
            };
        }, delayTime.value);
    });
};

// 执行图片加载(async/await 触发 Suspense 等待)
await loadImage(props.src);

</script>

第二步:实现懒加载组件

  • default(默认插槽):要渲染的异步内容
  • fallback:等待异步内容加载时显示的兜底内容
  • 使用v-bind()绑定属性

<Suspense></Suspense>中直接使用异步组件即可,<template #fallback></template>则是加载时显示

<template>
    <div>
        <Suspense>
            <template #default>
                <div class="image">
                    <Image v-bind="{ ...props }"></Image>
                </div>
            </template>
            <template #fallback>
                <div class="skeleton"></div>
            </template>
        </Suspense>
    </div>
</template>

<script setup lang="ts">
import { computed } from "vue";
import Image from "./Image.vue";
// 图片形状可选项
type ShapeOption = "circle" | "square" | "roundRect";

interface ILazyImage {
  src: string;
  width:string|number;
  height:string|number;
  delay?:number;//延迟执行?
  timeout?:number;//超时时间?
  shape?:ShapeOption;//图片形状
  alt?:string;
}

const props = defineProps<ILazyImage>();

//计算超时
const timeoutSet = computed(() => {
    let time = Number(props.timeout) || 5;
    return time * 1000;
});

//计算延迟
const delayTime = computed(() => {
    let time = Number(props.delay) || 0.1;
    return time * 1000;
});


//计算宽度
const setWidth = computed(() => {
    const val = parseFloat(props.width)
    if(!val) return '100px'
    return val  + "px";
});

//计算高度
const setHeight = computed(() => {
    const val = parseFloat(props.Height)
    if(!val) return '100px'
    return val  + "px";
});

// 计算骨架屏动画:遮罩宽度(取容器宽度的 40%) 
const setSeletonWidth = computed(() => { 
    // 提取宽度数值(兼容 px 单位) 
    const widthNum = parseFloat(setWidth.value) || 100; 
    return `${widthNum * 0.4}px`;
}); 


// 骨架屏动画起始位置(从容器左侧外 30% 开始)
const setSeletonStartRight = computed(() => { 
    const widthNum = parseFloat(setWidth.value) || 100; 
    return `-${widthNum * 1.3}px`;  // 起始在容器左侧外
});

// 骨架屏动画结束位置(到容器右侧外 30%) 
const setSeletonEndRight = computed(() => { 
    const widthNum = parseFloat(setWidth.value) || 100; return `${widthNum * 1.3}px`;
    // 结束在容器右侧外
});

// 计算圆角
const rounded = computed(() => {
    switch (props.shape) {
        case "circle":
            return "50%";
        case "square":
            return "0";
        case "roundRect":
            return "10px";
        default:
            return "10px";
    }
});
</script>

<style scoped>
.image {
    overflow: hidden;
}

.skeleton {
    width: v-bind(setWidth);
    height: v-bind(setHeight);
    background-color: #dfe4ea;
    overflow: hidden;
    position: relative;

    border-radius: v-bind(rounded);
}

.skeleton::before {
    content: "";
    display: block;
    background-color: #ced6e0;
    box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
    position: absolute;
    top: 0;
    right: v-bind(setSeletonStartRight);
    width: v-bind(setSeletonWidth);
    height: v-bind(setHeight);
    transform: skewX(-30deg);
    animation: ping 1s infinite ease-out;
    filter: blur(10px);
}

@keyframes ping {
    from {
        right: v-bind(setSeletonStartRight);
    }

    to {
        right: v-bind(setSeletonEndRight);
    }
}
</style>

补充

  • 代码内有些重复的内容,可使用另外的ts来声明或实现。

  • 其中可能有些变量或实现过程有误,此文章仅供参考。

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

本文将分享我们在 RPA 自动化编辑器中实现浏览器指纹管理的完整方案,涵盖技术选型、架构设计、核心实现到踩坑经验,帮助你理解如何在同一台设备上运行多个具有独立指纹的浏览器实例。

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

在 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 机制保存登录状态
配置验证 自动验证和修复配置问题
配置导入/导出 支持 JSON 格式导入导出

适用场景

  • 🤖 RPA 自动化 - 多账号管理
  • 🛒 跨境电商 - 多店铺运营
  • 📱 社交媒体 - 多账号运营
  • 🕷️ 数据采集 - 爬虫反检测
  • 🧪 自动化测试 - 环境隔离

后续规划

  1. 配置模板 - 提供预设的配置模板
  2. 批量管理 - 支持批量创建/导入配置
  3. 指纹分析 - 实时分析指纹检测通过率
  4. 更多指纹维度 - 支持更多指纹类型的自定义

深入浅出 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 讨论。

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

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

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
}
❌