阅读视图

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

从零到一打造 Vue3 响应式系统 Day 5 - 核心概念:单向链表、双向链表

ZuB1M1H.png

在昨天,我们建立了响应式的基本运作模式。在继续深入之前,要先了解 Vue 内部用来优化性能的一个核心概念:数据结构。Vue 3 的响应式系统之所以效率高,其内部对数据结构的选择是关键。

一个理想的数据结构需要能有效处理以下操作:

  • 动态关联:effect 与数据之间的依赖关系是能动态建立与解除的。
  • 快速增删:当依赖关系变化时,需要快速地执行新增或移除操作。

为了满足这些高性能要求,Vue 选择了链表 (Linked List) 作为解决方案。本文将深入探讨其运作原理。

单向链表

  • 类型是对象
  • 第一个节点是头节点、最后一个节点称为尾节点
  • 所有节点都通过 next 属性连接起来。

day05-01.png

// 头节点是 head
let head = { value: 1, next: undefined }
const node2 = { value: 2, next: undefined }
const node3 = { value: 3, next: undefined }
const node4 = { value: 4, next: undefined }

// 建立链表之间的关系
head.next = node2
node2.next = node3
node3.next = node4

删除中间节点

假设我们要删除 node3,但在单向链表中,仅凭 node3 本身的引用是无法直接进行操作的,因为我们无法访问到它的前一个节点 (node2) 。因此,我们必须从头节点 (head) 开始遍历,直到找到 node2 为止:

const node3 = { value: 3, next: undefined }

let current = head
while (current) {
  // 找到 node3 的上一个节点
  if (current.next === node3) {
    // 把 node3 的上一个节点指向 node3 的下一个节点
    current.next = node3.next
    break
  }
  current = current.next
}

console.log(head) // 输出新的链表 1->2->4

双向链表

  • 每个节点都有:

    • value: 存储的值
    • next: 指向下一个节点
    • prev: 指向上一个节点
  • 双向链表中,通常头节点没有 prev,尾节点没有 next

它最大的优势在于,从任何一个节点出发,都能够双向遍历,这使得在特定节点前后进行新增或删除操作都非常快速。

// 假设链表的头节点是 head
let head = { value: 1, next: undefined, prev: undefined }
const node2 = { value: 2, next: undefined, prev: undefined }
const node3 = { value: 3, next: undefined, prev: undefined }
const node4 = { value: 4, next: undefined, prev: undefined }

// 建立链表之间的关系
head.next = node2
// node2 的上一个节点指向 head
node2.prev = head
// node2 的下一个节点指向 node3
node2.next = node3
// node3 的上一个节点指向 node2
node3.prev = node2
// node3 的下一个节点指向 node4
node3.next = node4
// node4 的上一个节点指向 node3
node4.prev = node3

删除中间节点

假设我们现在手上有中间节点 node3 要删除,该怎么做:

const node3 = { value: 3, next: undefined, prev: undefined }

// 如果 node3 有上一个节点,就把上一个节点的 next 指向 node3 的下一个节点
if (node3.prev) {
  node3.prev.next = node3.next
} else {
  // 如果 node3 没有上一个节点,说明它是头节点
  head = node3.next
}

// 如果 node3 有下一个节点,就把下一个节点的 prev 指向 node3 的上一个节点
if (node3.next) {
  node3.next.prev = node3.prev
}
console.log(head) // 输出新的链表 1->2->4

可以看到,在已知目标节点的前提下,执行删除操作完全不需要从头遍历,时间复杂度为 O(1)。

单向链表与双向链表比较

现在我们要在 C 节点之前新增一个 X 节点。

单向链表

day05-02.png

  • 时间复杂度:O(n)
  • 原因:需要遍历才能找到前一个节点。

执行步骤

步骤 1:从头节点开始遍历查找。

步骤 2:检查节点 A,不是目标节点的前一个,继续遍历。

步骤 3:找到目标节点 C 的前一个节点 B(因为 B 的 next 属性是 C)。

