阅读视图

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

LeetCode 149. 直线上最多的点数:题解深度剖析

LeetCode 中等难度题目「149. 直线上最多的点数」,这道题核心考察对“直线斜率”的理解和哈希表的运用,看似简单但细节超多,一不小心就会踩坑。下面结合完整代码,一步步讲透解题逻辑,新手也能轻松看懂。

题目回顾

题目很直白:给你一个数组 points ,其中 points[i] = [xi, yi] 表示 X-Y 平面上的一个点。求最多有多少个点在同一条直线上。

举个例子:如果 points = [[1,1],[2,2],[3,3]],那么这三个点在同一条直线上,答案就是 3;如果 points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]],答案则是 4(有4个点共线)。

核心难点:如何表示“同一条直线”?如何避免重复计数?如何处理斜率的精度问题?

解题核心思路

直线的核心特征是「斜率」—— 同一平面内,两点确定一条直线,而斜率相同(且经过同一点)的点,必然在同一条直线上。

基于这个原理,我们可以用「固定一点,遍历其他点」的思路,具体步骤如下:

  1. 边界处理:如果点的数量 ≤ 2,直接返回点的数量(因为两点必然共线);

  2. 遍历每个点 points[i],将其作为「基准点」;

  3. 计算基准点与其他所有点 points[j](j > i)的斜率,用哈希表记录「斜率对应的点的数量」;

  4. 统计当前基准点对应的最大共线点数,更新全局最大值;

  5. 优化剪枝:如果当前全局最大值已经 ≥ 剩余未遍历的点的数量,或者超过总点数的一半,直接终止循环(无需继续计算,因为不可能出现更大值)。

关键细节:斜率的表示(避坑重点)

这道题最容易踩坑的地方,就是「斜率的表示」。直接用 dy/dx (即两点纵坐标差除以横坐标差)会有两个问题:

  • 精度问题:浮点数计算会有误差(比如 1/3 和 2/6 本是同一个斜率,但浮点数表示可能不同);

  • 特殊情况:垂直直线(dx=0,斜率不存在)、水平直线(dy=0,斜率为0),无法用常规除法表示。

解决方案:用「最简整数比」表示斜率,将 dy 和 dx 化简为互质的整数,再用一个唯一的key表示这个比值。

具体做法(对应代码中的gcd函数和key计算):

  1. 计算两点的横坐标差 dx = x_i - x_j,纵坐标差 dy = y_i - y_j;

  2. 特殊处理:

    • dx=0(垂直直线):令 dy=1(统一表示所有垂直直线的斜率);

    • dy=0(水平直线):令 dx=1(统一表示所有水平直线的斜率);

  3. 符号统一:如果 dy 为负,将 dx 和 dy 同时取反(保证斜率的符号一致,比如 2/-3 和 -2/3 是同一个斜率,统一为 2/3);

  4. 化简:用最大公约数(gcd)将 dx 和 dy 化简为互质的整数(比如 dx=4,dy=2,化简为 dx=2,dy=1);

  5. 生成key:将二维的 (dy, dx) 转化为一维key,避免哈希表的key冲突。代码中用「dy + dx * 20001」,因为题目中坐标的范围是 [-10^4, 10^4],dx的最大绝对值是 20000,乘以20001后,再加上dy(范围 [-20000, 20000]),可以保证每个 (dy, dx) 对应唯一的key。

完整代码+逐行解析

先贴完整代码(TypeScript版本),再逐行拆解核心逻辑:

function maxPoints(points: number[][]): number {
  const n = points.length;
  if (n <= 2) return n; // 边界处理:2个及以下点必共线
  let res = 0;

  // 最大公约数函数:用于化简dx和dy
  const gcd = (a: number, b: number): number => {
    return b != 0 ? gcd(b, a % b) : a;
  }

  // 遍历每个点作为基准点i
  for (let i = 0; i < n; i++) {
    // 剪枝:如果当前最大结果已经≥剩余点数量,或超过总点数的一半,无需继续
    if (res >= n - i || res > n / 2) {
      break;
    }
    const map = new Map(); // 记录当前基准点下,斜率对应的点的数量

    // 遍历所有在i之后的点j(避免重复计算,因为i和j与j和i的斜率相同)
    for (let j = i + 1; j < n; j++) {
      let dx = points[i][0] - points[j][0];
      let dy = points[i][1] - points[j][1];

      // 特殊处理:垂直/水平直线,统一斜率表示
      if (dx === 0) {
        dy = 1; // 垂直直线,斜率统一用(1,0)表示
      } else if (dy === 0) {
        dx = 1; // 水平直线,斜率统一用(0,1)表示
      } else {
        // 符号统一:dy为负时,dx和dy同时取反
        if (dy < 0) {
          dx = -dx;
          dy = -dy;
        }
        // 化简dx和dy为互质整数
        const gcdXY = gcd(Math.abs(dx), Math.abs(dy));
        dx /= gcdXY;
        dy /= gcdXY;
      }

      // 生成唯一key,存入哈希表
      const key = dy + dx * 20001;
      map.set(key, (map.get(key) || 0) + 1);
    }

    // 统计当前基准点下,最多的共线点数(map的值是“与基准点共线的点的数量”,需+1包含基准点本身)
    let maxn = 0;
    for (const num of map.values()) {
      maxn = Math.max(maxn, num + 1);
    }
    // 更新全局最大值
    res = Math.max(res, maxn);
  }
  return res;
};

