# 百度前端技术实战训练营结课作业 **Repository Path**: baidu-front-end-izhan/mvvm ## Basic Information - **Project Name**: 百度前端技术实战训练营结课作业 - **Description**: 百度前端技术实战训练营结课作业-mvvm 南京大学 张云菲 亮点:具有可扩展性(具体表现在compile.js中的解耦等) - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 4 - **Created**: 2023-01-16 - **Last Updated**: 2023-07-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 百度前端技术实战训练营结课作业-MVVM 姓名:张云菲 学校:南京大学 专业:软件工程 年级:2020级本 大二 QQ号:1773701137 ## 目录 - [1 引言](#1-引言) - [1.1 MVVM 的概念](#11-mvvm-的概念) - [1.2 MVVM 的优点](#12-mvvm-的优点) - [1.3 MVVM 的缺点](#13-mvvm-的缺点) - [1.4 实现思路](#14-实现思路) - [1.5 项目结构示意图](#15-结构示意图) - [2 实现代码](#2-实现代码) - [2.1 实现 Dep 类](#21-实现-dep-类) - [2.2 实现 Watcher 类](#22-实现-watcher-类) - [2.3 实现 Observer 类](#23-实现-observer-类) - [2.4 实现 Compile 类](#24-实现-compile-类) - [2.5 实现 MVVM 类](#25-实现-mvvm-类) - [3 测试](#3-测试) - [3.1 代码](#31-代码) - [3.2 运行结果及覆盖率截图](#32-运行结果及覆盖率截图) - [4 结果展示](#4-结果展示) - [4.1 代码](#41-代码) - [4.2 界面截图](#42-界面截图) ## 1 引言 ### 1.1 MVVM 的概念 Model - View - ViewModel,通过数据劫持+发布订阅模式来实现。 Mvvm 是一种设计思想。Model 代表数据模型,可以在 Model 中定义数据修改和操作的业务逻辑;View 表示 UI 组件,负责将数据模型转换为 UI 展现出来,它做的是数据绑定的声明、 指令的声明、 事件绑定的声明。而 ViewModel 是一个同步 View 和 Model 的对象。在 MVVM 框架中,View 和 Model 之间没有直接的关系,它们是通过 ViewModel 来进行交互的。MVVM 不需要手动操作 DOM ,只需要关注业务逻辑就可以了。    MVVM 和 MVC 的区别在于:MVVM 是数据驱动的,而 MVC 是 DOM 驱动的。MVVM 的优点在于不用操作大量的 DOM ,不需要关注 Model 和 View 之间的关系,而 MVC 需要在 Model 发生改变时,需要手动的去更新 View 。大量操作 DOM 使页面渲染性能降低,使加载速度变慢,影响用户体验。 ### 1.2 MVVM 的优点 - 低耦合性。View 和 Model 之间没有直接的关系,通过 ViewModel 来完成数据双向绑定。 - 可复用性。组件是可以复用的。可以把一些数据逻辑放到一个 ViewModel 中,让很多 View 来重用。 - 独立开发。开发人员专注于 ViewModel ,设计人员专注于 View。 - 可测试性 。ViewModel 的存在可以帮助开发者更好地编写测试代码。 ### 1.3 MVVM 的缺点 - bug 很难被调试,因为数据双向绑定,所以问题可能在 View 中,也可能在 Model 中,要定位原始 bug 的位置比较难,同时 View 里面的代码没法调试,也添加了 bug 定位的难度。 - 一个大的模块中的 Model 可能会很大,长期保存在内存中会影响性能。 - 对于大型的图形应用程序,视图状态越多,ViewModel 的构建和维护的成本都会比较高。 ### 1.4 实现思路 1. 实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者 2. 实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数 3. 实现一个Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图 4. mvvm入口函数,整合以上三者 ### 1.5 结构示意图 ![](https://pic.imgdb.cn/item/62da1221f54cd3f9371aa6f3.png) ## 2 实现代码 ### 2.1 实现 Dep 类 对应文件`./dep.js` Dep 类实现了一个消息订阅器,维护一个数组,用来收集订阅者,数据变动触发notify,再调用订阅者的update方法。 ```javascript export default class Dep{ constructor() { // 订阅的数组 this.subs = []; } addSub(watcher){ this.subs.push(watcher); } notify(){ this.subs.forEach(watcher=>watcher.update()); } } ``` ### 2.2 实现 Watcher 类 对应文件`./watcher.js` Watcher订阅者作为Observer和Compile之间通信的桥梁,主要做的事情是: 1. 在自身实例化时往属性订阅器(dep)里面添加自己 2. 自身必须有一个update()方法 3. 待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调 ```javascript import Dep from "./dep"; // 观察者的目的就是给需要变化的元素增加一个观察者,当数据变化后执行对应的方法 export default class Watcher{ constructor(vm, expr, cb) { this.vm = vm; this.expr = expr; this.cb = cb; // 先获取一下原来的值 this.value = this.get(); } getVal(vm, expr){ // 获取实例上对应的数据 expr = expr.split('.'); return expr.reduce((prev, next)=>{ return prev[next]; },vm.$data); } get(){ Dep.target = this; let value = this.getVal(this.vm, this.expr); Dep.target = null; return value; } // 对外暴露的方法 update(){ let newValue = this.getVal(this.vm, this.expr); let oldValue = this.value; if(newValue != oldValue){ this.cb(newValue); // 调用watch的callback } } } ``` ### 2.3 实现 Observer 类 对应文件`./observer.js` Observer 类实现监听对象的每一个属性的变化。 ```javascript import Dep from "./dep"; export default class Observer{ constructor(data) { this.observe(data); } observe(data){ // 对data数据将原有的属性改成get和set形式 if(!data || typeof data !== 'object'){ return; } // 将数据一一劫持 先获取到data的key和value Object.keys(data).forEach(key=>{ // 劫持 this.defineReactive(data, key, data[key]); this.observe(data[key]); // 深度递归劫持 }); } // 定义响应式 defineReactive(obj, key, value){ let that = this; let dep = new Dep(); // 每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作 Object.defineProperty(obj, key, { enumerable:true, configurable:true, get(){ // 当取值时调用的方法 Dep.target && dep.addSub(Dep.target); return value; }, set(newValue){ // 给data属性中设置值的时候,更改获取的属性的值 if(newValue != value){ // 这里的this不是实例 that.observe(newValue); // 如果是对象,继续劫持 value = newValue; dep.notify(); // 通知所有人数据更新了 } } }); } } ``` ### 2.4 实现 Compile 类 对应文件`./compile.js` Compile 类主要做的事情是解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图。 因为遍历解析的过程有多次操作dom节点,为提高性能和效率,会先将跟节点`el`转换成文档碎片`fragment`进行解析编译操作,解析完成,再将`fragment`添加回原来的真实dom节点中。 ```javascript import Watcher from "./watcher"; export default class Compile{ /** * * @param el * @param vm mvvm实例 */ constructor(el, vm) { this.el = this.isElementNode(el)?el:document.querySelector(el); this.vm = vm; if(this.el){ // 如果能获取到这个元素,才开始编译 // 1. 先把真实的DOM移到内存中 fragment let fragment = this.node2fragment(this.el); // 2. 编译 => 提取想要的元素节点 v-model 和文本节点 {{}} this.compile(fragment); // 3. 把编译好的fragment放回页面 this.el.appendChild(fragment); } } // 辅助方法 isElementNode(node){ return node.nodeType === 1; } isDirective(name){ return name.includes('v-'); } // 核心方法 compileElement(node){ // 带v-model let attrs = node.attributes; // 取出当前节点的属性 Array.from(attrs).forEach(attr=>{ // 判断属性名字是否包含v- // [v, ] let attrName = attr.name; if(this.isDirective(attrName)){ // 取到对应的值放到节点中 let expr = attr.value; let [, type] = attrName.split('-'); // node this.vm.$data expr CompileUtil[type](node, this.vm, expr); } }) } compileText(node){ // 带{{}} let expr = node.textContent; // 去文本中的内容 let reg = /\{\{([^}]+)\}\}/g; if(reg.test(expr)){ // node this.vm.$data text CompileUtil['text'](node, this.vm, expr); } } compile(fragment){ let childNodes = fragment.childNodes; Array.from(childNodes).forEach(node=>{ if(this.isElementNode(node)){ // 元素节点,还需要深入检查 // 需要编译元素 this.compileElement(node); this.compile(node); }else { // 文本节点 // 需要编译文本 this.compileText(node); } }) } node2fragment(el){ // 需要将el中的内容放到内存中 // 文档碎片 let fragment = document.createDocumentFragment(); let firstChild; while(firstChild = el.firstChild){ fragment.append(firstChild); } return fragment; // 内存中的节点 } } ``` Compile 将遍历所有节点及其子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定。本次实现 text 和 model 两个例子,分别代表单向绑定和双向绑定。此处解耦,具有可扩展性。 ```javascript let CompileUtil = { getVal(vm, expr){ // 获取实例上对应的数据 expr = expr.split('.'); return expr.reduce((prev, next)=>{ return prev[next]; },vm.$data); }, getTextVal(vm, expr){ // 获取编译文本后的结果 return expr.replace(/\{\{([^}]+)\}\}/g, (...args)=>{ return this.getVal(vm, args[1]); }) }, text(node, vm, expr){ // 文本处理 let updateFn = this.updater['textUpdater']; let value = this.getTextVal(vm, expr); expr.replace(/\{\{([^}]+)\}\}/g, (...args)=>{ new Watcher(vm, args[1], (newValue)=>{ // 如果数据变化了,文本节点需要重新获取依赖的数据更新文本中的内容 updateFn && updateFn(node, this.getTextVal(vm, expr)); }) }) updateFn && updateFn(node, value); }, setVal(vm, expr, value){ expr = expr.split('.'); // 收敛 return expr.reduce((prev, next, currentIndex)=>{ if(currentIndex === expr.length - 1){ return prev[next] = value; } return prev[next]; },vm.$data) }, model(node, vm, expr){ // 输入框处理 let updateFn = this.updater['modelUpdater']; // 这里应该加一个监控,数据变化了应该调用watch的callback new Watcher(vm, expr, (newValue)=>{ // 当值变化后会调用cb,将新的值传递过来 updateFn && updateFn(node, this.getVal(vm, expr)); }); node.addEventListener('input', (e)=>{ let newValue = e.target.value; this.setVal(vm, expr, newValue); }) updateFn && updateFn(node, this.getVal(vm, expr)); }, updater:{ // 文本更新 textUpdater(node, value){ node.textContent = value; }, // 输入框更新 modelUpdater(node, value){ node.value = value; } } } ``` ### 2.5 实现 MVVM 类 对应文件`./MVVM.js` MVVM 作为数据绑定的入口,整合 Observer、Compile 和 Watcher 三者,通过 Observer 来监听自己的 model 数据变化,通过 Compile 来解析编译模板指令,最终利用 Watcher 搭起 Observer 和 Compile 之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化 (input) -> 数据 model 变更的双向绑定效果。 以下代码还包括数据代理,即`proxyData`方法,实现后数据代理,不用再通过`$data`获取数据。 ```javascript import Observer from "./observer"; import Compile from "./compile"; export default class MVVM{ constructor(options) { // 将可用内容挂载到实例上 this.$el = options.el; this.$data = options.data; // 如果有要编译的模板就开始编译 if(this.$el){ // 数据劫持 把对象的所有属性改成get和set方法 new Observer(this.$data); this.proxyData(this.$data); // 用数据和元素进行编译 new Compile(this.$el, this); } } // 数据代理,不用再通过'$data'获取数据 proxyData(data){ Object.keys(data).forEach(key=>{ Object.defineProperty(this, key, { get(){ return data[key]; }, set(newValue){ data[key] = newValue; } }) }) } } // 挂载 window.MVVM = MVVM; ``` ## 3 测试 ### 3.1 代码 对应文件`./__tests__/MVVM.js` 在 jsdom 环境下,针对`v-model`和`v-text`分别写一套单测。 ```javascript /** * @jest-environment jsdom */ import MVVM from "../MVVM"; it('v-model', ()=>{ document.body.innerHTML = '
' const vm = new MVVM({ el:'#app', data:{ message: { a: 'hello', }, a:1 } }) vm.message.a = 1 ; expect(vm.message.a).toBe(1); }); it('v-text', ()=>{ document.body.innerHTML = '
{{message.a}}
' const vm = new MVVM({ el:'#app', data:{ message: { a: 'hello', }, a:1 } }) vm.message.a = 1 ; expect(vm.message.a).toBe(1); }); ``` ### 3.2 运行结果及覆盖率截图 ![](https://pic.imgdb.cn/item/62da2e65f54cd3f937b21cba.png) ## 4 结果展示 ### 4.1 代码 对应文件`./index.html` 其中引入的`./dist/MVVM.js`为使用 webpack 打包获得的文件,入口为`./MVVM.js`。 ```html MVVM Demo
{{message.a}}
``` ### 4.2 界面截图 初始化界面如下图所示 ![](https://pic.imgdb.cn/item/62da3155f54cd3f937c3b26a.png) 改变输入框中的值,不需要刷新界面,右侧内容同时改变,如下图所示 ![](https://pic.imgdb.cn/item/62da3163f54cd3f937c405e6.png)