阅读视图

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

用 3100 个数字造一台计算机

你有没有想过,一台计算机最少需要什么?

不是说你桌上那台——那个有几十亿个晶体管、跑着操作系统和浏览器的庞然大物。我说的是最本质的那个东西:能算数、能画画、能放音乐、能响应你的键盘和鼠标。

答案可能会让你意外:一个数组就够了。

Little Virtual Computer 是一台用 TypeScript 写的虚拟计算机,原作者是 jsdf。我在他的基础上做了不少重构和优化——把代码拆分成了清晰的模块结构,加了音频系统、断点调试、内存追踪、中英文切换等功能。3100 个内存槽位,23 条指令,你可以在上面写汇编程序,画像素,甚至播放一首 Chocolate Rain。打开链接就能玩,不用装任何东西。

接下来聊聊拆解和重构这台计算机的过程中,那些让我觉得"原来如此"的时刻。

"硬件"就是一行代码

这台计算机的内存,就是这行:

static ram: number[] = new Array(3100).fill(0)

3100 个数字,所有东西都住在里面——变量、程序、输入设备、屏幕、声卡:

地址 用途
0 - 999 工作内存(变量)
1000 - 1999 程序代码
2000 - 2051 键盘、鼠标、随机数、时钟
2100 - 2999 屏幕(30x30 像素)
3000 - 3008 声卡(3 个通道)

这就是"内存映射 I/O"。真实计算机里,显卡有自己的显存,声卡有自己的缓冲区,键盘通过中断传递信号。但在这里,一切都是内存地址。想在屏幕左上角画一个红色像素?往地址 2100 写个 2。想让扬声器发出正弦波?往地址 3001 写频率,地址 3000 写 3

第一次把重构后的代码跑起来,盯着屏幕上亮起的那个像素,我突然理解了一件事:CPU 不需要"知道"什么是屏幕。它只是往一个地址写了个数字,恰好有人在监听那个地址。 输入输出不需要特殊的指令,读写内存就是一切。

CPU 其实在做一件很无聊的事

读原作者的 CPU 代码时,我以为会很复杂。结果核心逻辑是这样的:

static step(trace: boolean = true) {
  if (trace) Memory.beginTrace();       // 需要调试时才追踪
  const opcode = this.advanceProgramCounter();  // 从内存读一个数
  const instructionName = this.opcodesToInstructions.get(opcode); // 数字变指令名
  const operands = instruction.operands.map(() => this.advanceProgramCounter()); // 再读几个数当参数
  instruction.execute.apply(null, operands);  // 执行
  if (trace) this.lastStepTrace = Memory.endTrace();
}

程序计数器从地址 1000 开始。读一个数,往前走一步。读到 9010?那是 add,再读三个数当参数,加一下,写回去。然后继续读下一个。没有流水线,没有分支预测,没有乱序执行。一个 while 循环,一直读数字、执行、读数字、执行。

这就是冯·诺依曼架构的全部:程序和数据住在同一片内存里,CPU 按顺序取指令执行。 你桌上那台电脑的 CPU,不管它有多少核、多少级缓存,本质上也在做同样的事——只是快了几十亿倍。

23 条指令够写一个游戏吗

一开始觉得不够。23 条指令,连函数调用都没有,能干什么?

结果发现,不只是够了,还能写出让人意外的东西。这 23 条指令分成五类:

搬运数据(5 条)—— 把值从一个地址复制到另一个,或者写入一个常量。还有两条指针操作,让你可以"地址 A 里存着地址 B,去 B 里取值"——间接寻址,这是实现数组遍历的关键。

算术(10 条)—— 加减乘除取模,每种都有两个版本:两个地址相加,或者一个地址加一个常量。add_constant counter 1 counter 就是 counter++

比较(2 条)—— 比较两个值,结果是 -1、0 或 1。没有布尔值,没有大于小于等于,就一个三态数字。刚开始觉得别扭,后来发现这样反而更灵活。

跳转(5 条)—— jump_to 无条件跳转,branch_if_equal 条件跳转。没有 for 循环?跳回去就是循环。没有 if-else?跳过去就是 else。

系统(3 条)—— data 嵌入原始数据,break 暂停调试,halt 终止。

用这些东西,能写出画板程序、弹球、乒乓球游戏,甚至音乐播放器。

