作者:菉竹


本身 iMove 的定位就是 “一个逻辑可复用的,面向函数的,流程可视化的 JavaScript 工具库。”

对开发者而言, iMove 恰好是可以完成这些目标的理想工具。动动鼠标,写一下节点函数,代码导出,放到具体工程里就可以直接使用,是不是很方便?

那么,为什么我们还要做 iMove 在线执行代码功能呢?站在用户视角看,对于开发者来说,选中节点,右键在线执行代码是必要的。这样可以把 iMove 的工具属性做到极致,让开发者体验更好,操作更简单。

上次在《登上 Github 趋势榜,iMove 原理技术大揭秘!》一文中我们卖了个关子,今天就和大家分享下 iMove 是如何实现在线执行节点代码的~

引子

故事还得从组内的一个小伙伴说起,当时她是第一次使用 iMove,询问我如何测试运行刚写好的代码。二话不说,我就在她电脑上一通操作:

  1. 安装 @imove/cli
  2. 在项目根目录执行命令: imove -d
  3. 找到项目中的组件,添加代码 logic.invoke('trigger')
  4. 启动项目,打开控制台面板,筛选 log 信息
  5. … …

“我就想运行下刚写好的代码,看看输出结果是什么…”

『步骤繁琐』、『操作麻烦』、『有上手成本』、『运行结果不直观』… 此刻,脑中第一时间闪过的就是以上这些词汇,再看向小伙伴那疑惑的眼神,仿佛下一刻她就要被劝退了…

这可怎么行,体验必须优化,使用成本必须降下来!于是就有了右键在线执行代码的功能。

5. 所见即所得! iMove 在线执行代码探索 - 图1

其实上述问题的诉求很简单: 写完节点的代码之后,旁边有个运行按钮,一点直接就能看到运行结果就行了。我们来看下就是这么一个优化都能带来哪些改变:

  • 无安装工具要求,0 上手成本: 代码在浏览器端就能运行,不再需要安装命令行工具,大大降低了学习成本。
  • 结果可视化,简单明了: 开发调试过程中最常用到的就是日志,每次都要打开控制台极不方便,直接将节点的运行结果用可视化的方式展示出来,简单直观。
  • 在线 mock 输入,方便测试: 每个节点的代码都会有各种可能的输入,如果可以直接 mock 输入,测试效率将得以极大的提升。
  • 测试用例可保存,保证代码质量: 除了方便测试节点代码之外,如果可以将成对的输入/输出作为测试用例加以保存,渐而形成完备的测试用例集,将能进一步保证节点的代码质量。
  • … …

想法是很美好,但具体该如何实现呢?接着往下看~

探索 1:节点代码在哪运行

首先,要解决的第一个问题就是:节点代码在哪运行?

经过评估,我们认为主要有两个选择:

  1. 浏览器端直接运行节点代码;
  2. 本地起一个服务,将节点信息发送至本地,本地构建编译完之后将 bundle 发送回浏览器端执行。

对于以上两个方案, iMove 选择了前者,理由很简单:在线运行节点代码的初心就是为了降低上手成本,如果还需要本地起一个服务,使用者势必又要学习一个新命令,这无形中又提高了使用门槛。

然而,要想在浏览器中直接运行节点代码,还是有很多阻碍,并非一个 eval 就完事了~

探索 2:如何运行 import/export

看过 iMove 的出码就知道,流程图中的每个节点代码最终都会被编译成一个单独的 js 文件。因此,每个节点是支持 import 其他 npm 包的,这也是为什么不能直接调用 eval 的原因。

1)浏览器原生支持 ES Module

当然,也许聪明的你早已想到浏览器是支持 native ES module 的,包括之前挺火的 Vite 其实底层原理也是基于这个。为此,我们先来简单介绍下如何让 import/export 代码在浏览器中跑起来~

先来看 MDN 上的 官方文档

