webpack之loader详解

基础概念

webpack是一款强大的模块打包工具,它可以引入配置文件完成前端高度定制化的构建工作.

webpack默认只能理解JavaScriptJSON文件,但实际工作中各种需求层出不穷,文件类型也多种多样.比如.vue.ts图片.css等,这就需要loader增强webpack处理文件的能力.

webpack的配置文件中,我们经常会看到如下设置.

module.exports = {
  ...
  module: {
    rules: [
         {
            test: /\.less$/i, // 匹配.less结尾的文件
            use: [
              "html-loader",
              "css-loader",
              'less-loader'
            ],
        }
        ],
  }
  ...
};

js代码里如果使用import导入一个样式文件style.less(代码如下),webpack碰到.less后缀的文件不知所措.因为它默认只能处理以.js.json结尾的文件.

//js文件
import "./style.less";

有了loader的赋能,webpack便有能力处理.less文件.

比如上面的配置代码,项目中一旦碰到导入以.less为后缀的样式文件,webpack会先将文件内容发送给less-loader处理,less-loader将所有less语法的样式转变成普通的css样式.

普通的css样式继续发送给css-loader处理,css-loader最主要的功能是解析css语法中的@import和图片路径,处理完后导入的css合并在了一起.

合并后的css文件再继续传递,发送给html-loader处理,它最终将样式内容插入到了html头部的style标签下,页面也因此添加了样式.

从上面的案例我们看出,每个loader的职责都是单一的,自己只负责自己的那一小块.但不管什么格式的文件,只要将特定功能的loader组合起来,它就能增强webpack的能力,使各种稀奇古怪的文件都能被正确识别并处理.

另外值得关注,loader在上面配置use数组中的执行顺序是从后往前.

了解了loader的基本用途之后,我们不禁思考,loader为什么功能这么强大,它是如何实现的呢?

我们接下来手写一个自定义loader,以此来加深理解loader的价值与用途.

自定义loader

随着es6的不断普及,应用async、await处理异步代码的情况越来越多(代码如下).async、await的出现使js处理异步操作变得简单.同时代码出现异常后,也可以通过try、catch进行捕捉.

async function start(){
  console.log("Hello world");
  await loadData();
  console.log("end world");
}

假设现在项目团队要为每个项目部署监控系统,一旦生产环境下js出现异常,要将报错信息及时上传到后台日志服务器.

项目需要对所有的async函数进行try、catch捕捉,期待的输出结果如下:

async function start(){
  try{
    console.log("Hello world");
    await loadData();
    console.log("end world");  
  }catch(error){
    console.log(error);
    logger(error); //处理错误信息
  }
}

如果项目规模庞大,人工手动添加try、catch不仅效率低下还容易出错,这时候工程化的价值便体现出来了.我们可以自定义一个loader自动给项目中所有的async函数添加异常捕捉.

loader基础API

首先我们先学习一下loader的基础api.在项目文件夹下创建一个文件error-loader.js,编写下面的测试代码(代码如下).

loader本质上是一个函数,参数content是一段字符串,存储着文件的内容,最后将loader函数导出就可以提供给webpack使用了.

webpack的配置文件在设置rules时(代码如下),只需要将use里的loader指向上面导出的loader函数的文件路径,这样webpack就能顺利引用loader了.另外我们还可以添加options属性给loader函数传参.

//error-loader.js
//loader函数
module.exports = function (content){
  console.log(this.query); // { name: 'hello' }
  return content;
}

//webpack.config.js
//webpack配置
module.exports = {
   module:{
    rules:[
      {
        test:/\.js$/,
        use:[
          {
            loader:path.resolve(__dirname,"./error-loader.js"),
            options:{
              name:"hello"
            }
          }
        ]
      }
    ]
  }
}

项目一旦启动打包,webpack检测到.js文件,它就会把文件的代码字符串传递给error-loader.js导出的loader函数执行.

我们上面编写的loader函数并没有对代码字符串content做任何操作,直接返回了结果.那么我们自定义loader的目的就是为了对content源代码做各种数据操作,再将操作完的结果返回.

