10X 程序员养成——开发效率翻倍之 loader 工具开发

218 阅读4分钟

大家好,我是右子。是一名热爱开发的前端开发工程师。

今天带你写一个基于webpack loader处理vue组件异步加载的小工具,慢慢养成10X的工作效率。

你在开发的时候有没有觉得频繁引入组件很烦躁!

你在开发的时候有没有想过按需引入的插件是如何开发的?

本文带你进入webpack loader开发的方式,了解如何自动按需注册组件,并可以按需加载使用。

充分提升web性能,提高关键代码覆盖率指标!

本文章分享的是在vue项目开发中,编写处理vue的loader插件,用于提高开发效率,解决一些问题。不讲解vue-loader相关知识点。

首先看一下package.json中的依赖工具,可以把这些贴进你的package里面。

{
  "devDependencies": {
    "@vue/compiler-core": "^3.2.45",
    "@babel/generator": "^7.17.9",
    "@babel/parser": "^7.17.8",
    "@babel/traverse": "^7.17.9",
    "@babel/types": "^7.20.5"
  }
}
  • @vue/compiler-core:可以将Vue源代码转换为可以在浏览器中执行的JavaScript代码。它使用抽象语法树(AST)来表示源代码,并使用插件来操作AST。它还提供了一组工具,用于检查源代码的有效性,提取源代码中的信息,以及生成源代码。
  • @babel/parser:Babel解析器可以将JavaScript代码解析成抽象语法树,从而使Babel转换器有更多的可能性来转换代码。另外,代码能够在不同的浏览器和环境中正常运行。
  • @babel/traverse:它可以深入遍历抽象语法树 (AST),允许用户在遍历中操作和更改 AST 中的节点。它可以用来做诸如类型检查、代码分析和转换等操作。
  • @babel/generator:它可以将高级 JavaScript 语法转换为低级 JavaScript 语法,也可以转换为源代码,以便在浏览器上运行。
  • @babel/types:它是一个用于操作 AST(抽象语法树)的 JavaScript 库,它可以帮助开发者构建、检查和操作 AST。它提供了一组类型定义和构建函数,可以帮助开发者构建 AST,并可以用于在 AST 中检查、替换和移除节点。

安装他们!

# 使用pnpm安装
pnpm i 

这时候我们可以在webpack的工程目录下创建一个目录,用于存放loader插件。

image.png

通过resolveLoader配置,可以方便后续的引用:

image.png

module.rules的配置

rules: [{
  test: /\.vue$/,
  use: ["vue-loader",{
    loader: "vue-dynamic-loader",
    options: {
      // 执行监听的目录
      target: [{
        path: getEntryAppPath("/components")
      }],
      // 是否开启异步的方式引入组件
      async: true
    }
  }]
}]

这些都加好后,直接打开vue-dynamic-loader.js文件,编写我们的代码!

loader的基本样貌

我们先书写一个loader的基本函数:

// 用于存放文件路径
const filePathMap = new Map();

function importComponentLoader(source) {
   const asyncCallback = this.async();
   asyncCallback(null, source);
};
importComponentLoader.pitch = function(filePath, loaderPath, data){

};
module.exports = importComponentLoader;

是的,你没看错,这就是一个基础的loader,包含工程化开发里整个构建、运行中的vue sfc的内容。

引入我们的依赖的工具包:

const fs = require("fs");
const path = require("path");
// 是vue.js的一个模块,提供用于解析和编译vue单文件组件(SFC)的工具。
const compiler = require('@vue/compiler-sfc');
// 提供了一系列用于操作和构建AST(抽象语法树)的工具。
const types = require('@babel/types');
// 用于将JS代码解析为抽象语法树(AST)。可以通过提供的语法规则来提取JS代码中的语法结构,并将它们表示为树形结构,以便进一步操作和分析。
const parser = require('@babel/parser');
// 用于遍历ast
const traverse = require('@babel/traverse').default;
// 用于ast生成源代码
const generate = require('@babel/generator').default;

pitch的作用

