为什么 webpack --watch 在不相关的文件上调用我的自定义加载器?

Why does webpack --watch invoke my custom loader on unrelated files?

我有一个简单的自定义 Webpack 加载器,它从 .txt 文件生成 TypeScript 代码:

txt-loader.js

module.exports = function TxtLoader(txt) {
  console.log(`TxtLoader invoked on ${this.resourcePath} with content ${JSON.stringify(txt)}`)
  if (txt.indexOf('Hello') < 0) {
    throw new Error(`No "Hello" found`)
  }
  return `export const TEXT: string = ${JSON.stringify(txt)}`
}

在现实生活中,我正在对输入进行一些解析;在此示例中,我们假设文件必须包含文本 Hello 才有效。

这个加载器让我像这样导入文本文件:

index.ts

import { TEXT } from './hello.txt'

console.log(TEXT)

一切正常,除了一件事:webpack watch(及其堂兄弟 webpack serve)。第一次编译没问题:

$ /tmp/webpack-loader-repro/node_modules/.bin/webpack watch
TxtLoader invoked on /tmp/webpack-loader-repro/hello.txt with content "Hello world!\n"
asset main.js 250 bytes [compared for emit] [minimized] (name: main)
./index.ts 114 bytes [built] [code generated]
./hello.txt 97 bytes [built] [code generated]
webpack 5.64.3 compiled successfully in 3952 ms

但后来我更改了 hello.txt 文件:

$ touch hello.txt

突然奇怪的事情发生了:

TxtLoader invoked on /tmp/webpack-loader-repro/index.ts with content "import { TEXT } from './hello.txt'\n\nconsole.log(TEXT)\n"
TxtLoader invoked on /tmp/webpack-loader-repro/custom.d.ts with content "declare module '*.txt'\n"
[webpack-cli] Error: The loaded module contains errors
    at /tmp/webpack-loader-repro/node_modules/webpack/lib/dependencies/LoaderPlugin.js:108:11
    at /tmp/webpack-loader-repro/node_modules/webpack/lib/Compilation.js:1930:5
    at /tmp/webpack-loader-repro/node_modules/webpack/lib/util/AsyncQueue.js:352:5
    at Hook.eval [as callAsync] (eval at create (/tmp/webpack-loader-repro/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:6:1)
    at AsyncQueue._handleResult (/tmp/webpack-loader-repro/node_modules/webpack/lib/util/AsyncQueue.js:322:21)
    at /tmp/webpack-loader-repro/node_modules/webpack/lib/util/AsyncQueue.js:305:11
    at /tmp/webpack-loader-repro/node_modules/webpack/lib/Compilation.js:1392:15
    at /tmp/webpack-loader-repro/node_modules/webpack/lib/HookWebpackError.js:68:3
    at Hook.eval [as callAsync] (eval at create (/tmp/webpack-loader-repro/node_modules/tapable/lib/HookCodeFactory.js:33:10), <anonymous>:6:1)
    at Cache.store (/tmp/webpack-loader-repro/node_modules/webpack/lib/Cache.js:107:20)
error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

似乎 Webpack 决定在我的加载器中抛出比配置中指定的更多的文件。

如果我删除加载程序中抛出的异常和 return 一些任意有效的 TypeScript 代码,生成的 main.js 看起来完全一样。所以看起来这些额外的操作完全是多余的。但我不认为正确的解决方案是让我的加载程序吞下这些异常。

loader配置如下:

webpack.config.js

const path = require('path')

module.exports = {
  mode: 'production',
  entry: './index.ts',
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
      },
      {
        test: /\.txt$/,
        use: [
          {
            loader: 'ts-loader',
            // Tell TypeScript that the input should be parsed as TypeScript,
            // not JavaScript: <
            options: { appendTsSuffixTo: [/\.txt$/] },
          },
          path.resolve('txt-loader.js'),
        ],
      },
    ],
  },
}

最后,这些是将它们组合在一起的必要部分:

custom.d.ts

declare module '*.txt'

tsconfig.json

{}

package.json

