函数级转译

现在大部分 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.barWind.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://unpkg.com/babel-standalone@6/babel.min.js"></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 了