为什么打字稿允许我导入它在运行时不能使用的依赖项?

Why does typescript allow me to import dependencies it can't use at runtime?

你可以在这里看到我的示例项目:https://github.com/DanKaplanSES/typescript-stub-examples/tree/JavaScript-import-invalid

我创建了这个名为 main.ts 的文件:

import uuid from "uuid";

console.log(uuid.v4());

虽然 typescript 对这个导入没问题,但当我尝试 node main.js 时,它给出了这个错误:

console.log(uuid_1["default"].v4());
                              ^

TypeError: Cannot read property 'v4' of undefined
    at Object.<anonymous> (C:\root\lib\main.js:5:31)
←[90m    at Module._compile (internal/modules/cjs/loader.js:1063:30)←[39m
←[90m    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)←[39m
←[90m    at Module.load (internal/modules/cjs/loader.js:928:32)←[39m
←[90m    at Function.Module._load (internal/modules/cjs/loader.js:769:14)←[39m
←[90m    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)←[39m
←[90m    at internal/main/run_main_module.js:17:47←[39m

如果我把文件改成这个,它执行得很好:

import * as uuid from "uuid";

console.log(uuid.v4());

如果第一个版本无效,为什么打字稿不通知我?

我有一个多文件 tsconfig 设置。查看 github 项目以获取更多详细信息,但这里是可能相关的共享编译器选项:

{
    "compilerOptions": {
        "rootDir": ".",
        "esModuleInterop": true,
        "module": "CommonJS",
        "moduleResolution": "node",
        "composite": true,
        "importHelpers": true,
    },
}

main.js 的外观如下:

不起作用

"use strict";
exports.__esModule = true;
var tslib_1 = require("tslib");
var uuid_1 = tslib_1.__importDefault(require("uuid"));
console.log(uuid_1["default"].v4());

有效

"use strict";
exports.__esModule = true;
var tslib_1 = require("tslib");
var uuid = tslib_1.__importStar(require("uuid"));
console.log(uuid.v4());

您的问题与 TypeScript/ECMAScript 模块和 CommonJS 之间的互操作性有关。

关于ECMAScript modules and CommonJS个模块的区别:

  • CommonJS modules are meant to be imported like const library = require('library') which allows to retrieve the full exports object of that library. There is no notion of default import in CommonJS
  • ECMAScript modules have explicit export clauses for every exported item. They also feature a default import syntax 允许在局部变量中检索 default 导出。

为了实现CommonJS modules and TypeScript's default import syntax, CommonJS模块之间的互操作性可以有default属性.

default 属性 甚至可以在 esModuleInterop is enabled (which also enables allowSyntheticDefaultImports 时由 TypeScript 自动添加)。此选项在转译时添加此辅助函数:

var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};

基本上这个函数的作用是:如果导入的模块将 __esModule 标志设置为 true,则按原样导出它,因为该模块旨在用作 ECMAScript moduleimport { feature } from 'library'。否则,使用 default 属性 将其导出到包装器对象中,这会启用 import localName from 'library' 语法。

uuid package is being built with @babel/plugin-transform-modules-commonjs which includes the __esModule flag and prevents you from using the default import syntax. Other packages like lodash 不包含此标志,它允许 TypeScript 添加 default 属性.

总而言之,TypeScript 提供了与遗留 CommonJS modules but these options don't work with "ECMAScript aware" CommonJS modules. TypeScript cannot warn or error out at transpilation time because a CommonJS 模块接口进行互操作的选项,除了 exports 对象外没有其他表示形式,只有在运行时才知道。

回答我自己的赏金问题让我有点内疚,所以我打算将其标记为社区。我自己写的原因是因为我觉得其他答案真的掩盖了领先优势。我必须在 阅读 之后进行数小时的研究才能写出这篇文章。既然如此,我认为我的回答对那些从我的船开始的人更有帮助,在那里我不知道我不知道什么。我还认为有一个额外的解决方案来解决这个问题,尽管它会更具侵入性。

我最初的问题是,“如果第一个版本无效,为什么打字稿不通知我?”这是另一个答案的解释:

Because you have enabled esModuleInterop which also enables allowSyntheticDefaultImports. The CommonJS bundle is actually incompatible with that option but TypeScript doesn't know.

这是千真万确的,但要了解正在发生的事情,这只是冰山一角:

如果您查看参考文档,它建议 您将 esModuleInterop 设置为 true。如果它会降低类型安全性,为什么要提出该建议?好吧,这不是它建议您将其设置为 true 的原因。事实上,此设置 不会 降低类型安全性——它通过修复一些遗留的打字稿错误来提高类型安全性,特别是两个处理打字稿处理方式的错误 requires。您可以阅读文档以获取更多详细信息,但我认为,如果您使用的是节点库,我认为将 esModuleInterop 设置为 true 是个好主意。

但是! esModuleInterop 有一个副作用。在其文档的最底部,它说:

Enabling esModuleInterop will also enable allowSyntheticDefaultImports.

呃……有点。 IMO,此文档不正确。它真正应该说的是,“启用 esModuleInterop 将使 default allowSyntheticDefaultImports 为真。”如果您查看 allowSyntheticDefaultImports 文档,它会在右侧显示:

嘿,注意右上角怎么没有说推荐这个设置?这可能是因为此设置降低了类型安全性:当模块未明确指定默认导出时,它允许您键入 import React from "react"; 而不是 import * as React from "react";

通常(即 allowSyntheticDefaultImports 设置为 false),这将是一个错误...因为它是:你不应该能够默认导入模块,除非它有默认导出。将其设置为 true 会使编译器说,“不,这很好。”​​

但是,当您将 allowSyntheticDefaultImports 设置为 true 时,“此标志不会影响 TypeScript 发出的 JavaScript。”这意味着,这个标志让你假装库是在编译时以一种方式编写的,即使它不是。在运行时,这会出错。为什么设置甚至存在?我不知道,但可能与历史原因有关:

This option brings the behavior of TypeScript in-line with Babel, where extra code is emitted to make using a default export of a module more ergonomic.

似乎有(/现在?)一个时间点,每个人都被假定为使用 Babel。我没有这样做,所以“符合人体工程学”的好处变成了运行时错误。

As a cleaner method, you should import uuid with import { v4 } from 'uuid';

是的,但我认为将 allowSyntheticDefaultImports 显式设置为 false 也是一个好主意。它给你更多的类型安全。不仅如此,它还使 import uuid from "uuid"; 成为编译时错误(应该是)。

还有一件事我不明白:

将 allowSyntheticDefaultImports 设置为 false 也会使 import os from "os";import _ from "lodash"; 等导入编译时出错。但是当 allowSyntheticDefaultImports 为真时,那些总是 运行 没问题。一定有一些我遗漏的部分解释了为什么这些工作但 uuid 没有。

我在我的 node_modules 中找不到 os 的来源,但我可以查看 lodash,它的 index.js 是这样做的:

module.exports = require('./lodash');

在那个必需的文件中,它在底部说:

...
/*--------------------------------------------------------------------------*/

  // Export lodash.
  var _ = runInContext();

  // Some AMD build optimizers, like r.js, check for condition patterns like:
  if (typeof define == 'function' && typeof define.amd == 'object' && define.amd) {
    // Expose Lodash on the global object to prevent errors when Lodash is
    // loaded by a script tag in the presence of an AMD loader.
    // See http://requirejs.org/docs/errors.html#mismatch for more details.
    // Use `_.noConflict` to remove Lodash from the global object.
    root._ = _;

    // Define as an anonymous module so, through path mapping, it can be
    // referenced as the "underscore" module.
    define(function() {
      return _;
    });
  }
  // Check for `exports` after `define` in case a build optimizer adds it.
  else if (freeModule) {
    // Export for Node.js.
    (freeModule.exports = _)._ = _;
    // Export for CommonJS support.
    freeExports._ = _;
  }
  else {
    // Export to the global object.
    root._ = _;
  }

我不太明白这一切在做什么,但我认为这是在运行时定义一个名为 _ 的全局变量?我想这意味着,从打字稿的角度来看,这恰好有效。类型声明文件没有默认值,这通常会导致运行时错误,但几乎巧合的是,这一切最终都解决了,因为 lodash javascript 定义了全局 _? 耸耸肩 也许这也是 os 使用的模式,但我已经花了足够多的时间研究它,所以我将把它留给另一个 day/question。

首先,我是在ESM模块环境下工作的

在过去的几年里,我多次与 uuid 包导入问题作斗争。

今天我 运行 再次进入它,发现了一些对我来说新的东西,并希望在您出色的分析答案下分享它。

TL;DR:当 uuid 包直接或间接依赖时,import UUID from 'uuid' 的行为是不同的。

I think this is related to the ESM behavior.

package.json dependency with uuid import * as UUID from 'uuid' import UUID from 'uuid'
directly (tsc) OK OK
directly (runtime) OK SyntaxError: The requested module 'uuid' does not provide an export named 'default'
in-directly (tsc) OK Module "node_modules/@types/uuid/index" has no default export.ts(1192)
in-directly (runtime) TypeError: UUID.v4 is not a function OK
  1. “与 uuid 直接依赖”意味着我们在 package.json
  2. dependencies 中有一个 uuid
  3. “与uuid的间接依赖”意味着我们在package.json的dependencies中没有uuid(但其他依赖模块已经包含它)

结论

似乎只有一种情况下输入系统和运行时都按预期与 ESM 一起工作:使用对 uuid 包的直接依赖。

总是npm install --save uuid。 (查看我的 issue here 与提交)