{
  "name": "webpack-loader-repro",
  "license": "MIT",
  "private": true,
  "devDependencies": {
    "ts-loader": "9.2.6",
    "typescript": "4.5.2",
    "webpack": "5.64.3",
    "webpack-cli": "4.9.1"
  },
  "dependencies": {}
}

对于那些想在家尝试这个的人,clone this minimal repro project

这是 Webpack 中的错误吗?在 ts-loader 中?在我的配置中?

在您的最小重现中,我发现注释掉这些行可以解决问题:

...
{
  test: /\.txt$/,
  use: [
    // remove ts-loader from this pipeline, and you don't get the unexpected watch behavior
    path.resolve('txt-loader.js'),
  ],
},
...

我认为发生的事情是,当您将 use 数组中的 ts-loader 链接到 /\.txt$/ 管道时,它会监视它认为是整个 typescript 项目的内容,然后在任何更改时重新调用管道(包括您的自定义 txt-loader)。通常这是一件好事,因为它会重新编译你的项目,例如,如果 .d.ts 文件更改仅通过 tsconfig.json 隐式包含,而不是通过显式 webpack 处理的导入语句。

至少在您提供的简单重现中,包似乎在 /\.txt$/ 管道中生成 运行 without ts-loader全部,这可能足以解决您的问题。

但是如果在你的真实案例中出于某种原因有必要在这个管道中包含 ts-loader,你应该能够告诉 ts-loader 只看 at/watch 使用 onlyCompileBundledFiles 选项显式捆绑文件(参见 docs):

...
{
  test: /\.txt$/,
  use: [
    { 
      loader: 'ts-loader',
      options: { appendTsSuffixTo: [/\.txt$/], onlyCompileBundledFiles: true },
    }
    path.resolve('txt-loader.js'),
  ],
},
...

1。问题

主要问题是 ts-loader 将加载其他文件并手动调用加载程序。

在您当前的 webpack 配置中,您最终会得到 2 个独立的 ts-loader 实例:

  • 一个用于 .ts 个文件
  • 还有一个用于 .txt 个文件
1.1。第一次编译

在初始编译期间将发生以下情况:

  • index.ts 将由第一个 ts-loader 实例处理,该实例将尝试编译它。
  • 第一个 ts-loader 不知道如何加载 .txt 文件,因此它四处寻找一些模块声明并找到 custom.d.ts 并加载它。
  • 现在第一个 ts-loader 知道如何处理 .txt 文件,它将注册 index.tscustom.d.ts 依赖于 hello.txt (addDependency call here)
  • 之后第一个ts-loader实例会要求webpack编译hello.txt
  • hello.txt 将由第二个 ts-loader 实例通过您的自定义加载程序加载(就像人们期望的那样)
2.1。二次编译

一旦您触摸(或修改)hello.txt,webpack 将尽职尽责地通知所有观察者 hello.txt 已更改。但是因为 index.tscustom.d.ts 依赖于 hello.txt,所以所有观察者也会收到这两者发生变化的通知。

  • 第一个 ts-loader 将获得所有 3 个更改事件,忽略 hello.txt 一个因为它没有编译那个并且对 index.ts 什么也不做& custom.d.ts 事件,因为它发现没有变化。

  • 第二个 ts-loader 也将获得所有 3 个更改事件,如果您只是触摸它,它将忽略 hello.txt 更改或重新编译它以防您编辑它。之后它看到 custom.d.ts 的变化,意识到它还没有编译那个,并且会尝试编译它,while invoking all loaders specified after itindex.ts 更改也会发生同样的事情。

  • 第二个 ts-loader 甚至尝试加载这些文件的原因如下:

    • 对于 index.ts:您的 .tsconfig 未指定 includeexcludefiles,因此 ts-loader 将使用默认值["**"]include,即它能找到的一切。因此,一旦它收到 index.ts 的更改通知,它就会尝试加载它。
      • 这也解释了为什么您不能使用 onlyCompileBundledFiles: true 获取它 - 因为在那种情况下 ts-loader 意识到它应该忽略该文件。
    • 对于 custom.d.ts 大部分是相同的,但即使 onlyCompileBundledFiles: true 也将包含它们:

      The default behavior of ts-loader is to act as a drop-in replacement for the tsc command, so it respects the include, files, and exclude options in your tsconfig.json, loading any files specified by those options. The onlyCompileBundledFiles option modifies this behavior, loading only those files that are actually bundled by webpack, as well as any .d.ts files included by the tsconfig.json settings. .d.ts files are still included because they may be needed for compilation without being explicitly imported, and therefore not picked up by webpack.

