TypeScript 中针对 ES3/ES5 的迭代降级

Typescript 2.3 引入了一个新的 --downlevelIteration 选项,支持将 ES2015 的迭代协议(iteration protocol)编译为 ES3 和 ES5 代码。for...of 循环现在能够被降级编译为带有正确语义的代码。

使用 for...of 迭代数组

假定对于后面的 Typescript 代码示例,我们使用以下简单的 tsconfig.json 配置文件。它只包含了一个编译选项,即我们编译后的 ESMAScript 语言版本目标——在这个例子中,是 ES5:

{
  "compilerOptions": {
    "target": "es5"
  }
}

我们再来看下面的 index.ts 文件。没有什么特别的,只是声明了一个数字数组,然后使用 ES2015 的 for...of循环来迭代这个数组,并打印每一个数字:

const numbers = [4, 8, 15, 16, 23, 42]

for (const number of numbers) {
  console.log(number)
}

我们可以直接执行 index.ts 文件,而不用先经过 Typescript 编译器编译,因为它没有包含任何 Typescript 特定的语法。

$ node index.ts
4
8
15
16
23
42

我们现在将 index.ts 编译为 index.js

$ tsc -p .

看一看编译生成的代码,我们可以看到 Typescript 编译器生成了传统的基于索引对数组进行迭代的 for 循环。

var numbers = [4, 8, 15, 16, 23, 42]
for (var _i = 0, numbers_1 = numbers; _i < numbers_1.length; _i++) {
  var number = numbers_1[_i]
  console.log(number)
}

如果我们运行以上的代码,它也能按预期工作:

$ node index.js
4
8
15
16
23
42

执行 node index.tsnode index.js 观察到的输出结果是一样的,也应该如此。这意味着通过 Typescript 编译后程序的行为并没有被改变。很好。

使用 for...of 迭代字符串

下面是另一个 for...of 循环,这一次,我们迭代的是字符串而不是数组:

const text = 'Booh! 👻'

for (const char of text) {
  console.log(char)
}

再一次,我们可以直接运行 node index.ts 因为我们的代码只包含 ES2015 语法,没有什么是 Typescript 特有的。下面是程序执行的输出:

$ node index.ts
B
o
o
h
!

👻

现在我们再把 index.ts 编译为 index.js。当编译目标是 ES3 或者 ES5 的时候,Typescript 编译器会愉快地生成基于索引的 for 循环代码:

var text = 'Booh! 👻'
for (var _i = 0, text_1 = text; _i < text_1.length; _i++) {
  var char = text_1[_i]
  console.log(char)
}

不幸的是,生成的 Javascript 代码执行结果和原始的 Typescript 版本并不一样:

$ node index.js
B
o
o
h
!

鬼魂 emoji——或者更准确地说,码位(code point) U+1F47B——是由两个编码单元(code units) U+D83DU+DC7B 组成。因为通过索引迭代字符串会返回当前索引的编码单元(而不是码位),所以 for 循环将鬼混 emoji 拆分成了两个独立的编码单元。

另一方面,字符串的迭代协议会迭代字符串的码位(code point),所以导致这两个程序最终的输出不一致。你可以通过对比字符串的 length 以及通过字符串迭代器生成的序列长度来验证这一点:

const ghostEmoji = '\u{1F47B}'

console.log(ghostEmoji.length) // 2
console.log([...ghostEmoji].length) // 1

长话短说:使用 for...of 迭代字符串如果编译为 ES3 或者 ES5,执行结果可能并不正确。这就是 Typescript 2.3 为什么要引入 --downlevelIteration 标识的原因。

--downlevelIteration 标识

下面的 index.ts 和之前的一样:

const text = 'Booh! 👻'

for (const char of text) {
  console.log(char)
}

现在我们修改一下 tsconfig.json 文件,将新的 downlevelIteration 编译选项设置为 true

{
  "compilerOptions": {
    "target": "es5",
    "downlevelIteration": true
  }
}

如果我们运行一次编译,会生成下面的 Javascript 代码:

var __values =
  (this && this.__values) ||
  function (o) {
    var m = typeof Symbol === 'function' && o[Symbol.iterator],
      i = 0
    if (m) return m.call(o)
    return {
      next: function () {
        if (o && i >= o.length) o = void 0
        return { value: o && o[i++], done: !o }
      },
    }
  }
