阅读 2743

webpack loader—自己写一个按需加载插件

写在前面:

在开发的工程中,线上环境需要引入一些统计和打印日志的js文件。但是对于开发环境,加速打包速度减少页面渲染时间很关键。我于是想根据开发环境,写一个简单的loader,按需加载一些资源。

例如:在index.js中,用自定义函数envLoader添加资源

index.js

//......

envLoader(
    '/vendor/log.js'
)

//......

复制代码

为了完成按需加载的功能。打算使用自定义的loader。 实现思路如下:

  1. 添加js loader 对index.js进行处理
  2. 解析envLoader函数
  3. 拿到传入的参数并根据环境判断是否加载。

结合官网的loader api了解webpack loader的工作原理。

将使用以下api

  • loader-utils
  • schema-utils
  • this.async
  • this.cacheable
  • getOptions
  • validateOptions
  • urlToRequest

开始撸一个自己的loader (^-^)V

Webpack Loader

loader 用于对模块的源代码进行转换。loader 可以使你在 import 或"加载"模块时预处理文件。因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的强大方法。loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript,或将内联图像转换为 data URL。

一、基本用法

loader 是导出为一个函数的 node 模块。该函数在 loader 转换资源的时候调用。给定的函数将调用 loader API,并通过 this 上下文访问。

loader是一个node module,那么它的基本形式如下

module.exports = function(source) {
  return source;
};
复制代码
  • loader只能传入一个包含包含资源文件内容的字符串(source)
  • 如果是同步loader,可以用 return 或者 this.callback(err, value…) 将代码返回
  • 异步loader:在一个异步的模块中,回传时需要调用 Loader API 提供的回调方法 this.async 来获取 callback 函数:
module.exports = function(content, map, meta) {
  var callback = this.async();
  someAsyncOperation(content, function(err, result) {
    if (err) return callback(err);
    callback(null, result, map, meta);
  });
};
复制代码

二、在webpack中引入loader

官网上介绍了配给单个和多个loader的方法。

主要原理是path.resolve方法,给loader添加路径。也可以使用resolveLoader.modules统一配置多个loader的路径。

webpack会在这些目录中搜索loaders,我在项目中新建了loaders本地目录,并修改文件如下:

webpack.config.js

module.exports = {
    //...
    resolveLoader: {// 配置查找loader的目录
        modules: [
            'node_modules',
            path.resolve(__dirname, 'src', 'loaders')
        ]
    },        
  module: {
    rules:[
       {
           test: /\.js$/,
           use: [
               {
                   loader: 'env-loader',
                   options: {
                       env: process.env.NODE_ENV
                   }
               },
               {
                   loader:'babel-loader',
                   options: {
                       presets: ['env','es2015','react'],
                   }
               },
           ]
       }]
    },
    //...
};
复制代码

注意:loader的执行方式是从右到左,链式执行,上一个 Loader 的处理结果给下一个接着处理

在package.json中定义了根据环境打包的命令

  "scripts": {
    "webpack": "cross-env NODE_ENV=development webpack-dev-server --open --mode development",
    "test": "cross-env NODE_ENV=test webpack --mode development",
    "dev": "cross-env NODE_ENV=dev webpack --mode development",
    "prd": "cross-env NODE_ENV=prd webpack --mode development",
    "boot":"cross-env NODE_ENV=boot webpack --mode development"
  },
复制代码

通过设置NODE_ENV来区分dev、prd环境。

Q1:怎么获取命令中设置的环境参数
A1:process.env对象上可以获取到打包时定义的NODE_ENV,在webpack.config.js中引入env-loader的时候,可以将参数传递给 loader 的options选项。

webpack.config.js

{
   loader: 'env-loader',
   options: {
       env: process.env.NODE_ENV
   }
},
复制代码

三、使用loader工具库,解析loader传参

  • loader-utils 包。它提供了许多有用的工具,但最常用的一种工具是获取传递给 loader 的选项
  • schema-utils 包配合 loader-utils,用于保证 loader 选项,进行与 JSON Schema 结构一致的校验