从文本到数字

手动往内存里填操作码太痛苦了,所以需要一个汇编器。你写这样的文本:

define counter 0
define limit 10
copy_to_from_constant counter 0
Loop:
  add_constant counter 1 counter
  branch_if_not_equal_constant counter limit Loop
halt

汇编器把它变成内存里的一串数字:9001 0 0 9011 0 1 0 9104 0 10 1003 9999

过程本身很有启发性。define 给地址起名字,Loop: 标记跳转目标。汇编器用经典的两遍扫描:第一遍收集所有标签的地址(这样你可以先 jump_to SomeLabel,后面再定义 SomeLabel:),第二遍把指令名替换成操作码,把标签和变量名替换成数字,逐个写入程序内存。

所谓"编译",最原始的形态就是这样——把人能读的东西翻译成机器能读的数字。

900 个像素的屏幕

30x30,900 个像素。听起来少得可怜。

但当你亲手用汇编一个像素一个像素地画出一个弹跳的小球时,你会对"像素"这个词产生全新的理解。每个像素就是一个内存地址,颜色就是 0 到 15 的一个数字。像素地址 = 2100 + y * 30 + x。16 种颜色:黑、白、红、绿、蓝、黄、青、品红、银、灰、栗、橄榄、深绿、紫、蓝绿、海军蓝。

渲染做了分场景优化:慢放模式下追踪"脏像素",只更新被写过的像素,被写入的像素还会短暂闪白,让你看到程序正在画什么——慢放下看着像素一个一个亮起来,有种看延时摄影的感觉。全速模式则跳过逐像素追踪,直接全量重绘,因为每帧都有大量像素变化,追踪反而是浪费。

用内存地址弹钢琴

音频部分是我最喜欢的设计。三个独立的振荡器通道,每个通道就是三个连续的内存地址:波形、频率、音量。

地址 3000: 波形 (0=方波, 1=锯齿波, 2=三角波, 3=正弦波)
地址 3001: 频率 (值 / 1000 = Hz)
地址 3002: 音量 (0-100)

往这几个地址写数字,声音就出来了。改个数字,音调就变了。

内置的 ChocolateRain 程序用两个通道演奏了一首完整的曲子。音乐数据全部用 data 指令嵌入在程序里——本质上就是一个大数组,记录着"第几拍、哪个通道、什么频率、多大音量"。程序读取当前时间,算出现在是第几拍,然后去数组里找对应的音符,写入音频内存。

一首歌,就是一个按时间索引的数组。

调试器:这才是重点

说实话,这台虚拟计算机最有价值的部分不是 CPU,不是显示器,不是音频——是调试器。

点"单步",程序计数器往前走一步。你能看到它读了哪个地址(蓝色高亮),写了哪个地址(橙色高亮)。设个断点,程序跑到那里自动停下来。把速度拉到慢放,看着弹球程序一帧一帧地擦掉旧位置、算出新位置、画上新像素。

我见过很多人学编程时卡在"不知道程序在干什么"。代码写完,跑起来,结果不对,然后就懵了。这台计算机的调试器让一切都暴露在外面:每一步读了什么、写了什么、程序计数器在哪里。没有黑箱,没有抽象层,你看到的就是全部。

六个程序,六种"原来如此"

内置的六个示例程序,每个都在教一件事:

Add —— 4 + 4 = 8。三行代码,结果存在地址 2。这是"指令怎么工作"的最小演示。

RandomPixels —— 用一个指针从地址 2100 扫到 2999,每个位置写一个随机颜色,然后从头再来。满屏闪烁的彩色像素,其实只是一个循环在往内存里写数字。

Paint —— 屏幕顶部一行是 16 色调色板,点击选色,然后在画布上画。鼠标位置就是一个内存地址里的数字,点击就是另一个地址从 0 变成 1。

BouncingBall —— 白色小球弹来弹去。用 Date.now() 控制帧率,每 60ms 更新一次位置,碰到边界就反转方向。这是"游戏循环"的最小实现。

MiniPong —— 乒乓球。两个挡板,一个球,碰到挡板反弹,错过就重置。这是最复杂的示例,用到了几乎所有指令。读完它的代码,你会对"游戏不过是一堆条件判断"有切身体会。

