Skip to content

每日一报

JS 工具集合

性能比较

  1. jsbench
  2. jsperf

JS 面试题

input 搜索如何中文输入

看过 element ui 框架源码的童鞋都应该知道,element ui是通过 compositionstartcompositionend 事件来做中文输入处理。

那么介绍一下 compositionstartcompositionend 两个事件触发的时机吧。

  1. compositionstart: 事件在用户开始进行非直接输入的时候触发。

  2. compositionend:事件在非直接输入结束,也即用户点选候选词或者点击「选定」按钮之后,比如按回车键时触发。

jsx
<input
  value={innerValue}
  onCompositionStart={handleCompositionStart}
  onCompositionEnd={handleCompositionEnd}
  onInput={handleInput}
/>

由于输入中文需要打开输入法,在开始编辑所需中文句子的时候会触发 compositionstart 事件,编辑完所需的中文句子时(即按下确认键)会触发 compositionend 事件。那么我们通过在这两个事件中做标记就可以知道输入完中文的确切时间。

jsx
const isInputting = useRef(false);
const [innerValue, setInnerValue] = useState<string | number>(0);

const handleCompositionStart = useCallback(() => {
    console.log('handleCompositionStart');
    isInputting.current = true;
}, []);

const handleCompositionEnd = useCallback(() => {
    console.log('handleCompositionEnd');
    isInputting.current = false;
}, []);

/**
 * onInput 事件在用户输入的时候会一直处于触发状态。
 *
 * 在 React 中,onChange 事件会随着用户输入不断触发,主要原因在于 React 对输入框的处理方式与浏* 览器原生处理方式的不同。
 */
const handleInput = useCallback((a: React.ChangeEvent<HTMLInputElement>) => {
    if (!isInputting.current) {
        console.log('handleChange');
    }
    setInnerValue(a.currentTarget.value);
}, []);

深究 JavaScript 数组

JavaScript 的数组通过哈希映射或者字典的方式来实现,所以不是连续的。我觉得这是一门劣等语言:连数组都不能正确的实现。

在众多编程语言中,数组定义为在内存中用一串 连续 的区域来存放一些值。而在 JavaScript 中,数组是哈希映射。它可以通过多种数据结构实现,其中一种是链表。

JavaScript 引擎已经在为同种数据类型的数组分配连续的存储空间了。优秀的开发者总是保持数组的数据类型一致,这样即时编译器 (JIT) 就能像 C 编译器一样通过计算读取数组了。但是,如果你想在同种类型的数组中插入不同类型的元素,JIT 会销毁整个数组然后用以前的办法重建。

在 ES2015/ES6 中, 数组还有其它改进。 TC39 决定在 JavaScript 中引入类型化数组,所以如今我们有 ArrayBuffer 了。ArrayBuffer 会有一大块连续的存储位置,你能用它做任何你想做的事情。不过,直接处理内存涉及非常底层的操作,相当复杂。可以通过 Views 来处理 ArrayBuffer。

js
var buffer = new ArrayBuffer(8);
var view = new Int32Array(buffer);
view[0] = 100;

你还可以使用 SharedArrayBuffer 在多个 web-workers 间共享内存数据来提升性能。

我们可以测试对比以下两种写法方式

写法一:

js
var LIMIT = 10000000;
var arr = new Array(LIMIT);
console.time('Array insertion time');
for (var i = 0; i < LIMIT; i++) {
  arr[i] = i;
}
console.timeEnd('Array insertion time');

写法二:

js
var LIMIT = 10000000;
var buffer = new ArrayBuffer(LIMIT * 4);
var arr = new Int32Array(buffer);
console.time('ArrayBuffer insertion time');
for (var i = 0; i < LIMIT; i++) {
  arr[i] = i;
}
console.timeEnd('ArrayBuffer insertion time');

__esModule 的用途

