每日一报
Something about TypeScript
Strict Mode in TypeScript
建议开启 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
的方式来分析具体存在的问题。
import { info } from './info.js';
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
import { secretKey } from './private.js';
export const info = await fetch(`/getInfo?secretKey=${secretKey}`);
export const secretKey = '12345678';
Esbuild
处理 Eval
的方式
在 0.14.8
版本之前,按照上述的 代码用例,通过 esbuild
打包后的产物如下:
// 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
),同样打包上述 代码用例后,打包产物如下:
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();
esbuild
对 eval
做了额外处理,似乎对 esm
打包的策略做了降级处理,即会使用 commonjs
的打包策略,使用函数作用域来包裹各个作用域,使模块间相互独立。
事实是如此吗?
查看 esbuild
对于 0.14.9
的 发布日志,不幸的是并没有发现关于 eval
的任何处理。
通过查看 0.14.8
~ 0.14.9
的 commit 记录,可以发现有一个有趣的提交:make esm detection more consistent。
改动前:
// 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".
改动后:
// 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
关键字。
import { info } from './info.js';
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
export {}
重新打包后,产物如下:
// 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
的方式
打包产物如下:
const secretKey = '12345678';
const info = await fetch(`/getInfo?secretKey=${secretKey}`);
console.log(info);
eval(`fetch('https://eval/expose?secretKey=secretKey')`);
rollup
与 esbuild
的行为是一样的,均没有对 eval
做特殊处理,eval
均存在 越界访问私有变量 的问题。
在打包时避免直接使用 eval
rollup
和 esbuild
均提倡 避免直接使用 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')
。会在 全局作用域 而非调用位置的词法作用域中执行代码。
<!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
的策略:
var eval2 = eval;
const foo = 43;
const value = 43;
const hook = () => {
var foo = 42;
eval2('console.log("with eval2:",foo)');
}
hook(value);
export {};
// main.js
var eval2 = eval;
var value = 43;
var hook = () => {
var foo = 42;
eval2('console.log("with eval2:",foo)');
};
hook(value);
rollup
使用间接 eval
的策略:
var eval2 = eval;
const foo = 43;
const value = 43;
const hook = () => {
var foo = 42;
eval2('console.log("with eval2:",foo)');
}
hook(value);
export {};
var eval2 = eval;
const hook = () => {
eval2('console.log("with eval2:",foo)');
};
hook();
观察到,esbuild
和 rollup
均会将 eval
视作 副作用语句,在 tree-shaking
时,会保留 eval
语句。
不过这里 rollup
采用更为激进的做法,认为 eval
的副作用不会对模块作用域中的任何变量引用产生影响;而 esbuild
则采取较为保守的策略,eval
的副作用会将所处的作用域感染为也具备 副作用。
小结
间接 eval
总是会在全局作用域中执行,因此:
- 无法访问局部变量:只能访问 全局作用域 中的变量,无法 读取 或 修改 打包文件中的 任何局部变量。
- 不影响变量重命名:
ESM Bundlers
可以自由 重命名局部变量 而不影响 间接eval
的行为。 - 允许优化:由于没有潜在的 词法作用域 依赖,
tree-shaking
和minification
可以正常进行。
从 ESM Bundlers
视角看:
- 直接
eval
:必须 保留 所有可能被 访问的变量名,禁用 重命名 优化,无法进行 树摇动。 - 间接
eval
:无需特殊处理,可以放心 重命名变量、tree-shaking、合并模块作用域、minification。
间接 eval
本质上解决了问题,因为他被语言规范限制在 全局作用域 中执行,无法 穿透 进入 局部作用域,从而与打包后的代码模块体系 完全隔离。
使用 new Function
new Function
会 在全局作用域而非定义作用域 中执行代码。
测试如下代码:
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
打包产物如下:
// 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
打包产物如下:
function outerFunc() {
const value = 'local val';
const fnResult = new Function('return localVar')(value);
return fnResult;
}
window.localVar = 'global var';
console.log(outerFunc());
可以看出 rollup
和 esbuild
在处理 new Function
与 eval
类似,均会将 new Function
视作 副作用语句,在 tree-shaking
时,会保留 new Function
语句。但这里有些不一样的是通过 new Function
调用的参数会被视为也 具备副作用。
补充知识
为什么 javascript vm
没有预处理 eval
语句?
有人可能会觉得上述存在的问题归因于 javascript vm
没有预处理 eval
语句,解析语句后就可以明确了解哪些变量被访问了,从而可以进行优化。
但 主要原因 是在于 eval
的 动态性,eval
的执行结果 依赖于运行时,而 非编译时,因此 javascript vm
无法在编译时进行优化处理,而是将解析步骤推迟到运行时。这也是 eval
性能较差的主要原因之一,强制引擎在运行时进行额外的解析和编译工作。
eval
与 new Function
的性能差异
eval
的性能较差,主要原因在于每次调用 eval
时,都需要 重新解析和编译 代码,而 new Function
则只需 解析和编译 一次;在变量检索时,直接调用 eval
需 遍历完整作用域链,包括局部变量。而 new Function
/ 间接 eval
则 仅在全局作用域中查找,作用域链更短。