ChocolateRain —— 用汇编写的音乐播放器。理解这个程序怎么工作,就理解了数据驱动编程的本质。

重构与实现细节

原作者 jsdf 的实现是一个完整的单体,功能齐全但耦合度较高。我把它拆成了独立模块——CPU、内存、显示器、音频、输入、汇编器——通过内存这个"总线"连接,加了 TypeScript 类型系统。

拆的过程本身就是一次学习。当你必须决定"这个职责属于 CPU 还是属于 Memory"的时候,你对计算机架构的理解会变得非常具体。

架构

项目分成两个独立的 bundle:

src/index.ts    → dist/computer.module.js   (核心计算机)
src/simulator.ts → dist/simulator.module.js  (模拟器 UI)

index.ts 初始化所有硬件组件,返回一个 Computer 接口对象——这是两层之间唯一的契约。模拟器只通过这个接口操作计算机,不直接碰内部类。换掉整个计算机实现,只要接口不变,模拟器照常工作。

几个有意思的实现决策

内存布局用 const enum——MemoryPosition 定义所有地址常量,编译后直接内联为数字,零运行时开销。改一个数字,整台计算机的内存布局就变了。这就是"硬件规格"。

指令是数据驱动的——每条指令是一个对象,包含名称、操作码、操作数描述和执行函数。operands 数组不只是文档——汇编器用它验证操作数数量,调试器用它显示操作数含义。一份数据,三个用途。

流程控制指令直接改程序计数器——jump_to 的 execute 就是 CPU.programCounter = labelAddress。这形成了循环依赖(CPU → instructions → CPU),更"干净"的做法是把 CPU 状态作为参数传入,但在这个规模的项目里,简单直接比架构纯洁更重要。

性能:在不同场景下做不同的事

性能优化的核心思路不是"让代码更快",而是"在不同场景下做不同的事"——和真实系统的优化思路一样。

全速模式用帧预算策略:用 performance.now() 在每帧 14ms 的预算内尽量多跑 CPU 周期(留 2ms 给浏览器渲染和 GC),用 requestAnimationFrame 和屏幕刷新率同步。同时跳过内存追踪和调试面板更新,显示器切换到全量重绘。

慢放模式每次只执行一条指令,开启内存读写追踪,更新所有调试面板,显示器用脏像素增量重绘。

音频也做了状态缓存——用 state 对象记录上一次的参数值,只在值真正变化时才调用 Web Audio API,避免每帧 9 次无意义的 API 调用。CPU 停止时只需静音所有通道然后立即返回。

其他细节:内存重置用 Array.fill(0) 替代 for 循环;endTrace() 复用同一个对象避免每周期分配新数组;显示器用预计算的 Uint8Array 颜色查找表,位移 << 2 代替乘法索引;程序内存视图用虚拟滚动,只渲染可见区域 ± 10 行。

最后

折腾这台计算机的过程中,我反复体会到一件事:我们日常使用的那些抽象——变量、循环、函数、屏幕、声音——在最底层都是同一个东西:往一个地址读一个数字,或者写一个数字。

3100 个数字,23 条规则。这就是一台计算机的全部。

不信的话,打开试试:wsafight.github.io/little-virt…

点"单步",看看你的程序在做什么。


原项目:github.com/jsdf/little…

重构版源码:github.com/wsafight/li…

深入理解 Signal:Push-Pull 响应式算法源码解读

什么是 Signal

Signal 是现代前端框架(Vue、Solid、Preact、Angular、Svelte)共同采用的响应式原语。它解决的核心问题是:当数据变化时,如何高效地通知依赖方更新?

答案是 Push-Pull 混合算法

  • Push:数据变化时,急切地向下游传播"你脏了"的通知
  • Pull:只有当值真正被读取时,才惰性地重新计算

本文将从零开始,逐层构建一个完整的 Signal 系统,每一层都在上一层基础上增加一个概念。


第一层:Signal — 带通知的盒子

Signal 是最基础的响应式单元。本质就是观察者模式:

type Sub<T> = (s: T) => void

export function signal<T>(initial: T) {
  let value = initial
  const subs = new Set<Sub<T>>()

  return {
    get value(): T {
      return value
    },
    set value(v: T) {
      if (value === v) return       // 值没变,跳过
      value = v
      for (const fn of [...subs]) fn(v)  // 通知所有订阅者
    },
    subscribe(fn: Sub<T>) {
      subs.add(fn)
      return () => subs.delete(fn)  // 返回取消订阅函数
    },
  }
}