提案

  1. ES模块和CommonJS模块的兼容性: __esModule 标识主要用于解决 ES 模块和 CommonJS 模块之间的兼容性问题。当使用打包工具(如WebpackRollup等)将 ES 模块转换为 CommonJS 模块时,会添加这个标识。

  2. 模块类型识别: 通过 __esModule 标识,运行时环境可以判断一个模块是原生的 CommonJS 模块还是由 ES 模块转换而来的。这对于正确处理默认导出和命名导出非常重要。

  3. 默认导出的处理:ES 模块中,可以使用 export default 语法。但在 CommonJS 中没有直接对应的概念。通过 __esModule 标识,可以正确地模拟 ES 模块的默认导出行为。

  4. 导入行为的一致性: 当其他模块导入一个带有 __esModule 标识的模块时,可以保持与导入原生 ES 模块时相似的行为,尤其是在处理 default importnamespace import

  5. 跨环境兼容: 这个标识使得同一份代码可以在支持 ES 模块的环境和只支持 CommonJS 的环境中都能正常工作,增强了代码的可移植性。

  6. 交互操作(Interoperability): __esModule 标识不仅帮助识别模块类型,还促进了不同模块系统间的交互操作。它允许使用 CommonJS 语法的代码可以无缝地使用转换后的 ES 模块,反之亦然。

  7. 运行时行为模拟: 在某些情况下,打包工具会生成额外的代码来模拟 ES 模块的运行时行为。例如,处理循环依赖时,ES 模块和 CommonJS 模块的行为是不同的。__esModule 标识有助于正确模拟这些行为差异。

  8. 静态分析支持: 虽然 __esModule 主要用于运行时,但它也为静态分析工具提供了有用的信息。这些工具可以利用这个标识来更准确地分析和优化代码。

  9. 版本兼容性: 随着 JavaScript 生态系统的发展,__esModule 标识帮助新旧版本的库和工具保持兼容性,使得渐进式升级成为可能。

  10. 打包工具的实现差异: 不同的打包工具可能会以略微不同的方式实现 __esModule 标识。例如,有些工具可能会使用 Symbol 而不是简单的布尔值来避免潜在的命名冲突。

  11. 性能考虑: 虽然添加 __esModule 标识会略微增加代码体积,但它带来的兼容性收益通常远大于这个小小的性能开销。

  12. 未来发展: 随着 JavaScript 模块系统的不断发展,__esModule 的作用可能会逐渐减少。然而,目前它仍然是确保跨环境兼容性的重要机制。

这里有一个更复杂的例子来说明 __esModule 的作用:

javascript
// 假设这是一个 ES 模块
// myComplexModule.js
export default class MyClass {
  constructor() {
    this.name = 'Default';
  }
}

export function helper() {
  return 'Helper function';
}

// 这可能会被转换为:

('use strict');
Object.defineProperty(exports, '__esModule', { value: true });

class MyClass {
  constructor() {
    this.name = 'Default';
  }
}
exports.default = MyClass;

function helper() {
  return 'Helper function';
}
exports.helper = helper;

// 使用时:
const myModule = require('./myComplexModule');
if (myModule.__esModule) {
  // 这是一个转换后的 ES 模块
  const MyClass = myModule.default;
  const { helper } = myModule;
} else {
  // 这是一个原生 CommonJS 模块
  const MyClass = myModule;
  const helper = myModule.helper;
}

这个例子展示了如何根据 __esModule 标识来正确处理默认导出和命名导出,确保模块在不同环境中的一致性使用。

__esModule 总结

为构建工具提供标识位,告知构件工具当前的 CommonJS 模块为 ESM 转译过来的,那么在处理 default importnamespace import 的时候,构建工具可以通过 __esModule 标记位来做处理,本质上就是做一层 CommonJS 的默认值处理。

js
import defaultValue from 'commonjs';

// => 最早按照如下方式转译,但是由于 CommonJS 没有默认导出,那么转译为 ESM 就会存在问题。
const m = require('commonjs');
const defaultValue = m.default;

/**
 * => __ESM 核心尊重 __esModule,根据 __esModule 的值来判断 default 的值。
 * __ESM 等价于 m && m.__esModule ? m : { ...m, default: m }
 */
const m = __ESM(require('commonjs'));
const defaultValue = m.default;

esModuleInterop 的用途

