大家好,我是右子。是一名热爱开发的前端开发工程师。
今天带你写一个基于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插件。
通过resolveLoader配置,可以方便后续的引用:
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 处理,以及如何处理它。
利用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);
}
我习惯把这些操作拆分成多个函数,这样可以提高代码的可读性和可维护性。
上面的代码中代码定义了两个名为getDirectoryIndex和getLetterName的函数,用于从文件路径中提取信息并生成字母名称。
1.它通过调用findFilePath函数并将调用pathResolve的结果传递给它,并将当前元素的path属性作为参数,从而找到当前元素的文件路径。
2.如果文件路径不是null或undefined,并且它还不在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文件监听并动态加入我们依赖的组件了。
补充
github地址是:。
开源免费使用,后期还会优雅升级的,让功能越来越强。
欢迎小伙伴们给建议。