Build Your Own MVVM Framework from Scratch with JavaScript
This article walks through the complete implementation of a lightweight MVVM framework—covering Object.defineProperty data hijacking, proxying, template compilation, publish‑subscribe reactivity, two‑way binding, computed properties and lifecycle hooks—using plain JavaScript.
MVVM Development
MVVM two‑way data binding originally used dirty checking in Angular 1.x, but modern frameworks such as React, Vue and Angular now rely on data hijacking combined with the publish‑subscribe pattern, implemented via
Object.defineProperty. Compatibility issues with
definePropertyexplain why Vue only supports IE8+.
defineProperty
Although developers rarely call
Object.definePropertydirectly, it is essential when building frameworks or libraries.
<code>let obj = {};
let hero = '鲁班二号';
obj.player = '小学生';
Object.defineProperty(obj, 'hero', {
configurable: true,
enumerable: true,
get() { return hero; },
set(val) { hero = val; }
});
console.log(obj); // {player: '小学生', hero: '鲁班二号'}
delete obj.hero; // requires configurable:true
obj.hero = '后羿'; // requires writable:true
for (let key in obj) { console.log(key); }
console.log(obj.hero); // '后羿'
</code>The above demonstrates basic usage of
defineProperty.
Building MVVM
HTML skeleton:
<code><body>
<div id="app">
<h1>{{hero}}</h1>
<p>身高{{info.height}}、智商{{info.IQ}}的峡谷英雄</p>
<p>操作他的玩家是{{player}}。</p>
</div>
<script src="mvvm.js"></script>
<script>
let mvvm = new Mvvm({
el: '#app',
data: {
hero: '鲁班二号',
info: { height: '150cm', IQ: 250 },
player: '小学生'
}
});
</script>
</body>
</code>Constructor:
<code>function Mvvm(options = {}) {
this.$options = options;
this._data = this.$options.data;
observe(this._data); // data hijacking
}
</code>Data Hijacking
Hijacking intercepts property access by defining getters and setters with
Object.defineProperty. Recursion ensures deep objects are also observed.
<code>function Observe(data) {
for (let key in data) {
let val = data[key];
observe(val); // recursive deep hijacking
Object.defineProperty(data, key, {
configurable: true,
get() { return val; },
set(newVal) {
if (val === newVal) return;
val = newVal;
observe(newVal);
}
});
}
}
function observe(data) {
if (!data || typeof data !== 'object') return;
return new Observe(data);
}
</code>Data Proxy
Proxy copies properties from
this._dataonto the MVVM instance, allowing
mvvm.a.binstead of
mvvm._data.a.b.
<code>for (let key in data) {
Object.defineProperty(this, key, {
configurable: true,
get() { return this._data[key]; },
set(newVal) { this._data[key] = newVal; }
});
}
</code>Data Compilation
The compiler replaces
{{}}expressions with actual data values and registers watchers.
<code>function Compile(el, vm) {
vm.$el = document.querySelector(el);
let fragment = document.createDocumentFragment();
while (child = vm.$el.firstChild) { fragment.appendChild(child); }
function replace(frag) {
Array.from(frag.childNodes).forEach(node => {
let txt = node.textContent;
let reg = /\{\{(.*?)\}\}/g;
if (node.nodeType === 3 && reg.test(txt)) {
function replaceTxt() {
node.textContent = txt.replace(reg, (m, p) => {
new Watcher(vm, p, replaceTxt);
return p.split('.').reduce((v, k) => v[k], vm);
});
}
replaceTxt();
}
if (node.childNodes && node.childNodes.length) replace(node);
});
}
replace(fragment);
vm.$el.appendChild(fragment);
}
</code>Publish‑Subscribe
Dep maintains a list of subscriber functions; Watcher wraps a callback with an
updatemethod.
<code>function Dep() { this.subs = []; }
Dep.prototype.addSub = function(sub) { this.subs.push(sub); };
Dep.prototype.notify = function() { this.subs.forEach(sub => sub.update()); };
function Watcher(fn) { this.fn = fn; }
Watcher.prototype.update = function() { this.fn(); };
</code>Data‑View Synchronization
During compilation, each expression creates a
Watcherthat registers with the corresponding
Dep. When a setter runs,
dep.notify()triggers all watchers to update the DOM.
<code>Object.defineProperty(data, key, {
get() { Dep.target && dep.addSub(Dep.target); return val; },
set(newVal) { if (val === newVal) return; val = newVal; observe(newVal); dep.notify(); }
});
</code>Two‑Way Data Binding
Elements with
v-modelare bound to data; input events update the model, which in turn notifies watchers to refresh the view.
<code>if (node.nodeType === 1) {
Array.from(node.attributes).forEach(attr => {
if (attr.name.includes('v-')) {
node.value = vm[attr.value];
new Watcher(vm, attr.value, newVal => { node.value = newVal; });
node.addEventListener('input', e => { vm[attr.value] = e.target.value; });
}
});
}
</code>Computed Properties & Mounted Hook
Computed properties are defined on the MVVM instance using getters; the
mountedhook runs after initialization.
<code>function initComputed() {
let vm = this;
let computed = this.$options.computed || {};
Object.keys(computed).forEach(key => {
Object.defineProperty(vm, key, {
get: typeof computed[key] === 'function' ? computed[key] : computed[key].get,
set() {}
});
});
}
function Mvvm(options = {}) {
this.$options = options;
this._data = options.data;
observe(this._data);
initComputed.call(this);
new Compile(options.el, this);
options.mounted && options.mounted.call(this);
}
</code>Summary
Data hijacking via
Object.definePropertygetters/setters.
Proxying data properties onto the MVVM instance.
Template compilation using
{{}}syntax.
Publish‑subscribe mechanism to keep data and view in sync.
37 Mobile Game Tech Team
37 Mobile Game Tech Team
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.