likes
comments
collection
share

Webpack 5中开发loader所遇到的几个问题的解决

作者站长头像
站长
· 阅读数 68

Webpack中,有两大机制,一个是loader机制,一个是插件机制。虽然社区开源的Webpack loader众多,但是在研发过程中的一些特定场景需求下,我们仍然免不了要自己开发一些loader。本文中,我们就来看看如何实现自定义Webpack loader。

下面我们以实现一个将Markdown文件内容转成HTML为例,来进行演示。我的相关系统环境如下:

  • 系统:Windows 10
  • webpack:5.6.0
  • webpack-cli:4.2.0
  • Node.js:v14.15.0
  • NPM:6.14.9

另外,通过npm i -g npx全局安装了npx包。

一、插曲:npm版本和Node.js版本的问题

之所以罗列系统环境,是因为我在没有更正成上述环境之前,执行下文的npm run build时遇到过如下报错:

> SyntaxError: Invalid regular expression: /(\p{Uppercase_Letter}+|\p{Low

后来通过搜索,发现是因为npm版本的问题,于是执行了npm i -g npm(参考此文)对npm进行了升级(6.14.9)。但此时带来了更严重的问题——在控制台执行npm命令不了了。发现是因为Node.js版本过低导致(当时应该是9.x版),于是又重新安装了Node.js的最新版本(v14.15.0)。此时问题才得以解决。

二、自定义loader返回的内容不符合要求导致的报错

在开始自定义loader之前,需要明确一点原则,那就是单一职责原则——一个loader只做一件事,这样不仅可以让 loader 的维护变得简单,还能让loader以不同的串联方式组合出符合各种场景需求的搭配,否则,多个功能堆在一起,该loader可以被应用到的场景就大大缩小了。

接下来,我们创建一些loader开发所用到的简单的文件,整体目录结构组织如下:

.\dev-webpack-loader
├─markdown-loader.js
├─package-lock.json
├─package.json
├─webpack.config.js
└─src
   ├─index.js
   └─markdown
      └test.md

其中,每一个文件的代码如下所示:

  • ./src/markdown/test.md
# test markdown

Hello, this is a test markdown!
  • ./src/index.js
import md from './markdown/test.md';

console.log(md);
  • ./markdown-loader.js文件:
const marked = require('marked');
module.exports = (source) => {
  console.log('*****************************');
  console.log(source);
  console.log('*****************************');
  return source;
};
  • ./package.json文件:
{
  "name": "dev-webpack-loader",
  "version": "0.1.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "npx webpack --config ./webpack.config.js"
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "webpack": "^5.6.0",
    "webpack-cli": "^4.2.0"
  },
  "dependencies": {
    "marked": "^1.2.5"
  }
}
  • ./webpack.config.js
/**
 * @type {import('webpack').Configuration}
 */
const path = require('path');

module.exports = {
  mode: 'none',
  entry: {
    index: './src/index.js',
  },
  output: {
    filename: '[name].[hash:8].js',
  },
  module: {
    rules: [
      {
        test: /\.md$/,
        use: [
          './markdown-loader.js'
        ]
      }
    ]
  },
}

因为每个文件的内容都比较简单,不作解释。但当我们执行npm run build之后,却得到了如下提示:

> dev-webpack-loader@0.1.0 build D:\dev-webpack-loader
> npx webpack --config ./webpack.config.js

*****************************
# test markdown

Hello, this is a test markdown!

*****************************
(node:12252) [DEP_WEBPACK_TEMPLATE_PATH_PLUGIN_REPLACE_PATH_VARIABLES_HASH] DeprecationWarning: [hash] is now [fullhash] (also consider using [chunkhash] or [contenthash], see documentation for details)
(Use `node --trace-deprecation ...` to show where the warning was created)
[webpack-cli] Compilation finished
assets by status 3.03 KiB [cached] 1 asset
runtime modules 657 bytes 3 modules
cacheable modules 211 bytes
  ./src/index.js 73 bytes [built] [code generated]
  ./src/markdown/test.md 138 bytes [built] [code generated] [1 error]

ERROR in ./src/markdown/test.md 1:15
Module parse failed: Unexpected token (1:15)
File was processed with these loaders:
 * ./markdown-loader.js
You may need an additional loader to handle the result of these loaders.
export default = "<p>// ./src/markdown/test.md</p>\n<h1>test markdown</h1>\n<p>Hello, this is a test markdown!</p>\n"
export default = "<p>// ./src/markdown/test.md</p>\n<h1>test markdown</h1>\n<p>Hello, this is a test markdown!</p>\n"
 @ ./src/index.js 2:0-36 4:12-14

webpack 5.6.0 compiled with 1 error in 165 ms
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! dev-webpack-loader@0.1.0 build: `npx webpack --config ./webpack.config.js`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the dev-webpack-loader@0.1.0 build script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     C:\Users\xxx\AppData\Roaming\npm-cache\_logs\2020-11-21T13_43_36_862Z-debug.log

从中可见,source参数中的内容就是./src/markdown/test.md这个文件的内容,这是符合预期的。但是,出现了一个报错,里面最有价值的提示是这一句:

> You may need an additional loader to handle the result of these loaders.

真奇怪,这么简单的内容,居然还要其它的loader来处理?!一番查找之后才发现,原来markdown-loader中的return source;所return出去的是个文字内容为普通文本的字符串,而实际上Webpack要求最后一个loader返回的是个一段文字内容为标准的JavaScript代码的字符串

我们把

return source;

那句改成:

return `module.exports = ${JSON.stringify(source)};`

再执行npm run build,终于OK了。

三、hash的误用

不过,上面的错误提示中还有个细节值得注意:

> DeprecationWarning: [hash] is now [fullhash] (also consider using [chunkhash] or [contenthash]

意思是说,hash已经不推荐使用了,改成了fullhash,这个名字确实比起原来的hash更清楚。

这里顺便提一下三个hash的区别:

  • hash:

项目中打包的文件中任何一个修改了,hash就会变。

  • chunkhash:

当前chunk中的打包的某个文件变了,chunkhash就会变。

  • contenthash:

文本文件的内容变了,contenthash会变。因为样式在Webpack中也是以JS那样的形式引入的,所以JS文件变化时,会导致chunkhash变化,而CSS由于与引入它的JS位于同一chunk,所以每次引入它的JS变了而CSS没变的时候,导出的CSS文件的chunkhash也会变,所以,对于导出的CSS文件的名称,应该用contenthash。

ExtractTextPlugin('[name].[chunkhash:8].css');

回到正题,这里

output: {
  filename: '[name].[hash:8].js',
},

filename我们用hash其实是不合适的,改成新的fullhash也仍然不合适,应该改成chunkhash:

output: {
  filename: '[name].[chunkhash:8].js',
},

四、插件使用中的报错

现在,我们在每次执行npm run build之后都会在./dist目录下新生成一个JS文件,这并不是我们所期望的。因此,我们应该在每次重新生成之前将./dist目录清空。具体操作如下。

先安装相应依赖:

npm i -D clean-webpack-plugin

再在./webpack.config.js中添加plugin的配置,配置完成后文件内容如下:

/**
 * @type {import('webpack').Configuration}
 */
var CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin;

module.exports = {
  mode: 'none',
  entry: {
    index: './src/index.js',
  },
  output: {
    filename: '[name].[chunkhash:8].js',
  },
  module: {
    rules: [
      {
        test: /\.md$/,
        use: [
          './markdown-loader.js'
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
  ]
}

其中需要注意,var CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin; 中的CleanWebpackPlugin的首字母需要大写,否则会报如下错误:

> TypeError: cleanWebpackPlugin is not a constructor

笔者在编写时就不小心写成了小写,从而遇到了这个问题。

五、output配置项未配置path配置项导致的clean-webpack-plugin失效

此时,看似clean-webpack-plugin已经生效了,但是当我们随便修改一下./src/index.js的内容,再次执行npm run build就会发现,./dist目录下生成了多个index.xxxxxxx.js文件,这表明其实clean-webpack-plugin并没有生效。通过排查,发现是因为output配置项未配置path配置项导致的,于是我们在output中将path补充上:

output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[chunkhash:8].js',
},

这下,clean-webpack-plugin才算真正地生效了。

下面我们来实现真正的markdown转HTML的逻辑,这里我们使用marked这个包来进行转换:

./markdown-loader.js文件:

// ./markdown-loader.js
const marked = require('marked');
module.exports = (source) => {
  console.log('*****************************');
  console.log(source);
  console.log('*****************************');
  // return `module.exports = ${JSON.stringify(source)};`
  const result = marked(source);
  console.log('-----------------------------');
  console.log(result);
  console.log('-----------------------------');
  const code = `module.exports = ${JSON.stringify(result)}`;
  return code;
};

执行npm run build后,得到如下控制台输出:

&gt; dev-webpack-loader@0.1.0 build D:\dev-webpack-loader
&gt; npx webpack --config ./webpack.config.js

*****************************
# test markdown

Hello, this is a test markdown!
*****************************
-----------------------------
<h1>test markdown</h1>
<p>Hello, this is a test markdown!</p>
-----------------------------
[webpack-cli] Compilation finished
asset index.302aeaa6.js 2.79 KiB [emitted] (name: index)
runtime modules 657 bytes 3 modules
cacheable modules 178 bytes
  ./src/index.js 74 bytes [built] [code generated]
  ./src/markdown/test.md 104 bytes [built] [code generated]
webpack 5.6.0 compiled successfully in 354 ms

可见,marked确实将

# test markdown

Hello, this is a test markdown!

转成了

<h1>test markdown</h1>
<p>Hello, this is a test markdown!</p>

六、分析打包结果文件时怎样去除干扰

开发过程中难免时不时地要去分析打包后的代码,以确定打包结果时候按照我们的预期进行了输出。不过,如果你将Webpack配置项中的mode设置成'development'或者'production'的话,会出现许多杂乱的信息,'production'模式下代码还是经过压缩的,非常干扰阅读,而且面对大段地代码,你可能都不知道从哪下手。可以通过如下两图感受下。

mode设置成'development'打包后的结果:

Webpack 5中开发loader所遇到的几个问题的解决

mode设置成'production'打包后的结果:

Webpack 5中开发loader所遇到的几个问题的解决

下面是几点有助于实操的经验:

首先,为了使得打包结果更易于阅读,本文中特地把mode设置成了'none',以避免因设置成'development'所导致的eval函数的出现以及设置成'production'时自动进行的压缩。

其次,我们将其中的/****/之类的注释去除,并格式化之后,再删除多余的空行。

经过上述操作后,代码变成如下:

(() => { // webpackBootstrap
  var __webpack_modules__ = ([
    /* 0 */
    ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony import */
      var _markdown_test_md__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
      /* harmony import */
      var _markdown_test_md__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/ __webpack_require__.n(_markdown_test_md__WEBPACK_IMPORTED_MODULE_0__);
      // ./src/index.js
      console.log((_markdown_test_md__WEBPACK_IMPORTED_MODULE_0___default()));
    }),
    /* 1 */
    ((module) => {
      module.exports = "<h1>test markdown</h1>\n<p>Hello, this is a test markdown!</p>\n"
    })
  ]);
  // The module cache
  var __webpack_module_cache__ = {};
  // The require function
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    if (__webpack_module_cache__[moduleId]) {
      return __webpack_module_cache__[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    var module = __webpack_module_cache__[moduleId] = {
      // no module.id needed
      // no module.loaded needed
      exports: {}
    };
    // Execute the module function
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    // Return the exports of the module
    return module.exports;
  }

  /* webpack/runtime/compat get default export */
  (() => {
    // getDefaultExport function for compatibility with non-harmony modules
    __webpack_require__.n = (module) => {
      var getter = module && module.__esModule ?
        () => module['default'] :
        () => module;
      __webpack_require__.d(getter, {
        a: getter
      });
      return getter;
    };
  })();

  /* webpack/runtime/define property getters */
  (() => {
    // define getter functions for harmony exports
    __webpack_require__.d = (exports, definition) => {
      for (var key in definition) {
        if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
          Object.defineProperty(exports, key, {
            enumerable: true,
            get: definition[key]
          });
        }
      }
    };
  })();

  /* webpack/runtime/hasOwnProperty shorthand */
  (() => {
    __webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop)
  })();

  /* webpack/runtime/make namespace object */
  (() => {
    // define __esModule on exports
    __webpack_require__.r = (exports) => {
      if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, {
          value: 'Module'
        });
      }
      Object.defineProperty(exports, '__esModule', {
        value: true
      });
    };
  })();

  // startup
  // Load entry module
  __webpack_require__(0);
  // This entry module used 'exports' so it can't be inlined
})();

