webpack | 2020-05-11 13:37:56 1084次 3次
webpack中一般需要loader将非js模块转化为js模块完成打包工作,其中每一个loader就像一个流,负责单一的任务,通过各个loader的组合,实现任务处理。
一、loader使用
1)、通过webpack.config.js配置
module.exports = { module: { rules: [ { test: /\.css$/, use: 'css-loader' }, { test: /\.ts$/, use: 'ts-loader' } ] }}; 或 module: { rules: [ { test: /\.css$/, use: [ { loader: 'style-loader' }, { loader: 'css-loader', options: { modules: true } } ] } ] } 或 module: { rules: [ { test: /\.css$/, use: [ 'style-loader', 'css-loader', 'less-loader' ] } ] }
2)、内联
// 对test.js使用loader1和loader2 import 'loader1!loader2!./test.js'
符号 ! 前缀禁用配置文件中的普通loader,比如:require("!raw!./script.js")
符号 !! 前缀禁用配置文件中所有的loader,比如:require("!!raw!./script.js")
符号 -! 前缀禁用配置文件中的pre loader和普通loader,但是不包括post loader
不应该直接使用行内 loader 和 ! 前缀,因为它们是非标准的。它们可在由 loader 生成的代码中使用。
3)、CLI
webpack --module-bind jade-loader --module-bind 'css=style-loader!css-loader'
二、loader执行顺序
正常的loader执行顺序是:use数组中从右到左执行(...index2 -> index1 ->index0 end)。
同时webpack支持enforce参数,可选值有:"pre" | "post",无值为普通loader,所有 loader 通过 前置, 普通, 行内, 后置 排序,并按此顺序使用。
在定义一个loader函数时,可以导出一个pitch方法,这个方法会在loader函数执行前执行。webpack全部的执行有两个阶段(类似dom事件捕获和冒泡):
Pitching阶段: post,inline,normal,pre Normal阶段:pre,normal,inline,post
如果pitch阶段有返回值,则会阻断后续的loader执行,直接返回当前loader之前的那个loader的normal阶段,如图:
pre loader 配置:图片压缩 普通loader 配置:coffee-script转换 inline loader 配置:bundle loader post loader 配置: 代码覆盖率工具
三、loader开发
webpack中提供了丰富的api方便loader开发,同时也提供了loader-utils和schema-utils工具。
1)、常见api介绍
this.async():返回一个callback,用来异步调用。在 Node.js 单线程环境下进行耗时长的计算应该使 loader 异步化。但如果计算量很小,同步 loader 也是可以的。
module.exports = function(content, map, meta) { var callback = this.async(); someAsyncOperation(content, function(err, result) { if (err) return callback(err); callback(null, result, map, meta); }); };
this.callback():如果loader中有多个返回值时使用,只有一个值可以直接return value;使用该方法时,loader必须返回undefined。(注意此 api 和上面介绍的写法,可以直接调用this.callback,或使用this.async返回调用)
this.callback( err: Error | null, content: string | Buffer, sourceMap?: SourceMap, meta?: any );
pitch:如果pitch有返回值(只能为string | buffer),则如第二部分中介绍的loader执行顺序所述,没有返回值,则按照正常顺序执行,不会发生阻断行为。loader根据返回值可以分为两种,一种是返回js代码(一个module的代码,含有类似module.export语句)的loader,还有不能作为最左边loader的其他loader。为了解决这种问题,我们需要在style-loader里执行require(css-loader!resouce), 这会把css-loader跑一遍,也就是说如果按正常顺序执行css-loader会跑两遍(第一遍拿到的js代码用不了), 为了只执行一次,style-loader利用了pitching, 在pitching函数里require(css-loader!resouce)。然后返回js代码(style-loader能够作为最左边loader)
module.exports.pitch = function(remainingRquest, precedingRequest, data){ const script = ( ` const style = document.createElement('style'); style.innerHTML = require(${loaderUtils.stringifyRequest(this, '!!' + remainingRquest)}); document.head.appendChild(style); ` ) return script; }
raw:结果转为二进制,直接挂载到函数上,loader.raw = true;
this.data:在 pitch 阶段和正常阶段之间共享的 data 对象。这里是直接使用,原理如下:
Object.defineProperty(loaderContext, "data", { enumerable: true, get: function() { return loaderContext.loaders[loaderContext.loaderIndex].data; } }); 测试案例: module.exports = function (content) { console.log(this.data); // {value: 8421} return 'res'; } module.exports.pitch = (remainingRequest, precedingRequest, data) =>{ data.value = 8421; // 此处没有return 会走到 上面normal阶段 //如果有return值 则 normal阶段会被忽略 }
2)、工具
loader-utils: 内含各种处理loader的options的各种工具函数
schema-utils: 用于校验loader和plugin的数据结构
loaderUtils.stringifyRequest(this, itemUrl)
loaderUtils.stringifyRequest(this, require.resolve("./test")); // = "../node_modules/some-loader/lib/test.js"
loaderUtils.getOptions(this)
获取loader的options对象
schemaUtils(schema, options)
校验options的格式
3)、loader插件编写
loader插件作用于生成目标代码的过程中,而webpack的plugin的则是作用于目标结果生成后的一些功能处理。一个最基本的loader插件结构就是导出一个函数,返回一个string|buffer即可。通过编写babel-loader和style-loader、less-loader了解下。
babel-loader:(利用现有的babel库api进行代码版本转化)
const babel = require('@babel/core'); const loaderUtils = require('loader-utils'); const path = require('path'); function loader(inputSource) { const loaderOptions = loaderUtils.getOptions(this); const options = { ...options, sourceMap: true, //是否生成映射 filename: path.basename(this.resourcePath) //从路径中获取目标文件名 } const {code, map, ast} = babel.transform(inputSource, loaderOptions); // 将内容传递给webpack /** * code: 处理后的字符串 * map: 代码的source-map * ast: 生成的AST */ this.callback(null, code, map, ast); } module.exports = loader;
less-loader:(利用less插件将less代码转为css代码)
let less = require('less'); function loader(source){ let css = ''; //利用less的api转化 less.render(source, (err, c) => { css = c.css; }); css = css.replace(/\n/g, '\\n') return css } module.exports = loader
style-loader:(将代码插入到html)
const loaderUtils = require('loader-utils'); //这里用到了pitch 因为css-loader 可能包含需要动态执行的函数 module.exports.pitch = function(remainingRquest, precedingRequest, data){ const script = ( ` const style = document.createElement('style'); style.innerHTML = require(${loaderUtils.stringifyRequest(this, '!!' + remainingRquest)}); document.head.appendChild(style); ` ) return script; }
style-loader 源码的 pitch 方法里面调用了 require('!!.../x.css'),这就会把 require 的 css 文件当作新的入口文件,重新链式调用剩余的 loader 函数进行处理。【!!】是一个标志,表示不会再重复递归调用 style-loader,只会调用 css-loader 处理。
通过一些简单举例,大致了解loader的开发流程,可以根据自己的需求自行开发,可以借助webpack现有的api、babel的api、loader-utils工具等方式开发。同时每个loader的职责应该是单一的,只处理自己负责的功能,这样才能更方便的扩展功能。
4)、loader插件配置本地加载
loader文件夹和src同级,其中分别为style-loader.js less-loader.js 等各个loader文件 module: { rules: [{ test: /\.less$/, use: [ path.resolve(__dirname, 'loader', 'style-loader'), path.resolve(__dirname, 'loader', 'less-loader') ] }] }, 简化写法: resolveLoader: { alias: {// 绝对路径 style-loader: path.resolve(__dirname, 'loader', 'style-loader') } }, 再或者(不推荐,比较耗时) resolveLoader: { // 先从node_modules中查找,没有从loader文件夹中查找所用的loader modules: ['node_modules', 'loaders'] }, 这样在rules就正常使用即可
四、loader运行原理
1)实例化 ruleset
RuleSet相当于一个规则过滤器,会将resourcePath应用于所有的module.rules规则,从而筛选出所需的loader。Ruleset 在内部会有一个默认的 module.defaultRules 配置,在真正加载 module 之前会和 webpack config 配置文件当中的自定义 module.rules 进行合并,然后转化成对应的匹配过滤器,在配置中我们可以写各种格式的规则,Ruleset最终将这些格式统一处理为一致。
其中默认配置(webpack/lib/WebpackOptionsDefaulter.js):
this.set("module.defaultRules", "make", options => [ { type: "javascript/auto", resolve: {} }, ... { test: /\.json$/i, type: "json" }, { test: /\.wasm$/i, type: "webassembly/experimental" } ]);
在NormalModuleFactory.js中被实例化:
module.exports = class RuleSet{ constructor(rules) { this.references = Object.create(null); this.rules = RuleSet.normalizeRules(rules, this.references, "ref-"); } static normalizeRules(rules, refs, ident) {} static normalizeRule(rule, refs, ident) {} static buildErrorMessage(condition, error) {} static normalizeUse(use, ident) {} static normalizeUseItemString(useItemString) {} static normalizeUseItem(item, ident) {} static normalizeCondition(condition) {} exec(data) {} _run(data, rule, result) {} indOptionsByIdent(ident) {} }
经过 normalizeUse 函数的格式化处理,最终的 rule 结果为一个数组,内部的 object 元素都包含 loader/options 等字段:
config中的配置 [ { loader: 'xxx-loader', options: { data: 'value' } } ... ] 经过 RuleSet 内部的格式化的处理,最终输出的 rules 为: rules: [ { resource: [Function], resourceQuery: [Function], use: [{ loader: 'xxx-loader', options: { data: 'value' } }] } ... ]
2)解析 inline-loader
①处理 inline-loaders格式统一处理,进行解析,获取对应 loader 模块信息
②利用 ruleset 实例上的 exec 匹配过滤方法对 webpack.config 中配置的相关 loaders 进行匹配过滤,获取构建这个 module 所需要的配置的的 loaders,并进行解析
③这个过程完成后,便进行所有 loaders 的拼装工作,并传入创建 module 的回调中
// NormalModuleFactory.js // 是否忽略 preLoader 以及 normalLoader const noPreAutoLoaders = requestWithoutMatchResource.startsWith("-!"); // 是否忽略 normalLoader const noAutoLoaders = noPreAutoLoaders || requestWithoutMatchResource.startsWith("!"); // 忽略所有的 preLoader / normalLoader / postLoader const noPrePostAutoLoaders = requestWithoutMatchResource.startsWith("!!"); // 首先解析出内联的 loader let elements = requestWithoutMatchResource .replace(/^-?!+/, "") .replace(/!!+/g, "!") .split("!"); let resource = elements.pop(); // 获取资源的路径 // 获取每个loader及对应的options配置(将inline loader的写法变更为module.rule的写法) elements = elements.map(identToLoaderRequest); --------------------------------------------------- 格式如下: [{ loader: '/workspace/node_modules/less/dist/index.js', //找到绝对路径 options: undefined //配置 }, { loader: '/workspace/node_modules/css-loader/lib/loader.js', options: undefined }]
3)webpack.config.js规则过滤解析
利用 ruleset 实例上的 exec 进行相关的匹配过滤工作。在 webpack 正常的工作流当中,在加载对应的 module 之前首先需要知道加载这个模块具体使用哪些 loader,调用 ruleset 实例上的 exec 去过滤对应的 loader。
class NormalModuleFactory extends Tapable { ... const result = this.ruleSet.exec({ resource: resourcePath, realResource: matchResource !== undefined ? resource.replace(/\?.*/, "") : resourcePath, resourceQuery, issuer: contextInfo.issuer, compiler: contextInfo.compiler }); ... } result最终格式为 [{ type: 'use', value: { loader: 'css-loader', options: {} }, enforce: undefined }, { type: 'use', value: { loader: 'style-loader', options: { data: 'something' } }, enforce: undefined // pre | post }]
然后根据enforce的值进行loader类型收集:
const useLoadersPost = []; const useLoaders = []; const useLoadersPre = []; for (const r of result) { if (r.type === "use") { if (r.enforce === "post" && !noPrePostAutoLoaders) { useLoadersPost.push(r.value); } else if ( r.enforce === "pre" && !noPreAutoLoaders && !noPrePostAutoLoaders ) { useLoadersPre.push(r.value); } else if ( !r.enforce && !noAutoLoaders && !noPrePostAutoLoaders ) { useLoaders.push(r.value); } } ... 非 use 类型判断,此处忽略 }
最后,使用neo-aysnc来并行解析三类loader数组:
asyncLib.parallel([ this.resolveRequestArray.bind( this, contextInfo, this.context, useLoadersPost, loaderResolver ), this.resolveRequestArray.bind( ... useLoaders ), this.resolveRequestArray.bind( ... useLoadersPre, ) ], (err, results) => { //放在下面第4小节 } );
4)组合
在上面的代码中,预留了一部分,这里将所有类型的loader组合:
if (err) return callback(err); if (matchResource === undefined) { loaders = results[0].concat(loaders, results[1], results[2]); } else { loaders = results[0].concat(results[1], loaders, results[2]); } process.nextTick(() => { ... }); }
其中 matchResource 跑了些demo,发现都是undefined,暂未了解其作用,所以loaders的值如下:
[results[0], loaders, results[1], results[2]] 对应关系: [post loader, inline loader, normal loader, pre loader] 这里解释了loader的执行顺序问题
5)运行
loader的绝对路径解析完毕后,在NormalModuleFactory的factory钩子中会创建当前模块的NormalModule对象。下面就是运行各个loader。webpack模块处理会首先进行loader的读取和处理。在compilation中有一个方法.addEntry(),它会调用._addModuleChain()会执行一系列的模块方法。对于未build过的模块,最终会调用到NormalModule对象的.doBuild()方法。
NormalModule.js class NormalModule extends Module { ... createLoaderContext(resolver, options, compilation, fs) { const loaderContext = {...} } //执行构建 doBuild(options, compilation, resolver, fs, callback) { //创建 context 对象 loader插件中的this就是这个 const loaderContext = this.createLoaderContext( resolver, options, compilation, fs ); //执行loader runLoaders( { resource: this.resource, loaders: this.loaders, context: loaderContext, readResource: fs.readFile.bind(fs) },() => { } ) ... } }
我们在编写插件的时候,会用到this上各种方法,那这个this就是loaderContext,然开始loader的运行阶段,逻辑放在了 loader-runner 这个包中,loader-runner中定义了 loader插件所用的 api、异步执行、pitch执行顺序等,loaderIndex贯穿全局,代表loader的索引。
exports.runLoaders = function runLoaders(options, callback) { // 模块的路径 var resource = options.resource || ""; // 模块所需要使用的 loaders var loaders = options.loaders || []; // 在 normalModule 里面创建的 loaderContext var loaderContext = options.context || {}; // fs.readFile.bind(fs) node api 读取文件 var readResource = options.readResource || readFile; var splittedResource = resource && splitQuery(resource); // 模块实际路径 var resourcePath = splittedResource ? splittedResource[0] : undefined; // 模块路径 query 参数 var resourceQuery = splittedResource ? splittedResource[1] : undefined; // 模块的父路径 var contextDirectory = resourcePath ? dirname(resourcePath) : null; ... // createLoaderObject方法给loader挂载一些属性 /** var obj = { path: null, query: null, ... }; Object.preventExtensions禁止扩展属性 */ loaders = loaders.map(createLoaderObject); loaderContext.context = contextDirectory; // 当前正在执行的 loader 索引 loaderContext.loaderIndex = 0; loaderContext.loaders = loaders; loaderContext.resourcePath = resourcePath; loaderContext.resourceQuery = resourceQuery; //异步 默认都是 null loaderContext.async = null; loaderContext.callback = null; //缓存 loaderContext.cacheable = function cacheable(flag) { if(flag === false) { requestCacheable = false; } }; //加入一个文件作为产生 loader 结果的依赖,使它们的任何变化可以被监听到。 //例如,html-loader 就使用了这个技巧,当它发现 src 和 src-set 属性时, //就会把这些属性上的 url 加入到被解析的 html 文件的依赖中。 loaderContext.dependency = loaderContext.addDependency = function addDependency(file) { fileDependencies.push(file); }; ... //每个 loader 在 pitch 阶段和正常执行阶段都可以共享的 data 数据 Object.defineProperty(loaderContext, "data", { enumerable: true, get: function() { return loaderContext.loaders[loaderContext.loaderIndex].data; } }); ... // 开始执行每个 loader 上的 pitch 函数 iteratePitchingLoaders(processOptions, loaderContext, function(err, result) { callback(null, {...}); }); };
picth运行原理分析,在iteratePitchingLoaders方法中执行loadLoader,loader被加载也是在loadLoader.js中完成:
loadLoader.js ... var module = require(loader.path); ...
var loadLoader = require("./loadLoader"); //加载loader ... loadLoader(currentLoaderObject, function(err) { var fn = currentLoaderObject.pitch; // 获取 pitch 函数 currentLoaderObject.pitchExecuted = true; // 如果这个loader没有提供pitch函数,跳过,index++,递归的调用这个iteratePitchingLoaders if(!fn) return iteratePitchingLoaders(options, loaderContext, callback); // 开始执行 pitch 函数 runSyncOrAsync( fn, loaderContext, //pitch回调方法中 传递这三个参数 //后面的loader+资源路径,loadername!的语法\资源路径\data全局对象 [ loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {} ], function(err) { if(err) return callback(err); var args = Array.prototype.slice.call(arguments, 1); // 根据是否有参数返回来判断是否向下继续进行 pitch 函数的执行 var hasArg = args.some(function(value) { return value !== undefined; }); if(hasArg) { // 执行normal阶段的loader,并且index前移,和第二部分的流程图对应 loaderContext.loaderIndex--; iterateNormalLoaders(options, loaderContext, args, callback); } else { //无返回值的pitch继续执行下一个pitch iteratePitchingLoaders(options, loaderContext, callback); } } ); })
那么什么时候执行loader的normal阶段呢?在iteratePitchingLoaders函数中有个判断,pitch完成后loaderIndex的值会大于当前loader数组的长度,此时会去加载normal阶段的loader资源,按照右到左的顺序执行:
if(loaderContext.loaderIndex >= loaderContext.loaders.length){ return processResource(options, loaderContext, callback); } function processResource(options, loaderContext, callback) { // 从最右侧开始执行loader 业务逻辑中是没有 loader的 所以 长度可能为0 loaderContext.loaderIndex = loaderContext.loaders.length - 1; var resourcePath = loaderContext.resourcePath; if(resourcePath) { loaderContext.addDependency(resourcePath); //读取业务代码需要loader处理的资源文件 待编译的文件 options.readResource(resourcePath, function(err, buffer) { if(err) return callback(err); options.resourceBuffer = buffer; iterateNormalLoaders(options, loaderContext, [buffer], callback); }); } else { iterateNormalLoaders(options, loaderContext, [null], callback); } } function iterateNormalLoaders(options, loaderContext, args, callback) { ... //buffer 和 utf8 string 之间的转化 convertArgs(args, currentLoaderObject.raw); //递归执行 runSyncOrAsync(fn, loaderContext, args, function(err) { var args = Array.prototype.slice.call(arguments, 1); iterateNormalLoaders(options, loaderContext, args, callback); }); }
最后执行 runSyncOrAsync方法,用来做异步或同步的判断,原理就是如果在loader插件内使用了 async 或 callback方法(这两个方法就是同一个),则判定为异步方法,否则是同步,同步中也做了插件返回值是否是promise判断,如果是则异步调用:
function runSyncOrAsync(fn, context, args, callback) { var isSync = true; var isDone = false; ... context.async = function async() { ... isSync = false; return innerCallback; }; // this.async 和 this.callback同一个指向 var innerCallback = context.callback = function() { ... isDone = true; isSync = false; try { callback.apply(null, arguments); }... }; try { //loader插件中返回值 var result = (function LOADER_EXECUTION() { return fn.apply(context, args); }()); //同步情况 if(isSync) { isDone = true; // 第一个判断感觉有点多余。。 // 这个函数中的callback是进行下一个loader查找运行 if(result === undefined) return callback(); if(result && typeof result === "object" && typeof result.then === "function") { return result.then(function(r) { callback(null, r); }, callback); } return callback(null, result); } }... }
五、总结
通过本文可以了解到如何配置使用 loader,以及 loader 的执行顺序,然后介绍了 loader 插件常用的api以及编写简单一些插件。最后,从源码层面分析了 loader 的执行原理,经历五个步骤,完成 loader 的调用。这个过程结束之后通过回调,回到normalModule.js的runLloader的回调函数处,把转译之后内容复制给模块的_source属性,然后调用模块源码解析,分析资源依赖。
3人赞