Webpack loader 中的 pitch 方法是一种特殊的方法,它在 loader 被应用到模块上之前被调用。这个方法可以用来确定模块是否应该被当前的 loader 处理,以及如何处理它。

举个例子,假如我们有一个用来处理 .ts 文件的 loader,并且它希望在某些特定条件下将模块转换为 js
在这种情况下,我们可以在 pitch 方法中检查模块的扩展名,如果它是 .ts ,那么就将它转换为 js,否则就跳过这个模块。

总之,pitch 方法的作用是让 loader 在处理模块之前做出决策,以决定是否应该对该模块进行处理,以及如何处理。

一句话来介绍pitch就是:

pitch 方法用于 loader 到模块之前决定是否处理该模块,它可以用来确定模块是否应该被当前的 loader 处理,以及如何处理它。

webpack官网pitch参考

利用pitch早处理

// pitch方法内
const query = this.getOptions();
query.target.forEach(it => {
  const fpath = findFilePath(pathResolve(it.path));
  if (fpath && !filePathMap.has(fpath)) {
    const dirIndex = getDirectoryIndex(fpath, it.path);
    const letterName = getLetterName(dirIndex);
    filePathMap.set(fpath, letterName);
  }
});

function getDirectoryIndex(filePath, path) {
  return filePath.replace(path, "").replace(/(\/index)+?(\.vue)$/gi, "");
}

function getLetterName(directoryIndex) {
  return directoryIndex.substr(0, 1).toUpperCase() + directoryIndex.substr(1);
}

我习惯把这些操作拆分成多个函数,这样可以提高代码的可读性和可维护性。

上面的代码中代码定义了两个名为getDirectoryIndexgetLetterName的函数,用于从文件路径中提取信息并生成字母名称。

1.它通过调用findFilePath函数并将调用pathResolve的结果传递给它,并将当前元素的path属性作为参数,从而找到当前元素的文件路径。

2.如果文件路径不是nullundefined,并且它还不在filePathMap映射中,则执行以下操作:

  • 它通过调用getDirectoryIndex函数,以文件路径和当前元素的“path”属性作为参数,从文件路径中提取目录索引。
  • 它通过调用以目录索引为参数的getLetterName函数为目录索引生成字母名称。
  • 它向filePathMap映射中添加一个条目,其中文件路径为关键字,字母名称为值。

该代码处理target数组中的每个元素,并从文件路径中提取信息,为其生成字母名称,然后将文件路径和字母名称添加到filePathMap映射中。

findFilePath函数

function findFilePath(_path, cb = () => {}) {
  let _cwd = path.resolve(_path);
  readDirectory(_cwd, cb);
}

function readDirectory(_cwd, cb) {
  fs.readdir(_cwd, (err, files) => {
    if (err) {
      return handleError(err);
    }
    files.forEach(it => checkFile(it, _cwd, cb));
  });
}

function checkFile(file, _cwd, cb) {
  let splitPath = path.join(_cwd, file);
  let stats = fs.statSync(splitPath);
  if (stats.isDirectory()) {
    findFilePath(splitPath, cb);
  } else if (stats.isFile() && /\.vue$/.test(splitPath)) {
    cb(splitPath);
  }
}

function handleError(err) {
  return err;
}

他的作用就是处理我们loader时配置options.target的目录指向,把文件路径提取出来。

我们再将importComponentLoader改写成这样:

function importComponentLoader(source){
    const query = this.getOptions();
    const asyncCallback = this.async();

    this.cacheable(false);

    const ast = compiler.parse(source);

    source = handlerTraverseContent.call(this, source, query, ast);

    asyncCallback(null, source);
};

监听目录中的vue文件,开启自动化注入组件

handlerTraverseContent 函数

解释

handlerTraverseContent 函数的作用是处理模板中的内容,使用深度遍历模板中的标签。
然后根据文件路径映射构建AST,最后根据用户是否有普通的script标签,进行指定操作,最终返回处理AST后生成的源代码的值。

/**
 * handlerTraverseContent 函数用于处理模板中使用到的组件,并自动注入
 * @param {Object} source 源文件
 * @param {Object} query 查询参数
 * @param {Object} ast AST结构
 * @return {Object} 返回新的源文件
 */