逐行解析核心代码

  1. 边界处理:if (n <= 2) return n; —— 这是最基础的优化,因为1个点返回1,2个点返回2,都无需后续计算。

  2. gcd函数:求两个数的最大公约数,用于化简dx和dy。比如gcd(4,2)=2,gcd(3,5)=1,核心是递归实现“辗转相除法”。

  3. 外层循环(基准点遍历):for (let i = 0; i < n; i++),每个i作为基准点,后续只遍历j > i的点,避免重复计算(比如i=0、j=1和i=1、j=0是同一个斜率,无需重复统计)。

  4. 剪枝逻辑:if (res >= n - i || res > n / 2) break; —— 比如总共有5个点,当前res=3,剩余未遍历的点只有2个(n-i=5-3=2),不可能超过3,直接终止循环;另外,最多共线点数不可能超过总点数的一半(如果超过,早就在之前的基准点中统计到了),这一步能大幅提升效率。

  5. 哈希表map:key是斜率的唯一标识,value是“与基准点i共线且在i之后的点的数量”。

  6. 内层循环(计算斜率):for (let j = i + 1; j < n; j++),计算基准点i和点j的dx和dy,然后进行化简和符号统一,生成key存入map。

  7. 统计当前基准点的最大共线点数:num + 1 是因为map的value是“除基准点外的共线点数”,加上基准点本身才是总共线点数。

  8. 更新全局最大值res:每次遍历完一个基准点,就用当前的maxn更新res,最终res就是答案。

常见坑点&优化建议

坑点1:斜率精度问题

千万不要用 dy/dx 计算斜率(比如用浮点数存储),会出现精度误差。比如 dx=1、dy=3 和 dx=2、dy=6,斜率都是1/3,但浮点数表示可能有微小差异,导致哈希表认为是两个不同的斜率。

坑点2:符号不统一

比如 dx=2、dy=-3 和 dx=-2、dy=3,其实是同一个斜率,但如果不统一符号,会生成两个不同的key。所以代码中才会判断“如果dy<0,dx和dy同时取反”,保证斜率符号一致。

坑点3:重复计算

如果内层循环遍历所有j(j从0到n-1,j≠i),会导致i和j、j和i重复计算,浪费时间。所以内层循环只遍历j > i的点,既避免重复,又提升效率。

优化建议

剪枝逻辑一定要加!尤其是当n较大时(比如n=1000),剪枝能大幅减少循环次数,避免超时。另外,哈希表的key生成方式可以灵活调整,只要能保证“不同斜率对应不同key,相同斜率对应相同key”即可,代码中的「dy + dx * 20001」是结合题目坐标范围的最优选择。

测试用例验证

我们用两个典型测试用例验证代码:

  1. 测试用例1:points = [[1,1],[2,2],[3,3]]

    • i=0(基准点[1,1]),j=1:dx=-1,dy=-1 → 符号统一后dx=1,dy=1 → key=1+1*20001=20002,map={20002:1};

    • j=2:dx=-2,dy=-2 → 化简后dx=1,dy=1 → key=20002,map={20002:2};

    • maxn=2+1=3,res=3;后续循环剪枝,最终返回3。

  2. 测试用例2:points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]]

    • i=0(基准点[1,1]),遍历j=1~5,计算各个斜率,最终map中最大value为3(对应4个点共线),maxn=4,res=4;

    • 后续循环无法超过4,最终返回4。

总结

这道题的核心是「用最简整数比表示斜率」,避免精度和符号问题,再通过「固定基准点+哈希表计数」的思路,统计每个基准点对应的最大共线点数,最后结合剪枝优化提升效率。

整体难度中等,重点在于细节处理——斜率的化简、符号统一、key的生成,这些都是避坑的关键。理解之后会发现,这道题本质是“哈希表的应用+直线斜率的数学理解”,掌握后可以举一反三,应对类似的几何计数问题。

TypeScript学习系列(二):高级类型篇

前言

在上一篇文章juejin.cn/post/762508… 中,介绍了 TypeScript 系统中的基础类型及其用法,本篇我们将进击 TypeScript 中一些高级类型,学完本篇,就能对 TypeScript 系统中的各大类型有个比较全面的理解了。

泛型

泛型可以理解为类型参数类型变量,在定义类型别名、接口、类、函数参数时都会用到。在上一篇文章中我们已经介绍过了,下边来看与它相关联的一些场景。

extends 约束

extends 关键字主要用于泛型约束中,例如:

type IsNumber<T> = T extends number ? T : never

IsNumber 类型接收泛型 T 作为类型参数,随后使用 extends 关键字

  • T extends number:表示 T 能赋值于 numberextends 表示 赋值于
  • T extends number ? T : never:这里运用条件类型(类似 js 的三元表达式),意思是,T 如果能赋值于 number 类型,那么返回结果就是 T 类型,反之就返回 never 忽略类型。

那上边这个例子就很好理解,主要使用 extends 关键字来约束泛型在满足 number 类型时再返回它。

内置工具约束

在了解了泛型extends 和基本的条件类型,我们可以来看 TypeScript 提供的一些内置工具,主要用于约束泛型,比如 Partial<T>Required<T>,都是工具名称 + <T> 的组合,专为约束泛型 T 而生。

Partial

Partial 允许我们将传入泛型中的所有属性变为可选的属性,例如:

type Person = {
  name: string,
  age: number
}

type MyPartialProperties = Partial<Person>

image.png

所有属性名旁边都加上了 ? 符号,表示可选属性。

Required

Required 将传入类型中的所有属性变为必传的属性,例如:

type Person = {
  name?: string,
  age: number
}

type MyRequiredProperties = Required<Person> // Person 上的所有属性都是必须的,? 会去掉

image.png

可以看到原本可选 name 属性旁边的 ? 被去掉了,变成了必须项。

ReadOnly

ReadOnly 可以将类型上的属性指定为只读的:

type Person = {
  name?: string,
  age: number
}

type MyReadOnlyProperties = Readonly<Person> // Person 上的所有属性前边会加上 readonly 表示只读属性

image.png

