每日一报
JS 工具集合
性能比较
JS 面试题
input
搜索如何中文输入
看过 element ui
框架源码的童鞋都应该知道,element ui
是通过 compositionstart
和 compositionend
事件来做中文输入处理。
那么介绍一下 compositionstart
和 compositionend
两个事件触发的时机吧。
compositionstart
: 事件在用户开始进行非直接输入的时候触发。compositionend
:事件在非直接输入结束,也即用户点选候选词或者点击「选定」按钮之后,比如按回车键时触发。
<input
value={innerValue}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onInput={handleInput}
/>
由于输入中文需要打开输入法,在开始编辑所需中文句子的时候会触发 compositionstart
事件,编辑完所需的中文句子时(即按下确认键)会触发 compositionend
事件。那么我们通过在这两个事件中做标记就可以知道输入完中文的确切时间。
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。
var buffer = new ArrayBuffer(8);
var view = new Int32Array(buffer);
view[0] = 100;
你还可以使用 SharedArrayBuffer 在多个 web-workers 间共享内存数据来提升性能。
我们可以测试对比以下两种写法方式
写法一:
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');
写法二:
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
的用途
ES模块和CommonJS模块的兼容性: __esModule 标识主要用于解决
ES
模块和CommonJS
模块之间的兼容性问题。当使用打包工具(如Webpack
、Rollup
等)将ES
模块转换为CommonJS
模块时,会添加这个标识。模块类型识别: 通过 __esModule 标识,运行时环境可以判断一个模块是原生的
CommonJS
模块还是由ES
模块转换而来的。这对于正确处理默认导出和命名导出非常重要。默认导出的处理: 在
ES
模块中,可以使用export default
语法。但在CommonJS
中没有直接对应的概念。通过 __esModule 标识,可以正确地模拟ES
模块的默认导出行为。导入行为的一致性: 当其他模块导入一个带有 __esModule 标识的模块时,可以保持与导入原生
ES
模块时相似的行为,尤其是在处理default import
和namespace import
。跨环境兼容: 这个标识使得同一份代码可以在支持 ES 模块的环境和只支持 CommonJS 的环境中都能正常工作,增强了代码的可移植性。
交互操作(Interoperability): __esModule 标识不仅帮助识别模块类型,还促进了不同模块系统间的交互操作。它允许使用 CommonJS 语法的代码可以无缝地使用转换后的 ES 模块,反之亦然。
运行时行为模拟: 在某些情况下,打包工具会生成额外的代码来模拟 ES 模块的运行时行为。例如,处理循环依赖时,ES 模块和 CommonJS 模块的行为是不同的。__esModule 标识有助于正确模拟这些行为差异。
静态分析支持: 虽然 __esModule 主要用于运行时,但它也为静态分析工具提供了有用的信息。这些工具可以利用这个标识来更准确地分析和优化代码。
版本兼容性: 随着 JavaScript 生态系统的发展,__esModule 标识帮助新旧版本的库和工具保持兼容性,使得渐进式升级成为可能。
打包工具的实现差异: 不同的打包工具可能会以略微不同的方式实现 __esModule 标识。例如,有些工具可能会使用 Symbol 而不是简单的布尔值来避免潜在的命名冲突。
性能考虑: 虽然添加 __esModule 标识会略微增加代码体积,但它带来的兼容性收益通常远大于这个小小的性能开销。
未来发展: 随着 JavaScript 模块系统的不断发展,__esModule 的作用可能会逐渐减少。然而,目前它仍然是确保跨环境兼容性的重要机制。
这里有一个更复杂的例子来说明 __esModule 的作用:
// 假设这是一个 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 import
和 namespace import
的时候,构建工具可以通过 __esModule
标记位来做处理,本质上就是做一层 CommonJS
的默认值处理。
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
的用途
提出背景
问题一:
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');
问题二:
Default Import
处理异常CommonJS
并不像ESM
需要区分named export
和default 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);
TypeScript
将ESM
转译为CommonJS
时,会通过Object.defineProperty(exports, "__esModule", { value: true });
来做标记,表示是通过ESM
模块转译而来的。在
TypeScript
转译规则上,ESM
的default export
将会被作为exports
对象的default
属性值,named default
将会被作为exports
对象的属性。TypeScript
转译ESM
为CommonJS
的产物TS 源码如下:
tsimport * 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
而提出的。如果使用了类似以下的做法
// main.ts
import * as namespaceImportFoo from './foo';
console.log(namespaceImportFoo);
/**
* 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
忽略警告,那么转译后的产物如下:
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
// main.ts
var namespaceImportFoo = require('./foo');
console.log(namespaceImportFoo);
'use strict';
module.exports = 'foo';
可以很明显地看出,TypeScript
在未启用 esModuleInterop
配置项时,namespace import
的值为 foo
,这是不符合 ESM
语义的。
实现原理
esModuleInterop
配置项通过生成额外的 helper code
来兼容 ESM
转译为 CommonJS
时语义不一致问题。该选项默认未启用,因为对现有 TypeScript
项目来说这将是一个破坏性变更。但 Microsoft 强烈建议议对新旧项目都应用此配置(然后更新代码),以便更好地与生态系统其他部分兼容。
tsconfig.json 配置项:
{
"include": ["src/main.ts"],
"compilerOptions": {
"lib": ["ES2023", "DOM"],
"outDir": "ts-dist",
"esModuleInterop": true
}
}
执行程序:
// main.ts:
import foo from './foo.js';
import * as namespaceImport from './foo.js';
foo();
console.log(namespaceImport);
// foo.js
module.exports = 'foo';
TSX 转译后:
'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
的解释,参考资讯。
其他构建工具的实现
rollup
vite
webpack
总结
由于历史遗留问题,TypeScript
默认情况下(target < ES6)会将模块转译为 CommonJS
。若 ESM
模块依赖了 CommonJS
模块,那么转译后的 default import
和 namespace import
就会存在问题。TypeScript
默认情况下并没有遵循 ESM
规范,esModuleInterop
配置项目的就是为了解决这个问题,通过额外注入 helper
的协助函数来兼容 ESM
。
注意
babel
默认的转译规则和 TS
开启 esModuleInterop
的情况差不多,也是通过两个 helper
函数来处理的。
vite
、rollup
、webpack
等构建工具并没有借助 TS
来做 ESM
和 CommonJS
之间的 interop
,他们均有自己的一套处理机制来处理这个兼容问题。
对于 Vite
项目来说,Vite
本身已经很好地处理了与 CommonJS
模块的交互性,因此通常不需要在 TypeScript
中配置 esModuleInterop
来进行额外的转译。
Vite
使用的是现代的打包工具(Rollup
和 Esbuild
),这些工具本身就支持 ES
模块和 CommonJS
模块的互操作性。需要注意的是,Vite
处理 CommonJS
和 ESM
之间的 interop
与其他两个构建工具处理 interop
的最终产物效果是一致的。小细节会存在一些不一致,Esbuild
处理 CommonJS
和 ESM
之间的 interop
会先将导入到 ESM
模块的 CommonJS
模块转译为类似于 namespace import
的导入,再取其指定值。
// demo.cjs
module.exports = {
a: 12134,
__esModule: true,
default: {
b: 214214
}
};
// 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
产物如下:
// 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
实现核心就是尊重 __esModule
(m && m.__esModule ? m : { ...m, default: m }
),将加载的 CommonJS
模块转译为 Namespace Import
的值,然后再取其具体的值(默认导入取 default
值、具名导入取具体的属性值)。这一点和 Vite
不一样,Esbuild
会将 CommonJS
包转译为 ESM
模块,其中 CommonJS
的 module.exports
值等价于 ESM
的 export default
值。Vite
加载 Esbuild
构建过的 ESM
模块后再做 interop
,如下:
// 先加载 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);
即使 Vite
和 Esbuild
中间实现 interop
的小细节存在不一致,但最终获取的产物均尊重 __esModule
,其保持一致。
还有一点需要注意的是 Esbuild
会将 CommonJS
的 module.exports
的值转译为 ESM
的 export default
,可参考。这里就需要注意一点 Esbuild 的转译
// 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
转译 ESM
为 CommonJS
会将 ESM
中的 export default
转译为 module.exports
的 default
属性。但是反之将原 ESM
的 CommonJS
模块转译为 ESM
模块则完全不一样,直接将原 ESM
的 CommonJS
的 module.exports
赋值为转译后的 ESM
模块的 export default
。也就是说若导入由 Esbuild
编译的 ESM
模块需要做一层 interop
,这也就是 Vite
现阶段在预构建期间所要做的事情。
当然对于 Vite
来说,也并不需要 Esbuild
将额外进行处理,Vite
内部会进一步对预构建产物做一层 interop
,此时 Vite
会尊重 __esModule
属性,做到 ESM
模块加载 CommonJS
模块的兼容性。
所以 TypeScript
中的 esModuleInterop
配置项对于构建工具来说并不是很重要。因此,除非你的项目有特别的需求(如某些特定情况下的类型检查问题),在 Vite
项目中一般不需要启用 esModuleInterop
。Vite
会通过它的构建流程自动处理这些兼容性问题,使得你可以专注于使用现代 ECMAScript
模块系统。