function handlerTraverseContent(source, query = {}, ast) {
  let { template, script, scriptSetup, styles } = ast.descriptor;

  // 侦测template中使用到的组件,然后自动注入。
  let templateTags = deepTraversalTag(template.ast);

  // 如果没有匹配上的组件数据
  if (!templateTags.length) {
    return source;
  }

  const properties = buildAstByFilePathMap(filePathMap, templateTags, query.async);

  // 侦测用户是否有普通的script标签,进行指定操作;
  if (script) {
    script = configScript(script, properties);
  } else {
    script = buildEmptyScript(source, properties);
  }

  return script.map.sourcesContent[0];
};

// 深度遍历模板中的标签
function deepTraversalTag(node, nodes = []) {
  if (!node || !node.tag || !node.children) {
    return nodes;
  }

  nodes.push(node.tag);
  for (const child of node.children) {
    deepTraversalTag(child, nodes);
  }

  return nodes;
};

buildAstByFilePathMap 函数

解释

buildAstByFilePathMap 函数的功能是根据文件路径映射构建ast,其中 filePathMap 为文件路径映射,templateTags为模板标记,isAsync为是否异步加载模式。
首先会遍历 filePathMap,将templateTags中的路径和key放入 echoImportMap 中,然后根据isAsync决定是否开启异步加载模式,最后将 echoImportMap 中的键值对转换成objectProperty格式的ast节点,最终返回properties。

// 根据文件路径映射构造AST结构
function buildAstByFilePathMap(filePathMap, templateTags, isAsync) {
  const echoImportMap = {};
  for (const [key, value] of filePathMap) {
    if (templateTags.includes(value) && !echoImportMap[value]) {
      echoImportMap[value] = key;
    }
  }
  // 开启异步加载模式
  if (isAsync) {

  } else {

  }
  const properties = Object.entries(echoImportMap).map(([key, value]) => (
    types.objectProperty(
      types.identifier(key),
      types.callExpression(types.identifier("defineAsyncComponent"), [
        types.arrowFunctionExpression([], types.callExpression(types.identifier("import"), [
          types.stringLiteral(value)
        ]))
      ])
    )
  ));
  return properties;
};

configScript 函数

解释

configScript 函数的功能是在指定的script标签中添加新的属性,以便在其中使用defineAsyncComponent这个函数。
它使用parser.parse来解析脚本内容,并使用traverse来遍历节点,查找是否存在import声明。
如果不存在,就添加一个import声明。
然后,它会查找ExportDefaultDeclaration节点,如果不存在,就添加一个新的components属性,最后使用generate来生成新的脚本内容,并将新的脚本内容替换到原来的脚本中。

// 配置script标签
function configScript(script, properties) {
  let _ast = parser.parse(script.content, { sourceType: "module" });
  traverse(_ast, {
    enter(path) {
      if (Array.isArray(path.node.body) && path.node.type === "Program") {
        if (!path.node.body.find(it => it.type === "ImportDeclaration")) {
          let _importDeclaration = {
            "type": "ImportDeclaration",
            "specifiers": [],
            "source": {},
            "assertions": []
          };
          _importDeclaration.specifiers.push({
            type: "ImportSpecifier",
            imported: {
              type: "Identifier",
              name: "defineAsyncComponent"
            },
            local: {
              type: "Identifier",
              name: "defineAsyncComponent"
            }
          });
          _importDeclaration.source = {
            "type": "StringLiteral",
            "value": "vue",
            "extra": {
              "rawValue": "vue",
              "raw": "\"vue\""
            }
          };
          path.node.body.unshift(_importDeclaration);
        }
        return;
      }

      if (path.node.type === "ImportDeclaration") {
        if (!path.node.specifiers.find(it => it.local.name === "defineAsyncComponent")) {
          path.node.specifiers.push({
            type: "ImportSpecifier",
            imported: {
              type: "Identifier",
              name: "defineAsyncComponent"
            },
            local: {
              type: "Identifier",
              name: "defineAsyncComponent"
            }
          });
          path.node.source = {
            "type": "StringLiteral",
            "value": "vue",
            "extra": {
              "rawValue": "vue",
              "raw": "\"vue\""
            }
          };
        }
      }
    },
    ExportDefaultDeclaration(path) {
      const componentsProp = path.node.declaration.properties.find(prop => prop.key.name === 'components');

      if (!componentsProp) {
        // 插入新的components属性
        path.node.declaration.properties.push({
          "type": "ObjectProperty",
          "key": {
            "type": "Identifier",
            "loc": {
              "identifierName": "components"
            },
            "name": "components"
          },
          "value": {
            "type": "ObjectExpression",
            "properties": properties
          }
        });
      } else {
        let concatArray = componentsProp.value.properties.filter(fit => properties.find(it => it.key.name !== fit.key.name));
        if (concatArray.length) {
          componentsProp.value.properties = concatArray;
        }
      }
    }
  });

  let { code: outputCode } = generate(_ast, {}, script.content);
  script.content = outputCode;
  script.loc.source = outputCode;
  script.map.sourcesContent = [script.map.sourcesContent[0].replace(/(?<=<script(?!.*setup).*>)[\s\S\r\n]+?(?=<\/script>)/ig, outputCode)];
  return script;
};