步骤 4:创建新节点 X。

步骤 5:设置 X.next = C

步骤 6:设置 B.next = X

双向链表

day05-03.png

  • 时间复杂度:O(1)
  • 原因:直接通过 prev 指针访问前一个节点。

执行步骤

步骤 1:直接通过目标节点的 prev 指针找到前一个节点 B。

步骤 2:创建新节点 X。

步骤 3:设置 X.next = C, X.prev = B

步骤 4:设置 B.next = X, C.prev = X


我们可以发现:

  • 单向链表:结构简单,适合只需要向前遍历的场景。
  • 双向链表:更灵活但占用更多内存,适合需要双向操作的场景。

到目前为止,我们已经了解了链表的原理。然而在许多可以用来存储数据集合的结构中,为什么 Vue 的响应式系统会选择链表,而不是我们更常用的数组 (Array) 呢?

链表与数组的比较

特性

数组 (Array) 最大的优点是读取性能极佳。由于内存空间是连续的,我们可以通过索引 [i] 直接定位到任何元素,时间复杂度为 O(1)。

const arr = ['a', 'b', 'c', 'd'] // a=>0  b=>1  c=>2  d=>3

// 删除数组的第一项
arr.shift()

console.log(arr) // ['b', 'c', 'd'] b=>0  c=>1  d=>2

链表:新增、删除元素更快 (O(1)),但查找元素需要遍历整个链表(O(n))。

// 头节点是 head
let head = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4, 
        next: null
      }
    }
  }
}
// 删除链表第一个节点
head = head.next // 将头节点指向下一个节点 node2
console.log(head)
// 输出新的头节点 [2, 3, 4]

删除头、尾项

数组

  • 新增操作(如 unshift)需要移动后续所有元素,可能导致性能下降(O(n))。
  • 删除操作(如 shift)同样需要移动后续所有元素,性能也为(O(n))。

链表

  • 新增操作只需修改指针,性能为 O(1)。
  • 删除操作也只需修改指针,性能为 O(1)。

总的来说,虽然双向链表在内存占用上略高于单向链表,但它提供的 O(1) 复杂度的新增与删除方法,对于需要频繁操作依赖集合的响应式系统来说,是非常重要的。

我们理解了链表的运作原理后,明天我们会继续在 ref 的实现中,结合今天学到的链表知识来改造响应式系统。


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

从零到一打造 Vue3 响应式系统 Day 4 - 核心概念:收集依赖、触发更新

ZuB1M1H.png

前言

const count = ref(0)

effect(() => {
  console.log('count.value ==>', count.value);
})

setTimeout(() => {
  count.value++
}, 1000)

昨天我们的目标是让一段简单的 refeffect 代码能够自动响应。

  1. 进入页面输出 count.value ==> 0
  2. 一秒后自动输出 count.value ==> 1

然而,我们初次实现时遇到了问题:无法正确取值 (undefined),也无法在值变更后触发更新。

为了解决这个问题,我们要思考 ref 需要做些什么:

  1. 当获取值时,ref 要怎么知道是谁在读取它?
  2. 当触发更新后,ref 又要怎么知道该通知谁?

让 Ref 知道谁在读取

// 原始代码
class RefImpl {
  _value;
  constructor(value){
    this._value = value
  }
}

现在要加入 getter 和 setter,让 count.value 能正常运作:

class RefImpl {
  _value;
  
  constructor(value){
    this._value = value
  }
  
  // 新增 getter:读取 value 时触发
  get value(){
    console.log('有人读取了 value!')
    return this._value
  }
  
  // 新增 setter:设置 value 时触发
  set value(newValue){
    console.log('有人修改了 value!')
    this._value = newValue
  }
}

day4-01.png

现在 count.value 看起来可以正常返回值了,但此时它还是不知道是谁在读取、需要通知谁。

Effect 函数

export function effect(fn){
  fn()
}