第三,我们充分应用IDE折叠展开代码的功能来增强可读性。

通过折叠代码,可见最终的打包结果是个IIFE函数:

Webpack 5中开发loader所遇到的几个问题的解决

展开一部分内容后,如下图:

Webpack 5中开发loader所遇到的几个问题的解决

可见,先是定义了一个__webpack_modules__存放打包后的模块,然后是定义了__webpack_module_cache__来存放对已加载过的模块的缓存,然后定义了require function——__webpack_require__,然后是定义了n、d、o、r这几个方法挂到__webpack_require__上,最后是通过执行__webpack_require__(0);载入入口模块(也就是moduleId为0)的模块。

然后我们展开__webpack_modules__的内容:

var __webpack_modules__ = ([
  /* 0 */
  ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    /* harmony import */
    var _markdown_test_md__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
    /* harmony import */
    var _markdown_test_md__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/ __webpack_require__.n(_markdown_test_md__WEBPACK_IMPORTED_MODULE_0__);
    // ./src/index.js
    console.log((_markdown_test_md__WEBPACK_IMPORTED_MODULE_0___default()));
  }),
  /* 1 */
  ((module) => {
    module.exports = "<h1>test markdown</h1>\n<p>Hello, this is a test markdown!</p>\n"
  })
]);

发现实际上它是个数组,数组的每一项是个函数,其内部内容就是打包后的./src/index.js和./src/markdown/test.md这两个模块文件的内容。