提出背景

  1. 问题一:NameSpace Import 处理异常

    由于历史原因,TypeScript 编译器默认(或 target < es6)将 ESM(ECMAScript 模块)语法编译为 CommonJS 语法。

    转译过程中会将 import * as foo from 'foo' 语法被转译为 const foo = require('foo') 语法。可能是因为在 TypeScript 采用这种转译方式的时候,ESM 仍然是一个提案。这种转译方式在一定程度上是没有问题的,但是,仍然存在 edge case 会使得这种转译方式会使得与 node 或其他平台执行 ESM 逻辑不符。

    Edge Case:

    js
    // foo module
    module.exports = 'foo';
    
    // main module
    import * as foo from 'foo';
    
    // tsx main module
    const foo = require('foo');

    针对以上例子,TypeScript 的默认转译方式就会存在问题。

    由于 require 函数可以返回任何 JavaScript 值,包括 字符串,但 namespace import 语法 总是生成对象,并不会以字符串的形式存在

    The module namespace object is a sealed object with null prototype. This means all string keys of the object correspond to the exports of the module and there are never extra keys. All keys are enumerable in lexicographic order (i.e. the default behavior of Array.prototype.sort()), with the default export available as a key called default. In addition, the module namespace object has a [Symbol.toStringTag] property with the value "Module", used in Object.prototype.toString().

    此时通过 TS 转译后的 CommonJS 就会与原 ESM 语义上存在不一致行为。

    js
    // foo module
    module.exports = 'foo';
    
    // main module
    import * as foo from 'foo';
    /**
     * tsx execute output: 'foo', but
     * esm execute output: { default: 'foo' }
     * there is an inconsistency issue between semantics and node.
     */
    const foo = require('foo');
  2. 问题二:Default Import 处理异常

    CommonJS 并不像 ESM 需要区分 named exportdefault export。任何导出的变量在 CommonJS 看来都是 module.exports 对象上的属性。

    js
    // CommonJS
    module.exports = {};
    
    const foo = require('foo');
    
    // ESM
    export default {};
    export const foo = 'foo';
    
    import defaultImport, { foo } from 'foo';
    console.log(defaultImport, foo);

    TypeScriptESM 转译为 CommonJS 时,会通过 Object.defineProperty(exports, "__esModule", { value: true }); 来做标记,表示是通过 ESM 模块转译而来的。

    TypeScript 转译规则上,ESMdefault export 将会被作为 exports 对象的 default 属性值,named default 将会被作为 exports 对象的属性。

    TypeScript 转译 ESMCommonJS 的产物

    TS 源码如下:

    ts
    import * as path from 'path';
    import { fileURLToPath } from 'url';
    export const getDirname = (url: string) => {
      return path.dirname(fileURLToPath(url));
    };
    export const namedUtils = 'namedUtils';
    export default {
      getDirname,
      defaultUtils: 'defaultUtils'
    };

    TS 转译后的 JS 代码如下:

    js
    'use strict';
    Object.defineProperty(exports, '__esModule', { value: true });
    exports.namedUtils = exports.getDirname = void 0;
    var path = require('path');
    var url_1 = require('url');
    var getDirname = function (url) {
      return path.dirname((0, url_1.fileURLToPath)(url));
    };
    exports.getDirname = getDirname;
    exports.namedUtils = 'namedUtils';
    exports.default = {
      getDirname: exports.getDirname,
      defaultUtils: 'defaultUtils'
    };

小结

综上所述,TypeScript 默认情况下的转译规则并不尊重 ESM 的语义,esModuleInterop 配置项就是为了兼容 ESM 而提出的。如果使用了类似以下的做法

ts
// main.ts
import * as namespaceImportFoo from './foo';
console.log(namespaceImportFoo);
ts
/**
 * foo.ts
 * 在 `TypeScript` 中,`export =` 是一种用于定义 `CommonJS` 模块的导出方式。
 * 这种语法允许你将一个对象、函数或类作为模块的默认导出,符合 `CommonJS` 规范。
 * 这种方式与 `ES6` 的 `export default` 有所不同。
 * `export =` 是 `TypeScript` 中的一种特有语法,主要用于定义 `CommonJS` 模块的导出方式。
 * 它的主要作用是帮助 `TypeScript` 解析和理解 `CommonJS` 模块,
 * 使其与 `JavaScript` 的模块系统兼容。
 * `export =` 只能在模块的顶层使用,不能在函数或类内部。
 */
export = 'foo';

那么 TypeScript 就会提供警告

This module can only be referenced with ECMAScript imports/exports by turning on the 'esModuleInterop' flag and referencing its default export.

意味着需要开启 esModuleInterop 配置项,否则语义就会不一致。

若通过 @ts-ignore 忽略警告,那么转译后的产物如下:

js
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
// main.ts
var namespaceImportFoo = require('./foo');
console.log(namespaceImportFoo);
js
'use strict';
module.exports = 'foo';

可以很明显地看出,TypeScript 在未启用 esModuleInterop 配置项时,namespace import 的值为 foo,这是不符合 ESM 语义的。

实现原理

esModuleInterop 配置项通过生成额外的 helper code 来兼容 ESM 转译为 CommonJS 时语义不一致问题。该选项默认未启用,因为对现有 TypeScript 项目来说这将是一个破坏性变更。但 Microsoft 强烈建议议对新旧项目都应用此配置(然后更新代码),以便更好地与生态系统其他部分兼容。

