背景
今天闲来无事, 就开始看公司使用的框架的一些实现. 目前公司主要用的开源框架有两个, 一个ruoyi, 一个jeecgboot.
ruoyi: 基于SpringBoot的权限管理系统, 前端vue2/3
, ui使用的是element-plus. 实现相对简单, 只有最基本的权限系统和代码生成等内容.
jeecgboot: 基于SpringBoot的权限管理系统, 前端vue3
, ui使用的是ant-design-vue. 实现相对复杂, 包含了更复杂的权限系统, 更复杂的代码生成器, 流程设计等企业级的内容.
相对ruoyi, jeecgboot的前端部分基本是配置化的, 权限控制也大都是通过配置实现的, 不过也有需要使用自定义指令的情况, 也就是v-auth
. 而相对的ruoyi则是基本完全依靠指令来对界面的权限进行显隐控制.
在指令方面, 两者的实现基本一致, 通过传入当前组件/按钮的权限字符串, 然后在指令中判断当前用户是否有权限, 如果有则显示, 否则隐藏. 隐藏的方式是在元素挂载后, 然后将传入的节点在DOM
中移除.
这种做法有一个问题, 虽然DOM
本身在节点中看起来被删除了, 但是实际上对应的VNode
并没有消失, 同时也导致VNode
对改DOM
的引用依然存在, 无法完全删除对应的元素, 会造成内存浪费.
而且对于没有实际渲染的组件也没有用处, 比如element-plus的el-table-column
.
目前在Vue
中唯一能够手动消除VNode
的方法只有v-if
, 因此如果需要真正的隐藏就需要写长一点的方法v-if="hasRole([...])"
, 并且还需要在使用的组件引入该方法.
v-if的编译结果
javascript
// 模板代码
<div v-if='false'></div>;
// 编译后
const __sfc__ = {};
import {
createCommentVNode as _createCommentVNode,
createElementBlock as _createElementBlock,
openBlock as _openBlock,
} from 'vue';
const _hoisted_1 = { key: 0 };
function render(_ctx, _cache) {
return false ? (_openBlock(), _createElementBlock('div', _hoisted_1)) : _createCommentVNode('v-if', true);
}
__sfc__.render = render;
__sfc__.__file = 'src/App.vue';
export default __sfc__;
可以看到, v-if
会在编译时变成三元表达式, 在不显示的时候, 完全不创建对应的VNode
, 也就没有了VNode
对DOM
的引用, 也不会造成内存浪费.
基于这种情况, 我开始考虑是否能够和v-if
一样, 在编译时就将自定义指令转换成v-if
这种形式, 这样就可以直接使用自定义指令来进行权限控制.
先说结论: 至少在 vite
中是可以实现的, 在其他环境中尚未尝试.
尝试一
在github上曾经有过这样的issue, 目的和我相同, 尤大的回答是为了不增加复杂性, 所以不为这个功能添加扩展.
在vue的compiler-core
中有着能够自定义编译指令和自定义对节点操作的参数, 每遍历一个节点, 都会经过这些transform
.
这两个参数可以通过@vite/plugin-vue的template.compilerOptions
传入, 会透传到baseCompile
函数中.
但是在transform
的过程中, 用户的compilerOptions
是最后执行的, 在此之前, vue会将图片资源和v-if
v-for
等指令转换成vue独立的AST
.
v-if
会将当前节点包裹一层, 自定义节点的方式也需要同样的做法, 用来生成三元表达式的AST
.
在内部是按照once -> if -> memo -> for -> element ... -> user
, 因此自己写的nodeTransform
一定是最后执行的, 而在此之前还有compiler-sfc
compiler-dom
的对于资源处理之类的插件.
这些插件会对资源url
进行处理, 将引入的静态资源进行处理, 转换成import
的形式.
在第一次的想法中, 意图通过nodeTransforms
对指令进行判断, 做出和v-if
一样的处理.
但是实际上, vue
并没有暴露出相关的方法, 如果想要做出同样的编译, 就需要自己实现v-if
的相关逻辑, 就是从源码中复制同样的内容.
但是经过测试, 结果并不如所想的一样.
因为v-if
会包裹一层, 所以当前节点会变成v-if
的子节点, 如果是引用了资源的节点的话则会导致该节点再次经过compiler-sfc
compiler-dom
等一系列的处理.
因为前面的部分资源地址已经被进行了转换, 成了bind
引入的地址, 导致进行了多余的处理.
因为前面的处理导致资源的地址被处理成了类似bind:src
这样的语句, 导致再次进行处理的时候, 编译程序开始从导出的结果里寻找是否存在对应的变量.
然后因为setup
并没有对应的导出, 致使引用路径变成了_ctx.xxx
, 转换成import
语句也变成了import _ctx.xxx from 'xxx'
导致在执行的时候发生了语法错误.
以下是会导致上述情况的大致代码:
自定义指令编译转换
javascript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import {
createConditionalExpression,
createCallExpression,
createStructuralDirectiveTransform,
NodeTypes,
} from '@vue/compiler-core';
import { CREATE_COMMENT } from '@vue/compiler-core';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
nodeTransforms: [
// @vue/compiler-core 导出的用来筛选指令的方法
createStructuralDirectiveTransform('zz', function (node, dir, context) {
const isParentIf = context.parent.type === NodeTypes.IF_BRANCH;
if (isParentIf) {
const parent = context.parent;
parent.condition.content = `(${parent.condition?.content || true}) && ${dir.exp?.content || true}`;
} else {
const ifNode = {
type: NodeTypes.IF,
loc: node.loc,
condition: dir.exp,
branches: [
{
type: NodeTypes.IF_BRANCH,
children: [node],
condition: dir.exp,
loc: node.loc,
},
],
};
context.replaceNode(ifNode);
// 创建代码生成用的节点
ifNode.codegenNode = createConditionalExpression(
dir.exp,
node,
createCallExpression(context.helper(CREATE_COMMENT), ['"v-zz"', 'true']),
);
}
}),
],
},
},
}),
],
});
这里是自己手动创建了IF
节点, 将当前节点放入分支, 可以看到是将当前节点放入了IF
的下一层.
这种情况导致了当前的节点在下一轮的遍历又再次被处理了, 或许是这个原因, 所以在源码中, v-if
的处理是优先于后续处理, 可以防止对应的子节点被再次处理.
除了自己手动创建节点以外, vue也导出来processIf
的内部方法, 用来对v-if
指令进行处理.
使用vue导出的方法进行处理
javascript
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
function createCodegenNodeForBranch(branch, node, context) {
if (branch.condition) {
return CompilerCore.createConditionalExpression(
branch.condition,
node,
// make sure to pass in asBlock: true so that the comment node call
// closes the current block.
CompilerCore.createCallExpression(context.helper(CompilerCore.CREATE_COMMENT), ['"v-xxx"', 'true']),
);
}
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
nodeTransforms: [
CompilerCore.createStructuralDirectiveTransform('xxx', function (node, dir, context) {
dir.name = 'if';
dir.rawName = 'v-if';
return CompilerCore.processIf(node, dir, context, (ifNode, branch, isRoot) => {
return () => {
ifNode.codegenNode = createCodegenNodeForBranch(branch, node, context);
};
});
}),
],
},
},
}),
],
});
但是这个方法也是会出现同样的问题, 理由同上. 看了尤大确实没有想要能够扩展这个方式的想法.
尝试二
在正常暴露的API
以及确定无法实现了, 那么就只能从其他方面入手了.
经过对@vite/plugin-vue的API
的传递和过程分析, 发现plugin
会在调用compiler-sfc
的时候传入自定义template.compiler
对象, 这个template.compiler
对象中的方法执行时机要早于template.compilerOptions
.
但是这里的问题是, 传入compiler.compile
方法的值, 只有源代码字符串, 也就是<template>
的内容, 也就是需要进行解析转换.
而在vue
内部, 不管是编译什么, 都会调用baseCompile
方法. 而在baseCompile
方法中, 会通过调用baseParse
将对应的<template>
转换成AST
.
幸运的是, 这个baseParse
是被暴露出来的, 所以可以自己调用, 将<template>
转换成AST
.
compiler-sfc
compiler-dom
都有导出对应的parse
方法, 不过因为需要在更早的时候进行处理, 因此直接取用compiler-core
的baseParse
进行解析.
比较省事的是, 在baseCompile
中, 除了直接传递源码以外, 也可以直接传递转换完成的AST
, 这样就可以直接使用baseCompile
进行编译了.
使用自定义的compile进行编译
javascript
import vue from '@vitejs/plugin-vue';
import * as CompilerCore from '@vue/compiler-core';
import * as CompilerSFC from '@vue/compiler-sfc';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
compiler: {
parse(template, options) {
return CompilerSFC.parse(template, options);
},
compile(source, options) {
const ast = CompilerCore.baseParse(source, {
prefixIdentifiers: true,
});
...;
const result = CompilerCore.baseCompile(ast, options);
return result;
},
},
},
}),
],
});
然后就是如何替换对应的节点, 实际上不考虑其他的情况下, 只需要将指令的名称改成if
, vue
就会在之后的处理中将其进行转换.
使用自定义的compile进行编译
javascript
import vue from '@vitejs/plugin-vue';
import * as CompilerCore from '@vue/compiler-core';
import * as CompilerSFC from '@vue/compiler-sfc';
import { visit } from 'unist-util-visit';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
compiler: {
parse(template, options) {
return CompilerSFC.parse(template, options);
},
compile(source, options) {
const ast = CompilerCore.baseParse(source, {
prefixIdentifiers: true,
});
visit(
ast,
function (node) {
return node.props && node.props.length > 0;
},
function (node) {
const findDirIndex = node.props.findIndex(({ name }) => name === 'xxx');
const findDir = node.props[findDirIndex];
if (!findDir) return;
findDir.loc.source = findDir.loc.source.replace(findDir.rawName, 'v-if');
findDir.name = 'if';
findDir.rawName = 'v-if';
},
);
const result = CompilerCore.baseCompile(ast, options);
return result;
},
},
},
}),
],
});
如果该元素本身就带有v-if
呢, 就需要进行指令的合并. 保留if
指令, 并将自定义指令的值添加到if
指令中.
但是有一个问题, 指令的值如果是具有逻辑的表达式, 则在编译过程中会将指令的内容转换成js expression
.
v-if编译后的格式
json
{
"type": 7,
"name": "if",
"rawName": "v-if",
"exp": {
"type": 4,
"loc": {
....,
"source": "(show) && (perim)"
},
"content": "(show) && (perim)",
"isStatic": false,
"constType": 0,
"ast": {
"type": "LogicalExpression",
"start": 1,
"end": 11,
"loc": ...
},
"left": {
"type": "Identifier",
"start": 2,
"end": 3,
"loc": ...,
"name": "show",
"extra": {
"parenthesized": true,
"parenStart": 1
}
},
"operator": "&&",
"right": {
"type": "Identifier",
"start": 9,
"end": 10,
"loc": ...,
"name": "perim",
"extra": {
"parenthesized": true,
"parenStart": 8
}
},
"extra": {
"parenthesized": true,
"parenStart": 0
},
"comments": [],
"errors": []
}
},
"modifiers": [],
"loc": ...,
}
可以看到v-if
编译后, exp
会有一个ast
字段是指令的内容AST
.
这样就导致无法简单的对content
进行修改合并, 而虽然vue
有导出processExpression
的方法, 但是这个方法是在后续预处理使用, 在这里尚没有对应的上下文.
所以为了方便这里选择创建一个简单的片段, 再次, 使用baseParse
再次进行解析.
使用自定义的compile进行编译
javascript
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
compiler: {
parse(template, options) {
return CompilerSFC.parse(template, options);
},
compile(source, options) {
const ast = CompilerCore.baseParse(source, {
prefixIdentifiers: true,
});
visit(
ast,
function (node) {
return node.props && node.props.length > 0;
},
function (node) {
const findDirIndex = node.props.findIndex(({ name }) => name === 'xxx');
const findIfDirIndex = node.props.findIndex(({ name }) => name === 'if');
const findDir = node.props[findDirIndex];
const findIfDir = node.props[findIfDirIndex];
if (findIfDir && findDir) {
// 移除自定义指令
node.props.splice(findDirIndex, 1);
// 合并表达式的字面值
const ifExpression = `(${findDir.exp.content}) && (${findIfDir.exp.content})`;
// 创建简单片段进行编译
const newAst = CompilerCore.baseParse(`<div v-if="${ifExpression}"></div>`, {
prefixIdentifiers: true,
});
const newIfDir = newAst.children[0].props.find(({ name }) => name === 'if');
if (newIfDir) {
// 替换掉原有的表达式
findIfDir.exp = newIfDir.exp;
}
return;
}
if (!findDir) return;
findDir.loc.source = findDir.loc.source.replace(findDir.rawName, 'v-if');
findDir.name = 'if';
findDir.rawName = 'v-if';
},
);
const result = CompilerCore.baseCompile(ast, options);
return result;
},
},
},
}),
],
});
由此就完成了自定义指令的编译转换.
最终编译的结果.
编译结果
javascript
// template
<script setup>
import { ref } from "vue";
const show = ref(false);
</script>
<template>
<img src="/vite.svg" class="logo" alt="Vite logo" v-xxx="show && true" />
</template>
// 编译结果
import {
createCommentVNode as _createCommentVNode,
createElementBlock as _createElementBlock,
openBlock as _openBlock,
} from '/node_modules/.vite/deps/vue.js?v=7cb80a28';
import _imports_0 from '/vite.svg?import';
const _sfc_main = {
__name: 'App',
setup(__props, { expose: __expose }) {
__expose();
const show = ref(false);
const __returned__ = { show, ref };
Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true });
return __returned__;
},
};
const _hoisted_1 = {
key: 0,
src: _imports_0,
class: 'logo',
alt: 'Vite logo',
};
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return $setup.show && true
? (_openBlock(), _createElementBlock('img', _hoisted_1))
: _createCommentVNode('v-if', true);
}
结语
目前主流的框架, 基本都需要对源代码进行source -> AST -> JS
的过程, 而编译过程是一个非常复杂的过程, 需要对源代码进行解析, 转换, 优化等操作.
因此vue
对模板的编译优化是非常优秀的, 不但能够将复杂的模板语法转换成渲染函数, 还能在此之上做出非常多的编译优化, 大幅度降低了开发者的负担.
本文探讨了在 Vite 环境中对 Vue 自定义指令进行编译性修改的可能性, 目的是为了优化权限控制指令 v-auth 的性能表现, 使其能像 v-if 一样在编译阶段就决定元素的可见性, 从而避免不必要的内存占用.
这次的过程让我对vue
的编译过程有了更深入的了解, 并且简单的实现了自定义指令的编译转换功能. 也为应用AST
的操作有了更加直观的感受.