500 行代码或更少
Blockcode:一个可视化编程工具包

Dethe Elza

Dethe 是一位极客爸爸、美学程序员、导师,也是 Waterbear 可视化编程工具的创造者。他是温哥华制造者教育沙龙的联合主持人,并希望用机器人折纸兔子填满世界。

在基于块的编程语言中,您通过拖放和连接代表程序各个部分的块来编写程序。基于块的语言不同于传统的编程语言,在传统的编程语言中,您键入单词和符号。

学习一门编程语言可能很困难,因为它们对最细微的输入错误都极其敏感。大多数编程语言区分大小写,具有模糊的语法,并且如果您将分号放在错误的位置——或者更糟的是,漏掉一个分号——就会拒绝运行。此外,当今使用的大多数编程语言都基于英语,它们的语法无法本地化。

相比之下,一个制作精良的块语言可以完全消除语法错误。您仍然可以创建执行错误操作的程序,但您无法创建语法错误的程序:块根本无法那样匹配。块语言更容易发现:您可以从块列表中看到该语言的所有结构和库。此外,块可以本地化为任何人类语言,而不会改变编程语言的含义。

Figure 1.1 - The Blockcode IDE in use

图 1.1 - Blockcode IDE 的使用

基于块的语言有着悠久的历史,其中一些突出的语言包括 乐高 MindstormsAlice3DStarLogo,尤其是 Scratch。网络上还有许多用于基于块的编程的工具:BlocklyAppInventorTynker,以及 许多其他

本章中的代码松散地基于开源项目 Waterbear,它不是一种语言,而是一个使用基于块的语法来包装现有语言的工具。这种包装的优点包括上面提到的那些:消除语法错误、可视化显示可用组件、易于本地化。此外,可视化代码有时更容易阅读和调试,块可以用于预打字的孩子。(我们甚至可以更进一步,在块上添加图标,无论是与文本名称结合使用还是替代文本名称,以允许识字前儿童编写程序,但在本示例中我们不会进行到这一步。)

选择此语言的乌龟图形可以追溯到 Logo 语言,Logo 语言的创建是为了专门教儿童编程。上面提到的许多基于块的语言都包含乌龟图形,这是一个足够小的领域,可以被像这样严格限制的项目所捕获。

如果您想感受一下基于块的语言是什么样的,您可以尝试使用作者 GitHub 存储库 中构建的本节中的程序进行实验。

目标和结构

我希望通过此代码实现几件事。首先也是最重要的是,我想实现一种用于乌龟图形的块语言,您可以使用该语言通过简单地拖放块来编写代码以创建图像,使用尽可能简单的 HTML、CSS 和 JavaScript 结构。其次,但同样重要的是,我想展示这些块本身如何作为除了我们的小乌龟语言之外的其他语言的框架。

为此,我们将所有特定于乌龟语言的内容封装到一个文件中 (turtle.js),我们可以轻松地用另一个文件替换该文件。其他内容不应该特定于乌龟语言;其余部分只应该与处理块有关 (blocks.jsmenu.js) 或者是一些通用的网络实用程序 (util.jsdrag.jsfile.js)。这是目标,尽管为了保持项目的规模很小,其中一些实用程序的用途不那么通用,而更多地是针对它们与块的使用情况而设计。

在编写块语言时,我发现的一件事是,语言本身就是它的 IDE。您不能仅仅使用您最喜欢的文本编辑器来编写块;IDE 必须与块语言并行设计和开发。这有一些优点和缺点。从好的方面来说,每个人都会使用一致的环境,并且没有关于使用哪个编辑器的宗教战争的空间。从不好的方面来说,它会成为构建块语言本身的巨大干扰。

脚本的本质

Blockcode 脚本,就像任何语言中的脚本一样(无论是基于块的还是基于文本的),都是要执行的一系列操作。在 Blockcode 的情况下,脚本由 HTML 元素组成,这些元素会被迭代,并且每个元素都与一个特定的 JavaScript 函数相关联,该函数将在该块的回合到来时运行。一些块可以包含(并负责运行)其他块,而一些块可以包含传递给函数的数字参数。

