最近闲暇之余在教五年级的小玉学习编程,她已经学会了基本的 React,并且模拟了淘宝、知乎和网易云音乐这几个网站。现在她似乎已经逐渐对枯燥无味地编程失去了兴趣。
为了继续让小玉学下去,我花了一个晚上教了她一些不一样的东西:自己做一个编程语言。
这个语言有什么用呢?画图。
比如说,它可以画一个掘金的 Logo。
由于考虑到小玉的理解能力等问题,我打算做一个最简单的脚本编程语言。
概要设计
做一个语言有两个大的步骤。
首先是要取一个名字,既然是画图的脚本语言,那就叫 DrawScript 好了。
第二部分是写代码。
代码分两块,第一块是一个编译器,第二块是一个运行时。
编译器又由三个部分组成:分词器、生成器和校验器。我们用 DS(DrawScript 的缩写)编写的代码,通过编译器生成 JS 代码。
然后放到运行时里面去运行。运行时应该也区分几个环境,比如 DOM、CLI 等。
好了,让我们开始吧。
语言设计
要实现 DrawScript 的第一步就是定义语法和关键字。
- 英文小于号<:表示绘图开始。
- 英文大于号>:表示绘图结束。
- r:rect 的缩写,内置函数,表示矩形。
- c:color 的缩写,内置函数,表示颜色。
- 英文圆括号(:参数开始
- 英文圆括号):参数结束
- 英文逗号,:参数分隔符
- 英文空格 :在一组参数中表示参数分隔符,在绘图中表示函数分隔符
使用 r 和 c 的语法和 JavaScript 的函数调用很像。
r 的参数是一组描述位置的坐标,由 x y 组成,中间用空格隔开。多组坐标之间用英文逗号分隔。理论上组成一个图形至少应该有 3 个以上的点。
c 的参数是任何有效的颜色值,比如 red、blue、green,或者十六进制格式的颜色都可以。
画一个正方形的语法是这样的:
<r(0 0, 0 40, 40 40, 40 0) c(blue)>
我们也可以画多个图形。
<r(0 0, 0 40, 40 40, 40 0) c(blue)> <r(50 50, 50 100, 100 50) c(red)>
语言实现
初始化项目
首先创建一个文件夹。
mkdir drawscript
初始化一下 Node.js 项目。
npm init -y
创建 index.js 文件。
这是我们的入口文件。
实现分词器
步骤比较多,但是思路很简单,直接贴上完整代码,代码中有详细的注释。
function tokenize(code) { const tokens = [];// 词令牌 let i = 0;// 跟踪代码位置 // 向 token 中加词令牌 const addToken = (type, value) => tokens.push({ type, value }) // 开启循环 遍历代码 while (i < code.length) { const char = code[i];// 当前字符 switch (char) { // 忽略空白字符 case " ": case "\t": case "\n": case "\r": i++; break; // 如果是 <,添加一个 START 类型的词令牌 case "<": addToken("START", char); i++; break; // 如果是 >,添加一个 END 类型的词令牌 case ">": addToken("END", char); i++; break; // 如果是 (,添加一个 PARAMETERS_START 类型的词令牌 case "(": addToken("PARAMETERS_START", char); i++; break; // 如果是 ,,添加一个 PARAMETERS_SEPARATOR 类型的词令牌 case ",": addToken("PARAMETERS_SEPARATOR", char); i++; break; // 如果是 ),添加一个 PARAMETERS_END 类型的词令牌 case ")": addToken("PARAMETERS_END", char); i++; break; // 除了上述情况外,我们开始检查它们是关键字还是数字 default: const isDigit = /\d|\./.test(char); // 是否是数字 const isLetter = /([a-z])|#/i.test(char); // 是否是字母或者十六进制颜色 // 处理数字的逻辑 if (isDigit) { let number = ""; // 拼接存储数字 while (i < code.length && /\d|\./.test(code[i])) { // 循环,直到不是数字或者十六进制颜色 number += code[i]; i++; } addToken("NUMBER", number); // 添加数字词令牌 } else if (isLetter) { let name = ""; // 拼接存储关键词令牌 while (i < code.length && /[a-z]|#/i.test(code[i])) { // 循环,直到不是字母 name += code[i]; i++; } addToken("NAME", name); // 添加关键词令牌 } else { throw new Error(`Unknown character: ${char}`); // 🤬 如果不是数字或字母,抛出错误 } break; } } // 返回词令牌 return tokens }
测试
我们现在来测试一下它是否正常工作。
首先创建一个 hello.ds 文件,ds 后缀是 DrawScript 的缩写。
在里面画一个蓝色的正方形。
<r(0 0, 0 40, 40 40, 40 0) c(blue)>
回到我们的编译器中,编写一个 load 函数,用来导入源代码文件。
const fs = require('fs'); function load(path) { return fs.readFileSync(path, "utf8") }
编写测试代码:
const code = load('./hello.ds') const tokens = tokenizer(code); console.log(tokens)
运行它。
node ./index.js
得到的结果:
[ { type: 'START', value: '<' }, { type: 'NAME', value: 'r' }, { type: 'PARAMETERS_START', value: '(' }, { type: 'NUMBER', value: '0' }, { type: 'NUMBER', value: '0' }, { type: 'PARAMETERS_SEPARATOR', value: ',' }, { type: 'NUMBER', value: '0' }, { type: 'NUMBER', value: '40' }, { type: 'PARAMETERS_SEPARATOR', value: ',' }, { type: 'NUMBER', value: '40' }, { type: 'NUMBER', value: '40' }, { type: 'PARAMETERS_SEPARATOR', value: ',' }, { type: 'NUMBER', value: '40' }, { type: 'NUMBER', value: '0' }, { type: 'PARAMETERS_END', value: ')' }, { type: 'NAME', value: 'c' }, { type: 'PARAMETERS_START', value: '(' }, { type: 'NAME', value: 'blue' }, { type: 'PARAMETERS_END', value: ')' }, { type: 'END', value: '>' } ]
现在分词器已经做好了。
实现生成器与校验器
我们的目标是将需要将分词器的产物编译为 JavaScript 代码。
步骤也比较多,但是思路简单,直接贴完整代码,代码中有详细的注释。
function generator(tokens) { let i = 0;// 跟踪代码位置 let out = '';// 输出代码 let addCode = code => out += `${code}\n`;// 添加代码 // 和分词器套路一样 while (i < tokens.length) { const token = () => tokens[i];// 当前词令牌 switch (token().type) { case "START": addCode("__ds__.start()"); break; case "END": addCode("__ds__.end()"); break; case "NAME": /** * 如果是 r position 函数 */ if (token().value === "r") { expect(tokens[++i].type, "PARAMETERS_START"); const params = [];// 存储参数 let param = []; // 拼接参数 while (token().type !== "PARAMETERS_END") { if (token().type === "NUMBER") { param.push(Number(token().value)) } if (tokens[i + 1].type === "PARAMETERS_SEPARATOR" || tokens[i + 1].type === 'PARAMETERS_END') { params.push(param); param = [] } i++; } addCode(`__ds__.rect(...${JSON.stringify(params)});`); } else if (token().value === "c") { /** * 如果是 c,执行 color 函数 */ expect(tokens[++i].type, "PARAMETERS_START"); const params = [];// 存储参数 // 拼接参数 while (token().type !== "PARAMETERS_END") { if (token().type === "NAME" || token().type === "NUMBER") { params.push(token().value); } i++; } addCode(`__ds__.color("${params.join('')}");`); } else { throw new Error(`Unknown name: ${token().value}`); // 🤬 Error } break; } i++; } return out } function expect(t1, t2) { if (t1 !== t2) { throw new Error(`Expected ${t2}, got ${t1}`); } }
同样需要对它进行测试。
const code = load('./hello.ds') const tokens = tokenizer(code); const output = generator(tokens); console.log(output);
运行。
node ./index.js
得到的结果:
__ds__.start() __ds__.rect(...[[0,0],[0,40],[40,40],[40,0]]); __ds__.color("blue"); __ds__.end()
现在我们需要把它输出到文件中。
编写 compile 函数。
function compile(entryFile) { const code = load(path.join(__dirname, entryFile)); const tokens = tokenize(code); const output = generator(tokens); console.log(output); fs.writeFileSync(path.join(__dirname, 'output.js'), output); }
现在完整的编译器已经搞定了。
添加构建脚本
我们需要添加一个 build 命令来构建项目。
在 index.js 最后添加代码。
compile(process.argv[2])
在 packages.json 中的 scripts 中添加 build 命令。
"build": "node ./index.js ./hello.ds"
现在我们可以通过 npm run build 来构建项目了。
框架实现
我们的底层使用 canvas 技术来实现。
canvas 就不多赘述了,直接上代码。
创建 runtime.js 文件。
(function (global) { const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const start = () => { ctx.beginPath(); } const rect = (...points) => { points.forEach(([x, y], i) => { if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }) } const color = (color) => { ctx.fillStyle = color } const end = () => { ctx.fill(); ctx.fillStyle = '' } global.__ds__ = { start, rect, color, end } } )(window)
创建 index.html 用来测试这个功能。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head> <body> <canvas id="canvas"></canvas> <script src="./runtime.js"></script> <script src="./output.js"></script> </body> </html>
然后就运行起来了。
画掘金 Logo
掘金的 Logo 不是很复杂,只需要找到几个点的坐标就可以画出来。
拿出我的华为 Metapad paper 画个图就可以得到这些点的坐标。
不过具体的坐标画的不准确,后面又经过手动二次调整。
一共三个图形,第一个图形四个点,第二个和第三个都是六个点。
那就可以这样画。
<r(50 10, 40 20, 50 30, 60 20) c(#487df8)> <r(30 30, 20 40, 50 57.5, 80 40, 70 30, 50 42.5) c(#487df8)> <r(10 50, 0 60, 50 85, 100 60, 90 50, 50 70) c(#487df8)>
然后就跑起来了。
哦,对了,上面的截图是我临时做的一个 drawscript playground。
分为 4 个区域,上方左侧区域编写 DS 代码,上方右侧区域是画布。点一下中间的 RUN 按钮就可以跑了。
下方左侧区域是生成的 Tokens,下方右侧区域是生成的 Code。
没什么技术含量,就不多赘述了。
结语
小玉终于又找到有挑战性的事情做了。
我还给小玉布置了一系列任务:
- 添加 a(arc) 函数来画弧形
- 添加 l(line)函数来画线
- 添加 qc(quadratic curve)函数来画二次贝塞尔曲线
- 添加 bc(bezier curve)函数来画三次贝塞尔曲线
- 支持用 node.js 绘制出 canvas 并导出 png。
- ......
估计这个简单的编程语言够她研究一段时间的。
对了,我还把它部署到线上环境去了,感兴趣的小伙伴可以去试试哦!
线上体验地址:drawscript.vercel.app/