看完这篇webpack-loader,再也不怕面试官问了

对于webpack,一切皆模块。所以,不管什么文件,都须要转换成js可识别模块。你能够理解为,不管什么后缀的文件,都看成js来使用(即便是img、ppt、txt文件等等)。可是直接看成js使用确定是不行的,需转换为一种能被js理解的方式才能看成js模块来使用——这个转换的过程由webpack的loader来处理。一个webpack loader 是一个导出为函数的 js 模块。webpack内部的loader runner会调用这个函数,而后把上一个 loader 产生的结果或者资源文件传入进去,而后返回处理后的结果前端

下面会从基本使用开始出发,探究一个loader怎么写,并实现raw-loaderjson-loaderurl-loaderbundle-loadernode

准备工做: 先安装webpackwebpack-cliwebpack-dev-server,后面的实践用到什么再装什么react

loader使用

  1. 常规方法:webpack.config里面配置rules
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/, // 匹配规则
        use: ['babel-loader'] // require的loader路径数组
      }
    ]
  }
}
复制代码

写了这个规则,只要匹配的文件名以.js为结尾的,那就会通过use里面全部的loader处理webpack

  1. loadername! 前缀方式 好比有一个txt文件,咱们想经过raw-loader来获取整个txt文件里面的字符串内容。除了使用统一webpack config配置的方式以外,咱们还能够在引入的时候,用这样的语法来引入:
import txt from "raw-loader!./1.txt";
// txt就是这个文件里面全部的内容
复制代码

其实使用webpack.config文件统一配置loader后,最终也是会转成这种方式使用loader再引入的。支持多个loader,语法: loader1!loader2!yourfilenamegit

query替代optionsgithub

使用loadername! 前缀语法:raw-loader?a=1&b=2!./1.txt,等价于webpack配置:web

{
        test: /^1\.txt$/,
        exclude: /node_modules/,
        use: [
          { loader: "raw-loader", options: { a: '1', b: '2' } },
        ]
      },
复制代码

在写本身的loader的时候,常常会使用loader-utils(不须要特意安装,装了webpack一套就自带)来获取传入参数json

const { getOptions } = require("loader-utils");
module.exports = function(content) {
  const options = getOptions(this) || {};
  // 若是是配置,返回的是options;若是是loadername!语法,返回根据query字符串生成的对象
 // ...
};
复制代码

下文为了方便演示,会屡次使用此方法配置loader。若是没用过这种方法的,就看成入门学习吧😊。搞起~api

一个loader通常是怎样的

一个loader是一个导出为函数的 js 模块,这个函数有三个参数:content, map, meta数组

  • content: 表示源文件字符串或者buffer
  • map: 表示sourcemap对象
  • meta: 表示元数据,辅助对象

咱们实现一个最最最简单的,给代码加上一句console的loader:

// console.js
module.exports = function(content, map, meta) {
  return `${content}; console.log('loader exec')`;
};
复制代码

webpack配置

module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          { loader: "./loaders/console" }, // 加上本身写的loader
        ]
      }
    ]
  },
复制代码

咱们发现,从新跑构建后,每个js都打印一下 'loader exec'

最简单的loader——raw-loader和json-loader

这两个loader就是读取文件内容,而后可使用import或者require导入原始文件全部的内容。很明显,原文件被看成js使用的时候,缺乏了一个导出语句,loader作的事情就是加上导出语句。

好比有一个这样的txt

this is a txt file
复制代码

假如你把它看成js来用,import或者require进来的时候,执行this is a txt file这句js,确定会报错。若是想正常使用,那么这个txt文件须要改为:

export default 'this is a txt file'
复制代码

最终的效果就是,不管是什么文件,txt、md、json等等,都看成一个js文件来用,原文件内容至关于一个字符串,被导出了:

// 本身写的raw-loader
const { getOptions } = require("loader-utils");
// 获取webpack配置的options,写loader的固定套路第一步

module.exports = function(content, map, meta) {
  const opts = getOptions(this) || {};

  const code = JSON.stringify(content);
  const isESM = typeof opts.esModule !== "undefined" ? options.esModule : true;
// 直接返回原文件内容
  return `${isESM ? "export default" : "module.exports ="} ${code}`;
};
复制代码

raw-loaderjson-loader几乎都是同样的,他们的目的就是把原文件全部的内容做为一个字符串导出,而json-loader多了一个json.parse的过程

注意:看了一下官方的loader源码,发现它们还会多一个步骤

JSON.stringify(content)
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');
复制代码