所有属性前边都加上了 readonly 描述符,表示属性是只读的。

Pick

Pick 可以从类型中筛选出某个属性:

type Person = {
  name: string,
  age: number
}

type MyPickProperties = Pick<Person, "name"> // 仅筛选出 Person 中为 name 的那个属性

image.png

从泛型 Person 中取出了 name 属性作为新类型,如上图 MyPickProperties 只剩下 name 属性。

Omit

可以从对象类型中排除掉不需要的属性,支持传联合类型用于同时排除多个属性:

type Person = {
  name: string,
  age: number,
  sex: string
}

type MyOmitProperties1 = Omit<Person, "name"> // 排除掉 Person 类型中的 name 属性
type MyOmitProperties2 = Omit<Person, "name" | "age"> // 排除掉 Person 类型中的 name 和 age 属性

image.png

将泛型 Person 中的 name 属性排除掉了。

image.png

通过联合类型将泛型 Person 中的 nameage 属性一块排除掉。

Record

可以创建一个新的对象类型,这个新对象类型是由某个指定类型中的属性组成的,同时可以指定新类型上的属性类型:

type Example = 'name' | 'age' | 'sex'

type MyRecord = Record<Example, string> // 新类型中属性由 Example 中组成,同时新类型上属性类型指定为 string

image.png

Example 类型中的所有属性组成的新类型 MyRecord,并且指定新类型中属性的类型为 string

ReturnType

ReturnType 用于提取函数类型的返回值类型,而可以不用手动指定函数的返回值类型:

function testExample(a: number, b: number) {
  return a + b
}

type GetFuncReturnType = ReturnType<typeof testExample> // number

image.png

提取 testExample 函数的返回值类型于新类型 GetFuncReturnType 中。

Extract

Extract 用于从两个类型中取出相互兼容的部分,其实就是取交集:

type A = string | number
type B = boolean | number

type ExtractAB = Extract<A, B> // number(相交的部分就是 number)

image.png

类型 A 和类型 B 共有的部分就是 number,自然取到的交集就是 number,作为 ExtractAB 的新类型。

Exclude

Exclude 主要用于从联合类型中排除掉不需要的属性:

type Example = string | number | boolean
type ExcludeString = Exclude<Example, string> // ExcludeString 剩下 number | boolean

image.png

Example 类型中排除掉 string 类型,剩下的 numberboolean 就作为 ExcludeString 的类型。

type Example1 = "dog" | "cat"
type Example2 = "cat"
type ExcludeCat = Exclude<Example1, Example2>

image.png

上边这个例子可以看出,Exclude 会排除掉 Example1 中和 Example2 相同那一部分,Example1 中和 Example2 相同的那部分是 cat,故排除掉 Example1 中的 cat

我们可以发现,ExtractExclude 的操作刚好相反,Extract 是取两个泛型相交的部分,而 Exclude 是从第一个泛型中排除掉和第二个泛型相同(相交)的那部分。Extract 是取,而 Exclude 是排,两者都是取交集

NonNullable

NonNullable 用于排除类型中为 nullundefined 的部分,返回一个新类型:

type Example = number | undefined | null
type NotNullAndUndefined = NonNullable<Example> // 排除类型 C 中为 null 和 undefined 的部分

image.png

NonNullable 可以排除 Example 类型中的 nullundefined

条件类型

条件类型我们在开篇泛型那里就见过了,ts 中的条件类型类似于 js 中的三元运算符,一般配合 extends 一起使用,如:T extends U ? U : never。下面我们来看下分布式条件类型

分布式条件类型

当条件类型作用于泛型类型参数时,如果该类型是联合类型(注意是联合类型),则条件会分布到每一个联合成员上,分别计算,再将结果合并成一个新的联合类型,我们来举例看下:

type Animal = "dog" | "cat"
type AnimalOrFruit = "dog" | "apple" | "cat" | "banana"

type TogetherExample<T> = T extends Animal ? T : never

type OnlyAnimal = TogetherExample<AnimalOrFruit>

image.png

  • T extends AnimalT 能否赋值于 Animal 类型(Animal 类型中包含 dogcat,也就是 T 是否为 dogcat)。
  • T extends Animal ? T : never:接收的泛型 T 如果能赋值于 Animaldogcat),那么就取这个 T,反之就用 never 来忽略类型。
  • TogetherExample<AnimalOrFruit>AnimalOrFruit 类型中为 dogcat 的就正常收集到新类型 OnlyAnimal 中,其他的 applebanana 由于不能赋值于 Animal,被使用 never 类型忽略了。故最终新类型 OnlyAnimal 中仅包含 dogcat

从上边例子也能看出来,所谓分布式条件类型,就是当接收的泛型参数为联合类型时,会将条件作用于每个类型中。

infer

infer 关键字的作用是延时推导,它会在类型未推导时进行占位,等到真正推导出来后,它能返回准确的类型:

type Example<T> = T extends (...args: any) => infer R ? R : never

type ExampleFunc = (a: number, b: number) => number

type TestGetFuncReturnExample = Example<ExampleFunc> // number
  • T extends (...args: any) => infer R ? R : never:判断 T 是否为一个函数类型
  • (...args: any) => infer Rargs 为函数入参,infer R 为返回值类型的占位操作

整个意思就是,T 是函数类型的话,能推导出它的返回值类型 R;反之,就返回 never。代入上边例子,ExampleFunc 是一个函数类型,先使用 infer R 占位返回值类型,等到真正推导出函数的返回值类型为 number 时,它能准确返回类型。

映射类型

映射类型可以基于现有的类型来修改某个属性或通过排除属性来生成新的类型,修改属性包括把属性映射为只读可选、属性名添加前缀等操作。排除属性主要就是将不符合条件的属性映射为 never。我们逐个举例来看下:

  • 映射为只读属性
