背景: 最近在做低代码平台,低代码除了可视化组装组件外,还有重要一块功能就是平台组件的丰富程度。在增加组件的过程中,我们制定了组件的一系列符合平台的标准。其中包括一个组件目录中的文件可能是这样的: wd_ui_download ├─README.md ├─config.json ├─index.vue ├─package.json ├─template.xml ├─utils | └download.js
比如上面这一个下载组件,跟组件内容相关的文件:index.vue、utils/download.js、package.json,跟平台组件规范相关:README.md、config.json、template.xml。组件内容相关的文件我们不展开,我们看看
config.json:
template.xml:
config.json用于组件选中时,描述组件的可编辑属性,template.xml用于server端,将用户编辑组件的属性信息写入到运行时组件中。那这两个文件有个特点就是他们都是跟组件属性相关的。那我们是不是可以联想,通过某种方法,把组件的属性提取出来,然后根据属性结合平台的规范生成对应的规则文件。
解决过程: 那这里问题点就变成解析vue文件,把属性提取出来,然后根据属性和平台规范生成对应文件。解析vue文件的第一想法是通过vue框架,他是如何一步步解析编译vue文件,那下面我们就一起来看看。下面分析基于3.2.13版本
- vue项目编译,我们一般执行 npm run build来编写vue项目。那我们看看build执行的是什么,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
这里执行的是vue-cli-service命令,那我们知道,这里最终会找到.nodemodule/.bin目录下去查找对应的命令,这个目录下的命令,是npm install package的时候,会把package.json中的bin执行脚本注册到.nodemodule/.bin下。我们直接找到vue-cli-service命令,这是我们看到文件在mac系统上显示是替身(软连接),如下图所示,其实就是类似Windows上的快捷方式。
显示原项目,我们看到脚本所在的真实目录,如下图,引用cli-service包下的vue-cli-service脚本。
2. 接下来,我们具体看下这个脚本中具体执行了什么,如下图所示,实例化lib/Service,并获取第一个命令参数,后,执行service.run命令。
- 我们看下 Service这个类的功能,首先初始化类,就是初始化service这个类所需要的必要参数,包括webpack的依赖配置,项目工程的devDependencies和dependencies转换为可执行的结构化数据,这个数据结构是一个数组,如下所示
[{id: 'built-in:config[/base]()', apply: () => {/*通过require进来的*/, return require("path")}}];
- 执行service.run方法,我们重点来看下这个方法做了几个事情,第一个获取当前的一个编译模式,也就是我们设置的mode参数(development/production),根据mode参数类型初始化对应的环境变量、用户配置和插件,最后根据传入参数执行命令,一般有 build/serve/lint,这里我们以build命令为例。
5. 接下来我们看下this.init里面做了什么事情,如下图代码所示,加载环境配置参数,加载用户配置(vue.config.js),
init (mode = process.env.VUE_CLI_MODE) {
if (this.initialized) {
return
}
this.initialized = true
this.mode = mode
// load mode .env
if (mode) {
this.loadEnv(mode)
}
// load base .env
this.loadEnv()
// load user config
const userOptions = this.loadUserOptions()
const loadedCallback = (loadedUserOptions) => {
this.projectOptions = defaultsDeep(loadedUserOptions, defaults())
debug('vue:project-config')(this.projectOptions)
// apply plugins.
this.plugins.forEach(({ id, apply }) => {
if (this.pluginsToSkip.has(id)) return
apply(new PluginAPI(id, this), this.projectOptions)
})
// apply webpack configs from project config file
if (this.projectOptions.chainWebpack) {
this.webpackChainFns.push(this.projectOptions.chainWebpack)
}
if (this.projectOptions.configureWebpack) {
this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
}
}
if (isPromise(userOptions)) {
return userOptions.then(loadedCallback)
} else {
return loadedCallback(userOptions)
}
}
这里我们看下 loadEnv方法,这里我们发现就是加载我们项目中配置的.env.production以及.env.production.local配置,因为我们这里用的build命令,所以加载production参数,如果是serve则是加载.env.development配置。最后设置process.env.NODE_ENV变量。
这里我们小结一下,从我们控制台执行命令开始,我们找到命令执行脚本的具体目录后,跟踪,发现它初始化了一个Service类,初始化过程中,把依赖加载进来,根据命令加载环境配置(.env.*.local),用户配置(vue.config.js)。
- 下面我们看看主要看下build这个命令执行过程。我们调试程序进入到“node_modules/@vue/cli-service/lib/commands/build/index.js”这个文件中,这个文件代码我们看看,我们看到cli-service最后执行是获取到最后的webpack打包参数后,交给webpack执行。
...
new Promise((resolve, reject) => {
webpack(webpackConfig, (err, stats) => {
stopSpinner(false)
if (err) {
return reject(err)
}
if (stats.hasErrors()) {
return reject(new Error('Build failed with errors.'))
}
if (!args.silent) {
const targetDirShort = path.relative(
api.service.context,
targetDir
)
log(formatStats(stats, targetDirShort, api))
if (args.target === 'app' && !isLegacyBuild) {
if (!args.watch) {
done(`Build complete. The ${chalk.cyan(targetDirShort)} directory is ready to be deployed.`)
info(`Check out deployment instructions at ${chalk.cyan(`https://cli.vuejs.org/guide/deployment.html`)}\n`)
} else {
done(`Build complete. Watching for changes...`)
}
}
}
// test-only signal
if (process.env.VUE_CLI_TEST) {
console.log('Build complete.')
}
resolve()
})
})
...
- 那webpack这一块的执行,我们先忽略,这里面涉及webpack初始化,加载plugins, 实例化complier,分析项目依赖,生成template代码等。我们看下处理vue文件的loader,我们直接根据loader规则,找到对应的处理代码。如下图所示,我们看到了处理vue文件的两个loader和一个plugin,下面我们继续定位到指定的loader和plugin里面去查看代码执行过程。
- 这里我们跟踪代码,看到vue-loader中引用vue/compile-sfc的parse方法,这个方法作用是将vue文件中的三个部分解析出来,也就是解析后的descriptor中分别有style/script/template三个字段,如下图二所示
至于compile-sfc这里面调用是vue/compile-core中的方法parse方法解析,旨在将模板字符串转为ast。那这部分内容看下面详细说明一下
- compile-sfc(node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js)中会先执行缓存检测,如果是执行parse过的,则直接启用缓存,这里面要提到是,vue-loader在解析vue文件会执行两次,第一次解析生成第八个步骤的三类代码(script/style/template),第二次则会根据中间代码再次解析每个模块代码。
- 缓存检测完成后,就开始解析vue,生成对应的ast
- 根据ast和类型生成每个类型模块代码
switch (node.tag) {
case 'template':
if (!descriptor.template) {
const templateBlock = (descriptor.template = createBlock(node, source, false));
templateBlock.ast = node;
// warn against 2.x <template functional>
...
}
else {
errors.push(createDuplicateBlockError(node));
}
break;
case 'script':
const scriptBlock = createBlock(node, source, pad);
...
break;
case 'style':
const styleBlock = createBlock(node, source, pad);
...
descriptor.styles.push(styleBlock);
break;
default:
descriptor.customBlocks.push(createBlock(node, source, pad));
break;
}
本次解析完生成的结果,数据结构如下:
interface SFCDescriptor {
filename: string
source: string
template: SFCBlock
script: SFCBlock
scriptSetup: SFCBlock
styles: SFCBlock[]
customBlocks: SFCBlock[]
}
interface SFCBlock {
type: 'template' | 'script' | 'style'
attrs: { lang: string, functional: boolean },
content: string, // 内容,等于 html.slice(start, end)
loc: {
source: String, // 源代码
start: {
colmun: number,
line: number,
offset: number,
}, // 开始偏移量
end: {
colmun: number,
line: number,
offset: number,
}, // 结束偏移量
},
}
- 最后生成的中间代码如下:
源代码:
<template>
<div class="home">
<img
alt="Vue logo"
src="../assets/logo.png"
>
<HelloWorld msg="Welcome to Your Vue.js App" />
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue';
export default {
name: 'HomeView',
components: {
HelloWorld,
},
};
</script>
中间代码:
import { render } from "./HomeView.vue?vue&type=template&id=73b6a07e"
import script from "./HomeView.vue?vue&type=script&lang=js"
export * from "./HomeView.vue?vue&type=script&lang=js"
import exportComponent from "/Users/linjian/documentmaclery/project/IEGG/wg_component_test/node_modules/vue-loader/dist/exportHelper.js"
const __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])
export default __exports__
- 从上面可以看出,中间代码每个引入路径中都添加了id和type,那我们在上面webpack配置我们看到有个plugin处理vue文件。那我们从这个plugin中看到如下代码:
const pitcher = {
loader: require.resolve('./pitcher'),
resourceQuery: (query) => {
if (!query) {
return false;
}
const parsed = qs.parse(query.slice(1));
return parsed.vue != null;
},
};
// replace original rules
compiler.options.module.rules = [
pitcher,
...jsRulesForRenderFn,
templateCompilerRule,
...clonedRules,
...rules,
];
所以这里也加载了一个piter的loader,那我们看下这里做了什么事情
const pitch = function () {
...
return genProxyModule(loaders, context, query.type !== 'template');
};
exports.pitch = pitch;
function genProxyModule(loaders, context, exportDefault = true) {
const request = genRequest(loaders, context);
// return a proxy module which simply re-exports everything from the
// actual request. Note for template blocks the compiled module has no
// default export.
return ((exportDefault ? `export { default } from ${request}; ` : ``) +
`export * from ${request}`);
}
function genRequest(loaders, context) {
const loaderStrings = loaders.map((loader) => {
return typeof loader === 'string' ? loader : loader.request;
});
const resource = context.resourcePath + context.resourceQuery;
return loaderUtils.stringifyRequest(context, '-!' + [...loaderStrings, resource].join('!'));
}
生成结果如下,核心功能是遍历用户定义的 rule 数组,拼接出完整的行内引用路径:
-!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use[1]!../../node_modules/vue-loader/dist/templateLoader.js??ruleSet[1].rules[3]!../../node_modules/vue-loader/dist/index.js??ruleSet[0].use[0]!./HomeView.vue?vue&type=template&id=73b6a07e
这里总结一下:
// 开发代码:
import xx from 'index.vue'
// 第一步,通过vue-loader转换成带参数的路径
import script from "./index.vue?vue&type=script&lang=js&"
// 第二步,在 pitcher 中解读loader数组的配置,并将路径转换成完整的行内路径格式
import mod from "-!../../node_modules/babel-loader/lib/index.js??clonedRuleSet-2[0].rules[0].use!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=script&lang=js&";
- 此时转换后的完整路径,会再次进入vue-loader中,那我们再看下vue-loader 代码,会执行到selectBlock方法中,这个方法根据不同的type,返回对应代码,然后交给下一个loader解析。从上面的完整路径中我们可以看出来是templateloader
15.经过template转换后,我们看到代码变成这样了,此时已经把我们平常通过语法糖编写的代码,变成了api的形式编写UI。
import { createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
import _imports_0 from '../assets/logo.png'
const _hoisted_1 = { class: "home" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("img", {
alt: "Vue logo",
src: _imports_0
}, null, -1)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_HelloWorld = _resolveComponent("HelloWorld")
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_createVNode(_component_HelloWorld, { msg: "Welcome to Your Vue.js App" })
]))
}
- 那遇到含有script标签的loader完整执行顺序如下,vue-loader执行完成,就交给babel-loader去处理了。
"-!../../../node_modules/thread-loader/dist/cjs.js!../../../node_modules/babel-loader/lib/index.js??clonedRuleSet-40.use[1]!../../../node_modules/vue-loader/dist/index.js??ruleSet[0].use[0]!./IndexTest.vue?vue&type=script&lang=js"
至此,vue文件解析就全部完成了。
那回到我们开头的问题,我们需要拿到vue文件中的props, 那我们一种做法就是通过调用vue-loader来解析,但是整体解析过程有点繁琐,所以最后,我们通过正则表达式方式和栈结构来提取vue文件中的属性信息。
export const getVueProps = (filePath: string) => {
const vueFile = getFileContent(path.resolve(filePath, 'index.vue'));
if (vueFile.err) {
console.log(vueFile.err.msg);
return null;
}
const { data: componentContent } = vueFile;
if (componentContent) {
// 用正则表达式,解析,获取props后面的字符内容。
const propsTemp = componentContent.match(/props:(.*|\s|\S)*/g);
// props为空的情况
if (!propsTemp || propsTemp.length === 0) {
const msg = '不存在props';
return { err: { msg } };
}
// 根据左尖括号和右尖括号一一匹配原则,获取到只属于props对象中的字符内容。
const leftPart = [];
let isHasLeftPart = false;
let propsStr = '';
for (let index = 0; index < propsTemp[0].length; index++) {
const str = propsTemp[0][index];
if (str === '{') {
leftPart.push('{');
if (!isHasLeftPart) {
isHasLeftPart = true;
}
}
if (str === '}') {
leftPart.pop();
}
if (isHasLeftPart) {
propsStr += str;
}
if (leftPart.length === 0 && isHasLeftPart) {
break;
}
}
// 去掉props中的注释内容
// propsStr = propsStr.replace(/\s*\/\/(.*?)(?:\r\n|\n|$)/g, '');
// 对props中的key进行处理
propsStr = propsStr
.replace(/default:/g, '"default":') // 对js的保留字进行处理
.replace(/[\f\n\r\t\v]/g, '') // 去掉换行符等无用字符
.replace(/[\s]{2,}/g, ' ') // 把多个空格换成两个空格
.replace(/, }/g, () => '}') // 去掉最后一个对象属性的逗号,
// 对type的值,加上双引号。
.replace(
/:\s*(String|Number|Object|Array|Function|Boolean){1}/g,
(k) => `:"${k.substring(1).replace(/^\s*/g, '')}"`,
);
// eslint-disable-next-line no-new-func
const props = Function(`"use strict";return (${propsStr})`)();
return props;
}
return null;
};
附录: node调试vue工程,在vscode中的配置文件如下,各位有兴趣也可以去试一下:
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/wg_component_test/node_modules/.bin/vue-cli-service",
"args": ["build"],
"cwd": "${workspaceFolder}"
}
]
}