在大多数(基于文本的)语言中,脚本会经历几个阶段:词法分析器将文本转换为识别出的标记,语法分析器将标记组织成抽象语法树,然后根据语言,程序可能会被编译成机器码或输入到解释器中。这只是一个简化;可能还有更多步骤。对于 Blockcode,脚本区域中块的布局已经代表了我们的抽象语法树,因此我们不必经历词法分析和语法分析阶段。我们使用访问者模式迭代这些块,并调用与每个块相关的预定义 JavaScript 函数来运行程序。

没有什么能阻止我们在更像传统语言的阶段中添加更多阶段。与其简单地调用关联的 JavaScript 函数,我们可以用一个生成不同虚拟机的字节码的块语言替换 turtle.js,甚至可以用 C++ 代码替换编译器。块语言存在(作为 Waterbear 项目的一部分),用于生成 Java 机器人代码,用于编程 Arduino,以及用于脚本在 Raspberry Pi 上运行的 Minecraft。

网络应用程序

为了使该工具能够被尽可能广泛的受众使用,它是网络原生的。它使用 HTML、CSS 和 JavaScript 编写,因此应该可以在大多数浏览器和平台上运行。

现代网络浏览器是功能强大的平台,具有丰富的工具集,可用于构建出色的应用程序。如果实现中的某些内容变得过于复杂,我会将其视为我没有以“网络方式”执行操作的迹象,并在可能的情况下尝试重新思考如何更好地使用浏览器工具。

网络应用程序与传统桌面应用程序或服务器应用程序的一个重要区别是缺乏 main() 或其他入口点。没有显式运行循环,因为这已经内置在浏览器中,并且在每个网页上都是隐式的。我们的所有代码都将在加载时进行解析和执行,此时我们可以注册我们感兴趣的事件,以与用户进行交互。在第一次运行之后,我们对代码的所有进一步交互都将通过我们设置和注册的回调来进行,无论我们是为事件(如鼠标移动)注册回调、超时(以我们指定的周期性触发)还是帧处理程序(在每个屏幕重新绘制时调用,通常每秒 60 帧)。浏览器也不公开完整的功能线程(只有无共享的 Web 工作线程)。

逐步浏览代码

在整个项目中,我一直试图遵循一些约定和最佳实践。每个 JavaScript 文件都包装在一个函数中,以避免将变量泄漏到全局环境中。如果它需要将变量公开到其他文件,它将定义每个文件的单个全局变量,基于文件名,并将公开的函数放在其中。这将位于文件末尾,紧随该文件设置的任何事件处理程序之后,因此您始终可以快速浏览文件末尾,以查看它处理哪些事件以及它公开了哪些函数。

代码风格是过程式的,而不是面向对象的或函数式的。我们可以在任何这些范式中完成相同的事情,但这将需要更多设置代码和包装器来强加于 DOM 中已存在的内容。最近关于 自定义元素 的工作使得以面向对象的方式使用 DOM 变得更加容易,并且在 函数式 JavaScript 上已经有了很多很棒的写作,但这两种方法都需要一些强加,因此感觉保持过程式的更简单。

此项目中有八个源文件,但 index.htmlblocks.css 是应用程序的基本结构和样式,不会讨论。两个 JavaScript 文件也不会详细讨论:util.js 包含一些辅助程序,并作为不同浏览器实现之间的桥梁——类似于 jQuery 之类的库,但代码少于 50 行。file.js 是一个类似的实用程序,用于加载和保存文件以及序列化脚本。

以下是其余的文件

blocks.js

每个块都包含几个 HTML 元素,用 CSS 进行样式设置,并有一些 JavaScript 事件处理程序用于拖放和修改输入参数。blocks.js 文件有助于创建和管理这些作为单个对象存在的元素分组。当将一种类型的块添加到块菜单时,它会与一个 JavaScript 函数关联,以实现该语言,因此脚本中的每个块都必须能够找到其关联的函数并在脚本运行时调用它。

Figure 1.2 - An example block

图 1.2 - 一个示例块

块有两个可选的结构部分。它们可以有一个数字参数(具有默认值),并且它们可以是其他块的容器。这些是硬限制,但在更大的系统中会放宽。在 Waterbear 中,还有表达式块,可以作为参数传递;支持多种类型的多个参数。在这里,在严格限制的土地上,我们将看看我们可以用一种类型的参数做些什么。

<!-- The HTML structure of a block -->
<div class="block" draggable="true" data-name="Right">
    Right
    <input type="number" value="5">
    degrees