比如我们可以使用正则表达式将content中所有的console.log语句全部去掉,那么最后我们生成的打包文件里就不会包含console.log.

另外我们在开发一些功能复杂的loader时,可以接收配置文件传入的参数.例如上面webpack.config.js中给error-loader传入了一个对象{name:"hello"},那么在自定义的loader函数中可以通过this.query获取到参数.

loader函数除了直接使用returncontent返回之外,还可以使用this.callback(代码如下)达到相同的效果.

this.callback能传递以下四个参数.第三个参数和第四个参数可以不填.this.callback传递的参数会发送给下一个loader函数接受,每一个loader函数形成了流水线上的一道道工序,最终将代码处理成期待的结果.

  • 第一个参数为错误信息,没有出错可以填null
  • 第二个参数为content,也是要进行数据操作的目标
  • 第三个参数为sourceMap,选填项.它将打包后的代码与源码链接起来,方便开发者调试,一般通过babel生成.
  • 第四个参数为meta额外信息,选填项.
module.exports = function (content){
  this.callback(null,content);  
}

以上介绍的内容都是使用同步的方式编写,万一loader函数里面需要做一些异步的操作就要采用如下方式.

this.async()调用后返回一个callback函数,等到异步操作完,就可以继续使用callbackcontent返回.

//上一个loader可能会传递sourceMap和meta过来,没穿就为空
module.exports = function (content,sourceMap,meta){
  const callback = this.async();
  setTimeout(()=>{ // 模拟异步操作
     callback(null,content);  
  },1000)
}

异常捕捉的loader编写

上面介绍完一些基本api之后,接下来开发一款捕捉async函数执行异常的loader.

loader函数的第一个参数content,我们可以利用正则表达式修改content.但如果实现的功能比较复杂,正则表达式会变得异常复杂难以开发.

主流的方法是将代码字符串转化对象,我们对对象进行数据操作,再将操作完的对象转化为字符串返回.

这就可以借助babel相关的工具帮助我们实现这一目的,代码如下.(如果对babel不熟悉的同学可以忽略这一小节,以后有机会单独对babel展开分析)

@babel/parser模块首先将源代码content转化成ast树,再通过@babel/traverse遍历ast树,寻找async函数的节点.

async函数的节点被寻找到后,通过@babel/types模块给async函数添加try,catch表达式包裹,再替换原来的旧节点.

最后使用@babel/generator模块将操作后的ast树转化成目标代码返回.

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generate = require('@babel/generator').default;
const t = require("@babel/types");

const ErrorLoader =  function (content,sourceMap,meta){
  
  const ast = parser.parse(content); // 将代码转换成为ast树

  traverse(ast,{
    //遍历函数表达式
    FunctionDeclaration(path){ 
        
        //判断当前节点是不是async函数
        const isAsyncFun = t.isFunctionDeclaration(path.node,{async:true});

        if(!isAsyncFun){ // 不是async函数就停止操作
          return ;
        }

        const bodyNode = path.get("body");

        // 是不是大括号表达式
        if(t.isBlockStatement(bodyNode)){

           const FunNode = bodyNode.node.body;

           if(FunNode.length == 0) { // 空函数
             return; 
           }

           if(FunNode.length !== 1 || t.isTryStatement(FunNode[0])){ // 函数内没有被try ... catch 包裹
            
            // 异常捕捉的代码
            const code = `    
                 console.log(error);
            `;

            //使用try、catch包裹,生成目标节点
            const resultAst = t.tryStatement(
              bodyNode.node,
              t.catchClause(t.identifier("error"),
              t.blockStatement(parser.parse(code).program.body) 
              )
            )
            
            //将转化后的节点替换原来的节点
            bodyNode.replaceWithMultiple([resultAst]);
           
          }

        }
     }
  })
  
  //将目标ast转化成代码
  this.callback(null,generate(ast).code,sourceMap,meta);

}

module.exports = ErrorLoader;

代码地址

loader源码解析

了解了自定义loader的实现方式,接下来我们解读一些平时工作中非常常见的loader源码,摸清楚它们的底层实现原理.

