| |

Vue.js 99 🟢 Custom AST Transforms and Vue Template Compiler Plugins

⏱️ Estimated read: 11 min | |

Vue’s template compiler is not a closed pipeline. The transform stage—the phase between parsing the template into an AST and generating the render function—is built from an array of nodeTransforms functions that run on every node, and a map of directiveTransforms that run on specific directives. A custom compiler plugin is a function inserted into that array or map. It receives the current AST node and the transform context, and it can inspect, modify, replace, or remove the node before the code generator sees it.

Key point: The nodeTransforms array is ordered and every node passes through every function in the array. The directiveTransforms object is keyed by directive name and only runs when the node has that directive. A custom transform that mutates the AST must account for the fact that other transforms may run before or after it, and that the node may already have a codegenNode by the time it runs.


Why custom transforms exist

The build-time stripping problem. Attributes that are needed in development but not in production—data-test, data-cy, debug IDs—do not need to exist in the final DOM. A custom transform can remove them from the AST during the build, so the generated render function never creates them. This is not a runtime directive; it is a compile-time deletion. The vue-plugin-remove-attributes package does exactly this, registering a nodeTransforms function that removes specified attributes and directives from every element node .

The custom syntax problem. A team may want a template syntax that Vue does not support natively—a v-permission directive that strips unauthorized nodes at build time, a <my-button size="large"> that compiles to a different component, or a conditional compilation flag. A custom transform can recognize the syntax and rewrite the AST to the equivalent standard Vue code before the built-in transforms run .

The instrumentation problem. A low-code platform or a visual editor needs a stable identifier on every element to map the runtime DOM back to the AST node. A custom transform can inject a data-tracking-id attribute into every element node during the transform stage, and the runtime can use that ID to correlate the rendered element with its source location .


a. The Transform Function Signature and Context

A NodeTransform is a function with the signature (node, context) => void | (() => void). The function runs when the traversal enters the node. If it returns a function, that function runs when the traversal exits the node—after all children have been processed .

import type { NodeTransform, TransformContext } from '@vue/compiler-core'
import { NodeTypes } from '@vue/compiler-core'

const myTransform: NodeTransform = (node, context) => {
  // Runs on enter
  if (node.type === NodeTypes.ELEMENT) {
    // inspect or modify node.props
  }
  return () => {
    // Runs on exit, after children are transformed
  }
}

The context object provides the helpers needed to modify the AST correctly. context.helper(SOME_HELPER) registers a runtime helper import, context.removeNode(node) removes a node from its parent, and context.replaceNode(node, replacement) swaps the node for a new one. The context.currentNode is the node currently being processed, and the context.parent is its parent .

The nodeTransforms array is ordered, and the order matters. The built-in transforms run in a specific sequence: v-if before v-for, expression processing before element transformation, and so on. A custom transform inserted at the wrong position may run before the node has the structure it expects, or after another transform has already consumed the part it wanted to modify .


b. Registering a Transform via Vite

The custom transform is registered through the compilerOptions.nodeTransforms array in the Vite plugin configuration. The @vitejs/plugin-vue plugin passes the compilerOptions to the template compiler, and the custom transform is appended to the built-in array.

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import createAttributesRemover from 'vue-plugin-remove-attributes'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          nodeTransforms: [
            // Custom transform runs alongside the built-in ones
            ...(process.env.NODE_ENV === 'production'
              ? [createAttributesRemover(['data-test'])]
              : []),
          ],
        },
      },
    }),
  ],
})

The spread pattern is what makes the transform conditional. In development, the array is empty, and the data-test attributes remain in the DOM for the testing tools. In production, the transform is present, and the attributes are removed from the AST before the render function is generated .

For a transform that needs to be reused across projects, the pattern is to package it as a factory function that returns the NodeTransform. The factory accepts options—which attributes to remove, which prefix to use—and the returned transform closes over those options.


c. The Transform Context and Node Manipulation

The most common operations are removing a node, replacing a node, and modifying its props. The context provides the methods, and the node is the target.

import { NodeTypes, createSimpleExpression } from '@vue/compiler-core'

const injectTrackingId: NodeTransform = (node, context) => {
  if (node.type !== NodeTypes.ELEMENT) return

  // Check if the attribute already exists
  const hasId = node.props.some(
    p => p.type === NodeTypes.ATTRIBUTE && p.name === 'data-tracking-id'
  )
  if (hasId) return

  // Generate an ID and inject it into the props
  const id = `el-${Date.now()}-${Math.random().toString(36).slice(2)}`
  node.props.push({
    type: NodeTypes.ATTRIBUTE,
    name: 'data-tracking-id',
    value: {
      type: NodeTypes.TEXT,
      content: id,
      loc: node.loc,
    },
    loc: node.loc,
  })

  // Store the mapping for reverse transforms
  if (context.trackingMap) {
    context.trackingMap.set(id, node)
  }
}

