手把手教你在Webpack写一个Loader

前言
有的时候,你可能在从零搭建 Webpack 项目很熟悉,配置过各种 loader ,面试官在 Webpack 方面问你,是否自己实现过一个loader?如果没有去了解过如果去实现,确实有点尴尬,其实呢,loader实现其实很简单的。下面说下loader是什么?

为什么需要Loader?
Webpack 它只能处理 js 和 JSON 文件。面对 css 文件还有一些图片等等,Webpack 它自己是不能够处理的,它需要loader 处理其他类型的文件并将它们转换为有效的模块以供应用程序使用并添加到依赖关系图中,

Loader是什么?
loader本质上是一个node模块,符合Webpack中一切皆模块的思想。由于它是一个 node 模块,它必须导出一些东西。loader本身就是一个函数,在该函数中对接收到的内容进行转换,然后返回转换后的结果

下面小浪为你简单介绍下webpack中的loader

常见的loader
我们先来回顾下常见的 Loader 基础的配置和使用吧(仅仅只是常见的,npm上面开发者大佬们发布的太多了)

那么开始吧,首先先介绍 处理 CSS 相关的 Loader

css-loader 和 style-loader
安装依赖

npm install css-loader style-loader
复制代码
使用加载器

module.exports = {
    // ...
    module: {
        rules: [{
            test: /.css$/,
            use: ['style-loader', 'css-loader'],
        }],
    },
};
复制代码
其中module.rules代表模块的处理规则。每个规则可以包含很多配置项

test 可以接收正则表达式或元素为正则表达式的数组。只有与正则表达式匹配的模块才会使用此规则。在此示例中,/.css$/ 匹配所有以 .css 结尾的文件。

use 可以接收一个包含规则使用的加载器的数组。如果只配置了一个css-loader,当只有一个loader时也可以为字符串

css-loader 的作用只是处理 CSS 的各种加载语法(@import 和 url() 函数等),如果样式要工作,则需要 style-loader 将样式插入页面

style-loader加到了css-loader前面,这是因为在Webpack打包时是按照数组从后往前的顺序将资源交给loader处理的,因此要把最后生效的放在前面

还可以这样写成对象的形式,里面options传入配置

module.exports = {
    // ...
    module: {
        rules: [{
            test: /.css$/,
            use: [
                'style-loader',
                  {
                    loader: 'css-loader',
                    options: {
                        // css-loader 配置项
                 },
               }
            ],
        }],
    },
};
复制代码
exclude与include

include代表该规则只对正则匹配到的模块生效

exclude的含义是,所有被正则匹配到的模块都排除在该规则之外

rules: [
    {
        test: /.css$/,
        use: ['style-loader', 'css-loader'],
        exclude: /node_modules/,
        include: /src/,
    }
],

复制代码
是否都还记得呢,现在有现成的脚手架,很多人都很少自己去配置这些了,欸~当然还有相关的 sass/less等等预处理器loader这里就不一一介绍了。

babel-loader
babel-loader 这个loader十分的重要,把高级语法转为ES5,常用于处理 ES6+ 并将其编译为 ES5。它允许我们在项目中使用最新的语言特性(甚至在提案中),而无需特别注意这些特性在不同平台上的兼容性。

介绍下主要的三个模块

babel-loader:使 Babel 与 Webpack 一起工作的模块
@babel/core:Babel核心模块。
@babel/preset-env:是Babel官方推荐的preseter,可以根据用户设置的目标环境,自动添加编译ES6+代码所需的插件和补丁
安装

npm install babel-loader @babel/core @babel/preset-env
复制代码
配置

rules: [
  {
    test: /.js$/,
    exclude: /node_modules/, //排除掉,不排除拖慢打包的速度
    use: {
      loader: 'babel-loader',
      options: {
        cacheDirectory: true, // 启用缓存机制以防止在重新打包未更改的模块时进行二次编译
        presets: [[
          'env', {
            modules: false, // 将ES6 Module的语法交给Webpack本身处理
          }
        ]],
      },
    },
  }
],
复制代码
html-loader
Webpack 可不认识 html,直接报错,需要loader转化

