Webpack的loader原理和实现

2,217 阅读5分钟

Webpack的loader的原理

上一篇文章中,我们讲到了webpack的执行原理Webpack5的打包分析,这篇文章我们讲解一下webpack是如何处理一些非js文件的内容的。本系列一共分为三篇

  1. Webpack5的打包分析
  2. Webpack的loader的实现
  3. Webpack的事件流和插件的原理

Loader的原理

我们知道在webpack内部是一切皆模块的概念,那我们如果处理不是js文件的内容呢?

/* 
目录结构
webpack-loader-study/src
├── a.js
├── src
	├── index.js
	├── index.css
├── public
	├── index.html
*/
// index.js
import "./index.css"
console.log('hello world')

我们的webpack配置更加简单

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  mode: 'development',
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'build.js'
  },
  plugins: [
      new HtmlWebpackPlugin({
        template: './public/index.html'
      })
  ]
}

当我们运行npx webpack的时候,会发现会抱一个错误, 发现webpack并不认识我们的css文件,所以这就需要一定的loader来处理不是js的文件内容

ERROR in ./src/index.css 1:5 Module parse failed: Unexpected token (1:5) You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. 在这个例子中我们可以通过css-loader来处理,使得我们的webpack可以处理css文件, 我们这样处理了后,发现css文件确实不报错了,但是并没有产生效果,这是因为 css-loader 只是单纯的把css文件转换成字符串,并没有作其他的逻辑,所以我们还需要把字符串提取出来到style中或者单独的文件中

  module: {
    rules: [
      {
        test: /\.css$/,
        loader: 'css-loader'
      }
    ]
  }
## 修改成
+  module: {
+    rules: [
+      {
+        test: /\.css$/,
+        use: ['style-loader', 'css-loader']
+      }
+    ]
+  }

自定义loader的开发

  1. loader是一个函数,接受一个源代码为参数
  2. loader不能是一个箭头函数,因为需要通过this上下文传递参数
  3. loader同步处理源码的时候必须要有返回值, 返回值必须是一个buffer或者string,异步的需要调用特定的函数
// 同步的loader 直接返回内容
module.exports = function (source) {
  console.log(source)
  return source.replace('hello', 'hcc')
}
// 通过this.callback实现同步的loader, 可以增加一些参数(错误信息,资源,sourcemap)
module.exports = function (source) {
  console.log(source)
  let newSource = source.replace('hello', 'hcc-1-2')
  return this.callback(null, newSource)
}

如果有一些异步的操作,去替换源代码,那么如果我们直接这样的话,会报错,因为它认为loader执行完了,没有返回值(这里和jest测试异步的原理很像)

module.exports = function (source) {
  let newSource = source.replace('hello', 'hcc-1-2')
  setTimeout(() => {
    return this.callback(null, newSource)
  }, 2000)
}

所以我们需要让它知道我们的处理没有完,可以通过this.async返回的callback进行处理,主动的去通知loader处理是否完成

module.exports = function (source) {
  let callback = this.async()
  let newSource = source.replace('hello', 'hcc-2-2')
  setTimeout(() => {
    return callback(null, newSource)
  }, 2000)
}

手写常用的loader

上面我们大致可以知道loader的用途和使用,接下来我们将手写一些loader来处理一些文件,方便大家加深对loader的理解

  1. 基于js的babel-loader
  2. 基于图片和文件的file-loader, url-loader
  3. 基于样式的style-loader, css-loader, less-loader

基于js的兼容处理

我们知道当我们书写ES7等高级语法的时候,低端浏览器存在兼容问题,所以我们需要通过babel-loader进行处理,将高端语法转换成低端语法

// index.js 中我们写一个class类
class A {
  getName() {
    console.log('name')
  }
}

接下来,我们自己来实现一个babel-loader, 平时我们使用的时候,会直接执行下面一句话,但是有很多疑问

  1. 为什么我没有使用到@babel/core但是也要安装依赖呢?
  2. @babel/preset-env 的作用是什么,为什么要在options中使用
npm install -D babel-loader @babel/core @babel/preset-env

原生的效果

  1. webpack.config.js中配置loader去处理js文件
+      {
+        test: /\.jsx?$/,
+        use: {
+          loader: 'babel-loader',
+          options: {
+            presets: [
+                '@babel/preset-env'
+            ]
+          }
+        }
+      }

当我们运行npx webpack的时候,查看输出,发现class被转义成了下面这样

function A() {
    _classCallCheck(this, A);
}