在loader中使用loader-utils包的getOptions方法,拿到loader的option选项({env:'dev'}。用schema-utils 包配合 loader-utils,用于保证 loader 选项,进行与 JSON Schema结构一致的校验。在index.js中添加这两个包:

env-loader/index.js

const loaderUtils = require('loader-utils')
const validate = require('schema-utils');

let json = {
    "type": "object",
    "properties": {
        "content": {
            "type": "string"
        }
    }
}

module.exports = function(source) {
    this.cacheable();
    let callback = this.async();
    let options = loaderUtils.getOptions(this) //{env:'dev'}
    validate(json, options, "env-loader");
}
复制代码

四、使用esprima解析js节点

Esprima parser把js程序转换成描述程序语法结构的语法树(AST)。产生的语法树对于从程序转换到静态程序分析的各种用途都很有用。

之前写过一篇介绍AST的文章 点击链接查看,这里就不详细展开。

使用方法:

esprima.parseScript(input, config, delegate)
esprima.parseModule(input, config, delegate)
复制代码
  • input入是表示要解析的程序的字符串
  • config是用于自定义解析行为的对象(可选)
  • delegate是为每个节点调用的回调函数(可选)

将source作为input参数,程序将会被解析成AST。

node返回每个节点对应的Syntax,meta是节点在程序中的具体位置。

esprima.parseModule(source, {}, async(node, meta)=> {
    console.log(node.meta)
    //....
})
复制代码

解析结果如下:

分析每个节点的Syntax是否满足判断条件,这里判断node的type类型和正在执行的函数callee的name==='envLoader'和type==='Identifier',对满足条件的节点进行处理。

function judgeType(node) {
    return (node.type === 'CallExpression')
        && (node.callee.name === 'envLoader')
        && (node.callee.type === 'Identifier')
}
if (judgeType(node)) {
    flag = true
    node.arguments.map(argument=>{
        entries.push({
            val: argument.value,
            start: meta.start.offset,
            end: meta.end.offset
        });
    })

}
复制代码

五、文件的路径处理

在节点分析中,拿到了自定义envLoader函数中传入的外部资源地址,接下来要再loader中。

在loader中一般使用require()或者import方法。这是因为webpack是在将模块路径转换为模块id之前计算散列的,所以我们必须避免绝对路径,以确保不同编译之间的哈希一致。

不要在模块代码中插入绝对路径,因为当项目根路径变化时,文件绝对路径也会变化。

loaderUtils.urlToRequest可以将一些资源URL转换为webpack模块请求。
//获取当前路径下的src文件夹
let downloadPath = path.resolve(process.cwd(), 'src')

if(env == 'prd'){
    //如果是prd环境
    //使用loaderUtils将请求转换为module
    const saveUrl = loaderUtils.urlToRequest(`${extName}`,downloadPath);// "path/to/module.js"
    //将转换好的module引入
    var replaceText = `import "${saveUrl}"`
}else{
    //其他环境
    var replaceText = 'function envLoad(){}'
}
    
//将envLoader函数替换
source = source.replace(transText, replaceText);
复制代码

六、测试

完成上面的步骤,已经开发完成了一个简单的loader,并且可以在本地运行。接下来让我们用一个简单的单元测试,来保证 loader 能够按照我们预期的方式正确运行。

我们将使用 Jest 框架。然后还需要安装 babel-jest 和允许我们使用 import / export 和 async / await 的一些预设环境(presets)。

6.1 安装依赖

npm install --save-dev jest babel-jest babel-preset-env
复制代码

.babelrc

{
  "presets": [[
    "env",
    {
      "targets": {
        "node": "4"
      }
    }
  ]]
}
复制代码

我们的 loader 将会处理 .js 文件,并且将任何实例中的

envLoader('xxx')
复制代码

在开发环境下替换成function envLoad(){},在生产环境下替换成 import '路径/xxx.js'。

在test文件夹下新建example.js

envLoader(
    '/vendor/lodash.min.js'
)
复制代码

我们将会使用 Node.js API 和 memory-fs 去执行 webpack。

npm install --save-dev webpack memory-fss
复制代码

test/compiler.js

import path from 'path';
import webpack from 'webpack';
import memoryfs from 'memory-fs';

export default (fixture, options = {}) => {
    const compiler = webpack({
        context: __dirname,
        entry: `./${fixture}`,
        output: {
            path: path.resolve(__dirname),
            filename: 'bundle.js',
        },
        module: {
            rules: [{
                test: /\.js$/,
                use: {
                    loader: path.resolve(__dirname, '../src/loaders/env-loader'),
                    options: {
                        env: process.env.NODE_ENV
                    }
                }
            }]
        }
    });

    compiler.outputFileSystem = new memoryfs();

    return new Promise((resolve, reject) => {
        compiler.run((err, stats) => {
            if (err || stats.hasErrors()) reject(err);

            resolve(stats);
        });
    });
};
复制代码

最后,我们来编写测试,并且添加 npm script 运行它。

import compiler from './compiler.js';

test('envLoader to import', async () => {
    const stats = await compiler('example.js');
    const output = stats.toJson().modules[0].source;
    if(process.env.NODE_ENV == 'prd'){
        expect(output).toBe('import "/Users/yuan/Documents/yuanyuan/Project/env-loader/src/vendor/lodash.min.js"');
    }else{
        expect(output).toBe('function envLoad(){}');
    }
});
复制代码

package.json

{
  "scripts": {
    "test-boot": "cross-env NODE_ENV=boot jest",
    "test-prd": "cross-env NODE_ENV=prd jest"
  }
}
复制代码

分别运行两个script

各自验证成功~测试通过

env-loader地址 详细实现过程点这里!!