使用:

const count = signal(1)
count.subscribe((v) => console.log('变了:', v))
count.value = 2  // 打印 "变了: 2"
count.value = 2  // 值没变,不触发

三个要点:

  • subs:订阅者集合,存的是回调函数
  • setter 里 if (value === v) return:值没变就不通知,避免无意义的更新
  • setter 遍历 subs 并调用 — 这就是 Push(主动推送)

这一层没什么难的。接下来加入 computed。


第二层:Computed — 惰性计算 + 缓存

Computed 是派生值,由一个计算函数 fn 定义:

export function computed<T>(fn: () => T) {
  let cachedValue: T
  let dirty = true

  function _internalCompute() {
    cachedValue = fn()
    dirty = false
  }

  return {
    get value(): T {
      if (dirty) _internalCompute()
      return cachedValue
    },
  }
}

使用:

const count = signal(1)
const double = computed(() => count.value * 2)
console.log(double.value)  // 此时才执行计算,返回 2
console.log(double.value)  // dirty=false,直接返回缓存

两个要点:

  • dirty 标记:true 表示需要重算,false 表示缓存有效
  • 只有读取 .value 时才计算 — 这就是 Pull(按需拉取)

但这个版本有两个大问题:

  1. computed 不知道自己依赖了谁,没人把它标记为 dirty
  2. 没有自动依赖追踪,需要手动管理

第三层解决这两个问题。


第三层:自动依赖追踪 — 全局栈

这是整个算法最精妙的部分。核心问题:computed 执行 fn() 时,怎么自动知道自己读了哪些 signal?

答案:用一个全局栈 STACK 当"暗号"。

类型定义

type ComputeContext = {
  setDirty: () => void                // 把我标记为 dirty
  addSource: (cleanup: () => void) => void  // 记录一个依赖源的退订函数
}

const STACK: ComputeContext[] = []

Signal 的改造

有了全局栈,signal 不再需要第一层的 subscribe 方法——依赖注册由 getter 自动完成。同时 subs 的类型从 Set<Sub<T>> 变为 Set<() => void>,因为现在存的是 computed 的 setDirty(无参函数),不再需要传递新值:

export function signal<T>(initial: T) {
  let value = initial
  const subs = new Set<() => void>()    // 改:存 setDirty 函数

  return {
    get value(): T {
      const ctx = STACK[STACK.length - 1]  // 看栈顶
      if (ctx) {
        subs.add(ctx.setDirty)                          // ①
        ctx.addSource(() => subs.delete(ctx.setDirty))  // ②
      }
      return value
    },
    set value(v: T) {
      if (value === v) return
      value = v
      for (const fn of [...subs]) fn()   // 改:不传参,只通知"脏了"
    },
  }
}

这两行是整个系统的核心"握手":

  • subs.add(ctx.setDirty) — signal 说:"我变了会通知你"。把 computed 的 setDirty 加入自己的订阅者列表。以后 signal 值变了,setter 遍历 subs 时就会调用它,把 computed 标记为 dirty。

  • ctx.addSource(退订函数) — signal 说:"你不要我了就用这个取消"。把一个清理函数交给 computed 保管。computed 下次重新计算前会调用它,从 signal 的 subs 里删掉自己。

这个"握手"只在 STACK 非空时发生——即某个 computed 正在执行 fn() 时。普通代码读取 signal(如 console.log(count.value))不会注册依赖。

Computed 的完整实现