5. 所见即所得! iMove 在线执行代码探索 - 图2
5. 所见即所得! iMove 在线执行代码探索 - 图3

可以看到,其实现代浏览器早就支持了,因此我们可以不用考虑兼容性问题。除此之外,继续浏览文档你会发现要让浏览器支持 ES Module 很关键的一点是要在 script 标签上添加 type="module" 属性。来看一个例子:

  1. # 文件目录
  2. index.html
  3. main.mjs
  1. <!--index.html-->
  2. <script type="module">
  3. import sayHello from './main.js';
  4. say('Hello iMove!');
  5. </script>
  1. // main.js
  2. const say = (words) => console.log(words);

注意: 运行上述例子时不要在本地直接打开 index.html 文件,因为浏览器会默认用 file:// 协议打开,请求 main.js 资源时会跨域。该问题可以在本地开一个 http 服务解决,或在 codesandbox 上尝试也可。

如上所示,要让浏览器跑 ES Module 代码也太简单了,可以说几乎没有成本,但问题真解决了吗?可以在代码中加一行 import get from 'lodash.get' 试试。

5. 所见即所得! iMove 在线执行代码探索 - 图4

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

5. 所见即所得! iMove 在线执行代码探索 - 图5

不幸的是,控制台还是报错了。错误的原因是 lodash.get 这个 package 遵循的是 cjs 规范,但浏览器只认 esm 规范,所以无法解析并执行这个 package 代码。

2)SystemJS 尝试

既然浏览器原生只支持 esm 规范的代码,那是否有办法支持 cjs 规范的代码呢?经过一番调研,我们发现了 SystemJS 这个库(传送门:https://github.com/systemjs/systemjs)。

5. 所见即所得! iMove 在线执行代码探索 - 图6

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

5. 所见即所得! iMove 在线执行代码探索 - 图7

根据官方回复的内容,我们可以提取以下几点关键信息:

  • 0.21版本之前的 SystemJS 是支持 cjs 规范的,但是目前已经不再支持。
  • 之前的版本支持 cjs 主要是因为:之前的做法是先下载代码字符串,用正则匹配 require 解析依赖,最后才执行代码;而现在的编译解析工作依靠的是浏览器自身。
  • 以前的这种解法其实存在性能隐患,故新版本中将不再考虑。

为此,我们又找到 0.21 版本的文档:https://github.com/systemjs/systemjs/blob/0.21/docs/module-formats.md

5. 所见即所得! iMove 在线执行代码探索 - 图8
5. 所见即所得! iMove 在线执行代码探索 - 图9

可以看到该版本的 SystemJS 似乎可以满足我们的需求,但最终 iMove 并没有采用。因为官方已经不推荐这种方式而且也不再维护,所以这条路又断了…

3)新一代 JS 模块 CDN 托管

上述的问题似乎走进了死胡同,但仔细想来问题的本质不就是『浏览器不支持 **cjs** 规范代码』吗?

可是反过来想,浏览器为什么要支持 cjs 规范代码呢? SystemJS 之前做的 cjs to esm 这个工作为什么要在浏览器侧做,而不是放在 CDN 上完成呢?狼叔的这篇《2021 再看 Deno(CDN for JavaScript modules的思考)》讲的很清楚,转换这事儿其实放在 CDN 上来做更合适~

简单介绍一下,jspm 做得就是这个事儿。
5. 所见即所得! iMove 在线执行代码探索 - 图10
5. 所见即所得! iMove 在线执行代码探索 - 图11

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

5. 所见即所得! iMove 在线执行代码探索 - 图12
5. 所见即所得! iMove 在线执行代码探索 - 图13

探索 3:多文件合并成单文件执行

前文提到的『如何运行 import/export 问题』看起来似乎已经完美解决,但实操起来我们却发现还是遗漏了一个细节:**iMove** 的出码是一个多文件的组织形式,因此浏览器将会以相对路径的形式引入其他文件,这意味着还需一个 **http** 服务来提供这些资源的加载,这是我们不能接受的。

