A Voodoo component is a scope with state, methods, computed, watchers, props, slots, and
lifecycle, mounted on an element that already exists. There's no compilation step, no
.vue file, and no JSX.
V.component('counter', {
props: { start: { type: 'number', default: 0 } },
state(props) {
return { value: props.start };
},
computed: {
double() { return this.value * 2; },
},
methods: {
add() { this.value++; },
reset() { this.value = 0; this.emit('reset'); },
},
watch: {
value(newVal, oldVal) { console.log(oldVal, '->', newVal); },
},
template: `
<button v-click="add">+1</button>
<strong>{ value }</strong>
<small>double: { double }</small>
`,
style: `strong { font-size: 1.4rem; }`,
mounted() { console.log('mounted on', this.$el); },
});<!-- 1. attribute -->
<div v-component="counter" start="10"></div>
<!-- 2. own tag -->
<counter start="10"></counter>
<!-- 3. PascalCase tag -->
<Counter start="10"></Counter>The name is normalized: UserCard, userCard, and user-card point to the same component. The
PascalCase form works because HTML delivers the tag in lowercase (usercard) and the library
keeps a map of hyphen-free names.
Choose a hyphen name when registering: that's what the custom elements standard expects and what avoids collision with native tags.
Two ways to write. The short one, with a list of names:
V.component('greeting', {
props: ['name', 'age'],
template: '<p>{ name } is { age } years old</p>',
});And the complete one, with type, default value, and required flag:
V.component('card', {
props: {
title: { type: 'string', default: 'Untitled' },
total: { type: 'number', default: 0 },
active: { type: 'boolean', default: false },
tags: { type: 'array', default: [] },
user: { type: 'object' },
any: { type: 'any' },
id: { type: 'string', required: true },
},
});Accepted types: string, number, boolean, array, object, any. A required prop that
doesn't arrive generates a console warning.
<!-- fixed value, converted to the declared type -->
<card title="Revenue" total="1200" active></card>
<!-- tied to parent state, updates by itself -->
<card :title="panel.name" :total="panel.revenue" :active="panel.enabled"></card>Boolean props accept empty attribute (active), text (active="true"), and reactive binding
(:active="enabled").
Names are resolved flexibly: user-name, username, and userName all arrive as
userName.
When the component doesn't declare any props, all common attributes become props with camelCase names. When it does, unknown attributes are ignored.
state(props) returns the initial object. It receives props already resolved:
V.component('editor', {
props: { text: { type: 'string', default: '' } },
state(props) {
return { draft: props.text, saving: false };
},
});data(props) is an alias, for those coming from Vue.
v-data on the same element complements the state:
<div v-component="editor" v-data="{ advancedMode: true }"></div>Inside methods, this is the instance. State, props, computed, and other methods are read
and written directly:
methods: {
async save() {
this.saving = true;
try {
const data = await V.http.post('/api/texts', { text: this.draft });
this.emit('saved', data);
V.toast.success('Saved!');
} finally {
this.saving = false;
}
},
}Functions loose in the definition also become methods, which shortens small components:
V.component('clock', {
state: () => ({ now: new Date() }),
format() { return this.now.toLocaleTimeString(); },
template: '<time>{ format() }</time>',
});computed: {
full() { return `${this.first} ${this.last}`; },
hasError() { return Object.keys(this.errors).length > 0; },
}Computed have cache and only recalculate when a dependency changes.
watch: {
search(newVal, oldVal) {
if (newVal.length >= 3) this.search(newVal);
},
}The template replaces the element's content. To receive the original content, use <slot>:
V.component('panel', {
props: { title: { type: 'string', default: '' } },
template: `
<section class="panel">
<header>
<slot name="header"><h3>{ title }</h3></slot>
</header>
<div class="body"><slot></slot></div>
<footer><slot name="footer"></slot></footer>
</section>
`,
});<panel title="Report">
<h3 slot="header">Custom header</h3>
<p>This paragraph goes in the default slot.</p>
<button slot="footer">Close</button>
</panel>Slot rules:
- the unnamed slot receives all content that wasn't addressed;
- content written inside
<slot>is the default, used when nobody fills it; - slot content is evaluated in the parent's scope, like in Vue. It doesn't see the component's internal state.
A component without template keeps its own HTML and just wraps everything in a scope:
<div v-component="filter">
<input v-model="term">
<p>{ results.length } results for "{ term }"</p>
</div>style injects CSS once, the first time the component is used:
V.component('alert', {
style: `.alert { padding: 12px; border-radius: 8px; background: var(--v-surface-2); }`,
template: '<div class="alert"><slot></slot></div>',
});CSS is not scope-isolated. Use unique class names or the component prefix.
methods: {
confirm() { this.emit('confirmed', { id: this.id }); },
}<dialog @confirmed="register($detail)"></dialog>
<dialog v-on:confirmed="onConfirm"></dialog>The event is a CustomEvent that bubbles up the tree. The detail arrives as $detail in the
expression and as the first argument when you pass just a function name.
$emit is an alias for emit.
| Hook | When it runs |
|---|---|
beforeMount |
Before the template replaces the content |
mounted |
After the round's DOM has been applied |
updated |
After any state change, when the hook is declared |
beforeUnmount |
Before the element leaves the DOM |
unmounted |
After removal |
destroyed |
Alias for unmounted |
V.component('clock', {
state: () => ({ now: new Date() }),
mounted() {
this.timer = setInterval(() => { this.now = new Date(); }, 1000);
},
beforeUnmount() {
clearInterval(this.timer);
},
template: '<time>{ now.toLocaleTimeString() }</time>',
});| Property | What is it |
|---|---|
this.$el |
Host element |
this.$props |
Reactive object with props |
this.$refs |
Elements marked with v-ref inside the component |
this.$scope |
Component scope |
this.$parent |
Parent component instance, or null |
this.$name |
Normalized name |
this.emit(name, detail) |
Fires an event |
this.$watch(expr, callback) |
Watches a scope expression |
this.$nextTick() |
Waits for DOM to reflect |
Inside the component's HTML, $self points to the instance.
By default the component doesn't see the v-data that wraps it. It talks to the root
scope and its own props. This prevents a component from accidentally depending on where it was pasted.
To inherit the parent's scope:
V.component('table-row', {
inheritScope: true,
template: '<td>{ item.name }</td>',
});Parent to child: props.
Child to parent: emit.
Between distant siblings: a global store or the event bus.
V.store('cart', { items: [] });V.on('product:added', (product) => console.log(product));
V.emit('product:added', { id: 7 });V.components is the Map with all registered definitions. V.instances is the Set with
mounted instances, useful for debugging and devtools.
V.components.has('counter'); // true
V.instances.size; // how many are mounted nowPrevious: Directives · Next: Ready-made components