作者:菉竹
本身 iMove 的定位就是 “一个逻辑可复用的,面向函数的,流程可视化的 JavaScript 工具库。”
对开发者而言, iMove 恰好是可以完成这些目标的理想工具。动动鼠标,写一下节点函数,代码导出,放到具体工程里就可以直接使用,是不是很方便?
那么,为什么我们还要做 iMove 在线执行代码功能呢?站在用户视角看,对于开发者来说,选中节点,右键在线执行代码是必要的。这样可以把 iMove 的工具属性做到极致,让开发者体验更好,操作更简单。
上次在《登上 Github 趋势榜,iMove 原理技术大揭秘!》一文中我们卖了个关子,今天就和大家分享下 iMove 是如何实现在线执行节点代码的~
引子
故事还得从组内的一个小伙伴说起,当时她是第一次使用 iMove,询问我如何测试运行刚写好的代码。二话不说,我就在她电脑上一通操作:
- 安装
@imove/cli - 在项目根目录执行命令:
imove -d - 找到项目中的组件,添加代码
logic.invoke('trigger') - 启动项目,打开控制台面板,筛选
log信息 - … …
“我就想运行下刚写好的代码,看看输出结果是什么…”
『步骤繁琐』、『操作麻烦』、『有上手成本』、『运行结果不直观』… 此刻,脑中第一时间闪过的就是以上这些词汇,再看向小伙伴那疑惑的眼神,仿佛下一刻她就要被劝退了…
这可怎么行,体验必须优化,使用成本必须降下来!于是就有了右键在线执行代码的功能。

其实上述问题的诉求很简单: 写完节点的代码之后,旁边有个运行按钮,一点直接就能看到运行结果就行了。我们来看下就是这么一个优化都能带来哪些改变:
- 无安装工具要求,0 上手成本: 代码在浏览器端就能运行,不再需要安装命令行工具,大大降低了学习成本。
- 结果可视化,简单明了: 开发调试过程中最常用到的就是日志,每次都要打开控制台极不方便,直接将节点的运行结果用可视化的方式展示出来,简单直观。
- 在线 mock 输入,方便测试: 每个节点的代码都会有各种可能的输入,如果可以直接
mock输入,测试效率将得以极大的提升。 - 测试用例可保存,保证代码质量: 除了方便测试节点代码之外,如果可以将成对的输入/输出作为测试用例加以保存,渐而形成完备的测试用例集,将能进一步保证节点的代码质量。
- … …
想法是很美好,但具体该如何实现呢?接着往下看~
探索 1:节点代码在哪运行
首先,要解决的第一个问题就是:节点代码在哪运行?
经过评估,我们认为主要有两个选择:
- 浏览器端直接运行节点代码;
- 本地起一个服务,将节点信息发送至本地,本地构建编译完之后将
bundle发送回浏览器端执行。
对于以上两个方案, iMove 选择了前者,理由很简单:在线运行节点代码的初心就是为了降低上手成本,如果还需要本地起一个服务,使用者势必又要学习一个新命令,这无形中又提高了使用门槛。
然而,要想在浏览器中直接运行节点代码,还是有很多阻碍,并非一个 eval 就完事了~
探索 2:如何运行 import/export
看过 iMove 的出码就知道,流程图中的每个节点代码最终都会被编译成一个单独的 js 文件。因此,每个节点是支持 import 其他 npm 包的,这也是为什么不能直接调用 eval 的原因。
1)浏览器原生支持 ES Module
当然,也许聪明的你早已想到浏览器是支持 native ES module 的,包括之前挺火的 Vite 其实底层原理也是基于这个。为此,我们先来简单介绍下如何让 import/export 代码在浏览器中跑起来~
先来看 MDN 上的 官方文档:


可以看到,其实现代浏览器早就支持了,因此我们可以不用考虑兼容性问题。除此之外,继续浏览文档你会发现要让浏览器支持 ES Module 很关键的一点是要在 script 标签上添加 type="module" 属性。来看一个例子:
# 文件目录index.htmlmain.mjs
<!--index.html--><script type="module">import sayHello from './main.js';say('Hello iMove!');</script>
// main.jsconst say = (words) => console.log(words);
注意: 运行上述例子时不要在本地直接打开 index.html 文件,因为浏览器会默认用 file:// 协议打开,请求 main.js 资源时会跨域。该问题可以在本地开一个 http 服务解决,或在 codesandbox 上尝试也可。
如上所示,要让浏览器跑 ES Module 代码也太简单了,可以说几乎没有成本,但问题真解决了吗?可以在代码中加一行 import get from 'lodash.get' 试试。

可以看到控制台报错了,原来是 lodash.get 被浏览器当做相对路径加载了,其实浏览器是支持 http 路径加载的,为此我们可以将其改成 import get from 'https://unpkg.com/lodash.get'。

