Language: English | Tiếng Việt | 日本語 | 中文
NS Framework is a tiny, lightweight mini-framework for Chrome Extensions, with a syntax close to Vue but with no build step required.
- ⚡ No external dependencies
- 🔄 Realtime binding via Proxy
- 🛡️ Manifest V3 / CSP friendly
- 🧩 Component support via ES Modules, props, slots (default + named)
- 🔒 Scope isolation per component
- 🧮 Computed properties (cached, auto-invalidated, setter support)
- 👀 Watch, lifecycle hooks close to Vue
- 🧠 Many directives close to Vue's syntax
npm i ns-frameworkCopy lib/ns.js into your extension's lib folder and load it in your HTML:
<script src="./lib/ns.js"></script><div id="app"></div>
<script src="./lib/ns.js"></script>
<script src="./popup.js"></script>// popup.js
new NS({
el: "#app",
data: {
title: "Hello NS",
count: 0,
isVisible: true
},
methods: {
increment() {
this.count += 1;
}
},
mounted() {
console.log("Ready");
},
template: `
<div>
<h1 ns-text="title"></h1>
<p>Count: <span ns-text="count"></span></p>
<button ns-click="increment">Increment</button>
<div ns-show="isVisible">Visible block</div>
</div>
`
});<span ns-text="name"></span>
<p ns-html="message"></p>The expression in a directive is only a path to a property (which can be nested), not an arbitrary JS expression:
<span ns-text="user.profile.name"></span>
<div ns-if="user?.profile?.name"></div> <!-- optional chaining "?." is supported, same as "." -->ns-text / ns-if / ns-show / etc. cannot run a ternary or comparison like a ? b : c, count > 5 directly in the template - precompute that value with computed and bind to the resulting property instead.
⚠️ Important: HTML always lowercases attribute names when parsed (this applies to.htmlfiles and totemplatestrings assigned viainnerHTMLin.jsfiles alike). Multi-word (camelCase) props declared inpropsmust be written as kebab-case attributes when bound with:, otherwise the attribute name loses its casing and won't match the prop.
<ns-header title="WebBlock | MLight"></ns-header>
<ns-footer :app-config-info="appConfigInfo"></ns-footer>// footer.js
export default {
props: {
title: {
type: String, // "type" is documentation only, it is NOT validated or coerced
default: "MLight" // "default" is applied when the parent doesn't pass a value
},
appConfigInfo: {
type: Object
}
},
template: `
<div>
<h1 ns-text="title"></h1>
</div>
`
};Props can also be declared as an array of names (no default/type): props: ["title", "appConfigInfo"].
Object/array props share a reference with the parent: if you mutate a field inside the object directly (this.appConfigInfo.author.name = "x"), the change automatically propagates and re-renders correctly in the child component (including any computed that depends on it). But if the parent reassigns the whole prop to a new value (this.appConfigInfo = {...} with a brand-new object, or changes a string/number/boolean prop), the child component does not resync automatically - props are currently only read once, at mount time.
computed: {
// simple getter form - cached, automatically recomputed when a dependency changes
// (including nested paths and props shared by reference from a parent)
isTextEmpty() {
return !this.input.text.value;
},
// form with a setter - assigning to this.fullName calls set()
fullName: {
get() {
return `${this.first} ${this.last}`;
},
set(value) {
const [first, last] = value.split(" ");
this.first = first;
this.last = last;
}
}
}<div ns-show="isTextEmpty">Empty</div>
<span ns-text="fullName"></span>watch: {
// fires when property "count" changes
count(newValue, oldValue) {
console.log(newValue, oldValue);
},
// "*" catches every property change (top-level and nested)
"*": (newValue, oldValue) => {
console.log("changed:", newValue, oldValue);
}
}// child
template: `
<div class="input-group">
<slot name="before"></slot>
<slot></slot>
<slot name="after"></slot>
</div>
`<!-- parent -->
<my-input>
<i slot="before" class="fa fa-search"></i>
<span>Default content</span>
<button slot="after">Clear</button>
</my-input>ns-slot="before" can be used instead of <slot name="before"> if you want the slot outlet to be a regular element instead of a <slot> tag.
// child component
methods: {
save() {
this.$emit("saved", { ok: true });
}
}<!-- parent: bind @event="handler" (recommended) -->
<child-component @saved="onSaved"></child-component>// parent
methods: {
onSaved(payload) {
console.log(payload);
}
}If you don't bind @event, a method with the same name as the event on the parent (saved()) still fires automatically (back-compat) - but prefer @event="handler" so you can name the method differently, avoiding collisions when several instances emit the same event name.
<child-component v-model="value"></child-component>The child component receives the value through a modelValue prop and reports changes back with this.$emit("update:modelValue", newValue).
<input ns-model="name" />
<textarea ns-model="description"></textarea><input ns-model="name.trim" />
<input ns-model="age.number" />
<input ns-model="search.lazy" />
<input ns-model="query.debounce300" />
<input ns-model="title.capitalize" />
<input ns-model="email.lowercase" />Supported on:
- checkbox
- radio
- select
- select multiple
<button ns-click="save"></button>
<button ns-click="save.prevent.stop"></button>
<button @click="save"></button>
<div ns-on="input:updateValue"></div>
<div @input="updateValue"></div><div ns-if="isLoggedIn">Logged in</div>
<div ns-else-if="isLoading">Loading...</div>
<div ns-else>Not logged in</div>Both arrays and plain objects (item/key) are supported, with or without Vue-style parentheses:
<ul>
<li ns-for="item in items" ns-text="item.name"></li>
<li ns-for="(item, index) in items" ns-text="item.name"></li>
<li ns-for="(value, key) in someObject" ns-text="key"></li>
</ul><div ns-class="className"></div>
<div ns-class="{ active: isActive, disabled: isDisabled }"></div>
<div ns-style="styleObject"></div>
<a ns-bind="href:linkUrl"></a>
<a :href="linkUrl"></a>| Option | Description |
|---|---|
el |
a selector or a DOM element |
data |
an object or a function (recommended as a function for components to avoid sharing state between instances) |
template |
an HTML string that overrides the content of el |
methods |
an object of methods; this inside a method points to the reactive data |
computed |
an object of getters (or {get, set}), cached and auto-invalidated |
watch |
an object watching property changes (by key or "*") |
components |
a registry of child components ({ "tag-name": ComponentConfig }) |
props |
prop declarations for a component (array of names, or an object with type/default) |
stateChanged |
(prop, value) => {}, called every time a property's value changes |
beforeCreate / created |
hooks before/after reactive data is initialized |
beforeMount / mounted |
hooks before/after the first render and DOM mount |
beforeUpdate / updated |
hooks before/after every re-render triggered by a state change |
beforeCreate → (reactive proxy created) → created → (methods bound)
→ (template loaded, slots rendered, child components mounted, first render, events bound)
→ beforeMount → mounted
On every property change: beforeUpdate → (the whole component subtree re-renders) → updated → stateChanged.
// Header.js
export default {
template: `
<header>
<h1 ns-text="title"></h1>
</header>
`,
data() {
return {
title: "Header Component"
};
}
};import Header from "./Header.js";
new NS({
el: "#app",
components: {
"app-header": Header
}
});<div id="app">
<app-header></app-header>
</div>
<script type="module" src="./main.js"></script>- When using ES Modules, serve the extension via a local server or as an unpacked extension.
- For Chrome Extensions, keep logic in a separate JS file rather than inline scripts.
- A custom element (
<my-component>) cannot self-close with<my-component />in HTML (HTML doesn't honor self-closing on regular elements) - always write it out explicitly as<my-component></my-component>. :keyonns-foris just a plain HTML attribute; the framework doesn't use it to diff/reconcile the DOM yet - the whole list is rebuilt from scratch on every render.