html-loader 用于将 HTML 文件转换为字符串并进行格式化,它允许我们通过 JS 加载一个 HTML 片段。

安装

npm install html-loader
复制代码
配置

rules: [
    {
        test: /.html$/,
        use: 'html-loader',
    }
],
复制代码
// index.js
import otherHtml from './other.html';
document.write(otherHtml);
复制代码
这样你可以在js中加载另一个页面,写刀当前index.html里面

file-loader
用于打包文件类型的资源,比如对png、jpg、gif等图片资源使用file-loader,然后就可以在JS中加载图片了

安装

npm install file-loader
复制代码
配置

const path = require('path');
module.exports = {
    entry: './index.js',
    output: {
        path: path.join(__dirname, 'dist'),
        filename: 'bundle.js',
    },
    module: {
        rules: [
            {
                test: /.(png|jpg|gif)$/,
                use: 'file-loader',
            }
        ],
    },
};
复制代码
url-loader
既然介绍了 file-loader 就不得不介绍 url-loader,它们很相似,但是唯一的区别是用户可以设置文件大小阈值。大于阈值时返回与file-loader相同的publicPath,小于阈值时返回文件base64编码。

安装

npm install url-loader
复制代码
配置

rules: [
    {
        test: /.(png|jpg|gif)$/,
        use: {
            loader: 'url-loader',
            options: {
                limit: 1024,
                name: '[name].[ext]',
                publicPath: './assets/',
            },
        },
    }
],
复制代码
ts-loader
TypeScript使用得越来越多,对于我们平时写代码有了更好的规范,项目更加利于维护...等等好处,我们也在Webpack中来配置loader,本质上类似于 babel-loader,是一个连接 Webpack 和 Typescript 的模块

安装

npm install ts-loader typescript
复制代码
loader配置,主要的配置还是在 tsconfig.json 中

rules: [
    {
        test: /.ts$/,
        use: 'ts-loader',
    }
],
复制代码
vue-loader
用来处理vue组件,还要安装vue-template-compiler来编译Vue模板,估计大家大部分都用脚手架了

安装

npm install vue-loader  vue-template-compiler
复制代码
rules: [
    {
        test: /.vue$/,
        use: 'vue-loader',
    }
],
复制代码





写一个简单的Loader
介绍了几个常见的loader的安装配置,我们在具体的业务的实现的时候,可能遇到各种需求,上面介绍的或者npm上都没有的加载器都不适合当前的业务场景,那我们可以自己去实现一个自己的loader来满足自己的需求,小浪下面介绍一下如何自定义一个loader

1.初始化项目
初始化项目

先创建一个项目文件夹(名字可以随意,当然肯定是英文名)后进行初始化

npm init -y
复制代码
安装依赖

安装依赖:Webpack 和 Webpack脚手架 和 热更新服务器

不同的版本 Webpack 可能有些差异,如果你跟着我的这个例子写的话,小浪建议和我装一样的版本

npm install webpack@4.39.2 webpack-cli@3.3.6 webpack-dev-server@3.11.0 -D
复制代码
新建一个index.html文件

dist/index.html

<!DOCTYPE html>
<html lang="zh-CN">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title></title>
    </head>
    <body>
        <script src="./bundle.js"></script>
    </body>
</html>

复制代码
新建一个入口文件 index.js 文件

src/index.js

document.write('hello world')
复制代码
创建 webpack.config.js 配置文件

配置出口和入口文件

配置devServer服务

const path = require('path')

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.js',
    },
    devServer: {
        contentBase: './dist',
        overlay: {
            warnings: true,
            errors: true,
        },
        open: true,
    },
}
复制代码
在 package.json 中配置启动命令

  "scripts": {
    "dev": "Webpack-dev-server"
  },
复制代码
启动 npm run dev

devServer帮我们启动一个服务器,每次修改index.js不需要自己在去打包,而是自动帮我们完成这项任务

页面内容就是我们index.js编写的内容被打包成在dist/bundle.js引入到index.html了








当前的文件目录

Webpack-demo
 ├── dist
 │   └── index.html
 ├── package-lock.json
 ├── package.json
 ├── src
 │   └── index.js
 └── Webpack.config.js
