Vivia Preview

Theme Toggle

试图在 vite 中对 vue 进行自定义指令编译

在 vite 环境中对 vue 自定义指令进行编译性修改的尝试

背景

今天闲来无事, 就开始看公司使用的框架的一些实现. 目前公司主要用的开源框架有两个, 一个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-plusel-table-column.

没有被实际移除的DOM

目前在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, 也就没有了VNodeDOM的引用, 也不会造成内存浪费.

基于这种情况, 我开始考虑是否能够和v-if一样, 在编译时就将自定义指令转换成v-if这种形式, 这样就可以直接使用自定义指令来进行权限控制.

先说结论: 至少在 vite 中是可以实现的, 在其他环境中尚未尝试.

尝试一

github上曾经有过这样的issue, 目的和我相同, 尤大的回答是为了不增加复杂性, 所以不为这个功能添加扩展. 在vuecompiler-core中有着能够自定义编译指令和自定义对节点操作的参数, 每遍历一个节点, 都会经过这些transform.

baseCompile

这两个参数可以通过@vite/plugin-vuetemplate.compilerOptions传入, 会透传到baseCompile函数中. 但是在transform的过程中, 用户的compilerOptions是最后执行的, 在此之前, vue会将图片资源和v-if v-for等指令转换成vue独立的AST.

transform

v-if会将当前节点包裹一层, 自定义节点的方式也需要同样的做法, 用来生成三元表达式的AST. vIf

在内部是按照once -> if -> memo -> for -> element ... -> user, 因此自己写的nodeTransform一定是最后执行的, 而在此之前还有compiler-sfc compiler-dom的对于资源处理之类的插件. 这些插件会对资源url进行处理, 将引入的静态资源进行处理, 转换成import的形式.

processAssetUrl

在第一次的想法中, 意图通过nodeTransforms对指令进行判断, 做出和v-if一样的处理. 但是实际上, vue并没有暴露出相关的方法, 如果想要做出同样的编译, 就需要自己实现v-if的相关逻辑, 就是从源码中复制同样的内容.

但是经过测试, 结果并不如所想的一样. 因为v-if会包裹一层, 所以当前节点会变成v-if的子节点, 如果是引用了资源的节点的话则会导致该节点再次经过compiler-sfc compiler-dom等一系列的处理. 因为前面的部分资源地址已经被进行了转换, 成了bind引入的地址, 导致进行了多余的处理.

trasnformExpression

因为前面的处理导致资源的地址被处理成了类似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-vueAPI的传递和过程分析, 发现plugin会在调用compiler-sfc的时候传入自定义template.compiler对象, 这个template.compiler对象中的方法执行时机要早于template.compilerOptions. 但是这里的问题是, 传入compiler.compile方法的值, 只有源代码字符串, 也就是<template>的内容, 也就是需要进行解析转换.

而在vue内部, 不管是编译什么, 都会调用baseCompile方法. 而在baseCompile方法中, 会通过调用baseParse将对应的<template>转换成AST.

baseCompile2

幸运的是, 这个baseParse是被暴露出来的, 所以可以自己调用, 将<template>转换成AST. compiler-sfc compiler-dom都有导出对应的parse方法, 不过因为需要在更早的时候进行处理, 因此直接取用compiler-corebaseParse进行解析.

比较省事的是, 在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的操作有了更加直观的感受.

© 9999 Vivia Name

Powered by Nextjs & Theme Vivia

主题完全模仿 Vivia 主题, 有些许差异, 及使用nextjs乱写

Theme Toggle