</div>

需要注意的是,菜单中的块和脚本中的块之间没有真正的区别。拖放会根据它们从哪里拖放进行稍微不同的处理,当我们运行脚本时,它只会查看脚本区域中的块,但它们本质上是相同的结构,这意味着当从菜单中拖放到脚本中时,我们可以克隆块。

createBlock(name, value, contents) 函数返回一个块作为 DOM 元素,其中填充了所有内部元素,已准备好插入文档中。这可以用于创建菜单中的块,或者用于恢复从文件或 localStorage 中保存的脚本块。虽然它以这种方式很灵活,但它是专门为 Blockcode“语言”构建的,并且对它做出了假设,因此如果有一个值,它假设该值表示一个数字参数,并创建一个类型为“number”的输入。由于这是 Blockcode 的限制,这很好,但是如果我们要扩展块以支持其他类型的参数,或多个参数,代码将不得不更改。

    function createBlock(name, value, contents){
        var item = elem('div',
            {'class': 'block', draggable: true, 'data-name': name},
            [name]
        );
        if (value !== undefined && value !== null){
            item.appendChild(elem('input', {type: 'number', value: value}));
        }
        if (Array.isArray(contents)){
            item.appendChild(
                elem('div', {'class': 'container'}, contents.map(function(block){
                return createBlock.apply(null, block);
            })));
        }else if (typeof contents === 'string'){
            // Add units (degrees, etc.) specifier
            item.appendChild(document.createTextNode(' ' + contents));
        }
        return item;
    }

我们有一些用于处理块作为 DOM 元素的实用程序

    function blockContents(block){
        var container = block.querySelector('.container');
        return container ? [].slice.call(container.children) : null;
    }

    function blockValue(block){
        var input = block.querySelector('input');
        return input ? Number(input.value) : null;
    }

    function blockUnits(block){
        if (block.children.length > 1 &&
            block.lastChild.nodeType === Node.TEXT_NODE &&
            block.lastChild.textContent){
            return block.lastChild.textContent.slice(1);
        }
    }

    function blockScript(block){
        var script = [block.dataset.name];
        var value = blockValue(block);
        if (value !== null){
            script.push(blockValue(block));
        }
        var contents = blockContents(block);
        var units = blockUnits(block);
        if (contents){script.push(contents.map(blockScript));}
        if (units){script.push(units);}
        return script.filter(function(notNull){ return notNull !== null; });
    }

    function runBlocks(blocks){
        blocks.forEach(function(block){ trigger('run', block); });
    }

drag.js

drag.js 的目的是通过实现视图的菜单部分和脚本部分之间的交互,将静态的 HTML 块转换为动态编程语言。用户通过将块从菜单拖放到脚本中来构建程序,系统运行脚本区域中的块。

我们使用 HTML5 拖放;它所需的特定 JavaScript 事件处理程序在此定义。(有关使用 HTML5 拖放的更多信息,请参阅 Eric Bidleman 的文章。)虽然内置的拖放支持很好,但它确实有一些怪癖和一些相当大的限制,例如在撰写本文时,它在任何移动浏览器中都没有实现。

我们在文件顶部定义一些变量。当我们拖动时,我们需要从拖动回调舞蹈的不同阶段引用这些变量。

    var dragTarget = null; // Block we're dragging
    var dragType = null; // Are we dragging from the menu or from the script?
    var scriptBlocks = []; // Blocks in the script, sorted by position

根据拖动开始和结束的位置,drop 将具有不同的效果。

dragStart(evt) 处理程序期间,我们开始跟踪块是从菜单复制还是从(或在)脚本中移动。我们还获取脚本中所有未被拖动的块的列表,以便稍后使用。evt.dataTransfer.setData 调用用于在浏览器和其他应用程序(或桌面)之间进行拖放,我们没有使用它,但必须调用它来解决一个错误。

    function dragStart(evt){
        if (!matches(evt.target, '.block')) return;
        if (matches(evt.target, '.menu .block')){
            dragType = 'menu';
        }else{
            dragType = 'script';
        }
        evt.target.classList.add('dragging');
        dragTarget = evt.target;
        scriptBlocks = [].slice.call(
            document.querySelectorAll('.script .block:not(.dragging)'));
        // For dragging to take place in Firefox, we have to set this, even if
        // we don't use it
        evt.dataTransfer.setData('text/html', evt.target.outerHTML);
        if (matches(evt.target, '.menu .block')){
            evt.dataTransfer.effectAllowed = 'copy';
        }else{
            evt.dataTransfer.effectAllowed = 'move';
        }
    }