这时候我们需要一个地方来存储当前正在执行的 effect 函数。

// effect.ts
// 用于保存当前正在执行的 effect 函数
export let activeSub;

export function effect(fn){
  activeSub = fn
  activeSub()
  activeSub = undefined
}

这个新版的 effect 函数做了三件事:

  1. 注册副作用: 在执行传入的函数 fn 之前,先将它赋值给全局变量 activeSub
  2. 执行副作用: 立即执行 fn()。如果在执行过程中读取了某个 ref.value,这个 ref 就能通过 activeSub 知道是谁在读取它。
  3. 清除副作用: 执行完毕后,必须将 activeSub 清空 (设为 undefined)。这一点非常重要,它能确保只有在 effect 的执行期间,读取 ref 的行为才会被视为依赖收集。

收集依赖实现

现在我们要让 ref 能够:

  1. 在被读取时,记录是谁在读取(依赖收集)
  2. 在被修改时,通知所有读取者(触发更新)

我们可以在 getter 读取值的时候,判断 activeSub 是否存在,来确认当前情况是否需要收集依赖。

// ref.ts
import { activeSub } from './effect'

class RefImpl {
  _value;
  subs;  // 新增:用于存储订阅者
  
  constructor(value){
    this._value = value
  }
  
  // 新增 getter:读取 value 时触发
  get value(){
    // 依赖收集:如果存在 activeSub,就记录下来
    if(activeSub){
      this.subs = activeSub
    }
    return this._value
  }
  
  // 新增 setter:设置 value 时触发
  set value(newValue){
    // 触发更新:如果存在订阅者,就执行它
    if(this.subs){
      this.subs()  // 重新执行 effect
    } // 可简写为 this.subs?.()
  }
}

为了方便在后续的系统中判断一个变量是否为 ref 对象,我们可以新增一个辅助函数 isRef 和一个内部标记:

enum ReactiveFlags {
  IS_REF = '__v_isRef'
}

class RefImpl {
  _value;
  subs;  // 新增:用于存储订阅者
  [ReactiveFlags.IS_REF] = true
  
  ...
}

export function isRef(value){
  return !!(value && value[ReactiveFlags.IS_REF])
}

现在,让我们将所有部分串联起来,完整地模拟一遍执行流程。


完整执行流程

页面初始化与依赖收集

刚开始进入页面。

import { ref, effect } from '../dist/reactivity.esm.js'

const count = ref(0)

程序执行:const count = ref(0)

  • 执行 ref(0),创建一个 RefImpl 实例。

  • 此时 count 实例的内部状态为:

    • _value: 0
    • 没有任何订阅者:subs: undefined
    • 带有一个内部标记:__v_isRef: true

调用 effect 函数,并传入匿名函数 fn 作为参数。

effect(() => {
  console.log('effect', count.value)
})

进入 effect 函数内部

export let activeSub;

export function effect(fn){
  activeSub = fn
  activeSub()
  activeSub = undefined
}
  1. 设置 activeSub activeSub 被赋值为 fn,即 activeSub = fn

  2. 立即执行 fn()

    1. 执行 console.log('effect', count.value)

    2. 这触发了 count 实例的 get value()

    3. 进入 getter 内部:

      • if(activeSub) 条件成立,因为 activeSub 正是我们的 fn

        JavaScript

        if(activeSub){
           this.subs = activeSub
        }
        
    4. 执行“依赖收集”this.subs = activeSub

    5. 现在 count 实例通过 subs 属性,记住了是 fn 在依赖它。

    6. getter 返回 this._value (也就是 0)。

    7. console.log 输出:effect 0

  3. activeSub = undefined (执行完毕后清空,表示当前没有正在执行的 effect)。

此时:

  1. count.subs 就是传入 effect 的那个函数。
  2. 依赖关系建立:counteffect(fn)

