前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Webpack插件是如何编写的——prerender-spa-plugin源码解析

Webpack插件是如何编写的——prerender-spa-plugin源码解析

作者头像
黄Java
发布2022-03-22 14:27:13
7230
发布2022-03-22 14:27:13
举报
文章被收录于专栏:黄Java的地盘

概述

本文主要的内容是通过之前使用的prerender-spa-plugin插件的源码阅读,来看下我们应该如何编写一个webpack的插件,同时了解下预渲染插件到底是如何实现的。

这个内容其实已经在使用prerender-spa-plugin里面有所涉及了,这一章的内容算是对之前一篇文章的补充和拓展,详细介绍下Webpack的插件机制到底是如何运行的,之前写的简单的替换的插件生效的原理到底是什么。

如果大家还没有看之前的如何使用prerender-spa-plugin插件对页面进行预渲染这篇文章,可以先去看看,了解下这个插件到底是做什么的,我们的插件大概是什么样的。

插件源码浅析

prerender-spa-plugin是开源的,源码在GitHub上面可以看到,大家如果有兴趣的话,可以自己点击看下。

首先,我们让我们来简单回顾下这个插件是如何使用的,这个对于我们了解其内部构造,有一定的帮助。我们就直接使用它官方文档上提供的一个例子。

代码语言:javascript
复制
const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')

module.exports = {
  plugins: [
    ...
    new PrerenderSPAPlugin({
      // Required - The path to the webpack-outputted app to prerender.
      staticDir: path.join(__dirname, 'dist'),
      // Required - Routes to render.
      routes: [ '/', '/about', '/some/deep/nested/route' ],
    })
  ]
}

从上面这个例子来看,我们可以知道这个插件需要初始化一个实例,然后传入对应的参数如输出的路径staticDir、需要渲染的路由routes等。

接下来,让我们来简单介绍下他的源码结构。代码具体分块如下:

代码语言:javascript
复制
function PrerenderSPAPlugin (...args) {
  ...
}

PrerenderSPAPlugin.prototype.apply = function (compiler) {
  const afterEmit = (compilation, done) => {
    ...
  }
  
  if (compiler.hooks) {
    const plugin = { name: 'PrerenderSPAPlugin' }
    compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
  } else {
    compiler.plugin('after-emit', afterEmit)
  }
}

整个prerender-spa-plugin的插件是由2大部分构成:

  1. 一个function函数,主要用于初始化数据获取与处理。在使用这个插件的过程中,我们需要先进行初始化。这个函数可以用来进行一些数据的处理和解析。
  2. 一个原型上的apply函数,作为一个钩子函数,主要用于处理Webpack触发插件执行后,相关逻辑的处理。

下面,我们就基于prerender-spa-plugin插件,来一个一个部分的看下。

初始化function函数

首先让我们来看下初始化的function函数。这个函数主要做的是一些初始化参数获取后的处理。具体代码如下:

代码语言:javascript
复制
function PrerenderSPAPlugin (...args) {
  const rendererOptions = {} // Primarily for backwards-compatibility.

  this._options = {}

  // Normal args object.
  if (args.length === 1) {
    this._options = args[0] || {}

  // Backwards-compatibility with v2
  } else {
    console.warn("[prerender-spa-plugin] You appear to be using the v2 argument-based configuration options. It's recommended that you migrate to the clearer object-based configuration system.\nCheck the documentation for more information.")
    let staticDir, routes

    args.forEach(arg => {
      if (typeof arg === 'string') staticDir = arg
      else if (Array.isArray(arg)) routes = arg
      else if (typeof arg === 'object') this._options = arg
    })

    staticDir ? this._options.staticDir = staticDir : null
    routes ? this._options.routes = routes : null
  }

  // Backwards compatiblity with v2.
  if (this._options.captureAfterDocumentEvent) {
    console.warn('[prerender-spa-plugin] captureAfterDocumentEvent has been renamed to renderAfterDocumentEvent and should be moved to the renderer options.')
    rendererOptions.renderAfterDocumentEvent = this._options.captureAfterDocumentEvent
  }

  if (this._options.captureAfterElementExists) {
    console.warn('[prerender-spa-plugin] captureAfterElementExists has been renamed to renderAfterElementExists and should be moved to the renderer options.')
    rendererOptions.renderAfterElementExists = this._options.captureAfterElementExists
  }

  if (this._options.captureAfterTime) {
    console.warn('[prerender-spa-plugin] captureAfterTime has been renamed to renderAfterTime and should be moved to the renderer options.')
    rendererOptions.renderAfterTime = this._options.captureAfterTime
  }

  this._options.server = this._options.server || {}
  this._options.renderer = this._options.renderer || new PuppeteerRenderer(Object.assign({}, { headless: true }, rendererOptions))

  if (this._options.postProcessHtml) {
    console.warn('[prerender-spa-plugin] postProcessHtml should be migrated to postProcess! Consult the documentation for more information.')
  }
}