interface Person {
    name: string,
    age: number,
}

type MyReadOnlyProperties<T> = {
    readonly [P in keyof T]: T[P] // 通过 readonly 关键字将属性映射为只读
}

type ReadOnlyPerson = MyReadOnlyProperties<Person>

image.png

  • 映射为可选属性
interface Person {
    name: string,
    age: number,
}

type MyPartialProperties<T> = {
    [P in keyof T]?: T[P] // 通过 ? 符号将属性映射为可选
}

type PartialPerson = MyPartialProperties<Person>

image.png

  • 为属性添加前缀,可以结合 as 来完成:
interface Person {
    name: string,
    age: number,
}

type MyPrefixProperties<T> = {
  [P in keyof T as `prefix_${string & P}`]: T[P]
}

type PrefixProperties = MyPrefixProperties<Person>

image.png

1、首先使用 P in keyof T 将枚举出 T 类型中的每个属性

2、使用 as 来将每个属性重命名为 prefix_${string & P},即加上 prefix_ 这个前缀,如 name 就会变成 prefix_name,这里的 string & P 作用就是确保 P 类型是一个字符串

3、T[P]:属性的值就正常映射为 P 属性在 T 上的原有类型

  • 仅映射特定类型来生成新的类型
interface Person { 
    name: string,
    age: number,
}

type GenerateNewTypeWithString<T> = {
    [P in keyof T as T[P] extends string ? P : never]: T[P] // 仅映射出类型为 string 的属性
}

type NewTypeString = GenerateNewTypeWithString<Person> // 仅包含 string 类型的属性,即 name

image.png

索引类型

索引类型可以通过属性名直接访问某个属性的具体类型,主要使用中括号 [],例如:

interface Person {
    name: string
    age: number
}
type PersonOfAge = Person["age"]

image.png

如上访问 Person 上的 age 属性,其类型就是 number

还可以获取类型中所有属性的联合类型,如下:

interface Person {
    name: string
    age: number
}

type PersonOfValue = Person[keyof Person]

image.png

使用 keyof 遍历出 Person 上所有的属性,相当于:

  • Person["name"] | Person["age"]
  • 那结果就是 string | age

类型守卫

类型守卫可以根据条件来细化变量的具体类型,从而使代码在运行时更加安全和可维护,主要通过几种方式来实现,包括 typeofinstanceofin自定义类型函数,我们逐个来看看。

type A = string | number | boolean

function logInfo(a: A): void {
    if(typeof a === 'string') {
        // 当 a 为 string 时执行某些操作...
        console.log(`variable ${a} is a string`)
    }
}
  • typeof

image.png

logInfo 函数接收形参 a,用类型 A 约束,类型 A 是个联合类型,也就是说形参 a 的类型可能是 stringnumberboolean 中的一个,在函数体内通过 typeof 来判断 astring 类型,那么在该条件分支内,ts 就能确定 a 的类型为 string 了,这也很好的避免由于类型不确定导致的意外操作。

  • instanceof
class Person {
    speak() {
        console.log('people can speak')
    }
}

class Animal {
    fly() {
        console.log('some animals can fly')
    }
}

const p1 = new Person()
const a1 = new Animal()

function personOrAnimal(a: Person | Animal) {
    if(a instanceof Person) {
        a.speak()
    } else if(a instanceof Animal) {
        a.fly()
    }
}

personOrAnimal(a1) // people can speak

使用 instanceof 能够检查变量是否属于某个类的实例,这样在对应条件分支内 ts 编译器就能确定该实例所属类,从而能给予我们该实例上能调用的方法和属性的提示,这也很好保证了运行时的准确性。

  • in
interface Person {
    write(): void
}
interface Animal {
    eat(): void
    fly(): void
}

class A implements Animal {
    eat() {
        console.log('animal can eat')
    }
    fly() {
        console.log('animal can fly')
    }
}

function getInfo(a: Person | Animal) {
    if('write' in a) {
        a.write()
    } else if('fly' in a) {
        a.fly()
        a.eat()
    }
}

上边 getInfo 函数接收一个实例,通过 in 来判断属性是否存在于实例上,如果存在就能直接使用,而且该属性所属类上的其他属性也能直接访问,比如判断 a 上如果存在 fly 属性。那么 fly 属性所属类上的 eat 也能访问了。

  • 自定义类型
function isString(str: any) {
    return typeof str === 'string'
}

function getInfo(a: any) {
    if(isString(a)) {
        console.log('the operation of string a')
    }
}

通过使用自定义的 isString 函数来判断某个变量是否为特定的类型,满足就能在条件分支内对该类型变量进行一些操作。

类是面向对象的核心概念,它主要封装了对象的状态和行为,也就对应着属性和方法,ts 为类提供了类型检查功能。为此我们可以在类中为属性或方法定义类型,如下例子:

class Animal {
    name: string
    constructor(name: string) {
        this.name = name
    }
    say(): void {
        console.log('动物发出声音')
    }
}

继承

在 ts 中实现类的继承和 js 中是一致的,如下示例:

class Dog extends Animal {
    constructor(name: string) {
        super(name) // 调用父类构造函数初始化
    }
    say(): void { // 重写父类方法
        console.log("wang wang~")
    }
}

const d1 = new Dog("哈士奇")
console.log(d1.name) // 哈士奇

super

super 主要用于调用父类的构造函数,将子类构造函数接收的参数传给父类构造函数,由父类构造函数来做初始化,这样也省去了在子类构造函数中重复声明初始化的操作。

class Dog extends Animal {
    constructor(name: string) {
        super(name) // 调用父类构造函数初始化
        // this.name = name // 相当于
        // this.xxx = xxx // 更多参数
    }
    say(): void { // 重写父类方法
        console.log("wang wang~")
    }
}