less-loader

less-loader简化后的源码如下,它的执行流程很简单.通过require("less")去加载less插件,然后调用less插件去编译source源代码输出结果.

这样所有的less语法都会编译成css,编译完成后调用callback返回处理结果.

async function lessLoader(source) {

  const options = this.getOptions(schema);
  
  const callback = this.async();
  
  const implementation = require("less");

  const lessOptions = getLessOptions(this, options, implementation);
  
  let result;

  try {
    result = await implementation.render(source, lessOptions);
  } catch (error) {
    callback(new LessError(error));
    return;
  }

  const { css, imports } = result;
  
  let map = typeof result.map === "string" ? JSON.parse(result.map) : result.map;

  callback(null, css, map);
}

export default lessLoader;

file-loader

file-loader通常被用来处理图片,字体以及其他格式的文件,执行流程可以梳理如下:

  • file-loader首先通过interpolateName函数根据配置中的name属性和content内容生成文件名
  • 有了文件名路径url,再根据用户配置options,生成目标outputPathpublicPath
  • 最后执行this.emitFile函数,调起webpack的钩子函数,向outputPath路径创建文件内容
//file-loader源代码简化
import path from 'path';
import { getOptions, interpolateName } from 'loader-utils';

export default function loader(content) {
  const options = getOptions(this); // 获取配置项
  const context = options.context || this.rootContext;
  const name = options.name || '[contenthash].[ext]';
  
  //据 name 配置和 content 内容 生成一个hash文件名
  const url = interpolateName(this, name, {
    context,
    content,
    regExp: options.regExp,
  });

  let outputPath = url;
  
  //如果用户配置了 outputPath
  if (options.outputPath) {
    if (typeof options.outputPath === 'function') {
      outputPath = options.outputPath(url, this.resourcePath, context);
    } else {
      outputPath = path.posix.join(options.outputPath, url); // 将outputPath和url拼接起来
    }
  }
  
  // publicPath 等于 webpack配置的根路径拼接上outputPath
  let publicPath = `__webpack_public_path__ + ${JSON.stringify(outputPath)}`;
   
  //用户没有有配置publicPath
  if (options.publicPath) {
    if (typeof options.publicPath === 'function') {
      publicPath = options.publicPath(url, this.resourcePath, context);
    } else {
      //将用户配置的publicPath拼接上文件名
      publicPath = `${
        options.publicPath.endsWith('/')
          ? options.publicPath
          : `${options.publicPath}/`
      }${url}`;
    }

    publicPath = JSON.stringify(publicPath);
  }

  
  if (typeof options.emitFile === 'undefined' || options.emitFile) {

    this.emitFile(outputPath, content, null); // 调用webpack的钩子函数创建文件

  }

  return `${esModule ? 'export default' : 'module.exports ='} ${publicPath};`;

}

export const raw = true;

vue-loader

前端同学做日常vue项目开发时,通常会使用单文件组件(代码如下).

单文件组件由三部分组成:templatescriptstyle.通过这三个标签,htmljs以及css可以写在一个文件里,通常该文件都会命名为.vue格式.

webpack是无法解析.vue文件,正是在vue-loader的作用下,webpack才将单文件组件解析成了浏览器能够执行的代码.

<template>
 <div class="main">hello world</div>
</template>
<script>
export default {}
</script>
<style>
 .main{
  color:red;
 }
</style>

vue-loader经过简化后的源码如下,我们可以从源码出梳理出它的运行机制.