再来展开__webpack_require__这个方法:

// The require function
function __webpack_require__(moduleId) {
  // Check if module is in cache
  if (__webpack_module_cache__[moduleId]) {
    return __webpack_module_cache__[moduleId].exports;
  }
  // Create a new module (and put it into the cache)
  var module = __webpack_module_cache__[moduleId] = {
    // no module.id needed
    // no module.loaded needed
    exports: {}
  };
  // Execute the module function
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  // Return the exports of the module
  return module.exports;
}

这个方法是加载模块的方法,传入的参数是模块Id。如果__webpack_module_cache__[moduleId]为真,则表明该模块已经加载过了,这样就从缓存中取。否则,就创建一个module并把缓存中,最后执行模块函数,并返回这个模块需要导出的内容。

然后我们来看看__webpack_require__.n__webpack_require__.d__webpack_require__.o__webpack_require__.r这四个工具方法。其代码如下:

/* webpack/runtime/compat get default export */
(() => {
  // getDefaultExport function for compatibility with non-harmony modules
  __webpack_require__.n = (module) => {
    var getter = module && module.__esModule ?
      () => module['default'] :
      () => module;
    __webpack_require__.d(getter, {
      a: getter
    });
    return getter;
  };
})();

/* webpack/runtime/define property getters */
(() => {
  // define getter functions for harmony exports
  __webpack_require__.d = (exports, definition) => {
    for (var key in definition) {
      if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
        Object.defineProperty(exports, key, {
          enumerable: true,
          get: definition[key]
        });
      }
    }
  };
})();