修饰符

  • public:可在任何地方访问
  • private:仅可在类内部访问,子类也不允许访问
  • protected:仅可在类内部、子类中访问

下边我们举例来理解这三个修饰符:

  • 实例访问:仅能访问公有属性(public)
class Person {
    public name: string // 公共属性
    private age: number // 私有属性
    protected sex: string // 保护属性

    constructor(name: string, age: number, sex: string) {
        this.name = name
        this.age = age
        this.sex = sex
    }
    getPersonAge(): number { 
        return this.age // 私有属性,仅在当前类能访问
    }
}

const p1 = new Person("man", 11, 'male')
console.log(p1.name) // man 
console.log(p1.age) // 编译报错,age 是类私有属性
console.log(p1.sex) // 编译报错,sex 是类的保护属性,仅可子类中访问
  • 子类访问:可以访问公有属性(public)和保护属性(protected)
class Student extends Person {
    constructor(name: string, age: number, sex: string) {
        super(name, age, sex)
    }

    getStudentName(): string {
        return this.name // 正常
    }

    getStudentSex(): string {
        return this.sex // 正常
    }

    getStudentAge(): number {
        return this.age // 编译报错,age 是私有属性,仅能声明类自身访问
    }
}

抽象类与接口

抽象类就是使用 abstract 修饰的类,抽象类中可以定义抽象、也可以定义具体方法,继承抽象类的子类必须实现抽象类中定义的抽象方法,同时也可以重写抽象类中的具体方法,我们看下边例子就知道了:

abstract class Person {
    abstract myHobby(): void // 抽象方法

    walk(): void {
        console.log('people walking')
    }
}

class Student extends Person {
    myHobby(): void { // 子类必须实现父类的抽象方法
        console.log('music')
    }
    walk(): void { // 重写父类的方法
        console.log('student walking')
    }
}

const s1 = new Student()
s1.myHobby() // music
s1.walk() // student walking

再来看看接口: 定义一组规范,不提供具体的实现,仅包含函数的签名

interface Play {
    games(): void // 函数签名
}

由实现类来完成函数体:

class Student extends Person implements Play {
    // 实现接口中的函数签名
    games() {
        console.log('lol')
    }

    myHobby(): void {
        console.log('music')
    }
    walk(): void {
        console.log('student walking')
    }
}
const s1 = new Student()
s1.games() // lol

为此,我们可以得出抽象类接口的区别:

相同点:都用于定义行为规范,抽象类的抽象方法和接口中的函数签名都必须在子类中实现。

不同点:抽象类中可以包含具体方法的实现,而接口仅含函数签名或属性签名,不包含具体方法的实现。抽象类不能直接实例化,只能作为基类通过子类来继承;而接口可以被类实现,一个类可以同时实现多个接口,使用 implements 关键字。

总结

本篇文章主要围绕 TypeScript 中的高级类型展开介绍,从泛型开始,扩展来看它的一些场景,包括泛型约束、ts 中的内置工具类型。然后就是常用的条件类型、映射类型、索引类型、类型守卫,以及我们常用的类,最后对比了抽象类与接口的区别。

我是 luckyCover,我正在持续更新 TypeScript 学习系列的文章,欢迎大家一起讨论学习呀~

装饰器:那个在代码里“贴标签”的黑魔法,到底有什么用?

你有没有在Angular或NestJS里见过@Component@Injectable这种稀奇古怪的“@符号”?它们就像给代码贴的“便利贴”,背后却能自动帮你做一堆事情。今天我们就来揭开TypeScript装饰器的神秘面纱,看看这个“贴标签”魔法到底怎么用,以及为什么它能让你少写几千行重复代码。

前言

想象你去餐厅吃饭,你在菜单上贴了个标签“@少油”,厨房看到后自动给你少放油。你又贴个“@加辣”,厨房又自动加辣。你只需要贴标签,厨房负责执行。

这就是装饰器。它是一种特殊的声明,可以附加在类、方法、属性、参数上,用来修改或增强它们的行为。你不用手动调用什么函数,只要贴上“标签”,背后的逻辑就会自动生效。

一、装饰器长啥样?先看个例子

在TypeScript里,装饰器以@expression的形式出现,expression是一个函数,会在运行时被调用。

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log('调用了方法:', propertyKey);
}

class Person {
  @log
  sayHello() {
    console.log('Hello');
  }
}

const p = new Person();
p.sayHello();
// 输出:
// 调用了方法: sayHello
// Hello

你什么都没改,只是加了个@log,每次调用sayHello就会自动打印日志。这就是装饰器的魅力。

二、启用装饰器:别急,先开个开关

TypeScript的装饰器目前是实验性特性,需要在tsconfig.json里开启:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true  // 可选,用于反射元数据
  }
}

三、装饰器的四种类型

装饰器可以贴在四个地方:类、方法、访问器/属性、参数。每种都有不同的参数签名。

1. 类装饰器

作用在类上,通常用来修改或替换类的定义。

function addTimestamp<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    timestamp = new Date();
  };
}

@addTimestamp
class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

const user = new User('张三');
console.log(user); // User { name: '张三', timestamp: 2025-04-10... }

类装饰器接收一个参数:类的构造函数。你可以返回一个新类替换它,或者直接修改原型。

2. 方法装饰器

最常用,可以拦截、修改、替换方法。

function measure(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    const start = performance.now();
    const result = original.apply(this, args);
    const end = performance.now();
    console.log(`${propertyKey} 耗时 ${end - start}ms`);
    return result;
  };
  return descriptor;
}

class Calculator {
  @measure
  add(a: number, b: number) {
    return a + b;
  }
}

参数:

  • target:类的原型(静态方法则是构造函数)
  • propertyKey:方法名
  • descriptor:属性描述符(可以修改value、writable等)