当我们拖动时,dragenterdragoverdragout 事件为我们提供了通过突出显示有效的放置目标等来添加视觉提示的机会。在这些事件中,我们只使用 dragover

    function dragOver(evt){
        if (!matches(evt.target, '.menu, .menu *, .script, .script *, .content')) {
            return;
        }
        // Necessary. Allows us to drop.
        if (evt.preventDefault) { evt.preventDefault(); }
        if (dragType === 'menu'){
            // See the section on the DataTransfer object.
            evt.dataTransfer.dropEffect = 'copy';  
        }else{
            evt.dataTransfer.dropEffect = 'move';
        }
        return false;
    }

当我们释放鼠标时,我们会收到 drop 事件。这就是魔法发生的地方。我们必须检查我们从哪里拖动(在 dragStart 中设置)以及我们拖动了哪里。然后,我们根据需要复制块、移动块或删除块。我们使用 trigger()(在 util.js 中定义)触发了一些自定义事件,以便在块逻辑中使用,这样我们就可以在脚本发生变化时刷新脚本。

    function drop(evt){
        if (!matches(evt.target, '.menu, .menu *, .script, .script *')) return;
        var dropTarget = closest(
            evt.target, '.script .container, .script .block, .menu, .script');
        var dropType = 'script';
        if (matches(dropTarget, '.menu')){ dropType = 'menu'; }
        // stops the browser from redirecting.
        if (evt.stopPropagation) { evt.stopPropagation(); }
        if (dragType === 'script' && dropType === 'menu'){
            trigger('blockRemoved', dragTarget.parentElement, dragTarget);
            dragTarget.parentElement.removeChild(dragTarget);
        }else if (dragType ==='script' && dropType === 'script'){
            if (matches(dropTarget, '.block')){
                dropTarget.parentElement.insertBefore(
                    dragTarget, dropTarget.nextSibling);
            }else{
                dropTarget.insertBefore(dragTarget, dropTarget.firstChildElement);
            }
            trigger('blockMoved', dropTarget, dragTarget);
        }else if (dragType === 'menu' && dropType === 'script'){
            var newNode = dragTarget.cloneNode(true);
            newNode.classList.remove('dragging');
            if (matches(dropTarget, '.block')){
                dropTarget.parentElement.insertBefore(
                    newNode, dropTarget.nextSibling);
            }else{
                dropTarget.insertBefore(newNode, dropTarget.firstChildElement);
            }
            trigger('blockAdded', dropTarget, newNode);
        }
    }

当我们鼠标抬起时,会调用 dragEnd(evt),但在我们处理 drop 事件之后。在这里,我们可以清理、从元素中删除类并为下一个拖动重置内容。

    function _findAndRemoveClass(klass){
        var elem = document.querySelector('.' + klass);
        if (elem){ elem.classList.remove(klass); }
    }

    function dragEnd(evt){
        _findAndRemoveClass('dragging');
        _findAndRemoveClass('over');
        _findAndRemoveClass('next');
    }

文件 menu.js 是将块与运行时调用的函数相关联的地方,并且包含在用户构建脚本时实际运行脚本的代码。每次修改脚本时,它都会自动重新运行。

在这个上下文中,“菜单”不是像大多数应用程序中的下拉(或弹出)菜单,而是您可以为脚本选择的块列表。此文件设置了它,并使用一个通常有用的循环块启动了菜单(因此它不是海龟语言本身的一部分)。这有点像一个零碎的文件,用于那些可能不适合任何其他地方的东西。

拥有一个单一文件来收集随机函数很有用,尤其是在开发体系结构时。我的保持干净房子的理论是为杂物指定位置,这同样适用于构建程序体系结构。一个文件或模块成为所有没有明确位置可以放入的项目的集合点。随着此文件的增长,重要的是要注意出现的模式:几个相关的函数可以拆分到一个单独的模块(或合并到一个更通用的函数中)。您不希望集合点无限增长,而只是在您找到组织代码的正确方法之前,它只是一个临时的存放场所。

