『手写webpack』实现 loader 和 plugin

123 阅读5分钟

loader

上一节我们实现了webpack的核心流程,但是webpack只认识js文件,如果我在main.js中引入了json,就会报错。

import user from './user.json';

image.png

报错的原因是,babel不能处理json文件。所以在source交给babel之前,先进行处理,把不是js的东西转换成js。

function createAsset(filePath) {
  let source = fs.readFileSync(filePath, {
    encoding: 'utf-8'
  });

  // loader对source进行处理

  const ast = parser.parse(source, {
    sourceType: 'module'
  });
}

loader的本质是一个函数,接收source(文件内容),把它转换成js,这样就能被babel处理了。比如我们可以这样实现jsonLoader:

function jsonLoader(source) {
  // console.log('JSON loader');
  
  // 返回的内容是js
  return `export default ${JSON.stringify(source)}`;
}

json-loader就是如此简单!可以看看webpack是怎么实现的:

image.png

我们提供配置文件对象,遍历所有loaders。当use是数组时,要从最后一个loader向前依次处理,靠前的loader依赖靠后loader的处理结果。

export const webpackConfig = {
  module: {
    rules: [
      {
        test: /\.json$/,
        use: [testLoader, jsonLoader]
      }
    ]
  }
};

遍历的逻辑:

  const loaders = webpackConfig.module.rules;
  loaders.forEach(({ test, use }) => {
    if (test.test(filePath)) {
      if (Array.isArray(use)) {
        // 调用reverse,从最后一个loader开始处理
        use.reverse().forEach((loader) => {
          source = loader(source);
        });
      } else {
        source = use(source);
      }
    }
  });

我们在配置文件中使用了两个loader,两个loader分别都会在控制台打印信息,可以验证一下它们的执行顺序:

image.png

这是bundle.js,可见json已经被打包进去了:

image.png

现在我们的webpack就增加了对json的支持,浏览器控制台输出:

image.png

plugin

tapable

在实现plugin之前,需要先了解tapable这个库,简单理解的话,它实现了订阅发布机制,可以使用tap订阅,使用emit触发,当然实际比这复杂一些。

这个库提供了很多类型的Hook,有两种常见的:SyncHook(同步), AsyncParallelHook(异步)。

这些Hook在使用的时候需要实例化,可以传入一个数组,数组中是参数列表。可以在触发时传入参数,在注册的回调函数中可以获取到该参数。

空说无益,还是看个例子吧。accelerate是一个同步hook,接收一个参数speed,调用tap方法注册,回调函数可以获取speed参数。调用call方法触发,并提供speed。

class Car {
  constructor() {
    this.hooks = {
      accelerate: new SyncHook(['newSpeed']),
      calculateRoutes: new AsyncParallelHook(['source', 'target', 'routesList'])
    };
  }

  setSpeed(newSpeed) {
    this.hooks.accelerate.call(newSpeed);
  }
}

const car = new Car();

// 注册
car.hooks.accelerate.tap('test 1', (speed) => {
  console.log('accelerate', speed);
});

// 触发
car.setSpeed(10);

// 输出:'accelarate' 10

异步的钩子也是同理,.tapPromise注册,.promise.then触发。由.promise.then也可以看出,then中的回调函数一定在toPromise resolve/reject之后调用:

import { SyncHook, AsyncParallelHook } from 'tapable';

class Car {
  constructor() {
    this.hooks = {
      calculateRoutes: new AsyncParallelHook(['source', 'target', 'routesList'])
    };
  }

  useNavigationSystemPromise(source, target) {
    const list = ['route 1', 'route 2', 'route 3'];
    return this.hooks.calculateRoutes
      // tapPromise中的回调,就对应这里的promise
      // 那边状态变resolved/rejectd了,才能继续往下
      .promise(source, target, list)
      .then((res) => {
        // res === undefined
        
        // 后
        console.log('promise callback');
      });
  }
}

const car = new Car();

// 注册
car.hooks.calculateRoutes.tapPromise(
  'test 2 promise',
  (source, target, routesList) =>
    new Promise((res, rej) => {
      setTimeout(() => {
        res();
      }, 1000);
    }).then(() => {
      // 先
      console.log('tapPromise', source, target, routesList);
    })
);

// 触发
car.useNavigationSystemPromise([1, 2, 3], 1);

// 输出:
// 一秒后:
// 'tapPromise' [1, 2, 3] 1 ['route 1', 'route 2', 'route 3']
// 'promise callback'

实现

webpack中plugin的实现利用了事件机制。

webpack提供了很多的事件钩子,比如下图的hooks.emit,在输出asset到output目录之前执行。plugin需要实现apply方法,在apply中,调用钩子的.tap方法,完成注册。等webpack运行到钩子规定的那个时间,就会.call调用,触发tap内的回调函数,在回调函数内,通过compilation对象,可以使用webpack提供的一些API,从而影响打包结果。

image.png

我们实现的webpack,没有实现那么多的钩子,只提供一个tapable同步的钩子意思一下就行了:

const hooks = {
  emitFile: new SyncHook(['context'])
};

由于plugin是一个类,需要调用它的apply方法,所以在config中,数组中传的是插件的实例对象。我们实现一个“ChangeOutputPath”插件,作用是改变打包文件的名称(之前叫bundle.js)。上面也说过,apply其实是对tap的封装,作用是完成注册。回调函数怎么写,取决于这个插件的目的。

export class ChangeOutputPath {
  apply(hooks) {
    hooks.emitFile.tap('changeOutputPath', (context) => {
      console.log('changeOutputPath');
      // 可以传入context,然后调用context上的方法,从而修改打包后的文件名
      context.changeOutputPath('./dist/danmo.js');
    });
  }
}

把这个插件加入config:

export const webpackConfig = {
  module: {
    rules: [
      {
        test: /\.json$/,
        use: [testLoader, jsonLoader]
      }
    ]
  },
  plugins: [new ChangeOutputPath()]
};

获取plugins数组,逐个调用其apply方法,完成注册:

function initPlugins() {
  const { plugins } = webpackConfig;
  plugins.forEach((plugin) => {
    plugin.apply(hooks);
  });
}

然后就是用call调用了,因为我们是想修改打包文件的名称,所以在build中,在writeFileSync之前调用call。

function build(graph) {
  // ...

  let outputPath = './dist/bundle.js';

  // 可以在回调函数中获取context,然后调用changeOutputPath方法
  // 这种实现,就类似于webpack官网那个图片中,用compilation上面的方法修改输出结果
  const context = {
    // 修改path
    changeOutputPath(path) {
      outputPath = path;
    }
  };

  // 调用事件,如果事件注册到对应的hook上,此时就会被调用
  hooks.emitFile.call(context);

  fs.writeFileSync(outputPath, code);
}

到这里,mini-webpack也就实现完了。

一点想法

从上面的代码可以推断,webpack提供的多个hooks,在其源码的不同位置调用了call。我们在实现插件时,把我们的回调函数注册到了某个hook上,运行webpack源代码时,这些hook会在不同的时间执行call方法(call写在某些特定的位置上,比如我们的例子中,call写在writeFileSync前面),我们在回调函数中就能获取到此时的状态,并用webpack提供的api去做某些修改。

webpack应该是实例化hook的时候传入参数列表['compilation', 'callback'](不确定还有没有别的参数列表),提供compilation对象,上面实现了很多方法,然后每次在call的时候,都把compilation和callback传入。

至于这个callback干了什么,为什么要在插件的最后调用它,暂时还不明白。