一秒之后

  • set value(newValue) 被调用,this._value = 1

  • this.subs?.() 被执行,即:如果存在订阅者,就调用它(这里就是前面存起来的 effect 函数)。

  • 触发更新: effect 函数再次执行。

    • console.log('effect', count.value) → 再次读取 getter。
    • 此时 activeSubundefined,所以不会重复收集依赖。
    • 这次是直接执行 effect 函数的本体,而不是再次经过 effect(fn) 的包装流程,所以第二次之后执行 effectactiveSubundefined
    • console.log 输出:effect 1

这样,我们就完成了响应式依赖收集的最小可行版本。


完整代码

ref.ts

import { activeSub } from './effect'

enum ReactiveFlags {
  IS_REF = '__v_isRef'
}

class RefImpl {
  _value; // 保存实际值
   // ref 标记,证明这是一个 ref 对象
  [ReactiveFlags.IS_REF] = true

  subs
  constructor(value){
    this._value = value
  }

  // 收集依赖
  get value(){ 
    // 当有人访问时,可以获取 activeSub
    if(activeSub){
      // 当存在 activeSub 时存储它,以便更新后触发
      this.subs = activeSub
    }
    return this._value
  }

  // 触发更新
  set value(newValue){ 
    this._value = newValue
    // 通知 effect 重新执行,获取最新的 value
    this.subs?.()
  }
}

export function ref(value){
  return new RefImpl(value)
}

export function isRef(value){
  return !!(value && value[ReactiveFlags.IS_REF])
}

effect.ts

// 用于保存当前正在执行的 effect 函数
export let activeSub;

export function effect(fn){
  activeSub = fn
  activeSub()
  activeSub = undefined
}

想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

Day 2 - 开发环境建置:monorepo

ZuB1M1H.png

前言

Vue 3 的源码由多个模块构成,除了我们常用的核心功能外,还包含了响应式、工具函数等多个独立模块。为了模拟 Vue 官方的开发环境,管理这些分散的模块,我们会采用 Monorepo 架构来进行项目管理,并且使用 pnpm workspace。

强烈建议大家一定要跟着动手编码,如果只是看,很容易停留在“知道”的层面。

什么是 Monorepo?

Monorepo 是一种代码管理方式,指将不同的项目放在单一的代码仓库 (repository) 中,对多个不同的项目进行版本控制。

Monorepo 的特点

  • 集中式开发:所有项目的代码都集中在同一个 repository 中。
  • 工具共享:因为统一管理,所以 CI/CD、代码风格工具等都可以共享,并且只需配置一次。
  • 统一版本控制:在 monorepo 中进行 commit,可以横跨多个子项目。

什么是 pnpm workspace?

pnpm workspace 是 pnpm 包管理工具提供的一个功能,核心目标是可以在 repo 内部安装依赖包,并且共享 node_module,子项目在 repo 中可以互相引用。

pnpm workspace 的特点

  • 依赖提升至根目录:节省磁盘空间。
  • 模块共享简单:用 workspace:* 直接引用。
  • 集中管理:一个命令可以管理所有子项目,例如 pnpm install → 安装全部项目的依赖包。

环境搭建

  1. 我们先创建一个文件夹,执行 pnpm init
  2. 新建 pnpm-workspace.yaml,并且我们要管理 packages 下面的子项目。