因为我们的插件使用的方式是实例化后添加(即new操作符实例化后使用),所以function函数的入参主要是将一些需要的参数绑定到this对象上,这样实例化后,就可以获取相关的参数。

很多的SDK或者说插件相关的工具,因为能够接受多种类型、不同长度的入参,因此会在一开始对参数类型进行判断,确定传入的参数类型到底是哪一种。

从代码中看,目前记录的参数有输出的参数staticDir、需要渲染的路由routes。如果自己定义了renderer函数,那么也绑定存储下来。同时,这个V3版本的代码还对V2版本进行了向前兼容。

钩子apply函数

说完了初始化的function,我们来看下最重要的apply函数。具体代码如下:

代码语言:javascript
复制
PrerenderSPAPlugin.prototype.apply = function (compiler) {
  const compilerFS = compiler.outputFileSystem

  // From https://github.com/ahmadnassri/mkdirp-promise/blob/master/lib/index.js
  const mkdirp = function (dir, opts) {
    return new Promise((resolve, reject) => {
      compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err))
    })
  }

  const afterEmit = (compilation, done) => {
    ...
  }

  if (compiler.hooks) {
    const plugin = { name: 'PrerenderSPAPlugin' }
    compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
  } else {
    compiler.plugin('after-emit', afterEmit)
  }
}

在说apply函数之前,我们先看下apply函数接收的参数compiler对象和mkdirp这个方法,以及生命周期绑定的代码。

complier对象

整个apply方法,接收的参数只有一个complier对象,详细的内容我们可以看webpack中关于complier对象的描述,具体的源码可以见此处。我下面简单介绍下:

complier对象是webpack提供的一个全局的对象,这个对象上面挂载了一些在插件生命周期中会使用到的功能和属性,比如options、loader、plugin等。我们可以通过这个对象,在构建中获取webpack相关的数据。

mkdirp方法

这个方法就是将执行mkdir -p方法的函数转化成一个Promise对象。具体可以看代码上面的原文注释。因为比较简单,这里我就不过多介绍了。

生命周期绑定

在最后,钩子函数生命完成后,需要将其关联到最近的生命周期上。这个插件关联的是afterEmit这个节点,大家如果想看下整个webpack相关构建流程的生命周期,可以参考这个文档

看完了简单的部分,下面我们来看下最重点的钩子函数。

钩子函数

接下来,让我们来看下这个插件中最核心的钩子函数。这个插件的关联的声明周期是afterEmit这个节点,接下来我们来看下具体的代码。

