神笔:在浏览器里造一门给小朋友的编程语言

Two Children Playing with Goldfish, Metropolitan Museum of Art
《两个孩子戏金鱼》— 孩子天生就在探索和互动中学习。少儿编程也应该如此——不是教语法,而是让孩子体会到"我写的东西能控制世界"的魔力
这篇文章的价值:Scratch 很强大但对小朋友来说太复杂,Apple Swift Playgrounds 很优雅但和真实编程语言脱节。这篇文章分享一个不同的方案——在浏览器里从零实现一门中英双语的 Python-like 编程语言,包含完整的 Lexer → Parser → Compiler → VM 流水线、积木/代码双向同步、以及一个 World 抽象让同一套语言能驱动不同类型的游戏。如果你对"怎么造一门语言"或"怎么设计少儿编程产品"感兴趣,这里有完整的工程细节。

为什么要自己造

市面上的少儿编程工具大致分两类:

我想要的是:

  1. 足够简单:5 岁的孩子看得懂积木,8 岁的孩子能读懂代码
  2. 和真实语言接轨:语法和 Python 高度一致,学完可以无缝过渡到真正的 Python
  3. 中英双语如果if 是同一个东西,孩子可以用中文入门,逐步切换到英文
  4. 积木和代码双向切换:不是"积木生成代码"的单向关系,而是积木和代码可以随时互转

找不到满足这些条件的工具,所以自己造了一个。叫神笔(Shenbi)——写代码像画画,画出来的东西能动。

整体架构

┌─────────────────────────────────────────────┐
│                  用户界面                      │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐   │
│  │ 积木编辑器 │  │ 代码编辑器 │  │  游戏画布  │   │
│  │ (Block)   │  │ (Code)   │  │ (Canvas) │   │
│  └─────┬─────┘  └─────┬─────┘  └─────▲─────┘   │
│        │    ↕ 双向同步   │              │        │
│        └───────┬────────┘              │        │
│                ▼                       │        │
│  ┌──────────────────────┐              │        │
│  │    MiniPython 语言     │              │        │
│  │  Lexer → Parser →     │              │        │
│  │  Compiler → IR        │              │        │
│  └──────────┬───────────┘              │        │
│             ▼                          │        │
│  ┌──────────────────────┐              │        │
│  │     栈式虚拟机 (VM)    │──────────────┘        │
│  │  单步执行 / 断点 /     │                       │
│  │  回退调试              │                       │
│  └──────────┬───────────┘                       │
│             ▼                                   │
│  ┌──────────────────────┐                       │
│  │    World (游戏世界)    │                       │
│  │  MazeWorld / TurtleWorld / ...               │
│  └──────────────────────┘                       │
└─────────────────────────────────────────────────┘

从下往上看:World 是游戏状态,VM 执行代码并操作 World,Canvas 渲染 World 的状态,积木/代码编辑器 是两种编辑 MiniPython 代码的方式。

第一部分:造一门语言

Lexer:中英双语在这里实现

中英双语的核心设计很简单:在 Lexer 层,中文关键字和英文关键字映射到同一个 Token 类型。Parser 和后续的所有阶段完全不知道用户写的是中文还是英文。

const KEYWORDS: Record<string, TokenType> = {
  // 中英文映射到相同的 Token
  '如果': 'IF',      'if': 'IF',
  '否则如果': 'ELIF', 'elif': 'ELIF',
  '否则': 'ELSE',    'else': 'ELSE',
  '重复': 'REPEAT',  'repeat': 'REPEAT',
  '次': 'TIMES',     'times': 'TIMES',
  '当': 'WHILE',     'while': 'WHILE',
  '对于': 'FOR',     'for': 'FOR',
  '在': 'IN',        'in': 'IN',
  '定义': 'DEF',     'def': 'DEF',
  '返回': 'RETURN',  'return': 'RETURN',
  '真': 'TRUE',      'True': 'TRUE',
  '假': 'FALSE',     'False': 'FALSE',
  '和': 'AND',       'and': 'AND',
  '或': 'OR',        'or': 'OR',
  '不': 'NOT',       'not': 'NOT',
  '类': 'CLASS',     'class': 'CLASS',
};