实现babel-loader

  1. 我们在loader目录下创建一个hcc-babel-loader.js文件
  2. webpack.config.js中同样的配置loader去处理js文件
      {
        test: /\.jsx?$/,
        use: {
_         loader: 'babel-loader',
+         loader: 'hcc-babel-loader',
          options: {
            presets: [
                '@babel/preset-env'
            ]
          }
        }
      }
  1. hcc-babel-laoder中通过Babel的核心模块(@babel/core)去处理代码
let babel = require('@babel/core')
let loaderUtils = require('loader-utils')
module.exports = function(source) {
  let cb = this.async()
  let options = loaderUtils.getOptions(this) // 获取到传入的options
  babel.transform(source, { // babel.transform是一个异步操作
    ...options,
  }, (err, result) => {
    cb(err, result.code)
  })
}
  1. 这样打包后的代码就是经过babel转义后的代码,但是我们打包后没有source-map的配置,需要处理一下可以输出源码映射,再webpack.config.js中需要打开devtool: source-map
// 通过@babel/core 来进行代码转换
let babel = require('@babel/core')
let loaderUtils = require('loader-utils')
module.exports = function(source) {
  let cb = this.async()
  let options = loaderUtils.getOptions(this) // 获取到传入的options
  babel.transform(source, { // babel.transform是一个异步操作
    ...options,
+   sourceMaps: true
  }, (err, result) => {
+   cb(err, result.code, result.map)
  })
}

实现file-loader 和 url-loader

webpack.config.js 中增加图片的处理

  {
    test: /\.(png|jpe?g|gif)(\?.*)?$/,
    loader: 'hcc-file-loader'
  }

入口文件中添加如下代码

import pic from './pic/hcc.jpg'
let image = new Image()
image.src = pic
document.body.appendChild(image)

在写file-loaderurl-loader之前有几个概念需要说明下

  1. url-loader包括file-loader, 图片必须转换成二进制之后才可以进行处理**loader.raw = true;**。
  2. file-laoder的原理是,当webpack发现处理的是图片等后缀的时候,处理图片的内容,然后导出一个打包后的地址,当我们引入图片的时候,就是引入了打包后地址
  3. 我们新建一个hcc-file-loader的文件,
const loaderUtils = require('loader-utils')
function loader(source) {
  let filename = loaderUtils.interpolateName(this, '[hash].[ext]', {
    content:source
  })
  console.log(filename)
  this.emitFile(filename, source) // 发送文件到dist目录下
  return `module.exports = "${filename}"`
}
// 把图片转换成二进制
loader.raw = true;
module.exports = loader

在这里我们需要导出文件(module.export = ...),因为在index.js中 有一个pic变量需要引入转换后的内容, 这里不注意很可能会出问题。

上面我们已经处理完了file-loader, 接下来我们基于file-loader来实现url-loader

  1. 增加url-loader的配置
      {
        test: /\.(png|jpe?g|gif)(\?.*)?$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 20000
          }
        }
      }
  1. 创建一个hcc-url-loader来进行文件大小的处理,如果文件大于2k就利用file-loader单独创建一个文件,如果小于20k的话转换成base64的文件
const loaderUtils = require('loader-utils')
const mime = require('mime');
function loader(source) {
  const { limit } = loaderUtils.getOptions(this);
  console.log(source.length)
  let size = source.length // 获取文件大小
  // 使用file-laoder 处理文件
  if(limit && size > limit) {
    return require('./hcc-file-loader').call(this, source) // 注意传递上下文
  } else {
    // 返回base64
    return `module.exports = "data:${mime.getType(this.resourcePath)};base64,${source.toString('base64')}"`;
  }
}
loader.raw = true
module.exports = loader

处理样式

基于样式处理分为主要下面三个loaderstyle-loader, css-loader, less-loader

  1. 一个less文件会首先经过less-loader, 把less语法转换成css语法
  2. 然后通过css-loader 进行css 处理,遇到css中使用图片需要通过url-loader来处理
  3. 通过style-loader插入到模板的style标签中
实现
  1. 我们现在webpack.config.js中创建对应的loader来处理less文件
  {
    test: /\.less$/,
    use: {
      loader: ['hcc-style-loader', 'hcc-css-loader', 'hcc-less-loader']
    }
  }
  1. 创建一个index.less文件,然后在入口文件index.js中引入
body {
  background: red;
  #app{
    color: skyblue;
  }
}

index.js文件

import './index.less'
  1. hcc-less-loader来利用less的render将less内容转换成css内容, 所以我们之前使用的less-loader的时候, 需要安装less依赖,是因为less-loader中使用了它来转换语法