The node.props array is the correct place to add attributes during the transform stage. The code generator reads this array when it emits the VNode creation call. Modifying node.props before the transformElement transform runs means the attribute is included in the generated code; modifying it after means the codegenNode has already been created and the change is ignored .

The context.removeNode() method is the mechanism for stripping. It removes the current node from its parent’s children array. If the node has already been transformed and has a codegenNode, the removal is still effective because the code generator traverses the parent’s children, not the removed node.


Complete Example Session

// ============================================
// PART 1: THE TRANSFORM FACTORY
// ============================================
import type { NodeTransform, TransformContext } from '@vue/compiler-core'
import { NodeTypes } from '@vue/compiler-core'

export function createAttributesRemover(attrs: string[]): NodeTransform {
  const attrSet = new Set(attrs)

  return (node: NodeTypes, context: TransformContext) => {
    if (node.type !== NodeTypes.ELEMENT) return

    node.props = node.props.filter(prop => {
      if (prop.type === NodeTypes.ATTRIBUTE) {
        return !attrSet.has(prop.name)
      }
      if (prop.type === NodeTypes.DIRECTIVE) {
        return !attrSet.has(prop.name)
      }
      return true
    })
  }
}


// ============================================
// PART 2: REGISTRATION IN VITE
// ============================================
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { createAttributesRemover } from './transforms/remove-attributes'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          nodeTransforms: [
            ...(process.env.NODE_ENV === 'production'
              ? [createAttributesRemover(['data-test', 'data-cy'])]
              : []),
          ],
        },
      },
    }),
  ],
})


// ============================================
// PART 3: THE INJECT TRANSFORM
// ============================================
import { createSimpleExpression } from '@vue/compiler-core'

const injectTrackingId: NodeTransform = (node, context) => {
  if (node.type !== NodeTypes.ELEMENT) return

  const id = `el-${Math.random().toString(36).slice(2)}`
  node.props.push({
    type: NodeTypes.ATTRIBUTE,
    name: 'data-tracking-id',
    value: createSimpleExpression(id, true),
    loc: node.loc,
  })
}


// ============================================
// PART 4: DIRECTIVE TRANSFORM
// ============================================
// A directive transform runs only when the node has the directive
import type { DirectiveTransform } from '@vue/compiler-core'

const vPermissionTransform: DirectiveTransform = (dir, node, context) => {
  // If the permission check is static, remove the node at build time
  const permission = dir.exp
  if (permission && permission.type === NodeTypes.SIMPLE_EXPRESSION) {
    // context.removeNode(node) // uncomment for build-time stripping
  }
  return {
    props: [],
  }
}


// ============================================
// PART 5: REGISTERING A DIRECTIVE TRANSFORM
// ============================================
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          directiveTransforms: {
            permission: vPermissionTransform,
          },
        },
      },
    }),
  ],
})

The five parts trace the node transform factory, the Vite registration, the attribute injection, the directive transform, and the directive registration.


Quick Reference

NodeTransform vs DirectiveTransform

AspectnodeTransformsdirectiveTransforms
TriggerEvery nodeNodes with the matching directive
TypeArrayObject keyed by directive name
Signature(node, context) => void | (() => void)(dir, node, context) => DirectiveTransformResult
OrderSequential, all runOnly matching directive runs

Context Methods

MethodPurpose
context.helper(HELPER)Register runtime helper import
context.removeNode(node)Remove node from parent
context.replaceNode(node, replacement)Swap node
context.currentNodeCurrent node
context.parentParent node

Common Node Types

TypeMeaning
NodeTypes.ELEMENTElement node
NodeTypes.ATTRIBUTEStatic attribute
NodeTypes.DIRECTIVEDirective (v-*)
NodeTypes.TEXTText node

Best Practices

✅ Do This:

// Filter props, do not mutate the array in place
node.props = node.props.filter(p => !attrSet.has(p.name))              // ✅
// Register the transform conditionally
...(isProd ? [createRemover(['data-test'])] : [])                      // ✅
// Use context.removeNode for stripping
context.removeNode(node)                                               // ✅

❌ Don’t Do This:

// Modify node.props after transformElement has run
// The codegenNode is already built; the change is ignored               // ❌
// Return a value from a node transform
return { props: [] }  // nodeTransforms return void or an exit function // ❌
// Assume the transform runs on every node type
// Check node.type before accessing element-specific properties          // ❌

Common Pitfalls

PitfallWhy It HappensFix
Attribute still in outputTransform ran after transformElementPlace transform earlier in the array
Transform crashesNode type not checkedif (node.type !== NodeTypes.ELEMENT) return
Helper not importedcontext.helper() not calledRegister the helper
Directive transform never runsWrong directive nameMatch the v- name exactly
Order mattersv-if runs before v-forInsert custom transform with the order in mind

