Vue.js 99 🟢 Custom AST Transforms and Vue Template Compiler Plugins
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
| Aspect | nodeTransforms | directiveTransforms |
|---|---|---|
| Trigger | Every node | Nodes with the matching directive |
| Type | Array | Object keyed by directive name |
| Signature | (node, context) => void | (() => void) | (dir, node, context) => DirectiveTransformResult |
| Order | Sequential, all run | Only matching directive runs |
Context Methods
| Method | Purpose |
|---|---|
context.helper(HELPER) | Register runtime helper import |
context.removeNode(node) | Remove node from parent |
context.replaceNode(node, replacement) | Swap node |
context.currentNode | Current node |
context.parent | Parent node |
Common Node Types
| Type | Meaning |
|---|---|
NodeTypes.ELEMENT | Element node |
NodeTypes.ATTRIBUTE | Static attribute |
NodeTypes.DIRECTIVE | Directive (v-*) |
NodeTypes.TEXT | Text 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Attribute still in output | Transform ran after transformElement | Place transform earlier in the array |
| Transform crashes | Node type not checked | if (node.type !== NodeTypes.ELEMENT) return |
| Helper not imported | context.helper() not called | Register the helper |
| Directive transform never runs | Wrong directive name | Match the v- name exactly |
| Order matters | v-if runs before v-for | Insert 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
| Item | Value |
|---|---|
| Transform stage | Between parse and generate |
nodeTransforms | Array of functions, run on every node |
directiveTransforms | Object keyed by directive name |
| Transform signature | (node, context) => void | (() => void) |
| Registration | compilerOptions.nodeTransforms in Vite |
| Node removal | context.removeNode(node) |
| Node replacement | context.replaceNode(node, replacement) |
| Helper registration | context.helper(HELPER) |
| Order matters | v-if before v-for |
Key takeaways:
- The transform stage is the extension point. The template compiler parses the template into an AST, runs the
nodeTransformsarray on every node, runs the matchingdirectiveTransformson directive nodes, and generates the render function. A custom compiler plugin is a function inserted into the array or the map . nodeTransformsrun unconditionally;directiveTransformsrun conditionally. A node transform receives every node and must checknode.typebefore operating. A directive transform receives only the nodes that have the matching directive, so the check is implicit .- The
contextobject provides the manipulation methods.context.removeNode()removes the node,context.replaceNode()swaps it, andcontext.helper()registers a runtime helper. Thecontext.currentNodeandcontext.parentprovide the position in the tree . - The order of
nodeTransformsdetermines 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-attributespackage is a working example. It registers anodeTransformsfunction 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.
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!