packages:
  - 'packages/*'
  1. 在根目录下新建 tsconfig.json,这是 TypeScript 的配置文件(感谢 GPT 帮忙写的注释):
{
  "compilerOptions": {
    // 编译输出 JavaScript 的目标语法版本
    // ESNext:始终输出为最新的 ECMAScript 标准
    "target": "ESNext",

    // 模块系统类型
    // ESNext:使用最新的 ES Modules(import / export)
    "module": "ESNext",

    // 模块解析策略
    // "node":模仿 Node.js 的方式来解析模块 (例如 node_modules, index.ts, package.json 中的 "exports")
    "moduleResolution": "node",

    // 编译后的输出目录
    "outDir": "dist",

    // 允许直接导入 JSON 文件,编译器会将其视为一个模块
    "resolveJsonModule": true,

    // 是否启用严格模式
    // false:关闭所有严格类型检查(较为宽松)
    "strict": false,

    // 编译时需要引入的内置 API 定义文件(lib.d.ts)
    // "ESNext":最新 ECMAScript API
    // "DOM":浏览器环境的 API,例如 document, window
    "lib": ["ESNext", "DOM"],

    // 自定义路径映射(Path Mapping)
    // "@vue/*" 会映射到 "packages/*/src"
    // 例如 import { reactive } from "@vue/reactivity"
    // 会被解析到 packages/reactivity/src
    "paths": {
      "@vue/*": ["packages/*/src"]
    },

    // 基准目录,用于 `paths` 选项的相对路径解析
    "baseUrl": "./"
  }
}
  1. 新建 packages 文件夹,里面会加入许多子项目,包含响应式系统等。

  2. 执行 pnpm i typescript esbuild @types/node -D -w,其中 -w 表示安装到 workspace 的根目录。

  3. 执行 pnpm i vue -w ,安装 vue,方便之后进行比较。

  4. 执行 npx tsc --init,初始化项目中的 TypeScript 配置。

  5. 在根目录的 package.json 中加入 "type": "module"

    • 这会让 Node.js 默认将 .js 文件视为 ES Module (ESM)。
    • 若没有此项设置,.js 文件则会被当作 CommonJS 模块处理。
  6. 接下来,我们在 package 文件夹下新建三个子项目目录 reactivitysharedvue,以及下列文件:

    • 响应式模块 reactivity: reactivity/src/index.tsreactivity/package.json
    • 工具函数 shared: shared/src/index.tsshared/package.json
    • 核心模块 vue: vue/src/index.tsvue/package.json
  7. 为了让我们的子项目拥有和 Vue 官方包类似的配置,我们先将 node_modules/.pnpm/@vue+reactivity/reactivity/package.json 复制一份到 reactivity/package.json,简化后的内容如下:

{
  "name": "@vue/reactivity",
  "version": "1.0.0",
  "description": "响应式模块",
  "main": "dist/reactivity.cjs.js",
  "module": "dist/reactivity.esm.js",
  "files": [
    "index.js",
    "dist"
  ],
  "sideEffects": false,
  "buildOptions": {
    "name": "VueReactivity",
    "formats": [
      "esm-bundler",
      "esm-browser",
      "cjs",
      "global"
    ]
  }
}
{
  "name": "@vue/shared",
  "version": "1.0.0",
  "description": "工具函数",
  "main": "dist/shared.cjs.js",
  "module": "dist/shared.esm.js",
  "files": [
    "index.js",
    "dist"
  ],
  "sideEffects": false,
  "buildOptions": {
    "name": "VueShared",
    "formats": [
      "esm-bundler",
      "esm-browser",
      "cjs",
      "global"
    ]
  }
}
{
  "name": "vue",
  "version": "1.0.0",
  "description": "vue 核心模块",
  "main": "dist/vue.cjs.js",
  "module": "dist/vue.esm.js",
  "files": [
    "dist"
  ],
  "sideEffects": false,
  "buildOptions": {
    "name": "Vue",
    "formats": [
      "esm-bundler",
      "esm-browser",
      "cjs",
      "global"
    ]
  }
}
  1. 执行 pnpm i @vue/shared --workspace --filter @vue/reactivity 将工具函数项目作为依赖安装到响应式模块中。

  2. 接着在根目录下新建一个 scripts/dev.js

    • 在根目录的 package.json 中加入 "dev": "node scripts/dev.js --format esm" 命令。
    • 开发时,我们将通过执行此脚本来启动编译。它会使用 esbuild 进行实时编译,并在首次编译后持续监听文件变动。