复制代码
2.实现一个简单的 loader
在 src/MyLoader/my-loader.js

module.exports = function (source) {
    // 在这里按照你的需求处理 source
    return source.replace('word', ', I am Xiaolang')
}
复制代码
返回其它结果 this.callback

this.callback(    
    // 当无法转换原内容时,给 Webpack 返回一个 Error   
    err: Error | null,    
    // 原内容转换后的内容    
    content: string | Buffer,    
    // 用于把转换后的内容得出原内容的 Source Map,方便调试
    sourceMap?: SourceMap,    
    // 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回,以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能
    abstractSyntaxTree?: AST
);
复制代码
打开代码对应的source-map,方便调试源代码。source-map 可以方便实际开发者在浏览器控制台查看源代码。如果不处理source-map,最终将无法生成正确的map文件,在浏览器的开发工具中可能会看到混乱的源代码。

为了在使用 this.callback 返回内容时将 source-map 返回给 Webpack

loader 必须返回 undefined 让 Webpack 知道 loader 返回的结果在 this.callback 中,而不是在 return

module.exports = function(source) {
    // 通过 this.callback 告诉 Webpack 返回的结果
    this.callback(null, source.replace('word', ', I am Xiaolang'), sourceMaps);   
    return;
};
复制代码
常用加载本地 loader 两种方式

1.path.resolve

使用 path.resolve 指向这个本地文件

const path = require('path')

module.exports = {
    module: {
        rules: [
            {
                test: /.js$/,
                use: path.resolve('./src/myLoader/my-loader.js'),
            },
        ],
    },
}

复制代码
2.ResolveLoader

先去 node_modules 项目下寻找 my-loader,如果找不到,会再去 ./src/myLoader/ 目录下寻找。


module.exports = {
 //...
    module: {
        rules: [
            {
                test: /.js$/,
                use: ['my-loader'],
            },
        ],
    },
    resolveLoader: {
        modules: ['node_modules', './src/myLoader'],
    },
}

复制代码
一个 loader的职责是单一的,使每个loader易维护。

如果源文件需要分多步转换才能正常使用,通过多个Loader进行转换。当调用多个loader进行文件转换时,每个loader都会链式执行。

第一个loader会得到要处理的原始内容,将前一个loader处理的结果传递给下一个。处理完毕,最终的Loader会将处理后的最终结果返回给 Webpack

所以,当你写loader记得保持它的职责单一,你只关心输入和输出。








3.option参数
module: {
    rules: [
        {
            test: /.js$/,
            use: [
                {
                    loader: 'my-loader',
                    options: {
                        flag: true,
                    },
                },
            ],
        },
    ],
},
复制代码
那么我们如何在loader中获取这个写入配置信息呢?

Webpack 提供了loader-utils工具






在之前写的loader修改

const loaderUtils = require('loader-utils')
module.exports = function (source) {
    // 获取到用户给当前 Loader 传入的 options
    const options = loaderUtils.getOptions(this)
    console.log('options-->', options)
    // 在这里按照你的需求处理 source
    return source.replace('word', ', I am Xiaolang')
}
复制代码
控制台也打印了出来




4.缓存
如果为每个构建重新执行重复的转换操作,这样Webpack构建可能会变得非常慢。

Webpack 默认会缓存所有loader的处理结果,也就是说,当待处理的文件或者依赖的文件没有变化时,不会再次调用对应的loader进行转换操作

module.exports = function (source) {
    // 开始缓存
    this.cacheable && this.cacheable();
    // 在这里按照你的需求处理 source
    return source.replace('word', ', I am Xiaolang')
}
复制代码
一般默认开启缓存,如果不想Webpack这个loader进行缓存,也可以关闭缓存

module.exports = function (source) {
    // 关闭缓存
    this.cacheable(false);
    // 在这里按照你的需求处理 source
    return source.replace('word', ', I am Xiaolang')
}
复制代码
5.同步与异步
在某些情况下,转换步骤只能异步完成。

例如,您需要发出网络请求以获取结果。如果使用同步方式,网络请求会阻塞整个构建,导致构建非常缓慢。