如何解?最简单的办法其实就是将 多文件合并成单文件执行,这样天然地就消灭了相对路径的问题。我们再来看个例子:

  1. // a.js
  2. import get from 'lodash.get';
  3. const obj = {text: 'a', say: () => console.log(get(obj, 'text'))};
  4. export default obj;
  5. // b.js
  6. import get from 'lodash.get';
  7. const obj = {text: 'b', say: () => console.log(get(obj, 'text'))};
  8. export default obj;
  9. // main.js
  10. import a from './a';
  11. import b from './b';
  12. a.say();
  13. b.say();

上述是合并前的多文件组织形式,要合并它们其实并不难,只需注意以下 2 点:

  • import 某个文件时,该文件的代码需要立即执行
  • 不同文件中的全局变量名可能相同,需要解决命名污染的问题
  1. // merged.js
  2. const run = async () => {
  3. const modA = await (async () => {
  4. const get = (await import('https://jspm.dev/lodash.get')).default;
  5. const obj = {text: 'a', say: () => console.log(get(obj, 'text'))};
  6. return obj;
  7. })();
  8. const modB = await (async () => {
  9. const get = (await import('https://jspm.dev/lodash.get')).default;
  10. const obj = {text: 'b', say: () => console.log(get(obj, 'text'))};
  11. return obj;
  12. })();
  13. modA.say();
  14. modB.say();
  15. };
  16. run();

需要注意的是原来的 import 由于合并的原因出现在了函数体当中,所以需要使用 dynamic import 的方式来加载网络包。剩下的只要用 正则表达式匹配替换 或者 AST 转换 都能实现对应的效果,本文就不再展开详述。

探索 4:Script 间如何传值通信

行文到这,可以说是『万事俱备,只欠东风』了。经过刚才的多文件合并,我们只需运行代码,获取结果展示即可。这里需要注意的是,由于待执行的代码遵循 esm 规范,如果想用 eval 执行代码就有一个前提:当前代码必须也在 type="module"script 标签下。不过我们可以换个思路,不用 eval 也能执行字符串代码:

  1. const script = document.createElement('script');
  2. script.type = 'module';
  3. script.text = 'code here';
  4. document.body.appendChild(script);

如上所示,我们可以通过动态插入 script 标签的方式来执行代码。可是随之而来的问题又变成了:我们该如何在代码运行完之后触发回调拿到结果展示呢?何况现在这两段代码还是在两个 script 标签下。

最容易想到的办法就是用类似 jsonp 的方式,一方在全局 window 下注册唯一方法,另一方在代码执行完之后调用该方法。在这里,我们介绍一种更优雅的方式,可以用事件通信的方式来解决~

MDN 官方文档传送门:Custom Events

  1. // 监听事件
  2. document.addEventListener('customEvent', function (evt) {
  3. console.log(evt.detail);
  4. }, false);
  5. // 发送事件
  6. document.dispatchEvent(new CustomEvent('customEvent', {detail: {text: 'iMove'} }));

总结

本文先后介绍了 iMove 在线运行节点代码这一路所踩过的坑,最终主要还是依靠 http-import 的思想来解决问题。无疑, deno 改变了大家的对包管理的看法。本身 deno 够小,试错成本低,它确确实实引领了一个潮流方向。这个改进虽说不算新,但反响确实很好,大概是天下人苦 npm(npm 开玩笑的说法是:你怕吗)久已,用法简单,高效,甚至是衍生出很多关于 CDN for JavaScript modules 的思考。如果你有更好的想法,欢迎一起交流。

另外,做 iMove 在线执行代码功能是用户视角做的决定,小伙伴们都非常认可这个决策,并在落地过程中,能够探索出一种全新的方式,这是非常值得称赞的。本身 iMove 就是一个以锻炼团队为目的开源的项目,大家能够定义问题,能够解决问题,能够建立信心,能够激发技术热情,它的目的就达到了。