var text = 'Booh! 👻'
try {
  for (
    var text_1 = __values(text), text_1_1 = text_1.next();
    !text_1_1.done;
    text_1_1 = text_1.next()
  ) {
    var char = text_1_1.value
    console.log(char)
  }
} catch (e_1_1) {
  e_1 = { error: e_1_1 }
} finally {
  try {
    if (text_1_1 && !text_1_1.done && (_a = text_1.return)) _a.call(text_1)
  } finally {
    if (e_1) throw e_1.error
  }
}
var e_1, _a

你可以看到,生成的代码比之前简单的 for 循环要复杂得多。这是因为它包含了迭代协议适当的实现:

  • __values 帮助函数会寻找 [Symbol.iterator] 方法,如果存在就调用它。如果没有,它会创建一个模拟迭代器,以数组的方式迭代这个对象。
  • 和迭代每一个编码单元不一样,这里的 for 循环调用了迭代器的 next() 方法直到结束,也就是返回的 donetrue 的时候。
  • 为了根据 ECMAScript 规范实现正确的迭代协议,生成了 try/catch/finally 代码块来处理异常情况。

如果我们现在再一次执行 index.js,我们会得到正确的输出:

$ node index.js
B
o
o
h
!

👻

注意当你的代码在一个没有原生支持 symbol 的环境中执行的时候,你依然需要 Symbol.iterator 的垫片代码(shim)。如果 Symbol.iterator 没有定义 ,__values 帮助函数会创建一个模拟的数组迭代器,但它并没有实现正确的迭代协议。

ES2015 集合类型使用降级迭代

ES2015 给标准库增加了两个新的集合类型 MapSet。在这一部分,我会讲解如何使用 for...of 循环来迭代 Map

下面的代码示例中,我们创建了一个数字到它对应英文名的映射。我给这个 Map 构造函数初始化了 10 个键值对(用二维数组的方式表示)。然后,我使用 for...of 循环以及数组的解构赋值模式来将每一个键值对解构为 digitname

const digits = new Map([
  [0, 'zero'],
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
  [4, 'four'],
  [5, 'five'],
  [6, 'six'],
  [7, 'seven'],
  [8, 'eight'],
  [9, 'nine'],
])

for (const [digit, name] of digits) {
  console.log(`${digit} -> ${name}`)
}

这是完全正确的 ES2015 代码,并且能如预期一样执行:

$ node index.ts
0 -> zero
1 -> one
2 -> two
3 -> three
4 -> four
5 -> five
6 -> six
7 -> seven
8 -> eight
9 -> nine

然而,Typescript 编译器并不开心,它抱怨找不到 Map

这是因为我们的编译目标是 ES5,它并没有实现 Map 类型的集合。那我们该如何让这个代码顺利通过编译,假定我们提供了 Map 的 polyfill 所以程序能够在运行时正常工作?

解决方案是给tsconfig.json 中的 lib 编译选项添加 es2015.collectiones2015.iterable 两个值。这会告诉 Typescript 编译器,它能够在运行时找到 ES2015 集合以及 Symbol.iterator symbol 的实现。 不过,一旦你显式地设定了 lib 选项,它的默认值将被覆盖。因此,你应该同时添加 domes5,这样你就可以访问其他的标准库方法。

下面是最终的 tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "downlevelIteration": true,
    "lib": ["dom", "es5", "es2015.collection", "es2015.iterable"]
  }
}

现在 Typescript 编译器不再抱怨,并且生成了以下的 Javascript 代码:

var __values =
  (this && this.__values) ||
  function (o) {
    var m = typeof Symbol === 'function' && o[Symbol.iterator],
      i = 0
    if (m) return m.call(o)
    return {
      next: function () {
        if (o && i >= o.length) o = void 0
        return { value: o && o[i++], done: !o }
      },
    }
  }
var __read =
  (this && this.__read) ||
  function (o, n) {
    var m = typeof Symbol === 'function' && o[Symbol.iterator]
    if (!m) return o
    var i = m.call(o),
      r,
      ar = [],
      e
    try {
      while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value)
    } catch (error) {
      e = { error: error }
    } finally {
      try {
        if (r && !r.done && (m = i['return'])) m.call(i)
      } finally {
        if (e) throw e.error
      }
    }
    return ar
  }
