手写webpack2-loader执行流程及实现

129 阅读4分钟

loader执行流程

针对每个文件,从config文件中收集起来所有的loader之后,执行过程可以概括为以下几步:

1、先将loader进行分类,其中inlineLoaders直接从文件require或import的路径中取到

2、require或import时,可以在引用路径前加上前缀,来控制使用哪些loader

3、将loader名称转换为绝对路径

4、执行所有loader的pitch方法

5、执行所有loader的normal方法

loader分类

//loader 的叠加顺序 = post(后置)+inline(内联)+normal(正常)+pre(前置)

// 厚 脸 挣 钱

//写的是一样的

//执行的时候 是从右向左执行的

pre=>normal=>inline=>post

pre、normal、inline、post可以在enforce中配置,默认是normal:

module.exports = {
		// ...
    resolveLoader: {
        //配置别名
        alias: {
            'inline1-loader': path.resolve(__dirname, 'loaders', 'inline1-loader.js'),
        },
        //配置去哪些目录里找loader
        modules: ['node_modules', path.resolve(__dirname, 'loaders')]
    },
    module: {
        rules: [
            {
                test: /.js$/,
              	enforce: 'post',
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ["@babel/preset-env"],
                        plugins: []
                    }
                }
            },

上面代码中,可以看到,我们能在webpack.config.js的resolveLoader中配置loader路径的别名

这里实际上有两种配置方式:

1、只配置alias,因为通过alias里面的绝对路径可以准确定位loader的位置

2、只配置modules,webpack在加载loader的时候可以从modules取出对应的目录挨个遍历取值

\

inlineLoaders、preLoaders、postLoaders、normalLoaders 4个数组中分别存储对应的loaders

const { runLoaders } = require('loader-runner');
const path = require('path');
const fs = require('fs');
//入口文件
const entryFile = path.resolve(__dirname, 'src', 'title.js');
//loader的转换规则配置
let rules = [
    {
        test: /title.js$/,
        use: ['normal1-loader.js', 'normal2-loader.js']
    },
    {
        test: /title.js$/,
        enforce: 'post',
        use: ['post1-loader.js', 'post2-loader.js']
    },
    {
        test: /title.js$/,
        enforce: 'pre',
        use: ['pre1-loader.js', 'pre2-loader.js']
    }
]
//手写style-loader的时候使用到
let request = `inline1-loader!inline2-loader!${entryFile}`;
let parts = request.replace(/^-?!+/, '').split('!');//['inline1-loader','inline2-loader',entryFile]
let resource = parts.pop();//entryFile
const inlineLoaders = parts;//['inline1-loader','inline2-loader']
const preLoaders = [], postLoaders = [], normalLoaders = [];
rules.forEach(rule => {
    //if (rule.test.test(resource)) {
    if (resource.match(rule.test)) {
        if (rule.enforce === 'pre') {
            preLoaders.push(...rule.use);
        } else if (rule.enforce === 'post') {
            postLoaders.push(...rule.use);
        } else {
            normalLoaders.push(...rule.use);
        }
    }
})

通过增加前缀来控制使用的loader

/**
 * -! noPreAutoLoaders 不要前置和普通loader
 * ! noAutoLoaders 不要普通loader
 * !! noPrePostAutoLoaders 不要前置、后置、普通loader,只要内联
 */
let loaders = [];
if (request.startsWith('!!')) {
    loaders = inlineLoaders;
} else if (request.startsWith('-!')) {
    loaders = [...postLoaders, ...inlineLoaders];
} else if (request.startsWith('!')) {
    loaders = [...postLoaders, ...inlineLoaders, ...preLoaders];
} else {
    loaders = [...postLoaders, ...inlineLoaders, ...normalLoaders, ...preLoaders];
}

将loader名称转换为绝对路径

//用于把loader的名称转变成一个绝对路径
const resolveLoader = loader => path.resolve(__dirname, 'runner', loader);
loaders = loaders.map(resolveLoader);

执行所有loader的pitch方法

如果pitch有返回值,则不会再去取文件内容,接下来会直接执行上一个loader的normal方法

关于pitch需要注意的点:

  • 比如 a!b!c!module, 正常调用顺序应该是 c、b、a,但是真正调用顺序是 a(pitch)、b(pitch)、c(pitch)、c、b、a,如果其中任何一个 pitching loader 返回了值就相当于在它以及它右边的 loader 已经执行完毕
  • 比如如果 b 返回了字符串"result b", 接下来只有 a 会被系统执行,且 a 的 loader 收到的参数是 result b
  • loader 根据返回值可以分为两种,一种是返回 js 代码(一个 module 的代码,含有类似 module.export 语句)的 loader,还有不能作为最左边 loader 的其他 loader(这种loader其实就是 '返回的字符串中没有模块化规范相关的js代码' 的模块)
  • 有时候我们想把两个第一种 loader chain 起来,比如 style-loader!css-loader! 问题是 css-loader 的返回值是一串 js 代码,如果按正常方式写 style-loader 的参数就是一串代码字符串
  • 为了解决这种问题,我们需要在 style-loader 里执行 require(css-loader!resources)

执行所有loader的normal方法

runLoaders({
    resource,//要加载和转换的模块
    loaders,//是一个绝对路径的loader数组
    context: { name: 'zhufeng' },//loader的上下文对象
    readResource: fs.readFile.bind(fs)//读取硬盘上资源的方法
}, (err, result) => {
    console.log(err);//运行错误
    console.log(result);//转换后的结果
    //resourceBuffer 是buffer格式的源代码的内容,如果是pitch返回的,没有读取源文件,那么它就是null
    if (result.resourceBuffer) {
        console.log(result.resourceBuffer.toString('utf8'));//最初始的转换前的源文件内容
    }
});

loader中的pitch可有可无,但normal必须要有

loader可以返回一个值 ,也可以返回多个值

返回一个值的话可以return 返回多个值的话,必须 this.callback(err,传递给下一个loader的参数)

// loader接收1个参数时的情况:
function loader(inputSource){
    const { getOptions } = require("loader-utils");
    let options = getOptions(this) || {};
    options.filename=path.basename(this.resourcePath);
    let {code,map,ast} = babel.transform(inputSource,options);
    return this.callback(null,code,map,ast);
}

// loader接收多个参数时的情况:
function loader(code,map,ast){
    const source = 'xxxx'
    // xxxxx
    // 传给下一个loader的参数是source
    return this.callback(source);
}

pitch和normal都在runLoaders方法中进行

loader中的this,默认是调用runLoaders时传的context:

runLoaders({
    resource,//将要加载和转换的模块路径
    loaders,//使用哪些loader来进行转换 8
    context:{name:'zhufeng'},//上下文对象 一般来说没有用
    readResource:fs.readFile//你可以自定义读取文件的方法
},(err,result)=>{
    console.log(err);
    console.log(result);
    //console.log(result.resourceBuffer.toString('utf8'));
});

但是在loader-runner执行的过会给context增加很多的方法和属性

\

babel-loader使用及实现:

如果希望代码经过babel转换之后,调试时还可定位到自己写的源码中,需要加上下面的options的sourceMaps

{
  test:/.js$/,
  use:[{
    loader:'babel-loader',
    options:{
      presets:["@babel/preset-env"],
      //如果这个参数不传,默认值false,不会生成sourceMap
      sourceMaps:true,
      // filename是sourceMap映射过后在浏览器控制台的Source里面出现的文件名
      // 这个文件名通常会在插件内部填上,因为每个文件都有不同的名字
      // filename:'xxx'
    }
  }]
},

babel-loader的实现:

const babel = require('@babel/core');
/**
 * babel-loader只是一个转换JS源代码的函数
 * @param {*} source 接收一个source参数
 * 返回一个新的内容
 */
function loader(source) {
    let options = this.getOptions({})
    let { code } = babel.transform(source, options);
    return code;//转换成ES5的内容
}
module.exports = loader;
/**
 * babel-loader
 * @babel/core 真正要转换代码从ES6到ES5需要靠 @babel/core
 *      babel/core本身只能提供从源代码转成语法树,遍历语法树,从新的语法树重新生成源代码的功能
 * babel plugin
 *     转换箭头函数的插件 plugin-babel-transform-arrow-functions
 *     插件知识如何转换语法树
 * babel preset
 *    单个配置插件太多太繁琐,所以可以把常用的插件打个包,起个名字进行配置比较方便
 *
 * 插件  保存的时候 reuqire => require
 */

file-loader使用及实现:

默认情况下loader接收的参数是字符串类型,即上一个loader传给当前loader的内容,或者是源文件的内容

如果用file-loader将图片从src目录拷贝到dist目录中,希望loader接收的参数是Buffer类型的,可以给loader加一个静态属性loader.raw = true

{
  test:/.(jpg|gif|png)$/,
  use:[
    {
      loader:'file-loader',
      options:{
        filename:'[hash].[ext]'
      }
    }
  ]
},

file-loader的内容:

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

function loader(content){
    let options = getOptions(this)||{};//{filename}
    //this=loaderContext filename=文件名生成模板[hash].[ext] content是文件的内容
    let filename = interpolateName(this,options.filename,{content});
    //向输出目录里输出一个文件
    //loaderRunner给的一个方法
    this.emitFile(filename,content);//  compilation.assets[filename]=content;
    //最后一个loader肯定要返回一个JS模块代码,导出一个值,这个值将会成为此模块的导出结果 
    return `module.exports=${JSON.stringify(filename)}`;
}
loader.raw =true;
module.exports = loader;

url-loader使用及实现:

和file-loader相比,url-loader多了limit参数

{
  test:/.(jpg|gif|png)$/,
  use:[
    {
      loader:'url-loader',
      options:{
        filename:'[hash].[ext]',
        limit:800*1024,
        fallback:path.resolve('./loaders/file-loader.js')
      }
    }
  ]
},

url-loader:

const { getOptions,interpolateName } = require("loader-utils");
const mime = require('mime');
/**
 * 
 * @param {*} content 上一个loader传给当前loader的内容,或者是源文件的内容
 * content默认是字符串 先把content转换字符串给loader,
 * 如果你希望得到Buffer,不希望转成字符串传递给你
 */
function loader(content){
    let options = getOptions(this)||{};//{filename}
    let {limit,fallback} = options;
    if(limit)
        limit = parseInt(limit,10);
    const mimeType = mime.getType(this.resourcePath);   
    if(!limit || Buffer.byteLength(content) < limit){
        let base64 = `data:${mimeType};base64,${content.toString('base64')}`;
        return `module.exports=${JSON.stringify(base64)}`
    }else{
        //require('file-loader');//会去node_modules里找 require跟webpack没关系,走的原生的node模块查找逻辑
        return require(fallback).call(this,content);
    }
}
loader.raw =true;
module.exports = loader;

注意:webpack5以后,对图片类文件资源的处理,将不再使用loader,而走了asset/resource的配置:

module.exports = {
  mode: 'development',
  devtool: false,
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
    publicPath: '/'
  },
  module: {
    rules: [
      { test: /.txt$/, use: 'raw-loader' },
      { test: /.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] },
      { test: /.less$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'] },
      { test: /.scss$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'] },
      {
        test: /.(jpg|png|gif|bmp|svg)$/,
        type:'asset/resource',
        generator:{
          filename:'images/[hash][ext]'
        }
      }
    ]
  },

less-loader实现:

默认情况下loader的执行是同步的,如果调用了async方法,可以把loader的执行变成异步

也可以直接调用this.callback实现异步

{
  test:/.less$/,
  use:[
    'style-loader',//生成一段JS脚本,向页面插入style标签,style的内容就是css文本
    'less-loader'//把less编译成css
  ]
}
let less = require('less');
/**
 * 希望这个loader可以放在最左侧
 * @param {*} inputSource 
 *   传入的参籹
 *   如果是最后在的或者说最右边的loader,参数就是模块的内容
 *   如果不是最后一个,参数就是上一个loader返回的内容
 */
function loader(inputSource) {
    console.log('less-loader');
    //默认情况下loader的执行是同步的,如果调用了async方法,可以把loader的执行变成异步
    let callback = this.async();
    //this.callback
    //写死的,就是同步
    less.render(inputSource, { filename: this.resource }, (err, output) => {
      	// 如果直接把output.css返回的话,就不是模块化代码了,在webpack的配置文件中,
      	// 就不能写在rules.use数组中的最左侧了
      	// callback(null, output.css);
        // 因此在实际的源码中less-loader其实返回的是一段JS脚本,来保证它可以放在最左侧
        callback(null, `module.exports = ${JSON.stringify(output.css)}`);
    });
}
module.exports = loader;

style-loader实现:

缩水版:

function loader(inputSource){
   let script = `
      let style = document.createElement('style');
      style.innerHTML = ${JSON.stringify(inputSource)};
      document.head.appendChild(style);
   `;
   return script;
}
module.exports = loader

完整版:

let loaderUtils = require('loader-utils');
/**
 * @param {*} inputSource less-loader编译后的CSS内容
 * inputSource  `module.exports = "#root{color:red}"`
 */
function loader(inputSource){
   /* let script = `
      let style = document.createElement('style');
      style.innerHTML = ${JSON.stringify(inputSource)};
      document.head.appendChild(style);
   `;
   return script; */
}
/**
 * 如果pitch函数有返回值,不需要于执行后续的loader和读文件了
 * @param {*} remainingRequest 
 * @param {*} previousRequest 
 * @param {*} data 
 * @returns 
 */
loader.pitch = function(remainingRequest,previousRequest,data){
   console.log('remainingRequest',remainingRequest);//less-loader.js!index.less
   console.log('previousRequest',previousRequest);//""
   console.log('data',data);//{}
   let script = `
      let style = document.createElement('style');
      style.innerHTML = require(${loaderUtils.stringifyRequest(this,"!!"+remainingRequest)});
      document.head.appendChild(style);
   `;
   //这个返回的JS脚本给了webpack了
   //把这个JS脚本转成AST抽象语法树,分析里的require依赖
   //
   return script;
}
module.exports = loader;
/**
 * [style-loader,less-loader]
 * request = style-loader!less-loader!index.less
 * previousRequest = ''
 * remainingRequest=less-loader!index.less
 * !!less-loader!index.less
 * stringifyRequest 把绝对路径转成相对路径
 * remainingRequest C:\less-loader.js!C:\index.less
 * 相对于根目录的路径 类似于此模块的ID
 * !!./loaders/less-loader.js!./src/index.less
 * loader执行完后会把下面的代码给webpack
 *  let style = document.createElement('style');
    style.innerHTML = require("!!./loaders/less-loader.js!./src/index.less");
    document.head.appendChild(style);
    webpack会去分析依赖
    !!的前缀代表只要行内或者说是内联,不要前置后置和普通
    
 * 
 * 
 */