A library to implement directive pattern for native HTML without any framework, which is inspired by Vue.js.
See DEMO
<button w-copy="Text to copy">
...
</button>
<script>
wd.register('copy', {
mounted(el, { value }) {
el.addEventListener('click', () => {
navigator.clipboard.writeText(value);
});
}
});
</script>For a long time we've relied on approaches where inserting a Web Component into HTML activates functionality immediately, or where frameworks like Vue and Angular use directives to extend HTML elements. Plain HTML doesn't have a directive-like functions that lets us inject JavaScript behaviors just by adding an attribute.
For example, if you want a copy to clipboard feature that works across projects and environments, the traditional way
is to write JS that binds a click event to the button:
for (var el of document.querySelectorAll('.js-copy-btn')) {
el.addEventListener('click', (e) => {
navigator.clipboard.writeText(e.currentTarget.dataset.text);
});
}This works in static HTML, but it can fail with virtual DOM frameworks like Vue or React because the virtual DOM may rewrite the DOM tree and remove event bindings.
<template>
<App>
<!-- This button not work in Vue -->
<button class="js-copy-btn" data-text="Hello">
Copy
</button>
</App>
</template>Also, if you dynamically change the DOM elements, you may break your event bindings.
toolbar.innerHTML = `<button class="js-copy-btn" data-text="Hello">
Copy
</button>`;
document.body.appendChild(toolbar); // Not work, you must bind events againTo make small features reusable across projects and environments, or handle dynamic layouts, a common solution is to use
delegated event listeners:
$(document).on('click', '.js-copy-btn', (e) => {
navigator.clipboard.writeText(e.currentTarget.dataset.text);
});This approach has some drawbacks. First, the delegate pattern is not commonly used for developers unfamiliar with
jQuery or similar libraries and may cause confused. Second, for SPAs that frequently need to unbind event
handlers, this method can lead to memory leaks or unexpected behavior because the listeners remain attached to the
DOM even after it have been removed.
Another solution is implement a CustomElement such as <copy-button>, which ensures it works everywhere.
<script>
class CopyButton extends HTMLElement {
connectedCallback() {
this.addEventListener('click', this.copy);
}
}
</script>
<copy-button>
<button data-text="Hello World">
Copy Text
</button>
</copy-button>However, Web Components have a higher development barrier: adding a custom HTML element for a simple feature can feel heavy or unintuitive, and enabling Shadow DOM may make CSS harder to manage.
WebDirective aims to let developers easily write cross-project, cross-environment HTML extensions that can be mounted
to existing HTML with non-invasively and removed cleanly without side effects or leftovers. The example below shows
implementing a copy to clipboard feature as a directive and mounting it in a Vue environment:
<script setup lang="ts">
import WebDirective from 'web-directive';
const wd = WebDirective();
wd.register('copy', {
mounted(el, { value }) {
el.addEventListener('click', copy);
},
unmounted(el) {
el.removeEventListener('click', copy);
}
});
function copy(e: MouseEvent) {
navigator.clipboard.writeText(e.currentTarget.dataset.text);
}
</script>
<template>
<App>
<MyButton w-copy data-text="Hello World">
Copy Text
</MyButton>
</App>
</template>As an early experimental version, we heavily referenced Vue.js for the interface to reduce the learning curve for developers. However, due to some limitations of native HTML, we cannot achieve exactly the same behavior.
But so far, a simple plug-and-play, non-invasive, side-effect-free directive system is already very useful for building standalone widgets that work across environments and frameworks. Implementations of directives like this have been used in our team since 2020, it is very stable and intuitive, and perfectly suitable for production use.
NPM or Yarn
npm i web-directive
# OR
yarn add web-directiveUnPkg
<!-- UMD -->
<script src="https://www.unpkg.com/web-directive/dist/web-directive.umd.min.cjs"></script>
<!-- ES Module -->
<script type="module">
import WebDirective from 'https://www.unpkg.com/web-directive/dist/web-directive.js';
// ...
</script>Bundler
import WebDirective from 'web-directive';
const wd = new WebDirective();
wd.listen();
export default wd;Browser
<script src="path/to/web-directive/dist/web-directive.umd.js"></script>
<script>
const wd = new WebDirective();
wd.listen(); // Will listen to document.body
</script>Listen to smaller scope.
const element = document.querySelector('#foo');
wd.listen(element);Stop listening
wd.disconnect();After register a directive (for example: foo), you can add w-foo directive to any HTML element and the directive
will instantly work.
This is very useful that if you want to add some cross-platform custom logic to existing Vue/React/Angular template without writing code for every frameworks.
wd.register('foo', {
// Reguired
// When element attach to DOM or attribute attach to element
mounted(el, binding) {
// Do any thing you want
const { value } = bindings;
el._foo = new Foo(value);
},
// Optional
// When element detach from DOM or attribute dettach from element
unmounted(el, binding) {
el._foo.stop();
delete el._foo;
},
// Optional
// When values changed
updated(el, binding) {
const { value } = bindings;
el._foo.setOptions(value);
}
});Now, add w-foo to HTML, it will run the mounted() hook:
const ele = document.querySelector('...');
ele.setAttribute('w-foo', '{ "options": ...}');The binding interface:
export interface WebDirectiveBinding<El extends Element = HTMLElement, Modifiers extends Record<string, boolean> = Record<string, boolean>> {
directive: string;
name: string;
node: El;
value: any;
oldValue: any;
mutation?: MutationRecord;
handler: WebDirectiveHandler<El, Modifiers>;
arg: string | null;
modifiers: Modifiers;
instance: WebDirective;
}Use JSON as value
wd.register('foo', {
mounted(el, { value }) {
const options = JSON.parse(value || '{}');
},
});WebDirective provides a static method to get singleton instance.
import { singleton } from 'web-directive';
wd.register('foo', {
mounted(el, { value }) {
// Get or create singleton instance
singleton(el, 'foo', () => new Foo(value));
},
updated(el, { value }) {
// Get singleton instance and update it
singleton(el, 'foo')?.setOptions(value);
},
unmounted(el, binding) {
// Remove singleton instance and clean up
const foo = singleton(el, 'foo', false);
foo?.stop();
},
});WebDirective provides a useEventListener() helper to help you listen to events and auto unbind them when element
unmounted.
import { useEventListener } from 'web-directive';
wd.register('foo', {
mounted(el, binding) {
useEventListener(el, 'click', (e) => {
console.log('Element clicked');
});
},
});Note if you use async function as mounted hook, you must all useEventListener() before first await.
wd.register('foo', {
async mounted(el, binding) {
// Must all before first await
useEventListener(el, 'click', (e) => {
console.log('Element clicked');
});
await someAsyncTask();
// ERROR: Can not find context to bind event
useEventListener(el, 'click', (e) => {
console.log('Element clicked');
});
},
});If you're only implementing a copy button, there's no need to use WebDirective. Below is a real-world example showing how to use WebDirective to make multiple widgets work well in dynamic layouts.
Imagine a repeatable table where each row contains several editable fields, and each field has features like a character counter and a date picker that rely on different external libraries. To properly manage instance creation and destruction, we can use WebDirective to implement these features and ensure they continue to work when rows are dynamically added or removed.
<table>
<tr>
<td>
<input type="text" w-text-count maxlength="255" />
</td>
<td>
<input type="text" w-calendar='{"tz":"UTC", "enableTime":true }' />
</td>
<td>
<input type="text" w-color='{"popup":true, ...more options }' />
</td>
<td>
<button w-add-more>Add More</button>
<button w-delete-self>Delete</button>
</td>
</tr>
...
</table>Below is an example demonstrating how to make this repeatable table work: each row's calendar, color picker, etc., will automatically initialize when the HTML is inserted into the page and will be automatically destroyed when the HTML is removed. The advantage of WebDirective is that it allows declaring specific behaviors for dynamically changing HTML without relying on an MVVM or model-driven framework:
import Calendar from 'calendar-lib';
import ColorPicker from 'color-picker-lib';
import TextCount from 'text-count-lib';
import { useEventListener } from 'web-directive';
wd.register('text-count', {
mounted(el, binding) {
singleton(el, 'text-count', () => new TextCount(el, { max: el.getAttribute('maxlength') }));
},
updated(el, binding) {
const instance = singleton(el, 'text-count');
instance?.setOptions({ max: el.getAttribute('maxlength') });
},
unmounted(el, binding) {
const instance = singleton(el, 'text-count', false);
instance?.destroy();
}
});
wd.register('calendar', {
mounted(el, binding) {
const options = JSON.parse(binding.value || '{}');
singleton(el, 'calendar', () => new Calendar(el, options));
},
updated(el, binding) {
const instance = singleton(el, 'calendar');
const options = JSON.parse(binding.value || '{}');
instance?.setOptions(options);
},
unmounted(el, binding) {
const instance = singleton(el, 'calendar', false);
instance?.destroy();
},
});
wd.register('color', {
mounted(el, binding) {
const options = JSON.parse(binding.value || '{}');
singleton(el, 'color-picker', () => new ColorPicker(el, options));
},
updated(el, binding) {
const instance = singleton(el, 'color-picker');
const options = JSON.parse(binding.value || '{}');
instance?.setOptions(options);
},
unmounted(el, binding) {
const instance = singleton(el, 'color-picker', false);
instance?.destroy();
},
});
wd.register('add-more', {
mounted(el, binding) {
useEventListener('click', () => {
const newRow = document.createElement('tr');
newRow.innerHTML = '/* Your line template */';
el.closest('table').appendChild(newRow);
});
},
});
wd.register('delete-self', {
mounted(el, binding) {
useEventListener('click', () => {
const row = el.closest('tr');
row.parentNode.removeChild(row);
});
},
});By default, unlike Vue.js, WebDirective's updated hook only listen to the updates of element itself.
If you want to listen to children elements' changes, you can set the enableChildrenUpdated option to true.
Different from Vue.js, you must use childrenUpdated hook to handle children elements' updates.
import WebDirective from './index';
const wd = new WebDirective({
enableChildrenUpdated: true
});
wd.register('foo', {
mounted(el, { value }) {
// ...
},
updated(el, { value }) {
// Self element updated, including attributes changed, innterText changed, etc.
},
childrenUpdated(el, binding) {
// Children elements tree updated.
},
});WebDirective supports argument and modifiers like Vue.js. However, due to native HTML not supports query elements by dynamic attribute names, this function must traverse elements to find attributes, which is not very efficient, so it is disabled by default, you must manually enable it.
import WebDirective from './index';
const wd = new WebDirective({
enableAttrParams: true
});
wd.listen();Now you can add directive like this:
<button w-foo:hello.bar.baz="value">
...
</button>And you can access the argument and modifiers in the binding object:
wd.register('foo', {
mounted(el, binding) {
console.log(binding.directive); // Full directive name: 'x-foo:hello.bar.baz'
console.log(binding.name); // Directive short name: 'x-foo'
console.log(binding.arg); // 'hello'
console.log(binding.modifiers); // { bar: true, baz: true }
},
});When enable argument and modifiers, you can add multiple directives to one element:
<button w-foo:arg1.mod1.mod2="value1" w-bar:arg2.mod3="value2">
...
</button>All modifiers are boolean values, if a modifier exists, its value is true, otherwise wll not exists.
Since HTML attributes not supports camelCase, all modifier must write as kebab-case, and will auto
convert to camelCase after parsed.
<button w-foo:mod-one.mod-two>
...
</button>wd.register('foo', {
mounted(el, binding) {
console.log(binding.modifiers.modOne); // true
console.log(binding.modifiers.modTwo); // true
},
});Important
Native HTML do not support to change argument and modifiers dynamically, if you change them,
it will be same as removed the old attribute and re-add a new attribute, so the unmounted and mounted hooks will
be called.
All hooks list below:
mounted(el, binding): Called when the directive is first bound to the element. This is where you can set up any initial state or event listeners.unmounted(el, binding): Called when the directive is unbound from the element. This is where you can clean up any resources or event listeners.updated(el, binding): Called when the value of the directive changes. This is where you can respond to changes in the directive's value.childrenUpdated(el, binding): Called when the children elements of the element are updated. This hook is only available whenenableChildrenUpdatedoption is set totrue.
Note
Unlike Vue.js, all hooks will be called after mutation occurs, at this time, the DOM is already updated.
So WebDirective do not provide beforeMount, beforeUpdate and beforeUnmount hooks.
WebDirective uses MutationObserver to listen to DOM changes, so the updated hook will be called after the mutation
occurs.
If you want to get the result after directive updated, you must wait next event loop.
let updated = 0;
wd.register('foo', {
mounted(el, binding) {
// ...
},
updated() {
updated++;
}
});
// Let's update directive value
element.setAttribute('w-foo', '1');
console.log(updated); // Still 0, mutation will triggered after next loop
await Promise.resolve().then();
console.log(updated); // 1WebDirective provides a static method nextTick() to wait for next update cycle.
import { nextTick } from 'web-directive';
element.setAttribute('w-foo', '1');
await nextTick();
console.log(updated); // 1WebDirective can emit custom events when directives are mounted, unmounted, or updated.
By default, the event names are prefixed with wd:. You can listen to these events on the element:
wd:mountedwd:unmountedwd:updatedwd:children-updated
el.addEventListener('wd:mounted', (e) => {
const binding = e.detail as WebDirectiveBinding;
console.log(`Directive ${binding.directive} mounted`);
});If you want to change the event prefix, you can set the eventPrefix option when creating WebDirective instance.
const wd = new WebDirective({
eventPrefix: 'flower:'
});You can use custom prefix to avoid conflicts with other libraries.
const wd = new WebDirective({
prefix: 'x-'
});List of all options as table
export interface WebDirectiveOptions {
prefix?: string;
eventPrefix?: string;
enableAttrParams?: boolean;
enableChildrenUpdated?: boolean;
}| Option | Type | Default | Description |
|---|---|---|---|
| prefix | string |
w- |
The prefix for directive attributes. |
| enableAttrParams | boolean |
false |
Enable argument and modifiers support. |
| enableChildrenUpdated | boolean |
false |
Enable children elements update listening. |
| eventPrefix | string |
wd: |
The prefix for custom events emitted. |