代码语言:javascript
复制
const afterEmit = (compilation, done) => {
  const PrerendererInstance = new Prerenderer(this._options);

  PrerendererInstance.initialize()
    .then(() => {
      return PrerendererInstance.renderRoutes(this._options.routes || []);
    })
    // Backwards-compatibility with v2 (postprocessHTML should be migrated to postProcess)
    .then((renderedRoutes) =>
      this._options.postProcessHtml
        ? renderedRoutes.map((renderedRoute) => {
            const processed = this._options.postProcessHtml(renderedRoute);
            if (typeof processed === "string") renderedRoute.html = processed;
            else renderedRoute = processed;

            return renderedRoute;
          })
        : renderedRoutes
    )
    // Run postProcess hooks.
    .then((renderedRoutes) =>
      this._options.postProcess
        ? Promise.all(
            renderedRoutes.map((renderedRoute) =>
              this._options.postProcess(renderedRoute)
            )
          )
        : renderedRoutes
    )
    // Check to ensure postProcess hooks returned the renderedRoute object properly.
    .then((renderedRoutes) => {
      const isValid = renderedRoutes.every((r) => typeof r === "object");
      if (!isValid) {
        throw new Error(
          "[prerender-spa-plugin] Rendered routes are empty, did you forget to return the `context` object in postProcess?"
        );
      }

      return renderedRoutes;
    })
    // Minify html files if specified in config.
    .then((renderedRoutes) => {
      if (!this._options.minify) return renderedRoutes;

      renderedRoutes.forEach((route) => {
        route.html = minify(route.html, this._options.minify);
      });

      return renderedRoutes;
    })
    // Calculate outputPath if it hasn't been set already.
    .then((renderedRoutes) => {
      renderedRoutes.forEach((rendered) => {
        if (!rendered.outputPath) {
          rendered.outputPath = path.join(
            this._options.outputDir || this._options.staticDir,
            rendered.route,
            "index.html"
          );
        }
      });

      return renderedRoutes;
    })
    // Create dirs and write prerendered files.
    .then((processedRoutes) => {
      const promises = Promise.all(
        processedRoutes.map((processedRoute) => {
          return mkdirp(path.dirname(processedRoute.outputPath))
            .then(() => {
              return new Promise((resolve, reject) => {
                compilerFS.writeFile(
                  processedRoute.outputPath,
                  processedRoute.html.trim(),
                  (err) => {
                    if (err)
                      reject(
                        `[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`
                      );
                    else resolve();
                  }
                );
              });
            })
            .catch((err) => {
              if (typeof err === "string") {
                err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(
                  processedRoute.outputPath
                )} for route ${processedRoute.route}. \n ${err}`;
              }

              throw err;
            });
        })
      );

      return promises;
    })
    .then((r) => {
      PrerendererInstance.destroy();
      done();
    })
    .catch((err) => {
      PrerendererInstance.destroy();
      const msg = "[prerender-spa-plugin] Unable to prerender all routes!";
      console.error(msg);
      compilation.errors.push(new Error(msg));
      done();
    });
};

在这个方法中,又出现了一个新的compilation对象。这个方法详细的介绍可以看Webpack compilation对象,具体的源码可以见此处。下面我简单介绍下:这个对象代表的是一次文件资源的构建。每次有文件变化时,就会创建一个新的对象。这个文件主要包含了当前资源构建和变化过程中的一些属性和信息。

另一个done参数,代表着当前插件执行完后执行下一步的一个触发器,和我们常见的Node框架中的next()一样。

接下来,我们来简单说下这个函数执行的逻辑:

  1. 初始化了一个Prerenderer的实例。这个实例是用于对页面进行预渲染的一个工具,具体的代码可以见GitHub
  2. 实例初始化后,针对每一个路由,进行了一次预渲染操作。
  3. 根据拿到的预渲染相关的数据,对有效性进行检查。
  4. 如果指定了压缩,那么对预渲染数据进行相关的压缩处理。
  5. 最终将预渲染相关的数据输出到指定路径上。
  6. 销毁Prerenderer实例。

这个就是一个插件执行的完整流程。

总结

通过prerender-spa-plugin这个插件,大家应该能够了解到我们现行的一个插件到底是如何运转的,我们编写一个插件需要的核心部件:

  • 一个初始化的function函数。
  • 一个原型链上的apply方法。

    - 一个钩子函数。

    - 一个绑定生命周期的代码。

有了这些东西,我们的一个Webpack的插件就完成了。

希望通过一个插件源码的示例,能够让大家了解下我们日常使用的看似很复杂的Webpack插件,到底是怎么实现的。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021-10-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 概述
  • 插件源码浅析
    • 初始化function函数
      • 钩子apply函数
        • complier对象
        • mkdirp方法
        • 生命周期绑定
      • 钩子函数
      • 总结
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档