A Vite Plugin That Renames v-model to v-mortal — and Why That's a Compile-Time Win
theme: devui-blue highlight: atom-one-dark
Yesterday, while reviewing code, I suddenly spotted several v-mortal entries in the diff.
Huh? Completely unfamiliar, never seen it before. Search engines turned up nothing, and AI said it didn't know either, asking if it was a typo — maybe v-model?
So I found the author of that code, my colleague Xiao Shuang, and asked: "Is this a typo? Shouldn't it be v-model?"
Xiao Shuang chuckled: "Brother, this is a custom directive I built myself. It's pretty advanced — it does two-way data binding just like v-model!"
Turns out Xiao Shuang's English name is Mortal, so he wrote a Vite plugin that replaces every v-mortal with v-model at compile time.
This is a clever approach: the Vue compiler never needs to handle v-mortal, so there's zero extra runtime overhead. Let's see how he implemented it!
Writing the plugin
Using Vite 8 as an example, consulting the Plugin API simple examples docs, first create a VMortalPlugin plugin function:
import type { Plugin } from "vite";
export function VMortalPlugin(): Plugin {
return {
name: "v-mortal",
enforce: "pre",
transform: {
filter: {
id: /\.vue$/
},
handler(code) {
// ...
}
}
};
}
- Use
v-mortalas thenameto identify the plugin; this name appears in logs and error messages; - Since processing must happen before the Vue compiler, set
enforcetopre, see plugin ordering; transform.filter.idtakes/\\.vue$/to indicate only Vue files are processed — directives are normally only used inside.vuefiles.
Next, inside transform.handler, use vue/compiler-sfc's parse() to parse the Vue code and extract the template block:
import { parse } from "vue/compiler-sfc";
const { template } = parse(code).descriptor;
Then traverse template.ast to find every v-mortal directive and replace it with v-model using MagicString:
const visit = (node: TemplateChildNode) => {
// Only process element nodes (e.g. <input>, <CustomComponent>, etc.)
if (node.type === NodeTypes.ELEMENT) {
for (const prop of node.props) {
// Look for the custom directive v-mortal
if (prop.type === NodeTypes.DIRECTIVE && prop.name === MORTAL) {
const start = prop.loc.start.offset;
// Replace v-mortal with v-model
// Only replace the directive name part, preserving arguments and values
ms.overwrite(start, start + V_MORTAL.length, "v-model");
}
}
}
// If the current node has children, continue recursively traversing child nodes
if ("children" in node) {
for (const child of node.children) {
// Filter out simple expression nodes, only process nodes that may contain template structures
if (
typeof child === "object" &&
child.type !== NodeTypes.SIMPLE_EXPRESSION
) {
visit(child);
}
}
}
};
// Start traversing all nodes from the root children of the template AST
for (const child of template.ast.children) {
visit(child);
}
- Using AST traversal finds
v-mortaldirectives precisely and quickly, without needing regex to handle many edge cases, and without mistakenly modifyingv-mortalinside strings or comments; - MagicString is a lightweight and efficient tool for manipulating strings and generating source maps.
Once processing is done, finally return code and map (source-map):
return {
code: ms.toString(),
map: ms.generateMap({ hires: true })
};
At this point the plugin is complete. Now every v-mortal directive in Vue code will be automatically replaced with v-model during the compilation phase.
The complete code is as follows:
// v-mortal.ts
import type { TemplateChildNode } from "@vue/compiler-core";
import { NodeTypes } from "@vue/compiler-core";
import type { Plugin } from "vite";
import { MagicString, parse } from "vue/compiler-sfc";
const MORTAL = "mortal";
const V_MORTAL = `v-${MORTAL}`;
export function VMortalPlugin(): Plugin {
return {
name: "v-mortal",
enforce: "pre",
transform: {
filter: {
id: /\.vue$/
},
handler(code) {
const { template } = parse(code).descriptor;
if (template == null || template.ast == null) {
return;
}
const ms = new MagicString(code);
const visit = (node: TemplateChildNode) => {
if (node.type === NodeTypes.ELEMENT) {
for (const prop of node.props) {
if (prop.type === NodeTypes.DIRECTIVE && prop.name === MORTAL) {
const start = prop.loc.start.offset;
ms.overwrite(start, start + V_MORTAL.length, "v-model");
}
}
}
if ("children" in node) {
for (const child of node.children) {
if (
typeof child === "object" &&
child.type !== NodeTypes.SIMPLE_EXPRESSION
) {
visit(child);
}
}
}
};
for (const child of template.ast.children) {
visit(child);
}
return {
code: ms.toString(),
map: ms.generateMap({ hires: true })
};
}
}
};
}
IntelliSense
When writing v-model, typing v-m usually triggers IDE IntelliSense to suggest v-model. So how can v-mortal get the same treatment?
Xiao Shuang, being clever, consulted the Vue official docs again and found the section on adding types for custom global directives. He followed the pattern and provided a v-mortal.d.ts.
// v-mortal.d.ts
import type { Directive } from "vue";
export type MortalDirective = Directive<HTMLElement, any>;
declare module "vue" {
export interface GlobalDirectives {
vMortal: MortalDirective;
}
}
Now v-mortal also gets IntelliSense — the developer experience is maxed out!
Summary and review
Through this small case, we've learned the basic principles of Vite plugin development and the engineering practice of using TypeScript to improve the development experience.
Extending Vue's syntax capabilities through compile-time transformations gives developers a way to write code that better fits team habits, without introducing extra runtime cost.
v-mortal, though just an inappropriate simple example, demonstrates a very important idea in frontend engineering: push complex logic as early as possible into the build phase, shifting the runtime burden onto the compiler and toolchain.
Note: this case is only for demonstrating plugin development. It's not recommended to do this in daily work, lest you receive "warm greetings" from enthusiastic colleagues~ 😁
Final words
I'm Xiao He (xiaohe0601), passionate about code, currently focused on AI and frontend development.
Feel free to follow my WeChat official account 「小何不会写代码」, where I share development insights, best practices, and technical explorations from time to time. Hope it helps you!
Top 2 of 4 from juejin.cn, machine-translated. The original thread is authoritative.
Since v-mortal gets replaced with v-model at build time, why not just use v-model directly?
Good question [look]
Hehe, brother~~~ keep up the high-quality sharing [heart]
Hehe, thanks Kieran for the support [heart]