Vue.js 93 🟢 Custom Macro Creation and Babel/Unplugin Transformations (unplugin-vue)
Vue’s compiler macros—defineProps, defineEmits, defineModel, defineOptions—are functions that look like runtime imports but are eliminated at compile time and replaced with the equivalent runtime code. The macro system is not closed. The vue-macros project demonstrates that custom macros can be created and shipped as unplugin transformations, and unplugin-vue is the underlying tool that makes a Vue SFC transform portable across Vite, Rollup, Webpack, esbuild, and other bundlers. The combination allows a team to define its own compile-time syntax sugar and apply it consistently across the build toolchain.
Key point: A custom macro is not a Vue feature; it is a build-time transform. The macro name is recognized by a plugin, the plugin rewrites the AST, and the output is standard Vue code. The macro has no runtime existence. If the plugin is not installed, the macro is undefined at runtime and the component fails.
Why custom macros and unplugin exist
The boilerplate problem. defineModel was a community macro in vue-macros before it became a stable Vue feature in 3.4 . It existed because the manual pattern—declaring modelValue props and emitting update:modelValue—was verbose and error-prone. The same pattern applies to any repeated compile-time transformation: a team that finds itself writing the same setup code in every component can encode that pattern as a macro and eliminate the repetition.
The bundler fragmentation problem. A Vue SFC transform that works in Vite does not automatically work in Webpack. The unplugin project provides a unified plugin interface that adapts to each bundler, and unplugin-vue is the Vue-specific implementation . A custom macro built on unplugin-vue inherits that portability. The macro plugin can be distributed as a single package that works in Vite, Rollup, esbuild, and Webpack without separate implementations.
The experimentation problem. The vue-macros project exists as an experimentation ground for macros that might eventually graduate to core. defineModel started there. A custom macro that proves useful can be published and adopted by other projects before the Vue team decides whether to include it. The macro system is a testing ground for syntax sugar.
a. The unplugin Architecture
unplugin-vue is built on createUnplugin, which takes a factory function and returns an object with bundler-specific methods: .vite(), .rollup(), .webpack(), .esbuild(), and others . The factory returns a plugin object with hooks like transformInclude and transform.
import type { UnpluginFactory } from 'unplugin'
import { createUnplugin } from 'unplugin'
export const unpluginFactory: UnpluginFactory<Options | undefined> = options => ({
name: 'unplugin-my-macro',
transformInclude(id) {
return id.endsWith('.vue')
},
transform(code, id) {
// rewrite the SFC source here
return code
},
})
export const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory)
export default unplugin
The transformInclude hook filters which files the plugin runs on. For a Vue macro, it should match .vue files, and often only the <script setup> block. The transform hook receives the source code and the file ID, and returns the rewritten source .
A Vue-specific macro plugin does not parse the entire SFC from scratch. It uses @vue/compiler-sfc to parse the SFC, extracts the <script setup> content, applies the macro transformation, and reassembles the SFC. The unplugin-vue package itself handles the SFC-to-JavaScript conversion; a custom macro plugin typically runs before or alongside it .
b. Creating a Custom Macro
A macro is recognized by name. The plugin’s transform hook parses the script block, finds calls to the macro function, and replaces them with the equivalent code. The macro function itself is never imported; it exists only as an identifier that the compiler recognizes.
A simplified example of a macro that generates a name option from a constant:
// The macro: defineName('MyComponent')
// The transform: replace with defineOptions({ name: 'MyComponent' })
import { parse, compileScript } from '@vue/compiler-sfc'
function transformMacro(code: string, id: string) {
const { descriptor } = parse(code)
if (!descriptor.scriptSetup) return code
const macroCall = descriptor.scriptSetup.content.match(
/defineName\s*\(\s*['"]([^'"]+)['"]\s*\)/
)
if (!macroCall) return code
const name = macroCall[1]
const newScript = descriptor.scriptSetup.content.replace(
macroCall[0],
`defineOptions({ name: '${name}' })`
)
// Reassemble the SFC with the modified script
return code.replace(
descriptor.scriptSetup.content,
newScript
)
}
The vue-macros library implements more sophisticated transformations. defineOptions is a macro that accepts an object and hoists it into the component’s options. When the macro is used with a constant declared in the same script, the hoistStatic feature is required to move the constant outside the setup function so it can be referenced in the generated options object . The plugin performs the AST analysis that makes this possible.
The macro’s TypeScript types must be declared for the editor and the compiler. The vue-macros project ships a macros-global type definition that declares the global functions, and the Volar plugin provides the type checking for the macro arguments . A custom macro needs the same: a .d.ts that declares the function signature so TypeScript does not error on the undefined identifier.
c. Integrating the Macro Plugin
The plugin is installed alongside unplugin-vue or @vitejs/plugin-vue. In a Vite configuration, both plugins are added to the plugins array. The order matters if the macro transformation must run before the SFC compilation.
// vite.config.ts
import Vue from 'unplugin-vue/vite'
import MyMacro from 'unplugin-my-macro/vite'
export default defineConfig({
plugins: [
MyMacro(), // runs the macro transform
Vue(), // compiles the SFC to JS
],
})
The macro plugin should run first if it rewrites the <script setup> content. The Vue plugin then compiles the modified SFC. If the macro plugin runs after the Vue plugin, the SFC has already been converted to JavaScript, and the macro syntax may not be recognizable.
For a library that wants to support multiple bundlers, the unplugin factory is exported, and the bundler-specific entry points are generated by calling unplugin.vite(options), unplugin.rollup(options), and so on . The package’s package.json exports map each entry point to the corresponding file.
Complete Example Session
// ============================================
// PART 1: THE MACRO TYPE DECLARATION
// ============================================
// macros.d.ts
declare function defineName(name: string): void
// ============================================
// PART 2: THE UNPLUGIN FACTORY
// ============================================
import type { UnpluginFactory } from 'unplugin'
import { createUnplugin } from 'unplugin'
import { parse } from '@vue/compiler-sfc'
interface Options {
prefix?: string
}
export const unpluginFactory: UnpluginFactory<Options | undefined> = options => ({
name: 'unplugin-define-name',
transformInclude(id) {
return id.endsWith('.vue')
},
transform(code, id) {
const { descriptor } = parse(code)
if (!descriptor.scriptSetup) return code
const match = descriptor.scriptSetup.content.match(
/defineName\s*\(\s*['"]([^'"]+)['"]\s*\)/
)
if (!match) return code
const name = options?.prefix
? `${options.prefix}${match[1]}`
: match[1]
const newContent = descriptor.scriptSetup.content.replace(
match[0],
`defineOptions({ name: '${name}' })`
)
return code.replace(descriptor.scriptSetup.content, newContent)
},
})
// ============================================
// PART 3: THE UNPLUGIN INSTANCE
// ============================================
export const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory)
export default unplugin
// ============================================
// PART 4: VITE ENTRY POINT
// ============================================
// vite.ts
import { createVitePlugin } from 'unplugin'
import { unpluginFactory } from './index'
export default createVitePlugin(unpluginFactory)
// ============================================
// PART 5: VITE CONFIGURATION
// ============================================
// vite.config.ts
import Vue from 'unplugin-vue/vite'
import DefineName from 'unplugin-define-name/vite'
export default defineConfig({
plugins: [DefineName(), Vue()],
})
// ============================================
// PART 6: COMPONENT USAGE
// ============================================
// MyComponent.vue
// <script setup lang="ts">
// defineName('MyComponent')
// const count = ref(0)
// </script>
// ============================================
// PART 7: TRANSFORMED OUTPUT
// ============================================
// <script setup lang="ts">
// defineOptions({ name: 'MyComponent' })
// const count = ref(0)
// </script>
The seven parts trace the macro declaration, the unplugin factory, the Vite entry point, the configuration, and the component usage. The critical step is the transform hook, which parses the SFC, finds the macro call, and replaces it with the standard defineOptions call.
Quick Reference
unplugin Hooks
| Hook | Purpose |
|---|---|
name | Plugin identifier |
transformInclude(id) | Filter which files to transform |
transform(code, id) | Rewrite the source |
resolveId / load | Virtual modules |
Bundler Entry Points
| Bundler | Import |
|---|---|
| Vite | unplugin.vite(options) |
| Rollup | unplugin.rollup(options) |
| Webpack | unplugin.webpack(options) |
| esbuild | unplugin.esbuild(options) |
Macro Plugin Requirements
| Requirement | Purpose |
|---|---|
.d.ts declaration | TypeScript does not error on the macro |
@vue/compiler-sfc | Parse the SFC |
| Transform hook | Replace the macro call |
| Volar plugin | Editor type support (optional) |
Best Practices
✅ Do This:
// Limit transform to .vue files
transformInclude(id) { return id.endsWith('.vue') } // ✅
// Parse the SFC with @vue/compiler-sfc
const { descriptor } = parse(code) // ✅
// Run the macro plugin before the Vue plugin
plugins: [MyMacro(), Vue()] // ✅
❌ Don’t Do This:
// Transform every file
transformInclude() { return true } // slow, may break other files // ❌
// Run the macro plugin after the Vue plugin
// The SFC is already compiled to JS; the macro syntax is gone // ❌
// Forget the .d.ts declaration
// TypeScript errors on the undefined macro name // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Macro is undefined at runtime | Plugin not installed or ran after Vue plugin | Add plugin, order before Vue |
| TypeScript errors on macro | Missing .d.ts | Declare the function |
| Transform runs on every file | transformInclude too broad | Filter to .vue |
| SFC parse fails | @vue/compiler-sfc version mismatch | Match Vue version |
Real-World Examples
1. unplugin Factory
export const unpluginFactory: UnpluginFactory<Options> = options => ({ ... })
2. createUnplugin
export const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory)
3. transformInclude
transformInclude(id) { return id.endsWith('.vue') }
4. transform Hook
transform(code, id) { return code.replace(...) }
5. Vite Entry
export default createVitePlugin(unpluginFactory)
6. Vite Config
plugins: [DefineName(), Vue()]
7. Macro Declaration
declare function defineName(name: string): void
8. SFC Parse
const { descriptor } = parse(code)
9. Macro Replacement
code.replace(macroCall[0], `defineOptions({ name: '${name}' })`)
10. Volar Plugin
"vueCompilerOptions": { "plugins": ["vue-macros/volar"] }
Visual
The unplugin Pipeline
┌──────────────────────────────────────────────────────────────┐
│ BUNDLER │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ unplugin-define-name ││
│ │ transformInclude → .vue files ││
│ │ transform → replace defineName(...) ││
│ └──────────────────────────┬──────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ unplugin-vue / @vitejs/plugin-vue ││
│ │ compile SFC to JavaScript ││
│ └──────────────────────────┬──────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ OUTPUT: standard Vue component JS ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ The macro plugin runs first. The Vue plugin compiles. │
│ │
└──────────────────────────────────────────────────────────────┘
The Transform Hook
┌──────────────────────────────────────────────────────────────┐
│ INPUT: MyComponent.vue │
│ <script setup> │
│ defineName('MyComponent') │
│ const count = ref(0) │
│ </script> │
│ │ │
│ ▼ │
│ parse() → descriptor.scriptSetup.content │
│ │ │
│ ▼ │
│ match(/defineName\(['"](.+)['"]\)/) │
│ │ │
│ ▼ │
│ replace with defineOptions({ name: 'MyComponent' }) │
│ │ │
│ ▼ │
│ OUTPUT: <script setup> │
│ defineOptions({ name: 'MyComponent' }) │
│ const count = ref(0) │
│ </script> │
│ │
│ The macro has no runtime existence. │
│ │
└──────────────────────────────────────────────────────────────┘
Portability
┌──────────────────────────────────────────────────────────────┐
│ unplugin-define-name │
│ │ │
│ ├── .vite(options) → Vite plugin │
│ ├── .rollup(options) → Rollup plugin │
│ ├── .webpack(options) → Webpack plugin │
│ └── .esbuild(options) → esbuild plugin │
│ │
│ One factory, four bundler adapters. │
│ The macro works wherever unplugin works. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| unplugin | Unified plugin interface for multiple bundlers |
createUnplugin | Factory that returns bundler adapters |
transformInclude | Filters which files the plugin runs on |
transform | Rewrites the source code |
| Vue SFC parsing | @vue/compiler-sfc |
| Macro declaration | .d.ts file with the function signature |
| Plugin order | Macro plugin before Vue plugin |
vue-macros | Community macro library and experimentation ground |
Key takeaways:
- A custom macro is a build-time transform, not a runtime feature. The macro name is recognized by a plugin, which rewrites the AST before the Vue compiler runs. The macro has no runtime existence; if the plugin is not installed, the macro is an undefined identifier .
unpluginprovides bundler portability. A macro plugin built oncreateUnpluginexports adapters for Vite, Rollup, Webpack, esbuild, and other bundlers from a single factory. The macro works wherever the adapter works, without separate implementations for each bundler .- The
transformIncludeandtransformhooks are the integration points.transformIncludefilters the files, andtransformreceives the source and returns the rewritten source. For a Vue macro, the transform parses the SFC with@vue/compiler-sfc, extracts the<script setup>content, and replaces the macro call. - The macro must be declared for TypeScript. Without a
.d.tsdeclaration, the editor and the compiler report the macro as an undefined function. Thevue-macrosproject ships amacros-globaltype definition that serves as the reference for this pattern . - The plugin order matters. The macro plugin must run before the Vue plugin. If the Vue plugin runs first, the SFC has already been compiled to JavaScript, and the macro syntax is no longer recognizable.
Remember: Vue’s macro system is a compile-time contract between the source code and the build plugin. The defineProps and defineModel macros are built into the Vue compiler, but the mechanism is the same for custom macros: a plugin recognizes a name, rewrites the AST, and emits standard code. unplugin-vue and unplugin make that rewrite portable across the bundler ecosystem. The vue-macros project demonstrates the pattern at scale, and its history—defineModel started as a community macro and became a core feature—shows that the macro system is a path for ideas to prove themselves before they become part of the framework. A custom macro is the same mechanism applied to a team’s specific problem, with the same requirement: the plugin must be present, the declaration must exist, and the order must be correct.
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!