携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 22 天,点击查看活动详情
最近闲暇之余在教五年级的小玉学习编程,她已经学会了基本的 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
实现分词器
步骤比较多,但是思路很简单,直接贴上完整代码,代码中有详细的注释。
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/
源码地址:github.com/luzhenqian/…