标识符也支持中文字符(Unicode CJK 范围),所以变量名可以是中文:

// 这两段代码在 Lexer 之后完全等价
重复 3 次:
    前进()
    左转()

repeat 3 times:
    forward()
    turnLeft()

Lexer 还需要处理 Python 风格的缩进——用 INDENT/DEDENT token 表示代码块的开始和结束,而不是花括号。这是整个 Lexer 里最复杂的部分。

Parser → Compiler → IR

Parser 是手写的递归下降,生成 AST。Compiler 做多趟编译:先收集函数和类的定义,再编译主体代码,最后编译函数体和类方法。输出是一组线性的栈式指令:

// IR 指令集(~35 个 opcode)
type OpCode =
  // 栈操作
  | 'PUSH' | 'POP' | 'DUP'
  // 变量
  | 'LOAD' | 'STORE'
  // 算术
  | 'ADD' | 'SUB' | 'MUL' | 'DIV' | 'MOD' | 'NEG'
  // 比较
  | 'EQ' | 'NEQ' | 'LT' | 'GT' | 'LTE' | 'GTE'
  // 控制流
  | 'JUMP' | 'JUMP_IF' | 'JUMP_IF_NOT'
  // 函数
  | 'CALL' | 'CALL_USER' | 'RETURN'
  // 数据结构
  | 'ARRAY_CREATE' | 'ARRAY_GET' | 'ARRAY_SET'
  | 'OBJECT_CREATE' | 'OBJECT_GET' | 'OBJECT_SET'
  // 类
  | 'CLASS_DEF' | 'NEW' | 'METHOD_CALL'
  | 'HALT' | 'NOP';

一个简单例子的编译结果:

// MiniPython 源码
重复 3 次:
    前进()

// 编译后的 IR
PUSH 0          // 循环计数器 = 0
STORE $i        // 保存到变量
PUSH 3          // 循环上限
STORE $limit
LOAD $i         // ← 循环开始
LOAD $limit
LT              // i < limit ?
JUMP_IF_NOT end // 不满足则跳出
CALL forward:0  // 调用 forward(),0 个参数
POP             // 丢弃返回值
LOAD $i
PUSH 1
ADD
STORE $i        // i++
JUMP loop       // 跳回循环开始
HALT            // ← end

VM:能暂停的虚拟机

VM 是一个栈式虚拟机:程序计数器(PC)、操作数栈、全局变量表、调用栈。但它不是一口气跑完所有指令——它每次只执行一步,然后把控制权交还给调用方。

这是少儿编程的核心需求:孩子写了 前进(),你需要看到角色走一格的动画,然后再执行下一条。如果 VM 一口气跑完,用户只能看到最终状态。

// VM.step() 返回执行结果,而不是跑到底
step(): StepResult {
  const inst = this.program[this.pc];

  switch (inst.opcode) {
    case 'CALL': {
      const { name, argCount } = parseCallArg(inst.arg);
      const args = this.popN(argCount);

      // 游戏命令:_forward, _turnLeft, ...
      if (this.commandHandlers.has(name)) {
        this.commandHandlers.get(name)!(args);
        this.stack.push(null);
        this.pc++;
        return { action: name, actionArgs: args };
        //       ↑ 返回给调用方:"刚执行了 forward"
        //         调用方可以播放动画,然后再调 step()
      }

      // 传感器:frontBlocked, atGoal, ...
      if (this.sensorHandlers.has(name)) {
        const result = this.sensorHandlers.get(name)!(args);
        this.stack.push(result);
        this.pc++;
        return { sensorQuery: name };
      }

      // 内置函数:len, print, random, ...
      // ...
    }
    // ... 其他 opcode
  }
}

这个设计还天然支持单步调试断点:调用方控制何时调 step(),可以在每一步之间检查变量、高亮当前行。

更进一步,VM 支持回退调试——在每一步执行前保存 VM 的完整状态快照,用户可以"倒放"程序执行过程。对小朋友理解代码流程特别有帮助。

第二部分:积木 ↔ 代码双向同步