tsconfig.json 配置项:

json
{
  "include": ["src/main.ts"],
  "compilerOptions": {
    "lib": ["ES2023", "DOM"],
    "outDir": "ts-dist",
    "esModuleInterop": true
  }
}

执行程序:

js
// main.ts:
import foo from './foo.js';
import * as namespaceImport from './foo.js';
foo();

console.log(namespaceImport);

// foo.js
module.exports = 'foo';

TSX 转译后:

js
'use strict';
var __createBinding =
  (this && this.__createBinding) ||
  (Object.create
    ? function (o, m, k, k2) {
        if (k2 === undefined) k2 = k;
        var desc = Object.getOwnPropertyDescriptor(m, k);
        if (
          !desc ||
          ('get' in desc
            ? !m.__esModule
            : desc.writable || desc.configurable)
        ) {
          desc = {
            enumerable: true,
            get() {
              return m[k];
            }
          };
        }
        Object.defineProperty(o, k2, desc);
      }
    : function (o, m, k, k2) {
        if (k2 === undefined) k2 = k;
        o[k2] = m[k];
      });
var __setModuleDefault =
  (this && this.__setModuleDefault) ||
  (Object.create
    ? function (o, v) {
        Object.defineProperty(o, 'default', { enumerable: true, value: v });
      }
    : function (o, v) {
        o.default = v;
      });
var __importStar =
  (this && this.__importStar) ||
  function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null)
      for (var k in mod)
        if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k))
          __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
  };
var __importDefault =
  (this && this.__importDefault) ||
  function (mod) {
    return mod && mod.__esModule ? mod : { default: mod };
  };
Object.defineProperty(exports, '__esModule', { value: true });
var foo_js_1 = __importDefault(require('./foo.js'));
var namespaceImport = __importStar(require('./foo.js'));
(0, foo_js_1.default)();
console.log(namespaceImport);

从上述转译结果可以发现 esModuleInterop 使用了 __importDefault 协助函数来处理 default import,同时使用 __importStar 协助函数来处理 namespace import

__importDefault 协助函数本质上就是给原 CommonJS 模块做了一层封装,确保了最后以 { "default": mod } 形式导出,调用方执行 mod.default

__importStar 协助函数做的事情就是创建一个新对象,新对象的属性 包含 CommonJS 模块中的 module.exports 的非 原型链 上且非 default 属性,同时 default 属性赋值为原 CommonJS 的导出(module.exports)。这样就确保通过 namespace import 获取到的引用必定为对象,兼容了 ESM 语义。

注意

__importDefault__importStar 两个协助函数对于 原ESM 模块(通过 __esModule 为真做判断)不做任何处理,直接返回。可以看出这里尊重了 babel 处理 default import 的解释,参考资讯

其他构建工具的实现

  1. rollup

  2. vite

  3. webpack

总结

由于历史遗留问题,TypeScript 默认情况下(target < ES6)会将模块转译为 CommonJS。若 ESM 模块依赖了 CommonJS 模块,那么转译后的 default importnamespace import 就会存在问题。TypeScript 默认情况下并没有遵循 ESM 规范,esModuleInterop 配置项目的就是为了解决这个问题,通过额外注入 helper 的协助函数来兼容 ESM

注意

babel 默认的转译规则和 TS 开启 esModuleInterop 的情况差不多,也是通过两个 helper 函数来处理的。

viterollupwebpack 等构建工具并没有借助 TS 来做 ESMCommonJS 之间的 interop,他们均有自己的一套处理机制来处理这个兼容问题。

对于 Vite 项目来说,Vite 本身已经很好地处理了与 CommonJS 模块的交互性,因此通常不需要在 TypeScript 中配置 esModuleInterop 来进行额外的转译。

Vite 使用的是现代的打包工具(RollupEsbuild),这些工具本身就支持 ES 模块和 CommonJS 模块的互操作性。需要注意的是,Vite 处理 CommonJSESM 之间的 interop 与其他两个构建工具处理 interop 的最终产物效果是一致的。小细节会存在一些不一致,Esbuild 处理 CommonJSESM 之间的 interop 会先将导入到 ESM 模块的 CommonJS 模块转译为类似于 namespace import 的导入,再取其指定值。

js
// demo.cjs
module.exports = {
  a: 12134,
  __esModule: true,
  default: {
    b: 214214
  }
};
js
// main.mjs
import * as namespaceImport from './demo.cjs';
import defaultImport, { a } from './demo.cjs';
console.log(a, defaultImport, namespaceImport);

