最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 我把世界上第一个 JS 引擎编译回了 JS

    正文概述 掘金(doodlewind)   2020-12-05   446

    1995 年,在我刚满周岁的时候,大洋彼岸有个叫 Brendan Eich 的人在十天内创造了一门今天我正以它谋生的编程语言,这就是 JavaScript。

    这个快速创造 JavaScript 的故事在程序员群体中广为流传。但对于今天的人们来说,或许已经没有多少人记得(甚至体验过)最早的 JavaScript 是什么样的,更不要说阅读当年的 JS 引擎源码了。

    不过在 2020 年,我们迎来了一个了解这段历史的契机。在研究编程语言历史的 HOPL-IV 学术会议上,由 Brendan Eich 和 ES6 首席作者 Allen Wirfs-Brock 联手撰写的《JavaScript 20 年》详细介绍了 JS 诞生和演化的历史。作为这本书的中文版译者,我逐个校订了超过原版中超过 600 条参考文献链接,其中正有一条指向了最早的 JS 引擎源码。这激发了我的好奇心——最早的 JS 引擎代码,今天还能不能编译运行?如果可以的话,能不能更进一步地把它编译回 JavaScript,让它在 Web 上复活呢?因此我进行了这次尝试。

    最早的 JS 引擎名为 Mocha(这是 Netscape 内部的网页脚本语言项目代号),由 Brendan Eich 在 1995 年 5 月完成了首个原型。在 1995 年全年和 1996 年的大部分时间里,Eich 都是仅有的全职负责 JavaScript 引擎的开发者。直到 1996 年 8 月发布 Netscape 3.0 时,Mocha 的代码库主要包含的仍然是这个原型中的代码。随 Netscape 3.0 发布的 JS 版本被称为 JavaScript 1.1,这个版本标志着 JavaScript 的初始阶段开发工作得以完成。在此之后,Eich 又花了两周时间重写了 Mocha,获得了一个更强的引擎,这就是今天 Firefox 搭载的 SpiderMonkey。

    如果你谷歌搜索「Netscape source code」,大概只能追溯到 1998 年 Mozilla 项目中的 SpiderMonkey 引擎代码。而真正的 Mocha 引擎源码,则位于网络上一份(来路不明的)Netscape 3.0.2 浏览器源码的压缩包中。但 Mocha 的源码早已在 Eich 重写 SpiderMonkey 后被彻底放弃,该怎样复活它呢?

    其实想了解任何软件,其手段都无非「自顶向下」和「自底向上」两条路。前者从架构层面入手了解宏观知识,后者从代码层面入手解决微观问题。由于我已经比较熟悉对 QuickJS 等 JS 引擎的使用,因此这里我直接选择了自底向上的实践手段。其基本的理念很简单:渐进地编译出引擎的各个模块,最后把它组合在一起跑起来

    原版 Mocha 采用 Makefile 作为构建系统,但它显然已经无法在今天的操作系统中正确工作了——那可是个 MacOS 还在使用 PPC 处理器的时代!但说到底,构建系统只不过是一个自动执行 gccclang 等编译器的辅助工具而已。而 C 语言项目的编译过程,概括说来也无非这么几件事:

    1. gcc -c 命令,逐个将「作为库被使用」的 .c 源码编译为 .o 格式的对象文件。这会把 C 源码中的每个函数都编译成二进制可执行文件中的所谓「符号」,就像是 ES Module 中 export 出来的函数那样。注意在这个时候,每个对象文件中都可以任意调用以 .h 形式引入的其他库的 API。此时编译不会出错,只会在对象文件中记录对外部符号的调用。
    2. ar 命令把这些 .o 对象文件制作成 .a 格式的静态库。这其实只相当于简单的文件拼接组装而已,获得的 .a 文件中会包含项目中所有的符号,类似于 cat *.js >> all.js 的效果。另外我们还可以制作更节约空间的动态库,但相对比较复杂,这里略过。
    3. gcc -l 命令编译出「调用这个库」的 .c 源码,这时编译器会将其产物与 .a 静态库相链接。链接器会把各个对象文件中形同「榫卯结构」式的符号依赖连接起来。这时对于第一步中的每个对象文件,其中所有调用外部 API 的符号都必须能被链接器找到,缺失任何一个符号都会导致链接失败——但只要链接成功,我们就最终获得了以 main 函数为入口的可执行文件。

    因此,整个渐进的移植过程是这样的:

    1. 编译出每份 Mocha 内部的(即除了入口之外的).c 源码文件,获得包含其符号的 .o 格式对象文件。
    2. 将包含这些符号的 .o 对象文件拼接起来,打包出 .a 格式的静态库文件,即 libmocha.a
    3. 编译 Mocha 入口的 mo_shell.c 文件,将其与 libmocha.a 静态库相链接,获得最终的可执行文件。

    在这个过程中,需要处理一些外部依赖,其中最典型的是对 prxxx.h 的依赖。这是 Netscape 当年开发的 Netscape Portable Runtime 跨平台标准库,其中实现了一些通用的宏定义与类型定义,以及 C 的哈希表、链表等基础数据结构,还有某些数学计算、时间转换等功能。NSPR 的源码也附带在了 Netscape 3 的源码中,但我并没有一次性把它们全部提交进新的移植版 Mocha 代码库。这里的处理方式是仅在遇到缺失的 NSPR 依赖时,才手动将涉及到的 NSPR 头文件和源码递归地引入,从而剥离出一份最小可用的 Mocha 代码树。

    整个移植过程中涉及到的源码改动,主要包括这些:

    • 移除掉 prcpucfg.h,直接使用 x86 和 WASM 的小端字节序。
    • 修订 prtypes.h 中的类型定义,用 C99 标准中的 uint16_t 代替 unsigned short 等存在兼容问题的类型,类似的还有 Bool 类型。
    • 补充 MOCHAFILE 宏,强制令 Mocha 进入读取文件的命令行模式,而不是浏览器中所使用的嵌入模式。
    • 补充部分代码中缺失的 include 引用。

    最后,我只用一个非常简单的 bash 脚本,就成功编译出了 Mocha 的全部模块。相信只要正经学过几天 C 语言就能搞明白:

    function compile_objs() {
        echo "compiling OBJS..."
        $CC -Iinclude src/mo_array.c -c -o out/mo_array.o
        $CC -Iinclude src/mo_atom.c -c -o out/mo_atom.o
        $CC -Iinclude src/mo_bcode.c -c -o out/mo_bcode.o
        $CC -Iinclude src/mo_bool.c -c -o out/mo_bool.o
        $CC -Iinclude src/mo_cntxt.c -c -o out/mo_cntxt.o
        $CC -Iinclude src/mo_date.c -Wno-dangling-else -c -o out/mo_date.o
        $CC -Iinclude src/mo_emit.c -c -o out/mo_emit.o
        $CC -Iinclude src/mo_fun.c -c -o out/mo_fun.o
        $CC -Iinclude src/mo_math.c -c -o out/mo_math.o
        $CC -Iinclude src/mo_num.c -Wno-non-literal-null-conversion -c -o out/mo_num.o
        $CC -Iinclude src/mo_obj.c -c -o out/mo_obj.o
        $CC -Iinclude src/mo_parse.c -c -o out/mo_parse.o
        $CC -Iinclude src/mo_scan.c -c -o out/mo_scan.o
        $CC -Iinclude src/mo_scope.c -c -o out/mo_scope.o
        $CC -Iinclude src/mo_str.c -Wno-non-literal-null-conversion -c -o out/mo_str.o
        $CC -Iinclude src/mocha.c -c -o out/mocha.o
        $CC -Iinclude src/mochaapi.c -Wno-non-literal-null-conversion -c -o out/mochaapi.o
        $CC -Iinclude src/mochalib.c -c -o out/mochalib.o
        $CC -Iinclude src/prmjtime.c -c -o out/prmjtime.o
        $CC -Iinclude src/prtime.c -c -o out/prtime.o
        $CC -Iinclude src/prarena.c -c -o out/prarena.o
        $CC -Iinclude src/prhash.c -c -o out/prhash.o
        $CC -Iinclude src/prprf.c -c -o out/prprf.o
        $CC -Iinclude src/prdtoa.c \
            -Wno-logical-not-parentheses \
            -Wno-shift-op-parentheses \
            -Wno-parentheses \
            -c -o out/prdtoa.o
        $CC -Iinclude src/log2.c -c -o out/log2.o
        $CC -Iinclude src/longlong.c -c -o out/longlong.o
    }
    

    当然在这中途抛出的编译器警告中,我也看到了一些不讲武德的代码。比如 mo_date.c 里的这个:

    if (i <= st + 1)
        goto syntax;
    for (k = (sizeof(wtb)/sizeof(char*)); --k >= 0;)
        if (date_regionMatches(wtb[k], 0, s, st, i-st, 1)) {
            int action = ttb[k];
            if (action != 0)
                if (action == 1) /* pm */
                    if (hour > 12 || hour < 0)
                        goto syntax;
                    else
                        hour += 12;
                else if (action <= 13) /* month! */
                    if (mon < 0)
                        mon = /*byte*/ (action - 2);
                    else
                        goto syntax;
                else
                    tzoffset = action - 10000;
            break;
        }
    if (k < 0)
    goto syntax;
    

    也有很多注释提醒着我这个项目的悠久历史,比如 mocha.c 里的这个:

    /*
    ** Mocha virtual machine.
    **
    ** Brendan Eich, 6/20/95
    */
    

    另外我也找到了一些体现 1995 年混沌兼容性问题的代码。它们让我更理解当时的人们为什么会期待「一次编写,到处运行」的 Java 了:

    #if defined(AIXV3)
    #include "os/aix.h"
    
    #elif defined(BSDI)
    #include "os/bsdi.h"
    
    #elif defined(HPUX)
    #include "os/hpux.h"
    
    #elif defined(IRIX)
    #include "os/irix.h"
    
    #elif defined(LINUX)
    #include "os/linux.h"
    
    #elif defined(OSF1)
    #include "os/osf1.h"
    
    #elif defined(SCO)
    #include "os/scoos.h"
    
    #elif defined(SOLARIS)
    #include "os/solaris.h"
    
    #elif defined(SUNOS4)
    #include "os/sunos.h"
    
    #elif defined(UNIXWARE)
    #include "os/unixware.h"
    
    #elif defined(NEC)
    #include "os/nec.h"
    
    #elif defined(SONY)
    #include "os/sony.h"
    
    #elif defined(NCR)
    #include "os/ncr.h"
    
    #elif defined(SNI)
    #include "os/reliantunix.h"
    #endif
    

    幸运的是,这些 C 代码都能顺利通过编译。这里为了保留历史遗迹,没有做画蛇添足的多余改动。而在获得全部对象文件后,只要用下面这几行 bash 脚本,就能链接出 Mocha 的可执行文件了!

    function compile_native() {
        export CC=clang
        export AR=ar
        compile_objs
        echo "linking..."
        $AR -rcs out/libmocha.a out/*.o
        $CC -Iinclude -Lout -lmocha tests/mo_shell.c -o out/mo_shell
        echo "mocha shell compiled!"
    }
    

    获得 Mocha 的原生版本之后,该怎样获得它的 WASM 版本呢?非常简单,只要把原生编译器 gcc(在 macOS 上其实是 clang)换成 WASM 编译器 emcc 就可以了!这个 Emscripten 编译器支持 JavaScript 和 WASM 作为编译后端,切换输出格式不过是改一个编译参数的事情:

    function compile_web() {
        export CC=emcc
        export AR=emar
        compile_objs
        echo "linking..."
        $AR -rcs out/libmocha.a out/*.o
        $CC -Iinclude -Lout -lmocha tests/mo_shell.c \
            --shell-file src/shell.html \
            -s NO_EXIT_RUNTIME=0 \
            -s WASM=$1 \
            -O2 \
            -o $2
        echo "mocha shell compiled!"
    }
    
    function compile_js() {
        compile_web 0 out/mocha_shell_js.html
    }
    
    function compile_wasm() {
        compile_web 1 out/mocha_shell_wasm.html
    }
    

    在获得可用的 Mocha 引擎后,我没有重新编写 Makefile。因为我发现这个完全手动实现的 bash 脚本虽然不具备增量编译的能力,但也非常简单易用,可以很方便地构建出不同的编译产物:

    $ source build.sh
    
    # build WASM
    $ compile_wasm
    
    # build js
    $ compile_js
    
    # build native
    $ compile_native
    

    不过,Emscripten 编译产物默认的侵入性很强,其输出本身是一个「只要打开页面就会立刻同步执行 WASM 内容」的 HTML。该如何使其接受文本框的用户输入呢?为了简单起见,这里直接将 WASM 引擎页面嵌入了一个 iframe 中。每次点击页面上的 Run 按钮,都会先将输入框内容插入 localStorage,然后重新加载相应的 WASM iframe 页面,在其中同步地读取 localStorage 内的字符串 JS 脚本内容作为(Emscripten 模拟出的)stdin 的标准输入,最后自动启动 Mocha 解释执行。

    这个过程很简单,相信任何一个普通的前端开发者都可以轻松地实现出来。这是最后的效果:

    我把世界上第一个 JS 引擎编译回了 JS

    这样就大功告成了!我们重新把世界上第一个 JS 引擎安装回了浏览器里!

    从开始移植 Mocha 源码到上线 WASM 版本,只花了我不到三天的业余时间。因此个人认为当年的 Mocha 引擎较好地考虑了可移植性和可维护性,具有不错的工程质量。但诸如引用计数等基础设计使其存在固有的性能瓶颈,因此后来需要重写,这就是另一个故事了。

    本文写作时,正好处于 JavaScript 正式发布 25 周年之际(1995 年 12 月 4 日,Netscape 与 Sun 召开联合发布会)。而介绍那次事件的新闻稿,也是《JavaScript 20 年》中的一份附件。作为中国的前端开发者,我很高兴能看到这本书在国内获得了不错的反响(个人相关文章共计约 6 万阅读量,GitHub 翻译项目 2.2k star)。有趣的是,JS 之父 Brendan Eich 的推特头像上也写着中文,可惜上面只能看到「無一」两个字,看起来像是在练混元形意太极拳:

    我把世界上第一个 JS 引擎编译回了 JS

    不过托 @顾轶灵 的福,我找到了 Eich 头像的原图。你看这里的汉字并不是玄学,而是一段程序员的心灵鸡汤,写的是「越多人贡献心力,对整个生态系的发展有益无害,开源俨然已成了一种文化」——

    我把世界上第一个 JS 引擎编译回了 JS

    今天我们这次小小的实践,也算是这种文化的一种体现吧。

    C 语言之父 Dennis Ritchie 说,成功的方式是靠运气——「你要出现在正确的时间和正确的地点,然后让自己被后人所延续。」而 JavaScript 也正是这样的。这门语言已经在 SpaceX 龙飞船上支撑起了人类首个宇宙飞船中的 GUI,甚至即将随着詹姆斯韦伯太空望远镜飞向远方。但当我们回顾这一切的起点时,那个带着不少瑕疵的 1995 年版 Mocha 引擎,无疑出现在了正确的时间和正确的地点——否则我们今天写的大概将会是 VBScript。

    在 2020 年结束之际回顾 1995,那真像是个不可思议的时代:WTO 成立,申根协议生效,中国劳动法施行,Windows 95、Java 和 JavaScript 陆续发布。而四分之一个世纪过去后,有些东西进步了,有些东西天翻地覆了,但也有些东西恐怕再也回不来了。

    忘记那些糟心事吧。就在今天,让我们为 1995 干杯,为 2020 干杯,为 JavaScript 干杯吧。

    传送门:Mocha 1995


    起源地下载网 » 我把世界上第一个 JS 引擎编译回了 JS

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元