大部分积木编程平台是单向的:积木生成代码,但代码改不回积木。这意味着一旦孩子切到代码模式改了点东西,就再也回不到积木模式了。

神笔做了双向同步:

积木 → 代码

每种积木类型(command、repeat、while、if、函数定义……)有对应的代码生成规则,同时维护一个 LineToBlockMap——记录生成的每一行代码对应哪个积木 ID,用于执行时高亮对应的积木:

function generateBlockCode(block: Block, indent: number,
                           lineMap: LineToBlockMap) {
  const spaces = '    '.repeat(indent);

  switch (block.type) {
    case 'command':
      lines.push(`${spaces}${commandName}(${args})`);
      lineMap.set(currentLine, block.id);
      break;

    case 'repeat':
      lines.push(`${spaces}repeat ${block.repeatCount} times:`);
      lineMap.set(currentLine, block.id);
      // 递归处理子积木
      for (const child of block.children) {
        generateBlockCode(child, indent + 1, lineMap);
      }
      break;

    case 'if':
      lines.push(`${spaces}if ${conditionToCode(block)}:`);
      // ...
  }
}

代码 → 积木

这是更有意思的方向。代码先经过真正的 Lexer/Parser 编译成 AST,然后把 AST 节点逆向转换为积木:

function parseCodeToBlocks(code: string): Block[] | null {
  try {
    const ast = compile(code);  // 用真正的编译器解析
    return ast.body
      .map(stmt => statementToBlock(stmt))
      .filter(b => b !== null);
  } catch {
    return null;  // 语法错误,无法转换
  }
}

这里复用了 MiniPython 的编译器——不是另写一个简化的 parser,而是真正地编译代码,拿到 AST,再转成积木。这保证了代码→积木的转换和积木→代码的转换是完全对称的。

难点在于:要处理中文函数名、英文函数名、历史遗留命名的映射。比如 forward前进move 都要映射到同一个 command 类型的积木:

const COMMAND_MAP: Record<string, CommandId> = {
  move: 'move', turn: 'turn',
  移动: 'move', 转向: 'turn',
  forward: 'move', backward: 'move',  // legacy
  前进: 'move', 后退: 'move',
  左转: 'turn', 右转: 'turn',
  setColor: 'setColor', 设置颜色: 'setColor',
};

效果:孩子在积木模式拼好逻辑,切到代码模式看到对应的 Python 代码;在代码模式改了几行,切回积木模式看到对应的积木变化。两个模式始终同步。

第三部分:World 模型

这是我觉得设计得最好的部分。神笔不只有迷宫游戏——还有海龟画图(Turtle Graphics)、后续可以扩展更多游戏类型。问题是:怎么让同一套语言和 VM 驱动完全不同类型的游戏?

答案是 World 抽象

架构模式:
- World:数据模型(状态 + 命令 + 传感器)
- VM:代码执行器(把代码映射到 World 的方法)
- Canvas:渲染器(显示 World 的状态)

每种游戏类型实现自己的 World、自己的 VM 绑定、自己的 Canvas,但共享同一个 MiniPython 语言和 VM 核心。

MazeWorld

class MazeWorld {
  // 状态
  grid: Cell[][];
  x: number; y: number;
  direction: 'up' | 'down' | 'left' | 'right';
  collected: number;
  steps: number;

  // 命令
  moveForward(): boolean;
  turnLeft(): void;
  turnRight(): void;
  collect(): boolean;

  // 传感器
  isFrontBlocked(): boolean;
  isAtGoal(): boolean;
  hasStarHere(): boolean;

  // 序列化(给 VM 用)
  toSharedState(): SharedMazeState;
  syncFromSharedState(state: SharedMazeState): void;
}

TurtleWorld

class TurtleWorld {
  // 状态
  x: number; y: number;
  angle: number;
  penDown: boolean;
  penColor: string;
  lines: LineSegment[];

  // 命令
  forward(distance: number): void;
  turnLeft(degrees: number): void;
  penUp(): void;
  setColor(color: string): void;

  // 序列化
  toSharedState(): SharedTurtleState;
  syncFromSharedState(state: SharedTurtleState): void;
}

关键设计:SharedState 桥接