3. 属性装饰器

作用在属性上,通常用于配合元数据做依赖注入或验证。

function format(formatStr: string) {
  return function(target: any, propertyKey: string) {
    let value: string;
    const getter = function() {
      return value;
    };
    const setter = function(newVal: string) {
      value = formatStr.replace('%s', newVal);
    };
    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true
    });
  };
}

class Greeting {
  @format('Hello, %s')
  name: string;
}

属性装饰器只能拿到目标类和属性名,不能直接修改属性值,但可以通过Object.defineProperty替换getter/setter。

4. 参数装饰器

作用在函数参数上,常用于依赖注入框架(比如Angular)。

function paramLogger(target: any, propertyKey: string, parameterIndex: number) {
  console.log(`参数位置 ${parameterIndex} 被装饰了`);
}

class UserService {
  getUser(@paramLogger id: number) {
    return { id };
  }
}

参数装饰器很少单独用,通常配合类装饰器或方法装饰器收集元数据。

四、装饰器工厂:给装饰器传参数

你看到@log@measure这些是不带参数的。如果想让装饰器接受配置,需要再包一层函数:

function log(prefix: string) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;
    descriptor.value = function(...args: any[]) {
      console.log(`${prefix} 调用 ${propertyKey}`);
      return original.apply(this, args);
    };
  };
}

class Test {
  @log('DEBUG')
  doSomething() {
    console.log('执行');
  }
}

这就是装饰器工厂:外层函数接收参数,内层函数是真正的装饰器。

五、多个装饰器:从下往上,从右往左

当你在同一个目标上使用多个装饰器时,它们的执行顺序是:先执行靠近目标的(从下往上),再执行外层的

@classDecoratorA
@classDecoratorB
class MyClass {}

执行顺序:classDecoratorB 先执行,然后 classDecoratorA

方法上的装饰器类似:先执行参数装饰器,再执行方法装饰器,最后是类装饰器(但方法装饰器本身的调用顺序是从下往上)。

六、实战:用装饰器实现权限校验

假设你要写一个类,某些方法只有管理员能调用。你可以用装饰器优雅地实现:

function adminOnly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    if (!this.isAdmin) {
      throw new Error('无权限,需要管理员角色');
    }
    return original.apply(this, args);
  };
}

class UserController {
  isAdmin = false;

  @adminOnly
  deleteUser(id: number) {
    console.log(`删除用户 ${id}`);
  }
}

const ctrl = new UserController();
ctrl.deleteUser(1); // 报错:无权限
ctrl.isAdmin = true;
ctrl.deleteUser(1); // 成功

看,你只需要在需要权限的方法上贴个@adminOnly,逻辑自动注入。

七、装饰器的实际应用场景

  • 日志记录:自动打印方法入参、返回值、耗时。
  • 权限校验:检查当前用户角色。
  • 数据验证:验证方法参数格式。
  • 依赖注入:Angular、NestJS 里大量使用。
  • 性能监控:自动记录方法执行时间。
  • 重试机制:方法失败后自动重试。

八、注意事项与坑点

  1. 装饰器目前是实验特性,虽然Angular、NestJS等框架广泛使用,但未来ECMAScript标准可能会有所变化。
  2. 不能用在普通JS文件,必须在TS或Babel中启用。
  3. 属性装饰器不能直接修改属性值,需要通过Object.defineProperty替换getter/setter。
  4. 装饰器在类定义时执行,而不是实例化时。这意味着你不能依赖实例属性(比如this.isAdmin)来做静态分析,但可以在返回的函数中延迟读取。

九、总结:装饰器就像“代码贴纸”

  • 装饰器是给类、方法、属性、参数贴的“标签”。
  • 标签背后的函数会在运行时自动执行,修改目标的行为。
  • 装饰器工厂可以传参,实现定制化。
  • 多个装饰器从下往上执行。
  • 常见用途:日志、权限、验证、注入。

学会装饰器,你就能写出更声明式、更优雅的代码。很多框架的魔法背后,其实就是这些小小的“@”符号。

如果你觉得今天的“便利贴”魔法够神奇,点个赞让更多人看到。明天我们将开启浏览器渲染原理之旅,从输入URL到页面显示,中间到底发生了什么?我们明天见!

泛型:像“填空”一样写类型,让你的代码从“复制粘贴”中解放

你是不是遇到过这种场景:写了一个函数,处理数字的版本写一遍,处理字符串的版本再写一遍,处理数组的又写一遍……最后代码里全是长得差不多的“双胞胎”。今天我们来学TypeScript的泛型——一个能让你写一次、处处用的“类型模板”。从此告别复制粘贴,做个体面的程序员。

前言

想象一下,你开了一家“万能快递公司”。客户要寄书,你准备书盒;要寄衣服,你准备衣服盒;要寄手机,你准备手机盒……每种物品都要单独设计盒子,累不累?

更好的做法:设计一种可调节大小的盒子,客户说寄什么,你就把盒子调成对应大小。这个“可调节的盒子”,就是泛型

TypeScript的泛型让你在定义函数、类、接口时,先“留个空”,等用的时候再往里填具体类型。这样既保证了类型安全,又避免了重复代码。

一、泛型长啥样?一个简单的例子

先看一个没有泛型的“悲惨世界”:

// 只能处理数字
function identityNumber(arg: number): number {
  return arg;
}
// 只能处理字符串
function identityString(arg: string): string {
  return arg;
}
// 要处理布尔值?再写一个……

用泛型,只需要一个:

function identity<T>(arg: T): T {
  return arg;
}

这里的<T>就像个“占位符”,你调用时可以指定具体类型:

let output1 = identity<string>('hello'); // 类型是 string
let output2 = identity<number>(123);     // 类型是 number

