Webpack 5中开发loader所遇到的几个问题的解决
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
后,得到如下控制台输出:
> 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!
*****************************
-----------------------------
<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'
打包后的结果:
mode
设置成'production'
打包后的结果:
下面是几点有助于实操的经验:
首先,为了使得打包结果更易于阅读,本文中特地把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_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 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。 它们可使用的键值分别如下:
即数据描述符可使用的键值有configurable
、enumerable
、value
、writable
,而存取描述符可使用的键值有configurable
、enumerable
、get
、set
。
了解了这些,我们继续来看打包后的代码。因为__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