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

Tho*_*mas 3 typescript webpack ts-loader webpack-watch webpack-loader

我有一个简单的自定义 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)}`
}
Run Code Online (Sandbox Code Playgroud)

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

该加载程序允许我导入文本文件,如下所示:

索引.ts

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

console.log(TEXT)
Run Code Online (Sandbox Code Playgroud)

一切都很好,除了一件事:(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
Run Code Online (Sandbox Code Playgroud)

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

$ touch hello.txt
Run Code Online (Sandbox Code Playgroud)

突然奇怪的事情发生了:

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.
Run Code Online (Sandbox Code Playgroud)

看来 Webpack 决定向我的加载器抛出比配置中指定的文件更多的文件。

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

加载器的配置如下:

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: </sf/answers/3314017451/>
            options: { appendTsSuffixTo: [/\.txt$/] },
          },
          path.resolve('txt-loader.js'),
        ],
      },
    ],
  },
}
Run Code Online (Sandbox Code Playgroud)

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

自定义.d.ts

declare module '*.txt'
Run Code Online (Sandbox Code Playgroud)

tsconfig.json

{}
Run Code Online (Sandbox Code Playgroud)

包.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": {}
}
Run Code Online (Sandbox Code Playgroud)

对于那些想在家尝试的人,请克隆这个最小的重现项目

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

Tur*_*ght 5

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.txtaddDependency在此处调用
  • 之后第一个ts-loader实例将要求 webpack 进行编译hello.txt
  • hello.txt将由第二个ts-loader实例通过您的自定义加载器加载(就像人们所期望的那样)
2.1. 第二次编译

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

  • 第一个ts-loader将获取所有 3 个更改事件,忽略一个hello.txt,因为它没有编译该事件,并且对index.ts&custom.d.ts事件不执行任何操作,因为它发现没有更改。

  • 第二个ts-loader也将获取所有 3 个更改事件,如果您刚刚触摸它,它将忽略更改hello.txt,或者在您编辑它时重新编译它。之后,它看到custom.d.ts更改,意识到它尚未编译该更改,并将尝试编译它,同时调用其后指定的所有加载器。变化也会发生同样的事情index.ts

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

    • For index.ts:您.tsconfig没有指定includeorexcludefiles,因此将使用forts-loader的默认值,即它可以找到的所有内容。因此,一旦它收到更改通知,它就会尝试加载它。 ["**"]includeindex.ts
      • 这也解释了为什么你没有得到它onlyCompileBundledFiles: true- 因为在这种情况下ts-loader意识到它应该忽略该文件。
    • 因为custom.d.ts它基本上是相同的,但即使使用以下内容,它们仍然会包含在内onlyCompileBundledFiles: true

      ts-loader 的默认行为是充当 tsc 命令的直接替代品,因此它尊重 tsconfig.json 中的 include、files 和 except 选项,加载这些选项指定的任何文件。onlyCompileBundledFiles 选项修改了此行为,仅加载那些实际由 webpack 捆绑的文件,以及 tsconfig.json 设置包含的任何 .d.ts 文件。.d.ts 文件仍然包含在内,因为它们可能需要在没有显式导入的情况下进行编译,因此不会被 webpack 拾取。

1.3. 之后的任何编译

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

if (txt.indexOf('Hello') < 0) {
    return txt;
}
Run Code Online (Sandbox Code Playgroud)

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

由于index.ts&custom.d.ts现在都在两个 s 的缓存中ts-loader,因此只有在这些文件中的任何一个发生实际更改时才会调用您的自定义加载程序。


2. 类似问题

你不是唯一遇到这个“功能”的人,甚至还有一个开放的 github 问题:


3. 潜在的解决方案

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

3.1. .txt ts-loader只进行转译

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

所以这会起作用:

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

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

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

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

这样您就拥有*.ts文件和*.txt文件的共享缓存,因此ts-loader不会尝试*.ts通过*.txtwebpack 规则传递文件。

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

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

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'),
        ],
      },
    ],
/* ... */
Run Code Online (Sandbox Code Playgroud)

不过,您需要小心这个,因为由 webpack 实例化的第一个加载器将决定选项。ts-loader您传递给所有其他具有相同选项的选项instance将被默默地忽略

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)}`
}
Run Code Online (Sandbox Code Playgroud)