函数级转译
现在大部分 JavaScript 转译器 (transpiler )的正确使用方法都是在发布之前先生成目标代码 (JavaScript ) , 然后直接交由浏览器执行 。 比如说 Babel , 几乎所有的使用案例都是让浏览器执行预先编译之后的代码 , 很少有人会把 Babel 也放进浏览器里 , 让客户端去转译 。
原因当然有很多 , 比如转译器体积太大 , 影响加载速度 ;又比如客户端配置不高 , 转译影响性能 ;再比如光转译不够 , 大多数情况下还需要打包 。 但是 , 如果说这个转译器很小呢 ?它可能不像 Babel 那样 , 致力于转译整个文件甚至整个项目 , 如果它的目标只是转译某个函数呢 ?
事实上 , 在 5 年前 async / await 提案还没有出现的时候 , 有个项目叫 Wind.js 风靡一时 , 用的就是这个套路 :
var fib = eval(Wind.compile("async", function () { var a = 0, current = 1; while (true) { var b = a; a = current; current = a + b; $await(Wind.Async.sleep(1000)); console.log(current); } })); fib().start();
Wind.compile() 会把传入的函数编译成 CPS 风格 (就是这种回调套回调的写法 ) :
(function() { var _builder_$0 = Wind.builders["async"]; return _builder_$0.Start(this, _builder_$0.Delay(function() { var a = 0, current = 1; return _builder_$0.While( function() { return true; }, _builder_$0.Delay(function() { var b = a; a = current; current = a + b; return _builder_$0.Bind(Wind.Async.sleep(1000), function() { console.log(current); return _builder_$0.Normal(); }); }) ); }) ); })
为了拿到 Wind.compile() 所在位置的上下文 , 以便让编译后的函数拥有调用者的权限 , 不得不用 eval() 来执行编译后的代码 。
工作原理其实很简单 。 利用 Function.prototype.toString() 可以拿到函数的源代码 :
(function() { foo.bar }) + '' // => "function () { foo.bar }"
对箭头函数一样适用 :
((ans = 42) => ans) + '' // => "(ans = 42) => ans"
上面的例子里有个 foo.bar , Wind.js 的例子里有个 $await() , 这些可能都是没有定义的 , 如果直接执行会抛异常 。 但是 , 只要没有语法错误 , 单单转化成字符串是没问题的 。 这也是为什么 Wind.js 只能定义一个虚拟的 $await() 函数 , 却不能增加一个 await 关键字一样 ——函数体里的代码会被 JavaScript 引擎解析 , 必须没有语法错误 。 可以看到 , Wind.js 转译后的代码中已经没有 $await() 了 。
这样 , 要转译传进来的函数 , 只需要把它转化成字符串得到源码 、 转译成目标代码 , 继而返回或者用它来构造 Function 。
const doge = fn => { let [, body] = /^\(\)\s*=>\s*([^]*)/.exec(fn) || []; if (!body) return fn; body = body.replace(/\d+/, $0 => parseInt($0, 10) + 1); return new Function(body); }; const print42 = doge(() => console.log(41)); print42(); // => 42
上面代码里的
() => console.log(41)
经过 doge 转译器转译之后 , 变成了一个打印 42 的函数 。
转译函数相比较转译文件 , 规模更小 。 不过很少有人会用这种方式去转译自己的语言 , 除非设计的语言语法上与 JavaScript 兼容 。
我个人认为比较好的一个应用是 CoffeeScript 里描述 grammar 的时候 , 定义了一个 Jison grammar 生成的 DSL :
# Since we're going to be wrapped in a function by Jison in any case, if our # action immediately returns a value, we can optimize by removing the function # wrapper and just returning the value directly. unwrap = /^function\s*\(\)\s*\{\s*return\s*([\s\S]*);\s*\}/ # Our handy DSL for Jison grammar generation, thanks to # [Tim Caswell](https://github.com/creationix). For every rule in the grammar, # we pass the pattern-defining string, the action to run, and extra options, # optionally. If no action is specified, we simply pass the value of the # previous nonterminal. o = (patternString, action, options) -> patternString = patternString.replace /\s{2,}/g, ' ' patternCount = patternString.split(' ').length return [patternString, '$ = $1;', options] unless action action = if match = unwrap.exec action then match[1] else "(#{action}())" # All runtime functions we need are defined on "yy" action = action.replace /\bnew /g, '><yy.' action = action.replace /\b(?:Block\.wrap|extend)\b/g, 'yy.><' # Returns a function which adds location data to the first parameter passed # in, and returns the parameter. If the parameter is not a node, it will # just be passed through unaffected. addLocationDataFn = (first, last) -> if not last "yy.addLocationDataFn(@#{first})" else "yy.addLocationDataFn(@#{first}, @#{last})" action = action.replace /LOC\(([0-9]*)\)/g, addLocationDataFn('$1') action = action.replace /LOC\(([0-9]*),\s*([0-9]*)\)/g, addLocationDataFn('$1', '$2') [patternString, "$ = #{addLocationDataFn(1, patternCount)}(#{action});", options]
如果用 Jison 的语法来描述 , 代码需要作为字符串传入 :
const grammar = [ Root: [ [ '', '$ = yy.addLocationDataFn(1)(new yy.Block());' ], [ 'Body', '$ = $1;' ], ], ... ]
一来编辑器没法高亮代码 , 二来 yy.
和 yy.addLocationDataFn()
的频率很高 。 定义了 o
函数之后 , 就可以大大简化 , 而且很漂亮 :
grammar = # The **Root** is the top-level node in the syntax tree. Since we parse bottom-up, # all parsing must end here. Root: [ o '', -> new Block o 'Body' ] # Any list of statements and expressions, separated by line breaks or semicolons. Body: [ o 'Line', -> Block.wrap [$1] o 'Body TERMINATOR Line', -> $1.push $3 o 'Body TERMINATOR' ] ...
代码片段级转译
根据 ES2015 的语法 , TemplateLiteral 前面可以加 CallExpression , 但是很少见人这么用 , 大多数 template literal 的使用场景还是拿来拼接字符串的 。 不过还是可以举个例子 :React 的一个组件库 styled-components 。
要写一个带样式的按钮组件 , 只需要写
import styled from 'styled-components'; const Button = styled.button` /* Adapt the colors based on primary prop */ background: ${props => props.primary ? 'palevioletred' : 'white'}; color: ${props => props.primary ? 'white' : 'palevioletred'}; font-size: 1em; margin: 1em; padding: 0.25em 1em; border: 2px solid palevioletred; border-radius: 3px; `; export default Button;
<Button>Normal</Button> <Button primary>Primary</Button>
我很喜欢这种设计思想 , 有的人可能并不喜欢 。 不过这篇文章不打算深究 styled-components 的设计好不好 , 而是想探讨一下它对于 template literal 的用法 。
它的 template literal 是 CSS ——一种 DSL (领域专用语言 ) 。 MDN 上也举了一个用 template literal 来嵌入 LaTeX 的例子 :
latex`\unicode` // Throws in older ECMAScript versions (ES2016 and earlier) // SyntaxError: malformed Unicode character escape sequence
这里还有个有趣的故事 。 ES2016 以及之前的版本上述代码会抛异常 , 因为在字符串里 \u
有特殊含义 , 是用来转译 Unicode 字符的 , 后面跟的 niconiconi 自然不是合法的十六进制数 。 有人提了个提案 Template Literal Revision , 允许 template literal 里出现不合法的转义字符 , 同时加了一个 raw
属性来读取未经转译的字符串 。
function tag(strs) { strs[0] === undefined strs.raw[0] === "\\unicode and \\u{55}"; } tag`\unicode and \u{55}`
还有个 String.raw()
供我们使用 , 像极了 C# 里的 verbatim string :
String.raw`C:\Users\foo\bar\baz` === "C:\\Users\\foo\\bar\\baz"
总而言之 , template literal 还是比较适合用来嵌入其他语言 (尤其是 DSL )的 , 也可能看作是运行时对其他语言的转译 。 比如说 , 我们可以用 tagged template 来生成和表示 HTML 元素 :
const el = (strs, ...values) => { const raw = String.raw(strs, ...values); const [ , tagName, innerHTML ] = /^\s*<([^\s>]+)[^]*?>([^]*?)<\/\1>\s*$/.exec(raw) || []; // 这里检测还是很粗略的,可以绕过 if (!tagName) { throw new Error('There should be exactly one root element'); } let element = document.createElement(tagName); element.innerHTML = innerHTML; return element; }; document.body.appendChild(el` <div> <p>Hello ${'World'}!</p> </div> `);
嵌入的语言是没有限制的 , 但是如果是世界上最好的语言就比较尴尬了 ——本身就到处是美元符号 , 需要频繁转义 。
我写了个完全没有优化的 Brainfxxk 转译器 :
const stmt = { '>': '++ptr;', '<': '--ptr;', '+': 'data[ptr] = ~~data[ptr] + 1;', '-': 'data[ptr] = ~~data[ptr] - 1;', '.': 'output += String.fromCharCode(~~data[ptr]);', ',': 'if ((data[ptr] = input.charCodeAt(i++)) !== data[ptr]) data[ptr] = -1;', '[': 'while (~~data[ptr]) {', ']': '}' }; const brainfxxk = (strs, ...values) => { const raw = String.raw(strs, ...values); let code = [ ...raw ].map(token => stmt[token]).join(''); code = 'const data = [];' + code + 'return output;'; return new Function('ptr', 'i', 'output', 'input', code).bind(this, 0, 0, ''); };
然后找了段 rot13 的实现跑了跑 :
const rot13 = brainfxxk` -,+[ Read first character and start outer character reading loop -[ Skip forward if character is 0 >>++++[>++++++++<-] Set up divisor (32) for division loop (MEMORY LAYOUT: dividend copy remainder divisor quotient zero zero) <+<-[ Set up dividend (x minus 1) and enter division loop >+>+>-[>>>] Increase copy and remainder / reduce divisor / Normal case: skip forward <[[>+<-]>>+>] Special case: move remainder back to divisor and increase quotient <<<<<- Decrement dividend ] End division loop ]>>>[-]+ End skip loop; zero former divisor and reuse space for a flag >--[-[<->+++[-]]]<[ Zero that flag unless quotient was 2 or 3; zero quotient; check flag ++++++++++++<[ If flag then set up divisor (13) for second division loop (MEMORY LAYOUT: zero copy dividend divisor remainder quotient zero zero) >-[>+>>] Reduce divisor; Normal case: increase remainder >[+[<+>-]>+>>] Special case: increase remainder / move it back to divisor / increase quotient <<<<<- Decrease dividend ] End division loop >>[<+>-] Add remainder back to divisor to get a useful 13 >[ Skip forward if quotient was 0 -[ Decrement quotient and skip forward if quotient was 1 -<<[-]>> Zero quotient and divisor if quotient was 2 ]<<[<<->>-]>> Zero divisor and subtract 13 from copy if quotient was 1 ]<<[<<+>>-] Zero divisor and add 13 to copy if quotient was 0 ] End outer skip loop (jump to here if ((character minus 1)/32) was not 2 or 3) <[-] Clear remainder from first division if second division was skipped <.[-] Output ROT13ed character from copy and clear it <-,+ Read next character ] End character reading loop `; alert(rot13('Uryyb Jbeyq!'));
有兴趣的可以在 JSFiddle 里跑跑看 。
全局转译
虽说 Babel 有 standalone 版本 , 可以用在浏览器内 , 但是很少有人这么用 , 官方也不推荐 。
<div id="output"></div> <!-- Load Babel --> <script src="https://www.geekschool.org/wp-content/uploads/2021/01/1610193887.7113836.jpg"></script> <!-- Your custom script here --> <script type="text/babel"> const getMessage = () => "Hello World"; document.getElementById('output').innerHTML = getMessage(); </script>
一方面是因为有性能问题 , 毕竟要找出每个 script[type="text/babel"]
分析 、 转译 、 执行 , 相比较普通的 <script>
标签大概会慢不少 。 另一方面这与普通的 <script>
标签行为不一致 , 不论是否内联 、 有没有设置 async 属性 , 都是异步执行的 (也就没法用 document.write()
) , 能做的事情也会打一些折扣 。
想要做全局的转译并不容易 , 甚至在以前都不可能实现 。
当然 , 我们可以妥协 。 不做全局转译 , 而是做模块级转译 。 比如说以前火过一阵子的 require.js 。 如果所有代码都是由 require.js 加载的 , 那么自然可以在它的身上动手 , 写一个插件来转译就可以了 。 但这要求编码者接受 AMD 的风格 ——如果要用工具来转换 , 那么为什么不索性预转译呢 ?
Service worker 给了全局转译一丝转机 。
我不是很了解 service worker , 只知道它是 Web 应用 、 浏览器和网络之间的一个代理 。 但是我知道大多数人用它来构建 PWA , 利用这层代理实现 offline first 。
既然是一层代理 , 那就可以做很多事情 。 比如说前一阵子看到的 Planktos , 就利用 service worker 和 WebTorrent 构建浏览器端的点对点网站 。
当时感觉很惊艳 , 这层代理确实可以做很多事情 。 既然都可以把网站变成点对点了 , 用来转译代码 (说白了就是修改 response )自然没什么问题 。
搜了一下 , 半年前就有人想到去做这件事情了 , 但是我也只找到两个相关项目 / 讨论 :
值得一提的是 , Safari 技术预览版和 Edge Build 14342 已经支持 import 了 。 两者配合起来可能挺有可玩性的 , 可以把某种语言在 service worker 里转译成 ES6 , 配合 import / export , 都不需要预编译和打包了 。
当然 , 这种语言不是 ES6 本身 , 因为支持 service worker 和 import 的浏览器 , 应该已经百分百支持 ES6 了 。