我们保留对 menuscript 的引用,因为我们经常使用它们;没有必要一遍又一遍地在 DOM 中搜索它们。我们还将使用 scriptRegistry,在其中存储菜单中块的脚本。我们使用了一个非常简单的名称到脚本映射,它不支持具有相同名称的多个菜单块或重命名块。更复杂的脚本环境将需要更健壮的东西。

我们使用 scriptDirty 来跟踪脚本自上次运行以来是否已修改,因此我们不会不断地尝试运行它。

    var menu = document.querySelector('.menu');
    var script = document.querySelector('.script');
    var scriptRegistry = {};
    var scriptDirty = false;

当我们想要通知系统在下一个帧处理程序期间运行脚本时,我们会调用 runSoon(),它将 scriptDirty 标志设置为 true。系统在每一帧上调用 run(),但除非 scriptDirty 设置,否则立即返回。当 scriptDirty 设置时,它将运行所有脚本块,并触发事件以让特定语言处理脚本运行之前和之后所需的任何任务。这将块作为工具包与海龟语言分离,从而使块可重用(或语言可插拔,具体取决于您的看法)。

作为运行脚本的一部分,我们遍历每个块,对其调用 runEach(evt),它在块上设置一个类,然后查找并执行其关联的函数。如果我们降低速度,您应该能够看到代码执行,因为每个块都会突出显示以显示它何时运行。

下面的 requestAnimationFrame 方法由浏览器提供,用于动画。它接受一个函数,该函数将在调用后由浏览器(以每秒 60 帧的速度)渲染的下一帧被调用。我们实际获得的帧数取决于我们在该调用中可以完成工作的速度。

    function runSoon(){ scriptDirty = true; }

    function run(){
        if (scriptDirty){
            scriptDirty = false;
            Block.trigger('beforeRun', script);
            var blocks = [].slice.call(
                document.querySelectorAll('.script > .block'));
            Block.run(blocks);
            Block.trigger('afterRun', script);
        }else{
            Block.trigger('everyFrame', script);
        }
        requestAnimationFrame(run);
    }
    requestAnimationFrame(run);

    function runEach(evt){
        var elem = evt.target;
        if (!matches(elem, '.script .block')) return;
        if (elem.dataset.name === 'Define block') return;
        elem.classList.add('running');
        scriptRegistry[elem.dataset.name](elem);
        elem.classList.remove('running');
    }

我们使用 menuItem(name, fn, value, contents) 将块添加到菜单中,该函数接受一个普通块,将其与一个函数关联,并将其放在菜单列中。

    function menuItem(name, fn, value, units){
        var item = Block.create(name, value, units);
        scriptRegistry[name] = fn;
        menu.appendChild(item);
        return item;
    }

我们在海龟语言之外定义 repeat(block),因为它在不同的语言中通常很有用。如果我们有用于条件语句和读写变量的块,它们也可以放在这里,或者放在一个单独的跨语言模块中,但现在我们只定义了一个这些通用块。

    function repeat(block){
        var count = Block.value(block);
        var children = Block.contents(block);
        for (var i = 0; i < count; i++){
            Block.run(children);
        }
    }
    menuItem('Repeat', repeat, 10, []);

turtle.js

turtle.js 是海龟块语言的实现。它不对代码的其余部分公开任何函数,因此其他任何东西都不依赖于它。这样,我们可以交换一个文件来创建一个新的块语言,并知道核心中的任何内容都不会中断。

Figure 1.3 - Example of Turtle code running

图 1.3 - 海龟代码运行的示例

海龟编程是一种图形编程风格,最初由 Logo 推广,您有一只假想的乌龟带着一支笔在屏幕上行走。您可以告诉乌龟抬起笔(停止绘制,但仍然移动)、放下笔(在它去的所有地方留下线条)、向前移动若干步或旋转若干度。仅使用这些命令与循环相结合,就可以创建令人惊叹的复杂图像。

在这个版本的乌龟图形中,我们有一些额外的块。从技术上讲,我们不需要 turn rightturn left,因为您可以使用其中一个,并使用负数获得另一个。同样,move back 可以使用 move forward 和负数来完成。在这种情况下,拥有两者感觉更平衡。