但TS很聪明,能自动推断,所以通常可以省略:

let output = identity('hello'); // TS推断出T为string

二、泛型不只是“传进去又返回来”

你可以约束参数的类型关系。比如,你想让函数接收一个数组,并返回数组的第一个元素:

function getFirst<T>(arr: T[]): T {
  return arr[0];
}

const firstNumber = getFirst([1, 2, 3]);   // 类型 number
const firstString = getFirst(['a', 'b']); // 类型 string

T帮我们保持了“数组元素类型”和“返回值类型”的一致性。

三、泛型约束:给“占位符”画个圈

有时候,你不能让T为所欲为。比如你想写一个函数,打印参数的length属性:

function logLength<T>(arg: T): T {
  console.log(arg.length); // ❌ 报错:T可能没有length
  return arg;
}

因为T可能是numberboolean,它们没有.length。这时候需要约束——告诉TS:T必须是有length属性的类型。

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length); // ✅ 安全
  return arg;
}

logLength('hello');    // 字符串有length
logLength([1, 2, 3]);  // 数组有length
logLength(123);        // ❌ 数字没有length,报错

extends关键字在这里不是继承,而是“约束为某个类型的子集”。

四、泛型接口:把接口变成“模具”

接口也可以泛型化,比如定义一个通用的响应结构:

interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

// 使用
type User = { name: string; age: number };
const response: ApiResponse<User> = {
  code: 200,
  message: 'success',
  data: { name: '张三', age: 18 }
};

这样,你就能用一个接口描述所有API返回格式,只需替换T

五、泛型类:像造模具一样造类

类同样可以泛型:

class Queue<T> {
  private data: T[] = [];
  push(item: T) {
    this.data.push(item);
  }
  pop(): T | undefined {
    return this.data.shift();
  }
}

const numberQueue = new Queue<number>();
numberQueue.push(123);
numberQueue.push('456'); // ❌ 报错,只能放数字

六、泛型工具类型:TS内置的“变形金刚”

TS提供了一些内置的泛型工具,能帮你快速转换类型。

1. Partial<T>:把属性都变成可选

interface User {
  name: string;
  age: number;
}
type PartialUser = Partial<User>; // { name?: string; age?: number; }

2. Readonly<T>:把所有属性变成只读

type ReadonlyUser = Readonly<User>; // { readonly name: string; readonly age: number; }

3. Pick<T, K>:从T中挑选部分属性

type UserName = Pick<User, 'name'>; // { name: string; }

4. Omit<T, K>:从T中排除部分属性

type UserWithoutAge = Omit<User, 'age'>; // { name: string; }

还有Record<K, T>ExcludeExtract等,遇到具体场景时再查文档即可。

七、联合类型与交叉类型:不是泛型,但常一起用

联合类型(|):这个或那个

let value: string | number;
value = 'hello'; // OK
value = 123;     // OK
value = true;    // ❌

联合类型适合“不确定具体是哪个,但知道是有限的几种”。

交叉类型(&):既要又要

interface Name { name: string; }
interface Age { age: number; }
type Person = Name & Age; // 同时有name和age属性

const p: Person = { name: '张三', age: 18 };

交叉类型常用来合并多个类型。

八、类型保护:让TS相信你

当你使用联合类型时,TS会限制你只能调用所有类型共有的方法。要调用特定类型的方法,需要类型保护

function printLength(value: string | number) {
  // console.log(value.length); // ❌ 报错,number没有length
  if (typeof value === 'string') {
    console.log(value.length); // ✅ 这里TS知道value是string
  } else {
    console.log(value.toFixed(2));
  }
}

除了typeof,还有instanceofin关键字、自定义类型守卫。

九、实战:用泛型写一个“万能”的缓存函数

interface Cache<T> {
  get(key: string): T | undefined;
  set(key: string, value: T): void;
}

function createCache<T>(): Cache<T> {
  const store: Record<string, T> = {};
  return {
    get(key) { return store[key]; },
    set(key, value) { store[key] = value; }
  };
}

const stringCache = createCache<string>();
stringCache.set('name', '张三');
const name = stringCache.get('name'); // 类型是 string | undefined

const numberCache = createCache<number>();
numberCache.set('age', 18);

看,一套代码同时服务了字符串缓存和数字缓存,类型还完全安全。

十、总结:泛型是“类型编程”的起点

  • 泛型就是“类型的参数”,让组件(函数、类、接口)能适应多种类型,同时保留类型关系。
  • 约束extends限定泛型的范围。
  • 泛型接口/类让数据结构通用。
  • 内置工具类型(Partial、Pick等)简化常见类型转换。
  • 联合类型表示“或”,交叉类型表示“且”,类型保护用来区分联合中的具体类型。

掌握泛型,你就能写出更抽象、更复用、更安全的代码。明天我们将继续TypeScript的高级主题——装饰器,看看这个类似Java注解的特性,如何在TS里玩出花样。

如果你觉得今天的“万能模具”讲得通透,点个赞让更多人看到。明天我们聊聊装饰器——那个在Angular和NestJS里无处不在的黑魔法。我们明天见!

你的JS代码总在半夜崩溃?TypeScript来“上保险”了

你有没有经历过:凌晨三点,线上报“Cannot read property 'name' of undefined”,你爬起来一看,原来是后端返回的数据少了一层。如果JS有“类型检查”,这种悲剧根本不会发生。今天我们就来认识TypeScript——给JavaScript买了一份“意外险”。

前言

JavaScript就像个自由散漫的天才:你给它一个字符串,它当数字用;你忘记传参数,它给你个undefined;你访问对象不存在的属性,它笑眯眯地说“没事,我给你undefined”。这种灵活在小型项目里很爽,但项目一大,就成了噩梦。

