从零构建MVVM框架:深入解析前端数据绑定原理
引言
在当今前端开发领域,MVVM(Model-View-ViewModel)架构模式已成为主流框架的核心设计思想。Vue.js、React等现代框架无不借鉴了MVVM的思想精髓。本文将带领读者从零开始构建一个简易的MVVM框架,通过实现数据绑定、指令系统、计算属性等核心功能,深入理解现代前端框架的工作原理。
结尾附全部代码
第一部分:MVVM架构概述
什么是MVVM?
MVVM(Model-View-ViewModel)是一种软件架构模式,它将应用程序分为三个主要部分:
- Model:代表应用程序的数据和业务逻辑
- View:用户界面,负责展示数据
- ViewModel:连接View和Model的桥梁,负责处理视图逻辑
在我们的实现中,MVVM类就是ViewModel的具体体现。
class MVVM {constructor(options) {this.$options = options;this.$data = options.data;this.$methods = options.methods;this.init();}// ...其他实现
}
为什么需要MVVM?
传统的前端开发中,我们经常需要手动操作DOM来更新视图,这种方式存在几个明显问题:
- 代码耦合度高:业务逻辑和视图操作混杂在一起
- 维护困难:当数据变化时需要手动更新多处DOM
- 效率低下:频繁的DOM操作影响性能
MVVM模式通过数据绑定自动同步视图和模型,开发者只需关心数据变化,框架会自动处理视图更新。
第二部分:核心功能实现
数据劫持与响应式系统
实现MVVM的第一步是建立响应式系统,当数据变化时能够自动通知依赖它的视图更新。我们使用Object.defineProperty来实现数据劫持:
function defineReactive(obj, key, val) {const dep = new Dep();Object.defineProperty(obj, key, {get() {if (Dep.target) {dep.addSub(Dep.target);}return val;},set(newVal) {if (newVal === val) return;val = newVal;dep.notify();}});
}
这里每个属性都有一个Dep(Dependency)实例,用来收集依赖这个属性的Watcher。当属性被访问时,当前Watcher会被添加到Dep中;当属性变化时,Dep会通知所有Watcher更新。
模板编译与指令系统
我们的框架需要解析模板中的特殊标记,如{{}}
插值表达式和m-
前缀的指令。模板编译的主要过程是:
- 遍历DOM节点
- 识别指令和插值表达式
- 为每个需要动态更新的部分创建Watcher
class Compile {constructor(el, vm) {this.$vm = vm;this.$el = document.querySelector(el);this.compile(this.$el);}compile(el) {// 遍历节点并编译}
}
指令处理器实现
我们采用策略模式将不同指令的处理逻辑封装成独立的函数:
const directiveHandlers = {text: (node, value) => {node.textContent = value;},model: (node, value, exp, vm) => {node.value = value;node.addEventListener('input', (e) => {setExpValue(vm, exp, e.target.value);});},on: (vm, node, eventType, method) => {node.addEventListener(eventType, (e) => {vm.$methods[method].call(vm, e);});}
};
这种设计使得添加新指令变得非常简单,只需在directiveHandlers中添加相应的处理函数即可。
第三部分:高级特性实现
计算属性
计算属性是基于其他数据派生出来的属性,具有缓存特性,只有当依赖的数据变化时才会重新计算:
class MVVM {initComputed() {const computed = this.$options.computed;if (!computed) return;Object.keys(computed).forEach(key => {Object.defineProperty(this, key, {get: () => {return computed[key].call(this);}});});}
}
侦听器
侦听器允许我们观察数据变化并执行自定义逻辑:
class MVVM {initWatch() {const watch = this.$options.watch;if (!watch) return;Object.keys(watch).forEach(key => {new Watcher(this, key, watch[key]);});}
}
列表渲染
实现简单的m-for
指令来处理列表渲染:
directiveHandlers.for = (vm, node, exp) => {const [alias, listName] = exp.split(' in ');const list = getVMValue(listName, vm);const parent = node.parentNode;parent.removeChild(node);list.forEach(item => {const clone = node.cloneNode(true);// 处理子模板中的item引用parent.appendChild(clone);});
};
第四部分:性能优化与实践建议
优化策略
- 异步更新队列:将多个数据变化合并为一次更新
- 虚拟DOM:通过diff算法最小化DOM操作
- 依赖收集优化:避免不必要的依赖收集
实践建议
- 避免在模板中使用复杂表达式
- 合理使用计算属性缓存计算结果
- 对于大型列表使用key属性优化diff性能
- 合理划分组件边界
第五部分:与现代框架的对比
虽然我们的实现简化了很多细节,但核心思想与Vue.js等现代框架类似:
- 数据劫持:Vue 2使用Object.defineProperty,Vue 3改用Proxy
- 虚拟DOM:我们的简单实现直接操作DOM,而Vue使用虚拟DOM优化
- 组件系统:我们没有实现组件系统,这是现代框架的重要特性
结语
通过从零构建这个简易MVVM框架,我们深入理解了现代前端框架的核心机制。虽然生产级框架需要考虑更多边界条件和性能优化,但基本原理是相通的。希望本文能为读者打开框架源码学习的大门,更好地理解和使用现代前端工具。
完整的实现代码已托管在GitHub(示例地址),读者可以克隆项目并进一步探索。前端技术的精髓在于理解其设计思想而非死记API,愿我们都能在技术的海洋中不断探索前行。
// mvvm.js
// 简易MVVM模型实现
class Dep {constructor() {this.subs = [];}add(watcher) {this.subs.push(watcher);}notify() {this.subs.forEach(w => w.update());}
}class Watcher {constructor(vm, exp, cb) {this.vm = vm;this.exp = exp;this.cb = cb;this.value = this.get();}get() {Dep.target = this;let value = getByPath(this.vm, this.exp); // 支持多级路径Dep.target = null;return value;}update() {const newVal = getByPath(this.vm, this.exp); // 支持多级路径const oldVal = this.value;if (newVal !== oldVal) {this.value = newVal;this.cb.call(this.vm, newVal, oldVal);}}
}function defineReactive(obj, key, val) {const dep = new Dep();Object.defineProperty(obj, key, {get() {if (Dep.target) dep.add(Dep.target);return val;},set(newVal) {if (val !== newVal) {val = newVal;dep.notify();}}});
}function observe(obj) {if (!obj || typeof obj !== 'object') return;Object.keys(obj).forEach(key => {defineReactive(obj, key, obj[key]);observe(obj[key]); // 递归子对象});
}function getByPath(obj, path) {const keys = path.split('.');return keys.reduce((acc, key) => acc && acc[key], obj);
}
function setByPath(obj, path, value) {const keys = path.split('.');let data = obj;for (let i = 0; i < keys.length - 1; i++) {if (!data) return;data = data[keys[i]];}if (data) data[keys[keys.length - 1]] = value;
}function compile(el, vm) {const nodes = Array.from(el.childNodes);nodes.forEach(node => {if (node.nodeType === 1) { // 元素// m-for 指令const mFor = Array.from(node.attributes).find(attr => attr.name === 'm-for');if (mFor) {const [item, arrExp] = mFor.value.split(' in ');const arrName = arrExp.trim();const itemName = item.trim();const parent = node.parentNode;const comment = document.createComment('m-for');parent.insertBefore(comment, node);parent.removeChild(node);function renderList() {// 清除旧内容let next;while ((next = comment.nextSibling) && !next.__mfor_end) {parent.removeChild(next);}const arr = vm[arrName] || [];arr.forEach((val, idx) => {const clone = node.cloneNode(true);// 处理插值 {{item}}Array.from(clone.childNodes).forEach(child => {if (child.nodeType === 3 && /\{\{(.+?)\}\}/.test(child.textContent)) {child.textContent = child.textContent.replace(/\{\{(.+?)\}\}/g, (m, exp) => {exp = exp.trim();if (exp === itemName) return val;// 支持 {{item.xxx}}if (exp.startsWith(itemName + '.')) return val[exp.slice(itemName.length + 1)];return vm[exp];});}});// 支持 m-model="item.xxx"Array.from(clone.attributes || []).forEach(attr => {if (attr.name === 'm-model' && attr.value.startsWith(itemName + '.')) {const prop = attr.value.slice(itemName.length + 1);clone.value = val[prop];clone.addEventListener('input', e => {val[prop] = e.target.value;});}});parent.insertBefore(clone, comment.nextSibling);});}renderList();new Watcher(vm, arrName, renderList);// 标记for结尾const end = document.createComment('m-for-end');end.__mfor_end = true;parent.insertBefore(end, comment.nextSibling);return;}}if (node.nodeType === 3) { // 文本const reg = /\{\{(.+?)\}\}/g;const text = node.textContent;if (reg.test(text)) {node.__template = text; // 保存原始模板function updateText() {node.textContent = node.__template.replace(reg, (match, exp) => {exp = exp.trim();return getByPath(vm, exp);});}updateText();// 为每个表达式都绑定watcherlet match;reg.lastIndex = 0;while ((match = reg.exec(text))) {const exp = match[1].trim();new Watcher(vm, exp, updateText);}}} else if (node.nodeType === 1) { // 元素Array.from(node.attributes).forEach(attr => {const exp = attr.value;if (attr.name === 'm-model') {node.value = getByPath(vm, exp);node.addEventListener('input', e => {setByPath(vm, exp, e.target.value);});new Watcher(vm, exp, val => {node.value = val;});} else if (attr.name.startsWith('m-on:')) {// 事件指令const eventType = attr.name.slice(5);node.addEventListener(eventType, e => {vm.$methods && typeof vm.$methods[exp] === 'function' && vm.$methods[exp].call(vm, e);});}});}if (node.childNodes && node.childNodes.length) {compile(node, vm);}});
}export default class MVVM {constructor(options) {this.$el = document.querySelector(options.el);this.$data = options.data;this.$methods = options.methods || {};this.$computed = options.computed || {};this.$watch = options.watch || {};this.proxyData(this.$data);this.proxyMethods(this.$methods);this.initComputed();observe(this.$data);this.initWatch();compile(this.$el, this);}proxyData(data) {Object.keys(data).forEach(key => {Object.defineProperty(this, key, {get() { return data[key]; },set(newVal) { data[key] = newVal; }});});}proxyMethods(methods) {Object.keys(methods).forEach(key => {this[key] = methods[key].bind(this);});}initComputed() {const computed = this.$computed;Object.keys(computed).forEach(key => {Object.defineProperty(this, key, {get: typeof computed[key] === 'function'? computed[key].bind(this): computed[key].get ? computed[key].get.bind(this) : () => undefined,set() {}});});}initWatch() {const watch = this.$watch;Object.keys(watch).forEach(key => {new Watcher(this, key, watch[key]);});}
}
//index.js
import MVVM from './mvvm.js';const vm = new MVVM({el: '#app',data: {msg: 'Hello MVVM',inputVal: '',count: 0,obj:{name: '张三',age: 20},list: [{ name: '张三', age: 20 },{ name: '李四', age: 22 }]},methods: {add() {this.count++;}},computed: {doubleCount() {return this.count * 2;}},watch: {count(newVal, oldVal) {console.log(`count变化: ${oldVal} -> ${newVal}`);}}
});window.vm = vm;
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>MVVM Demo</title></head><body><div id="app"><div>哈哈哈:{{msg}}</div><input m-model="inputVal"><div>{{inputVal}}</div><input m-model="obj.name"><div>{{obj.name}}</div><button m-on:click="add">加一</button><div>count: {{count}}</div><div>doubleCount: {{doubleCount}}</div><ul><li m-for="item in list">{{item.name}} - {{item.age}}</li></ul></div><script src="./src/index.js" type="module"></script></body>
</html>