/* webpack/runtime/hasOwnProperty shorthand */
(() => {
  __webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop)
})();

/* webpack/runtime/make namespace object */
(() => {
  // define __esModule on exports
  __webpack_require__.r = (exports) => {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, {
        value: 'Module'
      });
    }
    Object.defineProperty(exports, '__esModule', {
      value: true
    });
  };
})();

通过观察,我们发现,其中都用到了Object.defineProperty这个方法。我们知道Object.defineProperty(obj, prop, descriptor)这个方法的作用是会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。它有三个参数:

  • obj 要定义属性的对象。

  • prop 要定义或修改的属性的名称或 Symbol 。

  • descriptor 要定义或修改的属性描述符。 对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。 数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。 它们可使用的键值分别如下:

Webpack 5中开发loader所遇到的几个问题的解决

即数据描述符可使用的键值有configurableenumerablevaluewritable,而存取描述符可使用的键值有configurableenumerablegetset

了解了这些,我们继续来看打包后的代码。因为__webpack_require__.n里用到了__webpack_require__.d,而__webpack_require__.d里又用到了__webpack_require__.o

所以我们先来解读__webpack_require__.o的代码。__webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop),所以它不过是对于Object.prototype.hasOwnProperty的一个包装而已,用于判断obj对象上是否有prop属性。

