让简单的事情保持简单,让困难的事情变得可能 – Alan Kay

前言

和 Scratch 3.0 一样, Snap! 也基于 JavaScript 构建,运行在浏览器中。

由于浏览器正在成为新的操作系统, 新的能力层出不穷:

  • Web Serial
  • Web Bluetooth
  • WebGPU
  • WebAssembly
  • The File System Access API

如何将这些强大的能力暴露给最终用户, 是我长期感兴趣的话题。Snap! 是我目前探索这些想法时最喜欢的的实验环境。

开篇引用的 Alan Kay 的话,是我最喜欢的系统设计原则之一, Snap! 在这方面做得相当出色。

JavaScript function 积木

良好的设计:系统应以最少的不可变零件组成;这些部分应尽可能通用;系统的所有部分都应聚拢在一个统一的框架中。 – Smalltalk 背后的设计原则

Snap! 只通过一个通用的积木,就将浏览器的所有能力暴露了出来! 这个积木就是: JavaScript function 积木。

JavaScript function 积木允许你在 Snap! 里构建JavaScript 匿名函数, 然后在 Snap! 中调用它, 调用的过程中, 会自动转换 Snap! 和 JavaScript 的数据类型。由于函数在 JavaScript 是一等公民, 而积木(block)在 Snap! 也是一等公民, 所以这一个积木就将 JavaScript 的所有能力释放给了 Snap!

简单解释下什么是一等公民(摘自 SnapManual)。

如果一种数据类型可以作为以下事物, 则它在编程语言中是一等公民(first class):

  • 变量的值
  • 程序的输入
  • 函数返回的值
  • 数据聚合的成员
  • 匿名(未命名)

怎么用?

为了使用 JavaScript function 积木, 你需要做 3 件事:

  1. 打开权限
  2. 编写 JavaScript 函数
  3. 调用 JavaScript 函数

1. 打开权限

出于安全的考虑, Snap! 默认不允许你在积木 IDE 里使用 JavaScript, 要使用 JavaScript function 积木, 你需要先打开权限:

2. 编写 JavaScript 函数

开启权限之后,就可以在 运算 积木组里找到 JavaScript function 积木。上图编写了一个匿名函数, 参数是 what, 函数体是弹出提示这个参数值。

3. 调用 JavaScript 函数

接下来我们就可以调用它。

控制积木组有两个积木可用于执行 JavaScript function:

  • 运行
  • 调用

两者的区别是前者没有返回值, 后者有返回值(正是 command 与 reporter 的区别, 与直觉一致)。

更复杂的例子, 可以右键查看 JavaScript function 的帮助:

我的用例

使用 JavaScript function 开发 Snap! 插件

Snap! 内置了很多插件(积木库)来扩展 Snap! 的能力:

在 Snap! 里你可以在 IDE 里创建积木库(和 MicroBlocks 类似)。

有时候你定义的新积木, 无法通过组合已有的积木来构建, 这时就需要在 JavaScript 先构建 primitive(原语) , 然后在 Snap! 中调用它。 MQTT 插件 就是通过调用 mqttExtension.js 中定义的 primitive 来构建的。 CodeLab Adapter EIM 插件里也有很多积木用到了底层 primitive , 可供参考。

当我构建第一个 Snap 插件: Home Assistant 插件 (使用 Snap! 驱动可编程空间), 频繁地在 JavaScript 修改代码、刷新页面,让我非常厌烦, 我更喜欢在 Snap!(和其他个人计算环境, 如 Smalltalk、MicroBlocks)中的编程体验: 一切都是活的(liveness)! 没有重载和刷新, 整个系统的状态都是连续的! 这是个人计算环境的传统。

我当时想,要是能够在 Snap! 里直接编写 primitive 就好了, 于是我尝试使用 JavaScript function 来做,发现一切都非常愉快!

具体的开发流程是这样的: 我尽可能使用 Snap! 已有的积木来定义新的积木, 当发现有些事情无法做到, 需要底层新的原语提供支持时,我就使用JavaScript function编写一个最小的匿名函数来达成我的目标,这个过程,我可以随时重新定义这个函数,只需要点击积木而无需刷新页面!等我完成整个插件,我就将这些使用 JavaScript function 编写的 JavaScript 代码移到专门放置 primitive 代码的文件里。

尽可能少地引入 primitive 是 Smalltalk 社区编写 primitive 遵循的风格, 关于 primitive 的 big ideas, 我在MicroBlocks 与 Snap!的互操作里(视频)提到过一些

这是开发 CodeLab Adapter EIM 插件时,使用 JavaScript function 写的原型,它提供了巨大的灵活性:

这是重构为 primitive 之后:

之后所有与 CodeLab Adapter 相关的插件都是这样开发的,整个插件的开发过程中,我不需要刷新一次页面! 这样意味着我的思绪总是连贯的,没有被编写、刷新模式打断。它带来了数量级的开发效率提升。

使用 JavaScript function 探索 JavaScript API