TypeScript(简称TS)就是来解决这个问题的。它给JS加上了类型系统,在代码运行之前就帮你检查类型错误。就像给代码装了安检门,不规范的写法根本过不去。

一、TypeScript是啥?JS的“严格模式”Pro Max

TypeScript是微软开发的开源语言,它是JavaScript的超集。意思是:所有合法的JS代码,在TS里也合法。TS只是给JS加了类型注解和一些新特性,然后编译成干净的JS。

// JS写法
function greet(name) {
  return 'Hello, ' + name;
}

// TS写法(加了类型)
function greet(name: string): string {
  return 'Hello, ' + name;
}

greet(123); // ❌ 报错:参数不能是数字

你看,TS在编译阶段就抓住了错误,不用等到运行时。

二、为什么要用TS?三个字:稳、爽、香

  • :类型错误在写代码时就暴露,而不是在用户手里炸。
  • :编辑器智能提示飞起,不用记方法名、参数顺序。
  • :代码即文档,看函数签名就知道怎么用。

据统计,使用TS的项目,早期Bug能减少15%~25%。对于中大型项目,TS几乎是标配。

三、基础类型:TS的“基本词汇”

TS支持JS的所有类型,还加了一些新的。

1. 原始类型

let name: string = '张三';
let age: number = 18;
let isStudent: boolean = true;
let nothing: null = null;
let notDefined: undefined = undefined;
let big: bigint = 100n;
let sym: symbol = Symbol('id');

2. 数组

let list1: number[] = [1, 2, 3];
let list2: Array<string> = ['a', 'b'];  // 泛型写法

3. 元组(固定长度和类型的数组)

let person: [string, number] = ['张三', 18];
person[0] = '李四';  // OK
person[1] = '20';   // ❌ 报错,第二个元素必须是数字

4. 枚举(给一组数字起名字)

enum Color { Red, Green, Blue }
let c: Color = Color.Red;
console.log(c); // 0(默认从0开始)

// 自定义值
enum Status { Success = 200, NotFound = 404 }

5. Any(万能类型,慎用)

let notSure: any = 4;
notSure = '字符串';  // OK
notSure = true;      // OK

any会关闭类型检查,相当于回到JS。尽量少用,除非你确定这个值无法预知类型。

6. Unknown(安全的Any)

let value: unknown = 'hello';
value = 123;  // OK
// console.log(value.toUpperCase()); // ❌ 报错,unknown不能直接调用方法
if (typeof value === 'string') {
  console.log(value.toUpperCase()); // 类型收窄后可用
}

unknownany安全,因为使用前必须先判断类型。

7. Void(没有返回值)

function warnUser(): void {
  console.log('警告');
}
// 变量声明为void类型只能赋值为null或undefined(strict模式下只能undefined)

8. Never(永远不会发生的类型)

function error(message: string): never {
  throw new Error(message);
}

function infiniteLoop(): never {
  while (true) {}
}

四、类型注解:给变量贴标签

TS的核心就是类型注解:在变量、函数参数、返回值后面加上: 类型

let myName: string = '张三';
function add(a: number, b: number): number {
  return a + b;
}

但TS很智能,很多时候可以类型推断,不用显式写:

let age = 18; // TS自动推断为number
age = '18';   // ❌ 报错

五、接口(Interface):定义对象的形状

接口是TS里最常用的功能,用来描述对象的结构。

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

const zhangsan: Person = {
  name: '张三',
  age: 18,
  id: 1
};
zhangsan.id = 2; // ❌ 报错,只读属性不能改

接口还可以描述函数类型:

interface AddFunc {
  (a: number, b: number): number;
}
const add: AddFunc = (x, y) => x + y;

六、类型别名(Type):给类型起外号

类型别名和接口很像,但能表示联合类型、元组等更复杂的类型。

type ID = string | number;  // 联合类型
type Point = [number, number]; // 元组
type Callback = (data: string) => void;

let userId: ID = 123;
userId = 'abc';

接口 vs 类型别名

  • 接口可以扩展(extends),类型别名用交叉(&)。
  • 接口可以重复定义自动合并,类型别名不能重复。
  • 推荐优先用接口描述对象,用类型别名描述联合、元组等。

七、实战:用TS写一个简单的函数

// 需求:格式化用户信息
interface User {
  name: string;
  age: number;
  address?: string;
}

function formatUser(user: User, withAddress: boolean = false): string {
  let base = `${user.name}, ${user.age}岁`;
  if (withAddress && user.address) {
    base += `, 地址:${user.address}`;
  }
  return base;
}

const u: User = { name: '李四', age: 20, address: '北京' };
console.log(formatUser(u, true)); // "李四, 20岁, 地址:北京"

如果你在编辑器里打formatUser(,它会提示参数类型和返回值类型,爽不爽?

八、常见坑点与建议

  1. 不要滥用any:any越多,TS的价值越低。实在不知道类型,先写unknown
  2. 严格模式:开启strict: true(tsconfig.json),让TS更严格地检查。
  3. 第三方库:大多数库都有@types/xxx类型定义,安装后就能获得智能提示。
  4. 编译后的JS:TS只负责编译时检查,运行时还是JS,类型信息会被擦除。

九、总结:TS不是敌人,是保镖

  • 给JS加上类型,提前发现错误。
  • 基础类型、接口、类型别名是核心工具。
  • 用好类型推断,少写冗余注解。
  • 逐步迁移老项目,从.js改成.ts,开启allowJs: true

学TS并不难,你只需要把“写JS时的心理预期”明确写出来。明天我们继续深入TypeScript,聊聊高级类型——泛型、联合类型、交叉类型、类型保护,让你写出更灵活更安全的代码。

如果你觉得今天的“保险课”够实在,点个赞让更多人看到。我们明天见!

用 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…

❌