buildEmptyScript函数

解释

buildEmptyScript 是用来构建一个空的脚本的,它的功能是从给定的源代码和属性中构建一个空的脚本。
代码中使用了types和generate函数,types用于创建ast树,generate函数用于将ast树转换为代码。
首先,它会创建一个ast树,其中包含一个import语句和一个export default语句,import语句用于导入defineAsyncComponent函数,export语句用于将给定的属性作为组件对象导出。
然后,它会使用generate函数将ast树转换为代码,最后将代码和源代码封装成一个script对象并返回。

// 构造空script标签
function buildEmptyScript(source, properties) {
  const t = types;
  const _ast = t.program([
    t.importDeclaration(
      [t.importSpecifier(t.identifier('defineAsyncComponent'), t.identifier('defineAsyncComponent'))],
      t.stringLiteral('vue')
    ),
    t.exportDefaultDeclaration(
      t.objectExpression([
        t.objectProperty(
          t.identifier("components"),
          t.objectExpression(properties)
        )
      ])
    )
  ]);
  const { code: outputCode } = generate(_ast);

  const script = {
    content: outputCode,
    loc: {
      source: outputCode
    },
    map: {
      sourcesContent: [
        `${source} \n <script>${outputCode}</script>`
      ]
    }
  }
  return script;
};

findFilePath 函数

解释

findFilePath 函数的作用是使用path.resolve函数将传入的路径解析成绝对路径,然后使用fs.readdir函数读取指定路径下的文件列表,遍历该文件列表,使用path.join函数获取每个文件的绝对路径,然后使用fs.statSync函数获取文件的状态,如果是文件夹,则递归查找,如果是.vue文件,则将文件路径传入回调函数cb中。

// 查找 loader 配置的 options.target 目录中匹配的文件并返回数据
function findFilePath(_path, cb = () => { }) {
  let _cwd = path.resolve(_path);
  fs.readdir(_cwd, (err, files) => {
    if (err) {
      return err;
    }
    files.forEach(it => {
      let splitPath = path.join(_cwd, it);
      let stats = fs.statSync(splitPath);
      if (stats.isDirectory()) {
        findFilePath(splitPath, cb);
      } else if (stats.isFile() && /\.vue$/.test(splitPath)) {
        cb(splitPath);
      }
    });
  });
};

pathResolve 函数

解释

pathResolve 函数的作用是将传入的_path参数与当前工作目录的路径进行拼接,得到一个绝对路径,并返回该路径。
如果传入的_path参数为空,则返回当前工作目录的路径。

function pathResolve(_path = "", mPath = process.cwd()) {
  return path.resolve(mPath, _path);
};

到此,加入的loader就可以执行对Vue.js的SFC文件监听并动态加入我们依赖的组件了。

image.png

补充

github地址是:。

开源免费使用,后期还会优雅升级的,让功能越来越强。

欢迎小伙伴们给建议。