上面的图像通过将两个循环放在另一个循环中,并在每个循环中添加 move forwardturn right,然后交互式地玩弄参数,直到我喜欢的图像出现,从而形成的。

    var PIXEL_RATIO = window.devicePixelRatio || 1;
    var canvasPlaceholder = document.querySelector('.canvas-placeholder');
    var canvas = document.querySelector('.canvas');
    var script = document.querySelector('.script');
    var ctx = canvas.getContext('2d');
    var cos = Math.cos, sin = Math.sin, sqrt = Math.sqrt, PI = Math.PI;
    var DEGREE = PI / 180;
    var WIDTH, HEIGHT, position, direction, visible, pen, color;

reset() 函数将所有状态变量清除为其默认值。如果我们要支持多只乌龟,这些变量将封装在一个对象中。我们还有一个实用程序 deg2rad(deg),因为我们在 UI 中以度为单位工作,但在弧度中绘制。最后,drawTurtle() 绘制乌龟本身。默认的乌龟只是一个三角形,但您可以覆盖它以绘制一个更美观的乌龟。

请注意,drawTurtle 使用我们定义的相同基本操作来实现乌龟绘制。有时您不希望在不同的抽象层重用代码,但当含义明确时,这对于代码大小和性能来说可能是巨大的胜利。

    function reset(){
        recenter();
        direction = deg2rad(90); // facing "up"
        visible = true;
        pen = true; // when pen is true we draw, otherwise we move without drawing
        color = 'black';
    }

    function deg2rad(degrees){ return DEGREE * degrees; }

    function drawTurtle(){
        var userPen = pen; // save pen state
        if (visible){
            penUp(); _moveForward(5); penDown();
            _turn(-150); _moveForward(12);
            _turn(-120); _moveForward(12);
            _turn(-120); _moveForward(12);
            _turn(30);
            penUp(); _moveForward(-5);
            if (userPen){
                penDown(); // restore pen state
            }
        }
    }

我们有一个特殊的块,可以在当前鼠标位置绘制具有给定半径的圆圈。我们专门处理 drawCircle,因为虽然您当然可以通过重复 MOVE 1 RIGHT 1 360 次来绘制一个圆圈,但以这种方式控制圆圈的大小非常困难。

    function drawCircle(radius){
        // Math for this is from http://www.mathopenref.com/polygonradius.html
        var userPen = pen; // save pen state
        if (visible){
            penUp(); _moveForward(-radius); penDown();
            _turn(-90);
            var steps = Math.min(Math.max(6, Math.floor(radius / 2)), 360);
            var theta = 360 / steps;
            var side = radius * 2 * Math.sin(Math.PI / steps);
            _moveForward(side / 2);
            for (var i = 1; i < steps; i++){
                _turn(theta); _moveForward(side);
            }
            _turn(theta); _moveForward(side / 2);
            _turn(90);
            penUp(); _moveForward(radius); penDown();
            if (userPen){
                penDown(); // restore pen state
            }
        }
    }

我们的主要原语是 moveForward,它必须处理一些基本三角函数,并检查笔是否向上或向下。

    function _moveForward(distance){
        var start = position;
        position = {
            x: cos(direction) * distance * PIXEL_RATIO + start.x,
            y: -sin(direction) * distance * PIXEL_RATIO + start.y
        };
        if (pen){
            ctx.lineStyle = color;
            ctx.beginPath();
            ctx.moveTo(start.x, start.y);
            ctx.lineTo(position.x, position.y);
            ctx.stroke();
        }
    }

大多数其他乌龟命令可以很容易地用我们上面构建的内容来定义。

    function penUp(){ pen = false; }
    function penDown(){ pen = true; }
    function hideTurtle(){ visible = false; }
    function showTurtle(){ visible = true; }
    function forward(block){ _moveForward(Block.value(block)); }
    function back(block){ _moveForward(-Block.value(block)); }
    function circle(block){ drawCircle(Block.value(block)); }
    function _turn(degrees){ direction += deg2rad(degrees); }
    function left(block){ _turn(Block.value(block)); }
    function right(block){ _turn(-Block.value(block)); }
    function recenter(){ position = {x: WIDTH/2, y: HEIGHT/2}; }