\u2028\u2029是特殊字符,和\n\b之类的相似,但它们特殊之处在于——转义后直观上看仍是一个空字符串。能够看见它特殊之处:

即便你看得见中间有一个奇怪的字符,可是你再按下enter,仍是'ab'\u2028字符串在直观上来看至关于空字符串(实际上字符是存在的,却没有它的带来的效果)。而对于除了2028和2029,好比\u000A\n,是有换行的效果的(字符存在,也有它带来的效果)。所以,对于低几率出现的字符值为2028和2029的转义是有必要的

Unicode 字符值 转义序列 含义 类别
\u0008 \b Backspace
\u0009 \t Tab 空白
\u000A \n 换行符(换行) 行结束符
\u000B \v 垂直制表符 空白
\u000C \f 换页 空白
\u000D \r 回车 行结束符
\u0022 " 双引号 (")
\u0027 \‘ 单引号 (‘)
\u005C \ 反斜杠 ()
\u00A0 不间断空格 空白
\u2028 行分隔符 行结束符
\u2029 段落分隔符 行结束符
\uFEFF 字节顺序标记 空白

raw模式与url-loader

咱们前面已经实现了raw-loader,这个loader是把原文件里面的内容以字符串形式返回。可是问题来了,有的文件并非一个字符串就能够解决的了的,好比图片、视频、音频。此时,咱们须要直接利用原文件的buffer。刚好,loader函数的第一个参数content,支持string/buffer

如何开启buffer类型的content?

// 只须要导出raw为true
module.exports.raw = true
复制代码

url-loader的流程就是,读取配置,是否能够转、怎么转=>读取原文件buffer=>buffer转base64输出 => 没法转换的走fallback流程。咱们下面实现一个简易版本的url-loader,仅仅实现核心功能

const { getOptions } = require("loader-utils");

module.exports = function(content) {
  const options = getOptions(this) || {};
  const mimetype = options.mimetype;

  const esModule =
    typeof options.esModule !== "undefined" ? options.esModule : true;

// base编码组成:data:[mime类型];base64,[文件编码后内容]
  return `${esModule ? "export default" : "module.exports ="} ${JSON.stringify( `data:${mimetype || ""};base64,${content.toString("base64")}` )}`;
};

module.exports.raw = true;
复制代码

而后,咱们随便弄一张图片,import进来试一下:

// loader路径自行修改
// img就是一个base64的图片路径,能够直接放img标签使用
import img from "../../loaders/my-url-loader?mimetype=image!./1.png";
复制代码

至于file-loader,相信你们也有思路了吧,流程就是:读取配置里面的publicpath=>肯定最终输出路径=>文件名称加上MD5 哈希值=>搬运一份文件,文件名改新的名=>新文件名拼接前面的path=>输出最终文件路径

pitch与bundle-loader

官网对pitching loader介绍是: loader 老是从右到左地被调用。有些状况下,loader 只关心 request 后面的元数据(metadata),而且忽略前一个 loader 的结果。在实际(从右到左)执行 loader 以前,会先从左到右调用 loader 上的 pitch 方法。其次,若是某个 loader 在 pitch 方法中返回一个结果,那么这个过程会跳过剩下的 loader

pitch方法的三个参数:

  • remainingRequest: 后面的loader+资源路径,loadername!的语法
  • precedingRequest: 资源路径
  • metadata: 和普通的loader函数的第三个参数同样,辅助对象,并且loader执行的全程用的是同一个对象哦

loader从后往前执行这个过程,你能够视为顺序入栈倒序出栈。好比命中某种规则A的文件,会经历3个loader: ['a-loader', 'b-loader', 'c-loader']

会经历这样的过程:

  • 执行a-loader的pitch方法
  • 执行b-loader pitch方法
  • 执行c-loader pitch方法
  • 根据import/require路径获取资源内容
  • c-loader 执行
  • b-loader 执行
  • a-loader 执行

若是b-loader里面有一个pitch方法,并且这个pitch方法有返回结果,那么上面这个过程自从通过了b-loader后,就不会再将c-loader入栈

// b-loader
module.exports = function(content) {
  return content;
};

// 没作什么,就透传import进来再export出去
module.exports.pitch = function(remainingRequest) {
// remainingRequest路径要加-! 前缀
  return `import s from ${JSON.stringify( `-!${remainingRequest}` )}; export default s`;
};
复制代码

b-loader的pitch方法有返回结果,会经历这样的过程:

  • 执行a-loader的pitch方法
  • 执行b-loader pitch方法(有返回结果,跳过c-loader)
  • 根据import/require路径获取资源内容
  • b-loader 执行
  • a-loader 执行

