当前位置: 首页 > news >正文

从零构建MVVM框架:深入解析前端数据绑定原理

引言

在当今前端开发领域,MVVM(Model-View-ViewModel)架构模式已成为主流框架的核心设计思想。Vue.js、React等现代框架无不借鉴了MVVM的思想精髓。本文将带领读者从零开始构建一个简易的MVVM框架,通过实现数据绑定、指令系统、计算属性等核心功能,深入理解现代前端框架的工作原理。

结尾附全部代码

第一部分:MVVM架构概述

什么是MVVM?

MVVM(Model-View-ViewModel)是一种软件架构模式,它将应用程序分为三个主要部分:

  1. Model:代表应用程序的数据和业务逻辑
  2. View:用户界面,负责展示数据
  3. ViewModel:连接View和Model的桥梁,负责处理视图逻辑

在我们的实现中,MVVM类就是ViewModel的具体体现。

class MVVM {constructor(options) {this.$options = options;this.$data = options.data;this.$methods = options.methods;this.init();}// ...其他实现
}

为什么需要MVVM?

传统的前端开发中,我们经常需要手动操作DOM来更新视图,这种方式存在几个明显问题:

  1. 代码耦合度高:业务逻辑和视图操作混杂在一起
  2. 维护困难:当数据变化时需要手动更新多处DOM
  3. 效率低下:频繁的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-前缀的指令。模板编译的主要过程是:

  1. 遍历DOM节点
  2. 识别指令和插值表达式
  3. 为每个需要动态更新的部分创建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);});
};

第四部分:性能优化与实践建议

优化策略

  1. 异步更新队列:将多个数据变化合并为一次更新
  2. 虚拟DOM:通过diff算法最小化DOM操作
  3. 依赖收集优化:避免不必要的依赖收集

实践建议

  1. 避免在模板中使用复杂表达式
  2. 合理使用计算属性缓存计算结果
  3. 对于大型列表使用key属性优化diff性能
  4. 合理划分组件边界

第五部分:与现代框架的对比

虽然我们的实现简化了很多细节,但核心思想与Vue.js等现代框架类似:

  1. 数据劫持:Vue 2使用Object.defineProperty,Vue 3改用Proxy
  2. 虚拟DOM:我们的简单实现直接操作DOM,而Vue使用虚拟DOM优化
  3. 组件系统:我们没有实现组件系统,这是现代框架的重要特性

结语

通过从零构建这个简易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>
http://www.lryc.cn/news/584562.html

相关文章:

  • 深入理解 Linux 中的 stat 函数与文件属性操作
  • NGINX系统基于PHP部署应用
  • 开发需要写单元测试吗?
  • Camera2API笔记
  • 记录一下openGauss自启动的设置
  • 《测试开发:从技术角度提升测试效率与质量》
  • io_helper说明
  • 使用Word/Excel管理需求的10个痛点及解决方案Perforce ALM
  • 二层环路避免-STP技术
  • LangChain框架 Prompts、Agents 应用
  • Selenium 4 教程:自动化 WebDriver 管理与 Cookie 提取 || 用于解决chromedriver版本不匹配问题
  • C++实习面试题
  • dexie 前端数据库封装
  • 【前端】jQuery数组合并去重方法总结
  • MinerU2将PDF转成md文件,并分拣图片
  • uniapp滚动组件, HuimayunScroll:高性能移动端滚动组件的设计与实现
  • 【Fargo】发送一个rtp包的过程1:怎么统一加twcc序号
  • 创始人IP如何进阶?三次关键突破实现高效转化
  • 使用SpringAOP自定义权限控制注解
  • 音频 SDP 文件格式
  • ElementUI:高效优雅的Vue.js组件库
  • Linux epoll简介与C++TCP服务器代码示例
  • Rust中Option和Result详解
  • Rust Web 全栈开发(四):构建 REST API
  • 单片机基础(STM32-DAY2(GPIO))
  • Apache Shiro 框架详解
  • 缺乏日常项目进度例会机制,如何系统推进
  • python的第三方库(五分钟小白从入门到精通)
  • 什么是 领域偏好学习(DPO)与多目标强化学习(PPO)
  • 抽象类基础知识