export function computed<T>(fn: () => T) {
  const subs = new Set<ComputeContext>()    // 谁依赖我
  const sources = new Set<() => void>()     // 我依赖谁(存的是退订函数)
  let cachedValue: T
  let dirty = true

  function _internalCompute() {
    // 1. 清理旧依赖
    sources.forEach((cleanup) => cleanup())
    sources.clear()

    // 2. 压栈:告诉所有 signal "接下来读取是我发起的"
    STACK.push({
      setDirty: () => {
        if (dirty) return           // 已经 dirty 了,不重复传播
        dirty = true
        for (const sub of [...subs]) sub.setDirty()  // 向下游继续传播
      },
      addSource: (unsub) => sources.add(unsub),
    })

    // 3. 执行计算函数 — 过程中会触发 signal 的 getter,自动注册依赖
    cachedValue = fn()
    dirty = false

    // 4. 弹栈
    STACK.pop()
  }

  return {
    get value(): T {
      // 如果有上层 computed 在读我,也要注册依赖
      const ctx = STACK[STACK.length - 1]
      if (ctx) {
        subs.add(ctx)
        ctx.addSource(() => subs.delete(ctx))
      }
      if (dirty) _internalCompute()
      return cachedValue
    },
  }
}

注意 computed 的 subs 和 signal 的 subs 存的东西不一样:

  • signal.subs = Set<函数> — 存的是 setDirty 函数,值变了直接调用
  • computed.subs = Set<ComputeContext> — 存的是上下文对象,dirty 时调用 .setDirty() 继续向下游传播

因为 computed 既是消费者(依赖 signal),又是生产者(被其他 computed 依赖),需要把 dirty 继续往下传。


执行流程详解

用一个具体例子走完整个流程:

const count = signal(1)
const double = computed(() => count.value * 2)
const plusOne = computed(() => double.value + 1)

阶段一:首次读取 plusOne.value

plusOne.value 被读取
  → dirty === true,进入 _internalCompute()
  → STACK.push(plusOne_ctx)
  → STACK: [plusOne_ctx]
  → 执行 fn():double.value + 1
    │
    ├→ 读取 double.value
    │    → double 的 getter 检查栈顶 = plusOne_ctx
    │    → double.subs.add(plusOne_ctx)          // double 记住 plusOne
    │    → plusOne.sources.add(退订函数)          // plusOne 记住 double
    │    → double.dirty === true,进入 double._internalCompute()
    │    → STACK.push(double_ctx)
    │    → STACK: [plusOne_ctx, double_ctx]       // 两个同时在栈里!
    │    → 执行 fn():count.value * 2
    │    │
    │    ├→ 读取 count.value
    │    │    → count 的 getter 检查栈顶 = double_ctx(不是 plusOne_ctx!)
    │    │    → count.subs.add(double.setDirty)  // count 记住 double
    │    │    → double.sources.add(退订函数)      // double 记住 count
    │    │    → 返回 1
    │    │
    │    → cachedValue = 1 * 2 = 2
    │    → STACK.pop() → STACK: [plusOne_ctx]
    │    → 返回 2
    │
    → cachedValue = 2 + 1 = 3
    → STACK.pop() → STACK: []
    → 返回 3

执行完后的依赖关系:

count.subs = { double.setDirty }
double.subs = { plusOne_ctx }
double.sources = { count的退订函数 }
plusOne.sources = { double的退订函数 }

为什么用栈而不是单个变量? 因为 computed 的 fn() 内部可能触发另一个 computed 的求值,形成嵌套。栈的 LIFO 特性天然匹配:栈顶永远是当前最内层正在计算的 computed。JS 是单线程的,不存在两个 computed 同时执行的情况,所以栈不会冲突。

阶段二:修改 count.value = 5(Push 阶段)

count.value = 5
  → setter 触发
  → 遍历 subs,调用 double.setDirty()
    → double.dirty = true
    → double 遍历自己的 subs,调用 plusOne_ctx.setDirty()
      → plusOne.dirty = true

注意:只标记 dirty,不做任何计算。这就是 Push 的特点——轻量、快速,只传播"失效通知"。

阶段三:再次读取 plusOne.value(Pull 阶段)

plusOne.value 被读取
  → dirty === true,进入 _internalCompute()
  → 清理旧依赖(断开与 double 的连接)
  → 压栈,执行 fn()
    → 读取 double.value → double 也是 dirty → 重新计算
      → 清理旧依赖(断开与 count 的连接)
      → 压栈,执行 fn()
        → 读取 count.value → 重新注册依赖
      → 返回 10
    → 重新注册依赖
    → 返回 11

只有被读取的 computed 才会重算。如果没人读 plusOne,即使它被标记了 dirty,也不会浪费算力。


为什么要清理旧依赖

这是很多人困惑的地方。看这个例子:

const toggle = signal(true)
const a = signal(1)
const b = signal(2)
const result = computed(() => toggle.value ? a.value : b.value)