有了前边愉快而高效的插件开发经验, 我打算走得再远一点, 将 Snap! 看作一个挖掘浏览器潜力的实验环境。 所以 Snap! 现在成了我编写实验性 JavaScript 代码的环境。

这方面很好的一个案例是将 Web Serial 封装成通用积木。

一旦完成这项工作, 之后的一切探索都可以在 Snap! 中完成,这是巨大的杠杆!

MicroBlocks 插件就完全在 IDE 里完成, 无需针对性地编写任何 JavaScript 代码, MicroBlocks 串口通信协议完全在 Snap! 里实现。只需使用 JavaScipt 编写好 Web Serial 积木,之后就可以完全工作在 Snap! 环境里了。

我目前试着对 Web Bluetooth 做类似的事, 之后也准备在 WebGPU/WebAssembly 上这样做。

采用这种方式,可以将个人计算环境的好处用于探索任何的 Web APIs, 这对研究性工作有巨大好处, 诸如你可以将当前的研究工作整个保存下来(存为项目, 类似 Smalltalk 的 image), 下次加载的时候,就好像你从未离开。

一些技巧

全局对象

我喜欢使用对象来组织代码,当我开发一个复杂插件的时候(诸如CodeLab Adapter EIM插件),会把它的主体包装在一个对象里。开发期间,我会将这个对象暴露到全局, 诸如 CodeLab Adapter EIM 插件的主要对象 window.eim_client。 这样有两个好处:

  • 随时在 Chrome DevTools 里探索它们。
  • 不同的匿名函数(JavaScript function)可以共同操作一个全局对象,这使得许多事情变得简单,诸如在不同的函数之间交换信息。

FAQ

如何自动开启 JavaScript 扩展

从 url 末尾添加 &enableJS, 例子:

1
2
3
https://snap.codelab.club/snap.html#open:https://wwj718.github.io/post/img/mediapipe-Gesture-Recognition-cn.xml&enableJS

https://snap.codelab.club/snap.html#run:https://wwj718.github.io/post/img/mediapipe-Gesture-Recognition-cn.xml&noRun&enableJS

这个功能可能会有安全风险, 非官方功能, 只能在 https://snap.codelab.club/snap.html 使用。 对于演示体验项目可能会很有用。

JavaScript function 可访问哪些上下文(Context)?

JavaScript function 可访问的上下文与 primitive 相似(它们在许多方面都非常相似)。 大多数全局对象都可以访问:

  • List
  • Process

JavaScript function 的 this 是 SpriteMorph.

1
console.log(this); // SpriteMorph

通过 this, 可以进一步访问其他对象

1
2
3
4
5
// 参考 JavaScript function  的 help
let stage = this.parentThatIsA(StageMorph);
// this.bubble;
// this.gotoXY 
// this.direction()

如何保存状态

函数通常是无状态的, JavaScript function 是一个函数。

如果你期望在多个函数调用中, 操作同一个状态变量, 可以把它存放在 window 全局变量里, 如 window._result = "hello"

当然你也可以把它持久化存储在 localstorage 或 indexeddb, 这样即使刷新浏览器, 也还能找到这些状态。

如何往 JavaScript function 传递回调函数

有些时候, 我们需要往 JavaScript function 传递回调函数, 如:

将以上图片下载到本地, 然后拖拽到 Snap! 中, 它会变成对应脚本。

运行结果:

如何操作变量?

将设置/获取变量的积木作为一个回调函数传递

将以上图片下载到本地, 然后拖拽到 Snap! 中, 它会变成对应脚本。

运行结果:

如何传递列表

以下代码演示如何双向传递列表:

将以上图片下载到本地, 然后拖拽到 Snap! 中, 它会变成对应脚本。

运行结果:

嵌套列表

将 js 嵌套列表转化为 Snap! 嵌套列表:

将以上图片下载到本地, 然后拖拽到 Snap! 中, 它会变成对应脚本。

将 Snap! 嵌套列表转化为 js 嵌套列表:

你可以将以上两个辅助函数放到 window 命名空间下, 方便在不同的 JavaScript function 中使用它

如何引入第三方库?

有两种引入方式:

  • 全局引入
  • 引入模块

许多旧的库, 支持全局引入, 如 mqttjs, jquery, Pyodide…

比较新的库, 可能偏好采用模块的方式分发, 如 mediapipe…

全局引入

让我们试着引入 jquery 库:

https://code.jquery.com/jquery-3.7.1.min.js

在这个例子中, 我们首先加载了 jQuery 库, 然后使用 jQuery 内置的函数缓慢地隐藏当前的 Snap! 工作区, 之后快速显示它。

项目代码, 建议从这里开始, 将其修改为你要的样子

引入模块

参考 在 Snap! 中使用 mediapipe 库

获取移动端传感器信息

参考 Sensor APIs

目前似乎只有安卓设备支持, iOS 设备一如既往的封闭。

项目代码

运行代码之前打开 JavaScript 扩展

如何引入 Python 解释器 ?

参考 Snap! 中的 Python 解释器

参考