let less = require('less')
module.exports = function(source) {
  let result = ""
  less.render(source, null, (err, output) => {
    result = output.css
  })
  return result
}
  1. hcc-css-loader来将css输出,这里进行简单的处理,之后再详细介绍问题
module.exports = function(source) {
  return source
}
  1. hcc-style-loader来将css存放在新建的style的标签中
module.exports = function (source) {
  return `let style = document.createElement('style')
    style.innerHTML = ${JSON.stringify(source)} // 注意格式化代码,换行符保留
    document.head.appendChild(style)`
}

问题

如果我们把index.less中引入图片的内容,这样就会出现图片加载不到的问题,因为css-loader没有做任何处理,当传递给style-loader的时候打包后,在dist目录找不到对应的图片位置

body {
  background: url("./pic/hcc.jpg");
  #app{
    color: skyblue;
  }
}

所以我们需要对css-loader进行改造,需要把上面的url的图片地址通过require来执行,这样在打包的时候,就会通过url-loader来处理图片的内容,然后发送到dist目录下

body {
 background: url(require('./pic/hcc.jpg'))
}
body #app {
 color: skyblue;
}

但是我们这样修改了,还有一个问题,require需要在运行时执行,但是style-loader的执行后是在编译阶段,所以会有一个问题,css-loader处理后的值,在style-loader中没用这里我们就需要一些额外的处理。首先我们需要了解loader的执行机制

loader的执行机制

  1. 如果我们这样的书写use: ['loader1', 'loader2', 'loader3'], 正常情况下真是的loader是这样执行的,会优先进过loader的pitch, 然后依次的向后执行,但是我们一般都省略了pitch,所以loader在我们理解中,就是从左到右开始执行
  2. 当我们书写了pitch的话,loader的执行机制会发生变化,如果pitch有返回值的话,它会跳过之后的执行直接跳入上一个的正常的loader,loader链就会中断。例如loader2中我们使用了loader2.pitch并返回了内容的话,就会变成下面的执行过程 有了这个知识的话,我们可以通过动态引入css-loader 来实现

最后的实现

  1. style-loader中增加一个pitch,跳过之后的loader处理
    • pitch有一个参数,里面有剩余没有执行的loader和源文件
    • pitch处理后需要跳过所以的loader,直接运行文件 (!! 跳过 pre、 normal 和 post loader)
    • require() 需要一个相对路径,需要通过loader-utils来进行处理
let loaderUtils = require('loader-utils')
module.exports = function (source) {
  console.log('normal-style-loader', source)
  return ""
}
module.exports.pitch = function(remainingRequest) {
  // E:\hcc\hcc-webpack\webpack-loader-study\loader\hcc-css-loader.js!E:\hcc\hcc-webpack\webpack-loader-study\loader\hcc-less-loader.js!E:\hcc\hcc-webpack\webpack-loader-study\src\index.less
  console.log(remainingRequest)
  console.log('pitch-style-loader')
  // 通过!!跳过剩下的 loader, 相当于直接执行require('index.less')
  return `
    let style = document.createElement('style')
    style.innerHTML = require(${loaderUtils.stringifyRequest(this, '!!' + remainingRequest)})
    document.head.appendChild(style)`
}
  1. css-loader中的需要处理图片的require拼接
module.exports = function(source) {
  console.log('css-normal-loader', source)
  let arr = ['let list = []']
  let pos = 0
  let reg = /url\((.+?)\)/g
  let current;
  while(current = reg.exec(source)) {
    let [matchUrl, group] = current
    // console.log(matchUrl, group)
    let last = reg.lastIndex - matchUrl.length;
    arr.push(`list.push(${JSON.stringify(source.slice(pos, last))})`)
    pos = reg.lastIndex
    // 注意这里的字符串拼接
    arr.push(`list.push('url(' + require(${group}) +')')`)
  }
  arr.push(`list.push(${JSON.stringify(source.slice(pos))})`)
  arr.push(`module.exports = list.join('')`)
  console.log(arr.join('\r\n'))
  return arr.join('\r\n')
}
  1. less-loader不需要有任何改变还是之前的内容
let less = require('less')
module.exports = function(source) {
  let result = ""
  less.render(source, null, (err, output) => {
    result = output.css
  })
  return result
}
  1. 修改之后的运行路径就是这样的
    • 首先执行hcc-style-loaderpitch,然后执行require(index.less)进入webpack的less文件的处理
    • css-loader执行后通过module.exports 导出经过require处理后的模块
    • style-loadr中添加到style标签的innerHTML中,然后渲染到页面样式