第一次执行(toggle=true):result 读了 toggle 和 a,依赖关系:

toggle.subs = { result.setDirty }
a.subs      = { result.setDirty }
b.subs      = { }                   // b 没被读,没有依赖
result.sources = { toggle的退订, a的退订 }

现在 toggle 变为 false,result 被标记 dirty。重新计算时:

第一步:清理

sources.forEach((cleanup) => cleanup())  // 执行退订函数
sources.clear()
  • toggle.subs.delete(result.setDirty) → toggle.subs 变空
  • a.subs.delete(result.setDirty) → a.subs 变空
  • result.sources 清空

所有连接断开。

第二步:执行 fn()

这次 toggle=false,走 else 分支,读了 toggle 和 b(没读 a):

  • toggle 的 getter → 重新注册 result
  • b 的 getter → 注册 result
  • a 没被读 → 不注册

最终状态:

toggle.subs = { result.setDirty }   // 重新建立
a.subs      = { }                   // 干净了!
b.subs      = { result.setDirty }   // 新建立
result.sources = { toggle的退订, b的退订 }

如果不清理,a.subs 里还残留着 result 的 setDirty。以后 a 变了,会错误地通知 result 重算,但 result 根本不依赖 a 了。

清理的本质:先全部拆掉,再通过 fn() 的执行自动重建。 cachedValue = fn() 这一行同时完成了两件事:计算新值 + 重新建立所有依赖关系。


两个 subs 的区别

signal 和 computed 都有 subs,但存的东西不同:

signal.subs   = Set<函数>          // 存 setDirty 函数
computed.subs = Set<ComputeContext> // 存上下文对象(包含 setDirty + addSource)

为什么?

  • signal 是纯数据源,值变了只需要调用订阅者的 setDirty,一个函数就够了
  • computed 是中间节点,它被标记 dirty 后还需要继续向下游传播,所以下游注册的是完整的 context 对象,通过 sub.setDirty() 链式传播
count 变了
  → count.subs 里的函数被调用 → double.dirty = true
    → double.subs 里的 context.setDirty() 被调用 → plusOne.dirty = true

不用全局栈的替代方案

全局栈不是唯一的实现方式:

显式声明依赖:手动列出依赖,不需要运行时追踪。React 的 useMemo 依赖数组就是这个思路。缺点是手动维护容易出错,无法处理动态依赖。

Proxy 拦截:用 Proxy 包装数据对象,在 get 陷阱里记录访问。Vue 2 用的 Object.defineProperty 就是类似思路。缺点是 API 有侵入性。

编译时静态分析:编译器扫描代码,直接确定依赖关系。Svelte 的做法。缺点是需要编译器,动态依赖处理不了。

全局栈是唯一同时满足"自动追踪 + 动态依赖 + 零侵入 API + 无需编译"的方案,所以成为了主流选择。


编译时优化的可能性

运行时"清理+重建"的策略简单可靠,但对于静态依赖(依赖关系永远不变)来说有冗余开销。编译器可以识别这种情况并优化:

// 源码:依赖永远是 count
const double = computed(() => count.value * 2)

// 编译产物:直接硬连接,跳过 STACK 机制
subscribe(count, () => double.dirty = true)

各框架的策略:

  • Svelte:编译时分析,静态依赖直接硬连接
  • Vue 3.4:运行时用版本号优化,computed 重算后值没变则不向下游传播 dirty
  • Solid:区分静态/动态 computed,静态的不清理
  • React Compiler:编译时自动插入 memo,思路完全不同

总结

Signal 的 Push-Pull 算法 = 三个概念的组合:

  1. 观察者模式(signal 的 subs + setter 通知)
  2. 惰性求值(computed 的 dirty 标记 + 按需计算)
  3. 全局栈自动追踪(STACK + getter 里的"握手"注册)

Push 保证响应性——变化不会被遗漏。Pull 保证效率——没人读的值不会白算。全局栈保证易用性——不需要手动声明依赖。三者缺一不可。

完整实现只有约 70 行代码,却是 Vue ref/computed、Solid createSignal/createMemo、Preact @preact/signals 背后的共同原理。理解了这 70 行,再去读任何框架的响应式源码都不会陌生。

❌