// scripts/dev.js
/**
 * 用于打包“开发环境”的脚本
 *
 * 用法示例:
 * node scripts/dev.js --format esm
 * node scripts/dev.js -f cjs reactive
 *
 * - 位置参数(第一个)用于指定要打包的子包名称(对应 packages/<name>)
 * - --format / -f 指定输出格式:esm | cjs | iife(默认为 esm)
 */

import { parseArgs } from 'node:util'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { createRequire } from 'node:module'

/**
 * 解析命令行参数
 * allowPositionals: 允许使用位置参数(例如 reactive)
 * options.format: 支持 --format 或 -f,类型为字符串,默认为 'esm'
 */
const {
  values: { format },
  positionals,
} = parseArgs({
  allowPositionals: true,
  options: {
    format: {
      type: 'string',
      short: 'f',
      default: 'esm',
    },
  },
})

/**
 * 在 ESM 模式下创建 __filename / __dirname
 * - ESM 中没有这两个全局变量,因此需要通过 import.meta.url 进行转换
 */
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

/**
 * 在 ESM 中创建一个 require() 函数
 * - 用于加载 CJS 风格的资源(例如 JSON)
 */
const require = createRequire(import.meta.url)

/**
 * 解析要打包的目标
 * - 如果提供了位置参数,则取第一个;否则默认打包 packages/vue
 */
const target = positionals.length ? positionals[0] : 'vue'

/**
 * 入口文件(固定指向 packages/<target>/src/index.ts)
 */
const entry = resolve(__dirname, `../packages/${target}/src/index.ts`)

/**
 * 决定输出文件路径
 * - 命名约定:<target>.<format>.js
 * 例:reactive.cjs.js / reactive.esm.js
 */
const outfile = resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`)

/**
 * 读取目标子包的 package.json
 * - 常见做法是从中读取 buildOptions.name,作为 IIFE/UMD 的全局变量名
 * - 如果 package.json 中没有 buildOptions,请自行调整
 */
const pkg = require(`../packages/${target}/package.json`)

/**
 * 创建 esbuild 编译上下文并进入 watch 模式
 * - entryPoints: 打包入口
 * - outfile: 打包输出文件
 * - format: 'esm' | 'cjs' | 'iife'
 * - platform: esbuild 的目标平台('node' | 'browser')
 * * 这里示范:如果是 cjs,就倾向于 node;否则视为 browser
 * - sourcemap: 方便调试
 * - bundle: 将依赖打包进去(输出为单文件)
 * - globalName: IIFE/UMD 格式下挂载到 window 上的全局名称(esm/cjs 格式下不会用到)
 */
esbuild
  .context({
    entryPoints: [entry],                          // 入口文件
    outfile,                                       // 输出文件
    format,                                        // 输出格式:esm | cjs | iife
    platform: format === 'cjs' ? 'node' : 'browser',// 目标平台:node 或 browser
    sourcemap: true,                               // 生成 source map
    bundle: true,                                  // 打包成单文件
    globalName: pkg.buildOptions?.name,            // IIFE/UMD 会用到;esm/cjs 可忽略
  })
  .then(async (ctx) => {
    // 启用 watch:监听文件变更并自动重新构建
    await ctx.watch()
    console.log(
      `[esbuild] watching "${target}" in ${format} mode → ${outfile}`
    )
  })
  .catch((err) => {
    console.error('[esbuild] build context error:', err)
    process.exit(1)
  })
{
  "name": "vue3-source-code",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "dev": "node scripts/dev.js reactivity --format esm"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^24.2.1",
    "esbuild": "^0.25.9",
    "typescript": "^5.9.2"
  },
  "dependencies": {
    "vue": "^3.5.18"
  }
}

运行测试

  • packages/reactivity/src/index.ts 中编写一个导出函数
export function fn(a, b) {
  return a + b;
}
  • 执行 pnpm dev,你应该会在 packages/reactivity/dist/reactivity.esm.js 中看到以下内容
// packages/reactivity/src/index.ts
function fn(a, b) {
  return a + b;
}
export {
  fn
};

那就代表环境搭建成功了!

files.png

❌