World 和 VM 之间通过 SharedState 连接。VM 启动时,把 World 的状态序列化成一个普通 JavaScript 对象,注入到 MiniPython 的全局作用域里叫 world

// MazeVM.loadWithSource()
loadWithSource(userCode: string): void {
  const fullCode = MAZE_STDLIB + '\n' + userCode;
  const program = compileToIR(compile(fullCode));
  this.sharedState = this.world.toSharedState();
  this.vm.load(program);
  this.vm.setGlobal('world', this.sharedState);
  //                  ↑ MiniPython 代码里可以直接读写 world.x, world.y, ...
}

然后 MiniPython 的标准库代码直接操作这个 world 对象:

# MiniPython 标准库(迷宫)
def forward():
    offset = DIRECTION_OFFSETS[world.direction]
    newX = world.x + offset[0]
    newY = world.y + offset[1]
    if getCell(newX, newY) == 'wall':
        return False
    _forward()    # ← 唯一的 native 调用,通知 Canvas 播放动画
    return True

def frontBlocked():
    offset = DIRECTION_OFFSETS[world.direction]
    newX = world.x + offset[0]
    newY = world.y + offset[1]
    return getCell(newX, newY) == 'wall'

注意:forward() 的碰撞检测、坐标计算都是 MiniPython 自己做的。只有 _forward() 是 native 命令——它通知 Canvas "角色移动了一格",Canvas 播放动画。标准库是 用自己的语言写的

海龟画图的标准库也是同样的模式:

# MiniPython 标准库(海龟)
def forward(distance):
    radians = world.angle * 3.14159265359 / 180
    dx = _cos(radians) * distance * world.stepSize
    dy = _sin(radians) * distance * world.stepSize * -1
    oldX = world.x
    oldY = world.y
    world.x = world.x + dx
    world.y = world.y + dy
    if world.penDown:
        _drawLine(oldX, oldY, world.x, world.y,
                  world.penColor, world.penWidth)

只有 _cos_sin_drawLine 是 native——因为三角函数和画线是 MiniPython 做不了的。其余的坐标计算全在 MiniPython 里完成。

加一种新游戏需要什么

World 模型让扩展新游戏变得很清晰:

  1. 定义一个新的 World 类(状态 + 命令 + 传感器 + toSharedState)
  2. 写一个对应的 VM 绑定类,注册少量 native 命令
  3. 用 MiniPython 写标准库——大部分游戏逻辑在这里
  4. 写一个 Canvas 组件渲染 World 状态

语言、编译器、VM 核心、积木编辑器——全部复用,零修改。

关卡系统:条件也是表达式

每个关卡是一个 JSON 文件,用 ASCII art 定义地图:

{
  "id": "maze-01",
  "name": "First Steps",
  "grid": [
    "########",
    "#>...*G#",    // > = 玩家朝右, * = 星星, G = 终点
    "########"
  ],
  "availableBlocks": ["command"],
  "winCondition": "collectedCount() >= 1 and atGoal()",
  "failCondition": "stepCount() > 10",
  "hints": [
    "Use the Forward block to move the robot",
    "Keep going until you reach the flag!"
  ]
}

winConditionfailCondition 不是硬编码的枚举——它们是 MiniPython 表达式,由 VM 求值。这意味着关卡设计者(老师)可以组合任意条件,不需要改代码:

// 各种 win condition
"atGoal()"                                // 到达终点
"collectedCount() >= 3 and atGoal()"      // 收集3颗星 + 到达终点
"stepCount() <= 8 and atGoal()"           // 8步内到达终点
"collectedCount() == getRemainingStars()" // 收集所有星星

总结

回到最初的目标:

工程上最满意的三个设计:

  1. 中英双语只在 Lexer 层处理:一张关键字映射表解决问题,Parser/Compiler/VM 完全不需要关心语言。这个决定让整个系统的复杂度没有因为"双语"而翻倍
  2. World 抽象:游戏世界是纯数据模型,VM 通过 SharedState 桥接,标准库用自己的语言写。加新游戏不需要改语言和 VM
  3. VM 的 step() 设计:每次只执行一步而不是跑到底,让动画播放、单步调试、断点、回退调试都变得自然