接着,我们来看看__webpack_require__.r的代码:

/* webpack/runtime/make namespace object */
(() => {
  // define __esModule on exports
  __webpack_require__.r = (exports) => {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, {
        value: 'Module'
      });
    }
    Object.defineProperty(exports, '__esModule', {
      value: true
    });
  };
})();

其中用到了Symbol.toStringTag,它是一个内置 symbol,它通常作为对象的属性键使用,对应的属性值应该为字符串类型,这个字符串用来表示该对象的自定义类型标签,通常只有内置的 Object.prototype.toString() 方法会去读取这个标签并把它包含在自己的返回值里。这样说可能不好理解,我们用个例子演示一下:

const obj = {};
Object.defineProperty(obj, Symbol.toStringTag, { value: 'MyCustomObject' });

这样定义之后,再用Object.prototype.toString.call(obj)去获取其类型就会得到"[object MyCustomObject]"的结果。

由此可知,__webpack_require__.r不过是做了两件事情:

第一,让Object.prototype.toString.call(exports)的返回值为"[object Module]"

第二,将exports__esModule属性值为true。

总结起来,其作用就是给模块打上ES6模块的标识符。

接着再来看__webpack_require__.d的代码:

/* webpack/runtime/define property getters */
(() => {
  // define getter functions for harmony exports
  __webpack_require__.d = (exports, definition) => {
    for (var key in definition) {
      if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
        Object.defineProperty(exports, key, {
          enumerable: true,
          get: definition[key]
        });
      }
    }
  };
})();

它是对definition的key值做了一个遍历,当definition有该key值而exports没有该key值,则把exports上的该key值设置上getter方法,并且指定该key值在exports上是可枚举的。

最后来看下__webpack_require__.n的代码:

/* webpack/runtime/compat get default export */
(() => {
  // getDefaultExport function for compatibility with non-harmony modules
  __webpack_require__.n = (module) => {
    var getter = module && module.__esModule ?
      () => module['default'] :
      () => module;
    __webpack_require__.d(getter, {
      a: getter
    });
    return getter;
  };
})();

它的功能是根据modul是否为ES6模块(有没有__esModule属性)来返回不同的模块函数,并且给返回值增加getter函数。

至此,整个打包后的代码就分析完成了。整体上还是比较简单的,主要还是要掌握上述提到的用好Webpack的工作模式(mode设置为'none')、去除干扰性的注释、用好编辑器的代码折叠展开等方法。

转载自:https://juejin.cn/post/6897575210514382862
评论
请登录