执行 esbuild main.mjs --bundle --outfile=out.mjs 后获得 ESM 产物如下:

js
// out.mjs
var w = __toESM(require_demo());
var namespaceImport = __toESM(require_demo());
var import_demo = __toESM(require_demo());
console.log(import_demo.a, import_demo.default, namespaceImport);

可以看出此时在 ESM 模块中使用 helper 函数(__toESM)来做 interop__toESM 实现核心就是尊重 __esModulem && m.__esModule ? m : { ...m, default: m }),将加载的 CommonJS 模块转译为 Namespace Import 的值,然后再取其具体的值(默认导入取 default 值、具名导入取具体的属性值)。这一点和 Vite 不一样,Esbuild 会将 CommonJS 包转译为 ESM 模块,其中 CommonJSmodule.exports 值等价于 ESMexport default 值。Vite 加载 Esbuild 构建过的 ESM 模块后再做 interop,如下:

js
// 先加载 Esbuild 编译后的 ESM 模块
import __vite__cjsImport0_demo from '/node_modules/.vite/deps/demo.js?v=c8ba3f3d';
// 再做 ESM 加载 CommonJS 的 interop
const foo = (m =>
  m?.__esModule
    ? m
    : {
        ...((typeof m === 'object' && !Array.isArray(m)) ||
        typeof m === 'function'
          ? m
          : {}),
        default: m
      })(__vite__cjsImport0_demo);

即使 ViteEsbuild 中间实现 interop 的小细节存在不一致,但最终获取的产物均尊重 __esModule,其保持一致。

还有一点需要注意的是 Esbuild 会将 CommonJSmodule.exports 的值转译为 ESMexport default可参考。这里就需要注意一点 Esbuild 的转译

js
// main.js
export const a = 132;
export default {
  b: 456
};
// output { format: 'cjs' }
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === 'object' || typeof from === 'function') {
    for (const key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, '__esModule', { value: true }), mod);
var stdin_exports = {};
__export(stdin_exports, {
  a: () => a,
  default: () => stdin_default
});
// { a: 132, default: { b: 456 }}
module.exports = __toCommonJS(stdin_exports);
const a = 132;
var stdin_default = {
  b: 456
};

// cjs => esm
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) => function __require() {
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var require_stdin = __commonJS({
  '<stdin>'(exports, module) {
    var __defProp = Object.defineProperty;
    var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
    var __getOwnPropNames2 = Object.getOwnPropertyNames;
    var __hasOwnProp = Object.prototype.hasOwnProperty;
    var __export = (target, all) => {
      for (var name in all)
        __defProp(target, name, { get: all[name], enumerable: true });
    };
    var __copyProps = (to, from, except, desc) => {
      if (from && typeof from === 'object' || typeof from === 'function') {
        for (const key of __getOwnPropNames2(from))
          if (!__hasOwnProp.call(to, key) && key !== except)
            __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
      }
      return to;
    };
    var __toCommonJS = (mod) => __copyProps(__defProp({}, '__esModule', { value: true }), mod);
    var stdin_exports = {};
    __export(stdin_exports, {
      a: () => a,
      default: () => stdin_default
    });
    module.exports = __toCommonJS(stdin_exports);
    const a = 132;
    var stdin_default = {
      b: 456
    };
  }
});
// export default { a: 132, default: { b: 456 }, __esModule: true }
export default require_stdin();

可以看到 Esbuild 转译 ESMCommonJS 会将 ESM 中的 export default 转译为 module.exportsdefault 属性。但是反之将原 ESMCommonJS 模块转译为 ESM 模块则完全不一样,直接将原 ESMCommonJSmodule.exports 赋值为转译后的 ESM 模块的 export default。也就是说若导入由 Esbuild 编译的 ESM 模块需要做一层 interop,这也就是 Vite 现阶段在预构建期间所要做的事情。

当然对于 Vite 来说,也并不需要 Esbuild 将额外进行处理,Vite 内部会进一步对预构建产物做一层 interop,此时 Vite 会尊重 __esModule 属性,做到 ESM 模块加载 CommonJS 模块的兼容性。

所以 TypeScript 中的 esModuleInterop 配置项对于构建工具来说并不是很重要。因此,除非你的项目有特别的需求(如某些特定情况下的类型检查问题),在 Vite 项目中一般不需要启用 esModuleInteropVite 会通过它的构建流程自动处理这些兼容性问题,使得你可以专注于使用现代 ECMAScript 模块系统。

Contributors

Changelog

Discuss

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