module.exports = function(source) {    
    // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
    var callback = this.async()
    // someAsyncOperation 代表一些异步的方法
    someAsyncOperation(source, function (err, result, sourceMaps, ast) {
        // 通过 callback 返回异步执行后的结果
        callback(err, result, sourceMaps, ast)
    })
};
复制代码
6.处理二进制数据
默认情况下,Webpack 传递给 Loader 的原始内容是一个 UTF-8 格式编码的字符串。但是在某些场景下,加载器处理的不是文本文件,而是二进制文件

官网例子 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据

module.exports = function(source) {    
    // 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的    
    source instanceof Buffer === true;    
    // Loader 返回的类型也可以是 Buffer 类型的    
    // 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果    
    return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据
module.exports.raw = true;
复制代码
7.实现一个渲染markdown文档loader
安装依赖 md 转 html 的依赖,当然可以选择另外一个模块 marked

我这里使用的 markdown-it

npm install markdown-it@12.0.6 -D
复制代码
辅助工具 用来添加 div 和 class

module.exports = function ModifyStructure(html) {
    // 把h3和h2开头的切成数组
    const htmlList = html.replace(/<h3/g, '$*(<h3').replace(/<h2/g, '$*(<h2').split('$*(')

    // 给他们套上 .card 类名的 div
    return htmlList
        .map(item => {
            if (item.indexOf('<h3') !== -1) {
                return `<div class="card card-3">${item}</div>`
            } else if (item.indexOf('<h2') !== -1) {
                return `<div class="card card-2">${item}</div>`
            }
            return item
        })
        .join('')
}

复制代码
新建一个loader

/src/myLoader/md-loader.js

const { getOptions } = require('loader-utils')
const MarkdownIt = require('markdown-it')
const beautify = require('./beautify')
module.exports = function (source) {
    const options = getOptions(this) || {}
    const md = new MarkdownIt({
        html: true,
        ...options,
    })
    let html = beautify(md.render(source))
    html = `module.exports = ${JSON.stringify(html)}`
    this.callback(null, html)
}
复制代码
这样loader也写完了,this.callback(null, html) 和 return 在这里差不多哈。

html = `module.exports = ${JSON.stringify(html)}`
复制代码
这里解析的结果是一个 HTML 字符串。如果直接返回,也会面临Webpack无法解析模块的问题。正确的做法是把这个HTML字符串拼接成一段JS代码。

这时候我们要返回的代码就是通过module.exports导出这个HTML字符串,这样外界在导入模块的时候就可以接收到这个HTML字符串。

然后在webpack.config.js使用这个加载器

const path = require('path')

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.js',
    },
    module: {
        rules: [
            {
                test: /.js$/,
                use: [
                    {
                        loader: 'my-loader',
                        options: {
                            flag: true,
                        },
                    },
                ],
            },
            {
                test: /.md$/,
                use: [
                    {
                        loader: 'md-loader',
                    },
                ],
            },
        ],
    },
    resolveLoader: {
        modules: ['node_modules', './src/myLoader'],
    },
    devServer: {
        contentBase: './dist',
        overlay: {
            warnings: true,
            errors: true,
        },
        open: true,
    },
}

复制代码
使用

最后在index.js中加载一个md文件,我这里随便整个,新建github的readme.md

document.write('hello word')

import mdHtml from './test.md'
const content = document.createElement('div')
content.className = 'content'
content.innerHTML = mdHtml
document.body.appendChild(content)
复制代码
结果图


目录结构

Webpack-demo
 ├── dist
 │   └── index.html
 ├── package-lock.json
 ├── package.json
 ├── src
 │   ├── index.js
 │   ├── myLoader
 │   │   ├── beautify.js
 │   │   ├── md-loader.js
 │   │   └── my-loader.js
 │   └── test.md
 └── webpack.config.js
复制代码
github仓库地址[1]

结语
感谢大家能看到这里哈~ ,现在打包构建工具也慢慢增多了vue-cli,vite等等,但是 webpack 仍然有一席之地,很多值得学习的地方,继续努力学习~~

来源:小浪努力学前端,https://juejin.cn/post/7100534685134454815

作者:小浪努力学前端



欢迎关注微信公众号 :前端晚间课

更多文章,收录于小程序-互联网小兵