什么状况下须要跳过剩下的loader呢?最多见的,就是动态加载和缓存读取了,要跳事后面loader的计算。bundle-loader是一个典型的例子

bundle-loader实现的是动态按需加载,怎么使用呢?咱们能够对react最终ReactDom.render那一步改造一下,换成动态加载react-dom,再体会一下区别

- import ReactDom from "react-dom";
+ import LazyReactDom from "bundle-loader?lazy&name=reactDom!react-dom";

+ LazyReactDom(ReactDom => {
+ console.log(ReactDom, "ReactDom");
ReactDom.render(<S />, document.getElementById("root"));
+});
复制代码

能够看见reactdom被隔离开来,动态引入

点开bundle-loader源码,发现它利用的是require.ensure来动态引入,具体的实现也很简单,具体看bundle-loader源码。时代在变化,新时代的动态引入应该是动态import,下面咱们本身基于动态import来实现一个新的bundle-loader。(仅实现lazy引入的核心功能)

// 获取ChunkName
function getChunkNameFromRemainingRequest(r) {
  const paths = r.split("/");
  let cursor = paths.length - 1;
  if (/^index\./.test(paths[cursor])) {
    cursor--;
  }
  return paths[cursor];
}

// 原loader不须要作什么了
module.exports = function() {};

module.exports.pitch = function(remainingRequest, r) {
  // 带loadername!前缀的依赖路径
  const s = JSON.stringify(`-!${remainingRequest}`);
  // 使用注释webpackChunkName来定义chunkname的语法
  return `export default function(cb) { return cb(import(/* webpackChunkName: "my-lazy-${getChunkNameFromRemainingRequest( this.resource )}" */${s})); }`;
};

复制代码

用法和官方的bundle-loader基本差很少,只是动态import返回一个promise,须要改一下使用方法:

import LazyReactDom from "../loaders/my-bundle!react-dom";

setTimeout(() => {
  LazyReactDom(r => {
    r.then(({ default: ReactDom }) => {
      ReactDom.render(<S />, document.getElementById("root")); }); }); }, 1000); 复制代码

loader上下文

上文咱们看见有在写loader的时候使用this,这个this就是loader的上下文。具体可见官网

一堆上下文的属性中,咱们拿其中一个来实践一下: this.loadModule

loadModule(request: string, callback: function(err, source, sourceMap, module))

loadModule方法做用是,解析给定的 request 到一个模块,应用全部配置的 loader ,而且在回调函数中传入生成的 source 、sourceMap和webpack内部的NormalModule实例。若是你须要获取其余模块的源代码来生成结果的话,你可使用这个函数。

很明显,这个方法其中一个应用场景就是,在已有代码上注入其余依赖

let's coding

背景:已有一个api文件api.js

const api0 = {
  log(...args) {
    console.log("api log>>>", ...args);
  }
};
module.exports = api0;
复制代码

但愿效果:咱们使用下面这个a.jsjs文件的时候,能够直接使用api,且不报错

// a.js
export default function a() {
  return 1;
}
// 其余代码
// ...

api.log("a", "b");
复制代码

所以,咱们须要构建的时候loader把api打进去咱们的代码里面:

// addapi的loader
module.exports = function(content, map, meta) {
// 涉及到加载模块,异步loader
  const callback = this.async();
  this.loadModule("../src/api.js", (err, source, sourceMap, module) => {
// source是一个module.exports = require(xxx)的字符串,咱们须要require那部分
    callback(
      null,
      `const api = ${source.split("=")[1]}; ${content};`,
      sourceMap,
      meta
    );
  });
  return;
};
复制代码

loader写好了,记得去webpack配置里面加上,或者使用loadername!的语法引入a.js(./loaders/addapi!./a.js)

最后咱们能够看见成功运行了api.js的log

平时也有一些熟悉的场景,某某某api、某某某sdk、公共utils方法、每个index页面的pvuv上报等等,须要先把这些js加载执行完或者导入。若是咱们懒得一个个文件加import/require语句,就能够用这种方式瞬间完成。这种骚操做的前提是,保证后续同事接手项目难度低、代码无坑。注释、文档、优雅命名都搞起来

最后

loader的做用就是,让一切文件,转化为本身所须要、能使用的js模块运行起来。babel和loader双剑合璧更增强大,能够随心所欲的修改代码、偷懒等等。后续还会出webpack插件、babel相关的文章,你们一块儿来学习交流~

关注公众号《不同的前端》,以不同的视角学习前端,快速成长,一块儿把玩最新的技术、探索各类黑科技

相关文章
相关标签/搜索