当我们想要一个新的空白画布时,clear 函数会将所有内容恢复到我们开始的地方。

    function clear(){
        ctx.save();
        ctx.fillStyle = 'white';
        ctx.fillRect(0,0,WIDTH,HEIGHT);
        ctx.restore();
        reset();
        ctx.moveTo(position.x, position.y);
    }

当此脚本首次加载并运行时,我们使用 resetclear 来初始化所有内容并绘制乌龟。

    onResize();
    clear();
    drawTurtle();

现在,我们可以使用上面的函数以及 menu.js 中的 Menu.item 函数,为用户创建用于构建脚本的块。这些块被拖放到位以创建用户的程序。

    Menu.item('Left', left, 5, 'degrees');
    Menu.item('Right', right, 5, 'degrees');
    Menu.item('Forward', forward, 10, 'steps');
    Menu.item('Back', back, 10, 'steps');
    Menu.item('Circle', circle, 20, 'radius');
    Menu.item('Pen up', penUp);
    Menu.item('Pen down', penDown);
    Menu.item('Back to center', recenter);
    Menu.item('Hide turtle', hideTurtle);
    Menu.item('Show turtle', showTurtle);

经验教训

为什么不使用 MVC?

模型-视图-控制器 (MVC) 是 80 年代 Smalltalk 程序的一个很好的设计选择,它可以以某种变体或其他方式适用于 Web 应用程序,但它不是每个问题的正确工具。块语言中的块元素本身捕获了所有状态(MVC 中的“模型”),因此将其复制到 Javascript 中几乎没有益处,除非模型有其他需要(例如,如果我们正在编辑共享的分布式代码)。

Waterbear 的早期版本竭尽全力将模型保留在 JavaScript 中,并将其与 DOM 同步,直到我注意到超过一半的代码和 90% 的错误都是由于保持模型与 DOM 同步造成的。消除重复使代码更简单、更健壮,并且所有状态都在 DOM 元素上,许多错误可以通过简单地在开发者工具中查看 DOM 来发现。因此,在这种情况下,构建比我们在 HTML/CSS/JavaScript 中已经拥有的更进一步的 MVC 分离几乎没有益处。

玩具变化可以导致真实变化

构建我正在使用的更大系统的紧密范围的小版本一直是一次有趣的练习。有时在一个大型系统中,您会犹豫更改某些东西,因为它们会影响太多其他东西。在一个微小的玩具版本中,您可以自由地进行实验,并了解您可以带回更大的系统中的东西。对我来说,更大的系统是 Waterbear,而这个项目对 Waterbear 的结构方式产生了巨大的影响。

小实验使失败成为可能

我能够用这个精简的块语言进行的一些实验是

实验的特点是,它们不必成功。我们倾向于淡化工作中的失败和死胡同,失败被惩罚而不是被视为重要的学习工具,但如果你想要前进,失败是必不可少的。虽然我确实让 HTML5 拖放功能正常运行,但它在任何移动浏览器上都不受支持的事实意味着它对 Waterbear 来说是一个无法启动的项目。将代码分离出来并通过迭代积木来运行代码非常有效,以至于我已经开始将这些想法带入 Waterbear,并在测试和调试方面取得了显著的改进。简化的命中测试经过一些修改后也即将回归 Waterbear,微型向量和精灵库也是如此。实时编码还没有进入 Waterbear,但一旦当前一轮的更改稳定下来,我可能会引入它。

我们到底想要构建什么?

构建一个大型系统的缩小版本,可以清晰地关注真正重要的部分。是否有一些由于历史原因而保留的组件,它们没有任何作用(更糟糕的是,分散了目的)?是否有一些没有人使用但你需要付费维护的功能?用户界面是否可以简化?所有这些都是制作缩小版本时需要问的绝佳问题。彻底的更改,例如重新组织布局,可以在不担心后果在更复杂的系统中蔓延的情况下进行,甚至可以指导复杂系统的重构。

程序是一个过程,而不是一个事物

在这个项目范围内,有一些我无法进行实验的事情,我可能会在未来使用 blockcode 代码库来进行测试。创建一个“函数”积木,用现有积木创建新的积木,这将很有趣。在受限的环境中实现撤销/重做会更简单。让积木接受多个参数而无需大幅增加复杂度将会很有用。寻找各种方法在线共享积木脚本,将使该工具的网络特性得到完全体现。