Real-World Examples

1. Remove Test Attributes

node.props = node.props.filter(p => p.name !== 'data-test')

2. Conditional Registration

...(process.env.NODE_ENV === 'production' ? [remover] : [])

3. Inject Tracking ID

node.props.push({ type: NodeTypes.ATTRIBUTE, name: 'data-tracking-id', value: ... })

4. Remove Node

context.removeNode(node)

5. Register Helper

context.helper(CREATE_VNODE)

6. Directive Transform

directiveTransforms: { permission: vPermissionTransform }

7. Factory Pattern

export function createRemover(attrs) { return (node, context) => {} }

8. Check Node Type

if (node.type !== NodeTypes.ELEMENT) return

9. Static Expression

createSimpleExpression(id, true)

10. Vite Config

vue({ template: { compilerOptions: { nodeTransforms: [...] } } })

Visual

The Transform Pipeline

┌──────────────────────────────────────────────────────────────┐
│  TEMPLATE STRING                                             │
│         │                                                    │
│         ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ PARSE           │  → Template AST                        │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ TRANSFORM       │  → nodeTransforms run on every node   │
│  │                 │    directiveTransforms on v-* nodes   │
│  │                 │    → JavaScript AST                   │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ GENERATE        │  → render function string              │
│  └─────────────────┘                                        │
│                                                              │
│  Custom transforms are inserted into the transform stage.   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

nodeTransforms vs directiveTransforms

┌──────────────────────────────────────────────────────────────┐
│  nodeTransforms (array)                                      │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ for each node:                                          ││
│  │   transform1(node, context)                             ││
│  │   transform2(node, context)                             ││
│  │   transform3(node, context)                             ││
│  │   ...                                                   ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  directiveTransforms (object)                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ if node has v-model:  transformModel(dir, node, ctx)    ││
│  │ if node has v-on:     transformOn(dir, node, ctx)       ││
│  │ if node has v-custom: transformCustom(dir, node, ctx)   ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  Node transforms run unconditionally.                        │
│  Directive transforms run only for their directive.          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Enter/Exit Hooks

┌──────────────────────────────────────────────────────────────┐
│  const transform = (node, context) => {                      │
│    // ENTER: runs before children are processed              │
│    // modify node.props here                                │
│                                                              │
│    return () => {                                            │
│      // EXIT: runs after children are processed              │
│      // adjust based on child results                       │
│    }                                                         │
│  }                                                           │
│                                                              │
│  The enter hook is where attribute injection happens.        │
│  The exit hook is where codegenNode adjustments happen.      │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Transform stageBetween parse and generate
nodeTransformsArray of functions, run on every node
directiveTransformsObject keyed by directive name
Transform signature(node, context) => void | (() => void)
RegistrationcompilerOptions.nodeTransforms in Vite
Node removalcontext.removeNode(node)
Node replacementcontext.replaceNode(node, replacement)
Helper registrationcontext.helper(HELPER)
Order mattersv-if before v-for

Key takeaways:

  • The transform stage is the extension point. The template compiler parses the template into an AST, runs the nodeTransforms array on every node, runs the matching directiveTransforms on directive nodes, and generates the render function. A custom compiler plugin is a function inserted into the array or the map .
  • nodeTransforms run unconditionally; directiveTransforms run conditionally. A node transform receives every node and must check node.type before operating. A directive transform receives only the nodes that have the matching directive, so the check is implicit .
  • The context object provides the manipulation methods. context.removeNode() removes the node, context.replaceNode() swaps it, and context.helper() registers a runtime helper. The context.currentNode and context.parent provide the position in the tree .
  • The order of nodeTransforms determines what the custom transform sees. The built-in transforms run in a specific sequence, and a custom transform inserted at the wrong position may run before the node has the structure it expects, or after another transform has already consumed the part it wanted to modify .
  • The vue-plugin-remove-attributes package is a working example. It registers a nodeTransforms function that removes specified attributes and directives from the AST during compilation. The conditional registration in the Vite config—...(process.env.NODE_ENV === 'production' ? [remover] : [])—is the pattern for applying the transform only in the production build .

Remember: Vue’s template compiler is a pipeline of transforms, and the pipeline is open. A custom compiler plugin is not a runtime feature; it is a build-time function that receives the AST and the context and modifies the tree before the code generator runs. The nodeTransforms array is the general extension point, and the directiveTransforms object is the specific one. The transform can remove an attribute, inject a tracking ID, strip an unauthorized node, or rewrite a custom syntax into standard Vue code. The limits are the AST structure and the order of the built-in transforms. When the custom transform respects the node types and the registration order, it becomes part of the compilation, and its effect is indistinguishable from the built-in transforms.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!