var digits = new Map([
  [0, 'zero'],
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
  [4, 'four'],
  [5, 'five'],
  [6, 'six'],
  [7, 'seven'],
  [8, 'eight'],
  [9, 'nine'],
])
try {
  for (
    var digits_1 = __values(digits), digits_1_1 = digits_1.next();
    !digits_1_1.done;
    digits_1_1 = digits_1.next()
  ) {
    var _a = __read(digits_1_1.value, 2),
      digit = _a[0],
      name_1 = _a[1]
    console.log(digit + ' -> ' + name_1)
  }
} catch (e_1_1) {
  e_1 = { error: e_1_1 }
} finally {
  try {
    if (digits_1_1 && !digits_1_1.done && (_b = digits_1.return))
      _b.call(digits_1)
  } finally {
    if (e_1) throw e_1.error
  }
}
var e_1, _b

你可以自己试一试,这个代码会打印出正确的结果。

还有一点我们需要注意,现在生成的 Javascript 代码中包含了两个帮助函数,__values__read,这明显增加了代码体积。让我们来解决这个问题。

使用 --importHelpers 和 tslib 来减少代码体积

在上面的示例代码中,__values__read 帮助函数内联到了生成的 Javascript 代码中。这么做显然不好,如果你要编译一个包含很多文件的 Typescript 项目。因为每一个生成的 Javascript 文件都会包含所有必需的帮助文件,这回极大地增加代码量。

在一个典型的项目设置中,你通常会使用一个打包器比如 webpack 来将所有模块打包在一起。如果帮助函数被引入不止一次,那最终打包的文件会增加大量不必要的代码。

解决方案是使用 --importHelpers 编译选项以及 tslib 这个包。当设置以后,--importHelpers 会使 Typescript 编译器从 tslib 中导入所需的帮助函数。打包器比如 webpack 能够只将这个包内联一次,避免引入重复代码。

为了证明 --importHelpers 的效果,我首先将 index.ts 文件转成了一个模块,导出了一个函数:

const digits = new Map([
  [0, 'zero'],
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
  [4, 'four'],
  [5, 'five'],
  [6, 'six'],
  [7, 'seven'],
  [8, 'eight'],
  [9, 'nine'],
])

export function printDigits() {
  for (const [digit, name] of digits) {
    console.log(`${digit} -> ${name}`)
  }
}

现在我们需要修改编译选项,将 importHelpers 设置为 true。这是最终的 tsconfig.json 文件:

{
  "compilerOptions": {
    "target": "es5",
    "downlevelIteration": true,
    "importHelpers": true,
    "lib": ["dom", "es5", "es2015.collection", "es2015.iterable"]
  }
}

这是重新编译后生成的 Javascript 代码:

'use strict'
Object.defineProperty(exports, '__esModule', { value: true })
var tslib_1 = require('tslib')
var digits = new Map([
  [0, 'zero'],
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
  [4, 'four'],
  [5, 'five'],
  [6, 'six'],
  [7, 'seven'],
  [8, 'eight'],
  [9, 'nine'],
])
function printDigits() {
  try {
    for (
      var digits_1 = tslib_1.__values(digits), digits_1_1 = digits_1.next();
      !digits_1_1.done;
      digits_1_1 = digits_1.next()
    ) {
      var _a = tslib_1.__read(digits_1_1.value, 2),
        digit = _a[0],
        name_1 = _a[1]
      console.log(digit + ' -> ' + name_1)
    }
  } catch (e_1_1) {
    e_1 = { error: e_1_1 }
  } finally {
    try {
      if (digits_1_1 && !digits_1_1.done && (_b = digits_1.return))
        _b.call(digits_1)
    } finally {
      if (e_1) throw e_1.error
    }
  }
  var e_1, _b
}
exports.printDigits = printDigits

注意,上面的代码不再内联帮助函数,而是在最上面导入了 tslib 这个包。

好了,我们终于得到了兼容规范的降级编译成的 for...of 循环,它完全支持迭代协议,并且没有任何多余的 Typescript 帮助函数。