1.3。之后的任何编译

如果您将 txt-loader.js 修改为不抛出而是 return 内容不变,即:

if (txt.indexOf('Hello') < 0) {
    return txt;
}

我们可以看到第三次、第四次等……编译发生了什么。

因为 index.tscustom.d.ts 现在都在 ts-loader 的缓存中,只有当这些文件中的任何一个发生实际变化时才会调用您的自定义加载程序.


2。类似问题

您不是唯一一个 运行 进入此“功能”的人,甚至还有一个未解决的 github 问题:


3。可能的解决方案

有几种方法可以避免这个问题:

3.1。使 .txt ts-loader t运行spile-only

transpileOnly: true-mode ts-loader 将忽略所有其他文件,只处理那些 webpack 明确要求编译的文件。

所以这行得通:

/* ... */
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
      },
      {
        test: /\.txt$/,
        use: [
          {
            loader: 'ts-loader',
            options: { appendTsSuffixTo: [/\.txt$/], transpileOnly: true },
          },
          path.resolve('txt-loader.js'),
        ],
      },
    ],
/* ... */

尽管使用这种方法,您将失去对 .txt 文件的类型检查。

3.2。确保只有一个 ts-loader 个实例

只要您为每个加载器指定完全相同的选项,ts-loader就会重用加载器实例。

这样你就有了 *.ts 文件和 *.txt 文件的共享缓存,所以 ts-loader 不会尝试通过你的 [=73] 传递 *.ts 文件=] webpack 规则。

所以下面的定义也可以工作:

/* ... */
    rules: [
      {
        test: /\.ts$/,
        use: [
          {
            loader: 'ts-loader',
            options: { appendTsSuffixTo: [/\.txt$/] },
          }
        ],
      },
      {
        test: /\.txt$/,
        use: [
          {
            loader: 'ts-loader',
            options: { appendTsSuffixTo: [/\.txt$/] },
          },
          path.resolve('txt-loader.js'),
        ],
      },
    ],
/* ... */
3.2.1 使用 ts-loaderinstance 选项

ts-loader 有一个(相当隐藏的)instance 选项。

通常这将用于隔离两个具有相同选项的 ts-loader 实例 - 但它也可用于强制合并两个 ts-loader 实例。

所以这也行得通:

/* ... */
    rules: [
      {
        test: /\.ts$/,
        use: [
          {
            loader: 'ts-loader',
            options: { appendTsSuffixTo: [/\.txt$/], instance: "foobar" },
          }
        ],
      },
      {
        test: /\.txt$/,
        use: [
          {
            loader: 'ts-loader',
            options: { instance: "foobar", /* OTHER OPTIONS SILENTLY IGNORED */ },
          },
          path.resolve('txt-loader.js'),
        ],
      },
    ],
/* ... */

虽然你需要小心这个,因为第一个被 webpack 实例化的加载器决定了选项。您传递给具有相同 instance 选项的所有其他 ts-loader 的选项会被 默默忽略.

3.3 让你的加载器忽略 *.ts 个文件

最简单的选择是将您的 txt-loader.js 更改为不修改 *.ts 文件,以防它被调用。这不是一个干净的解决方案,但它仍然有效 :D

txt-loader.js:

module.exports = function TxtLoader(txt) {
  // ignore .ts files
  if(this.resourcePath.endsWith('.ts'))
    return txt;

  // handle .txt files:
  return `export const TEXT: string = ${JSON.stringify(txt)}`
}