不幸的是,控制台还是报错了。错误的原因是 lodash.get 这个 package 遵循的是 cjs 规范,但浏览器只认 esm 规范,所以无法解析并执行这个 package 代码。
2)SystemJS 尝试
既然浏览器原生只支持 esm 规范的代码,那是否有办法支持 cjs 规范的代码呢?经过一番调研,我们发现了 SystemJS 这个库(传送门:https://github.com/systemjs/systemjs)。

据其介绍,它就是一款用来解决浏览器运行 ES Module 的工具。但是当我们按照其文档摸索一番后,发现加载 lodash.get 包时还是失败了,而且报的是相同的错误… 好在我们在它的 issue 区另有收获,发现了这么一个帖子:ES Modules and CommonJS? (PS:由此可见,该问题还是普遍存在的)

根据官方回复的内容,我们可以提取以下几点关键信息:
- 0.21版本之前的
SystemJS是支持cjs规范的,但是目前已经不再支持。 - 之前的版本支持
cjs主要是因为:之前的做法是先下载代码字符串,用正则匹配require解析依赖,最后才执行代码;而现在的编译解析工作依靠的是浏览器自身。 - 以前的这种解法其实存在性能隐患,故新版本中将不再考虑。
为此,我们又找到 0.21 版本的文档:https://github.com/systemjs/systemjs/blob/0.21/docs/module-formats.md


可以看到该版本的 SystemJS 似乎可以满足我们的需求,但最终 iMove 并没有采用。因为官方已经不推荐这种方式而且也不再维护,所以这条路又断了…
3)新一代 JS 模块 CDN 托管
上述的问题似乎走进了死胡同,但仔细想来问题的本质不就是『浏览器不支持 **cjs** 规范代码』吗?
可是反过来想,浏览器为什么要支持 cjs 规范代码呢? SystemJS 之前做的 cjs to esm 这个工作为什么要在浏览器侧做,而不是放在 CDN 上完成呢?狼叔的这篇《2021 再看 Deno(CDN for JavaScript modules的思考)》讲的很清楚,转换这事儿其实放在 CDN 上来做更合适~
简单介绍一下,jspm 做得就是这个事儿。

为了验证效果,我们来做个试验对比,以下分别是浏览器从 unpkg 和 jspm 上加载的 lodash.get 截图:


探索 3:多文件合并成单文件执行
前文提到的『如何运行 import/export 问题』看起来似乎已经完美解决,但实操起来我们却发现还是遗漏了一个细节:**iMove** 的出码是一个多文件的组织形式,因此浏览器将会以相对路径的形式引入其他文件,这意味着还需一个 **http** 服务来提供这些资源的加载,这是我们不能接受的。
如何解?最简单的办法其实就是将 多文件合并成单文件执行,这样天然地就消灭了相对路径的问题。我们再来看个例子:
// a.jsimport get from 'lodash.get';const obj = {text: 'a', say: () => console.log(get(obj, 'text'))};export default obj;// b.jsimport get from 'lodash.get';const obj = {text: 'b', say: () => console.log(get(obj, 'text'))};export default obj;// main.jsimport a from './a';import b from './b';a.say();b.say();
上述是合并前的多文件组织形式,要合并它们其实并不难,只需注意以下 2 点:
import某个文件时,该文件的代码需要立即执行- 不同文件中的全局变量名可能相同,需要解决命名污染的问题
// merged.jsconst run = async () => {const modA = await (async () => {const get = (await import('https://jspm.dev/lodash.get')).default;const obj = {text: 'a', say: () => console.log(get(obj, 'text'))};return obj;})();const modB = await (async () => {const get = (await import('https://jspm.dev/lodash.get')).default;const obj = {text: 'b', say: () => console.log(get(obj, 'text'))};return obj;})();modA.say();modB.say();};run();
需要注意的是原来的 import 由于合并的原因出现在了函数体当中,所以需要使用 dynamic import 的方式来加载网络包。剩下的只要用 正则表达式匹配替换 或者 AST 转换 都能实现对应的效果,本文就不再展开详述。
探索 4:Script 间如何传值通信
行文到这,可以说是『万事俱备,只欠东风』了。经过刚才的多文件合并,我们只需运行代码,获取结果展示即可。这里需要注意的是,由于待执行的代码遵循 esm 规范,如果想用 eval 执行代码就有一个前提:当前代码必须也在 type="module" 的 script 标签下。不过我们可以换个思路,不用 eval 也能执行字符串代码:
const script = document.createElement('script');script.type = 'module';script.text = 'code here';document.body.appendChild(script);
如上所示,我们可以通过动态插入 script 标签的方式来执行代码。可是随之而来的问题又变成了:我们该如何在代码运行完之后触发回调拿到结果展示呢?何况现在这两段代码还是在两个 script 标签下。
最容易想到的办法就是用类似 jsonp 的方式,一方在全局 window 下注册唯一方法,另一方在代码执行完之后调用该方法。在这里,我们介绍一种更优雅的方式,可以用事件通信的方式来解决~
MDN 官方文档传送门:Custom Events
// 监听事件document.addEventListener('customEvent', function (evt) {console.log(evt.detail);}, false);// 发送事件document.dispatchEvent(new CustomEvent('customEvent', {detail: {text: 'iMove'} }));
总结
本文先后介绍了 iMove 在线运行节点代码这一路所踩过的坑,最终主要还是依靠 http-import 的思想来解决问题。无疑, deno 改变了大家的对包管理的看法。本身 deno 够小,试错成本低,它确确实实引领了一个潮流方向。这个改进虽说不算新,但反响确实很好,大概是天下人苦 npm(npm 开玩笑的说法是:你怕吗)久已,用法简单,高效,甚至是衍生出很多关于 CDN for JavaScript modules 的思考。如果你有更好的想法,欢迎一起交流。
另外,做 iMove 在线执行代码功能是用户视角做的决定,小伙伴们都非常认可这个决策,并在落地过程中,能够探索出一种全新的方式,这是非常值得称赞的。本身 iMove 就是一个以锻炼团队为目的开源的项目,大家能够定义问题,能够解决问题,能够建立信心,能够激发技术热情,它的目的就达到了。