module.exports = function (source) {
  
  const loaderContext = this

  const {
    request,
    sourceMap,
  } = loaderContext

  //将.vue文件解析后生成的结果,包含template、style、script
  const descriptor = parse({
    source,
    compiler,
    filename,
    sourceRoot,
    needMap: sourceMap
  })


  // 如果发现文件中包含不同type,比如 foo.vue?type=template&id=xxxxx
  // type = template | script | style 
  // selectBlock会给不同的type寻找相应的loader加载
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }

  // 处理template
  let templateImport = `var render, staticRenderFns`
  let templateRequest
  if (descriptor.template) {
     //...
    templateImport = `import { render, staticRenderFns } from ${request}`
  }

  // 处理script
  let scriptImport = `var script = {}`
  if (descriptor.script) {
     //...
    scriptImport = (
      `import script from ${request}\n` +
      `export * from ${request}` // support named exports
    )
  }

  // 处理styles
  let stylesCode = ``
  if (descriptor.styles.length) {
    stylesCode = genStylesCode(/*...*/)
  }
  
  /*
      import { render,staticRenderFns } from "./foo.vue?vue&type=template&id=32euy323lang=pug&";
      import script from "./foo.vue?vue&type=script&lang=js&";
      import style0 from "./foo.vue?vue&type=style&index=0&module=true&lang=css&"
  */
  let code = `
      ${templateImport}
      ${scriptImport}
      ${stylesCode}

      var component = normalizer(
        script,
        render,
        staticRenderFns,
        ...
      )

      export default component.exports;
  `;

  code = code.trim() + `\n`

  return code;
}

vue-loader其实会执行两轮,第一轮执行完先生成一个code字符串(代码如下).

这段代码最关键的三个变量:templateImportscriptImportstylesCode最后编译的数据结构对应着注释的那部分.

从注释代码我们可以看出,foo.vue又被import了三次,并且后面还携带了一个关键参数type,它被用来用来指定是templatescript还是style.


/* 
import { render,staticRenderFns } from "./foo.vue?vue&type=template&id=32euy323lang=pug&";  
import script from "./foo.vue?vue&type=script&lang=js&"; 
import style0 from "./foo.vue?vue&type=style&index=0&module=true&lang=css&" 
*/

let code = `
    ${templateImport} 
    ${scriptImport} 
    ${stylesCode} 
    var component = normalizer(  // 生成vue组件
                           script, 
                           render, 
                           staticRenderFns, 
                           ... 
                   ) 
    export default component.exports;
`
    return code;

这三次import会触发vue-loader的第二轮执行,此时代码执行到selectBlock(代码如下)时直接返回结果.

selectBlock内部会根据文件名后面的参数type加载相应的loader处理,最终templatescriptstyle都会被对应的loader处理并返回结果.

上面三块代码处理完毕后,就可以调用Vueapi生成组件,并编译成浏览器端能够执行的代码.

 if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }

css-loader

css-loader的功能非常强大,它可以导入所有使用的@import语法的css,并且还能处理css引入的图片url,另外还能实现css的模块化.

以下为简化后的css-loader源码.我们通过阅读源码可知,css-loader实现这些功能主要调用了postcss插件.

css-loader首先定义了一个plugins数组,plugins装载了处理css-modules@importurl以及icss插件,再以参数的形式提供给postcss调用,从而让css-loader也具备了相应的能力.

export default async function loader(content, map, meta) {
  
  const plugins = [];
  
  const callback = this.async();

  let options;
 
  const replacements = [];
  const exports = [];
  
  //处理css-modules
  if (shouldUseModulesPlugins(options)) {
    plugins.push(...getModulesPlugins(options, this));
  }
  
  //处理@import
  if (shouldUseImportPlugin(options)) {
    plugins.push(
      importParser({...})
    );
  }
  
  //处理url()语句
  if (shouldUseURLPlugin(options)) {
    plugins.push(
      urlParser({...})
    );
  }
  
  //处理icss相关逻辑
  if (needToUseIcssPlugin) {
    plugins.push(
      icssParser({...})
    );
  }

  const { resourcePath } = this;

  let result;

  try {
    result = await postcss(plugins).process(content, {...}); // 调用postcss插件处理content
  } catch (error) {
    callback(error);
    return;
  }

  const importCode = getImportCode(imports, options); //导入的依赖

  let moduleCode;

  try {
    moduleCode = getModuleCode(result, api, replacements, options, this); //导出的内容
  } catch (error) {
    callback(error);
    return;
  }

  const exportCode = getExportCode( // 其他导出的信息
    exports,
    replacements,
    needToUseIcssPlugin,
    options
  );

  callback(null, `${importCode}${moduleCode}${exportCode}`);
  
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值