上一篇文章我们写了MVC模式,以及说了MVC模式的缺点,这篇文章我们就来实现一个简单的MVVM模式。Model负责管理数据,View负责管理视图,ViewModel负责数据和视图的连接。
先看一下大概的目录结构:
08-MVVM├─ index.html├─ mvvm│ ├─ index.js│ ├─ render.js # 负责页面的渲染│ ├─ compiler│ │ ├─ event.js # 负责事件处理│ │ └─ state.js # 负责数据处理│ ├─ reactive│ │ ├─ index.js│ │ └─ mutableHandler.js # 对数据进行响应式处理│ └─ shared│ └─ utils.js└─ src└─ App.js # 程序的入口
这个案例使用了Vite作为服务器进行开发,所以你需要进行安装Vite和配置package.json文件。
{"name": "08-mvvm","version": "1.0.0","description": "","main": "index.js","scripts": {"dev": "vite"},"author": "","license": "ISC","devDependencies": {"vite": "^4.0.3"}}
App.js
// Vite 会自动补全 /index.js 的后缀import { useDom, useReactive } from "../mvvm/index";function App() {// 创建响应式const state = useReactive({count: 0,name: "TestName"});// 操作 state 数据的方法const add = function (num) {state.count += num;};const minus = function (num) {state.count -= num;};const changeName = function (name) {state.name = name;};// 模版的渲染return {template: `<h1>{{ count }}</h1><h2>{{ name }}</h2><button onClick="add(2)">新增</button><button onClick="minus(1)">减去</button><button onClick="changeName('xiechen')">更改名字</button>`,state,methods: {add,minus,changeName}};}useDom(App(), // 返回 template,state,methodsdocument.querySelector("#app"));
以上代码,我们引入了useDom方法对App的模版进行渲染,引入useReactive对state数据进行管理。
我们把所有需要用到的方法,都导入到了mvvm/index.js这个文件里面进行管理:
export { useReactive } from "./reactive";export { useDom, update } from "./render";export { eventFormat } from "./compiler/event";export { stateFormat } from "./compiler/state";
接下来,就让我们看看每个文件都负责干了点啥。
mvvm/reactive
该文件的useReactive方法在App.js文件中进行了调用:
// isObject 主要是判断是不是一个对象,如果你想看到更多的实现细节,你可以滑倒文章的最后。import { isObject } from "../shared/utils";import { mutableHandler } from "./mutableHandler";export function useReactive(target) {// target 为 App.js 中的 state , 也就是/*{count: 0,name: "TestName"}*/// mutableHandler 为 Proxy 对象拦截属性的一些方法return createReactObject(target, mutableHandler);}function createReactObject(target, baseHandler) {if (!isObject(target)) {return target;}return new Proxy(target, baseHandler);}
以上代码,我们在createReactObject方法中对App.js文件中的数据进行拦截,并引入了mutableHandler.js文件对Proxy的数据进行处理。
import { useReactive } from "./index";/*** hasOwnProperty 用于判断一个属性是不是对象本身上的属性,而非原型上的属性* isEqual 用于判断新值和旧值是否相等*/import { isObject, hasOwnProperty, isEqual } from "../shared/utils";import { update } from "../render";import { statePool } from "../compiler/state";function createGetter() {return function get(target, key, receiver) {// 通过 Reflect.get 方法去操作属性// 详见MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect/getconst res = Reflect.get(target, key, receiver);// 如果返回的值是一个对象,那么就继续调用 useReactive 去处理if (isObject(res)) {return useReactive(res);}// 否则直接返回return res;};}function createSetter() {return function set(target, key, value, receiver) {const isKeyExist = hasOwnProperty(target, key);const oldValue = target[key];// 同 Reflect.get ,但是 set 返回的是是否设置成功的布尔值const res = Reflect.set(target, key, value, receiver);// 如果对象上没有这个属性,那么这个属性就是新增的属性if (!isKeyExist) {console.log("响应式新增:", value);} else if (!isEqual(value, oldValue)) {// 否则就是去更改属性的值console.log("响应式修改:", key, value);// 然后调用视图的 update 方法update(statePool, key, value);}return res;};}const get = createGetter();const set = createSetter();export const mutableHandler = {get,set};
以上代码,我们分别对属性的set和get拦截进行了处理,在set方法中,无论是新增或更改对象的属性,我们都可以拦截的到。
mvvm/render.js
render.js文件主要负责了对视图管理,我们在App.js文件中调用了useDom方法进行视图的渲染,且在Proxy的set处理中调用了update进行视图的更新。
/*** bindEvent 用于给元素绑定事件*/import { bindEvent } from "./compiler/event";import { eventFormat, stateFormat } from "./index";export function useDom({ template, state, methods }, rootDom) {// 接收 App.js 方法返回的对象,也就是/*{template: xxxstate: xxxmethods: xxx}*/// 调用 render 方法,对模版数据进行处理rootDom.innerHTML = render(template, state);// 调用 bindEvent 方法进行绑定事件bindEvent(methods);}export function render(template, state) {/*eventFormat 方法会给绑定事件的模版新增一个属性 data-mark="xxx",例如模版<button onClick="add(2)">新增</button> =>><button data-mark="12345" onClick="add(2)">新增</button>并且保存到一个名为 eventPool 的数组中,数据结构如下:[{mark: 12345 // dom 标签上的 data-markhandler: add(2)type: click}]*/template = eventFormat(template);/*stateFormat 方法会给标签新增一个属性 data-mark="xxx",例如模版<h1>{{ count }}</h1> =>><h1 data-mark="12345">1</h1>并且保存到一个名为 statePool 的数组中,数据结构如下:[{mark: 12345,state: ["count"]}]*/template = stateFormat(template, state);return template;}/*update 方法接收了 statePool 为参数,也就是[{mark: 12345state: ["count"]}]还接收了 set 数据的时候,要更改的属性和值*/export function update(statePool, key, value) {const allElements = document.querySelectorAll("*");let oItem = null;// 进行遍历statePool.forEach((el) => {// 如果 statePool 中 el.state 中的数据等于要 set 的属性名if (el.state[el.state.length - 1] === key) {for (let i = 0; i < allElements.length; i++) {oItem = allElements[i];const _mark = parseInt(oItem.dataset.mark);// 如果 statePool.mark 等于某个节点的 data-mark 属性if (el.mark === _mark) {oItem.innerHTML = value;}}}});}
以上代码,我们分别调用了
bindEvent对DOM绑定事件。eventFormat把DOM和事件的对应关系进行存储。stateFormat把DOM和数据的对应关系进行存储,并且替换为state中对应的数据。
mvvm/compiler
以下是对mvvm/compiler/event.js文件的详解:
import { checkType, randomNum } from "../shared/utils";/*** {* mark: random,* handler: 事件处理函数的字符串* type: click* }*/const reg_onClick = /onClick\=\"(.+?)\"/g;const reg_fnName = /^(.+?)\(/;const reg_arg = /\((.*?)\)/;const eventPool = [];export function eventFormat(template) {return template.replace(reg_onClick, function (node, key) {const _mark = randomNum();// 把数据的对应关系存到 eventPool 里面,方面我们进行对比调用eventPool.push({mark: _mark,handler: key.trim(),type: "click",});/*eventPool 结构如下:[{mark: 12345 // dom 标签上的 data-markhandler: add(2)type: click}]*/// 给标签新增一个 data-mark="12345" 这样的属性return `data-mark="${_mark}"`;});}export function bindEvent(methods) {const allElements = document.querySelectorAll("*");let oItem = null;let _mark = 0;/*eventPool 结构如下:[{mark: 12345 // dom 标签上的 data-markhandler: add(2)type: click}]*/// 循环对比eventPool.forEach((el) => {for (let i = 0; i < allElements.length; i++) {oItem = allElements[i];_mark = parseInt(oItem.getAttribute("data-mark"));// 如果 eventPool 中 el.mark 等于某个 dom 的 data-mark 的属性if (el.mark === _mark) {// 绑定事件oItem.addEventListener(el.type, function () {const fnName = el.handler.match(reg_fnName)[1];const arg = checkType(el.handler.match(reg_arg)[1]);// 调用 state.methods 里面对应的方法methods[fnName](arg);}, false);}}});}
以下是对mvvm/compiler/state.js文件的详解:
import { randomNum } from "../shared/utils";const reg_html = /\<.+?\>\{\{(.+?)\}\}\<\/.+?\>/g;const reg_tag = /\<(.+?)\>/;const reg_var = /\{\{(.+?)\}\}/g;/*** {* mark: _mark* state: value* }*/export const statePool = [];let o = 0;export function stateFormat(template, state) {let _state = {};// 绑定 data-marktemplate = template.replace(reg_html, function (node, key) {const matched = node.match(reg_tag);const _mark = randomNum();/*_state 结构如下:{mark: 12345,}statePool 结构如下:[{mark: 12345,}]*/_state.mark = _mark;statePool.push(_state);_state = {};// 例如将 <h1>{{ count }}</h1> 替换为// <h1 data-mark="12345">{{ count }}</h1>return `<${matched[1]} data-mark="${_mark}">{{ ${key} }}</${matched[1]}>`;});// 替换模版数据template = template.replace(reg_var, function (node, key) {let _var = key.trim(); // 拿到 state 里面属性的 keyconst _varArr = _var.split(".");let i = 0;while (i < _varArr.length) {// 去拿 state 里面对应的数据,例如 _var 为 count,所以 state.count// 最后 _var 得到了 state.count 的值,也就是 0_var = state[_varArr[i]];i++;}_state.state = _varArr;statePool[o].state = _varArr;o++;/*statePool 的结构如下:[{mark: 12345state: ["count"]}]*/// 将 <h1 data-mark="12345">{{ count }}</h1> 中的 count 替换为真实的数据return _var;});return template;}
shared/utils.js
utils.js文件主要存放的是一些工具类的方法,我们在上面案例使用到的方法,在这里都可以找得到。
function isObject(val) {return typeof val === "object" && val !== null;}function hasOwnProperty(target, key) {return Object.prototype.hasOwnProperty.call(target, key);}function isEqual(newVal, oldValue) {return newVal === oldValue;}function randomNum() {return new Date().getTime() + parseInt(Math.random() * 10000);}function checkType(str) {const reg_check_str = /^[\'\"](.*?)[\'\"]/;const reg_str = /(\'|\")/g;if (reg_check_str.test(str)) {return str.replace(reg_str, "");}switch (str) {case "true":return true;case "false":return false;default:break;}return Number(str);}export { isObject, hasOwnProperty, isEqual, randomNum, checkType };
收尾
到这里,我们就把这个模式完整的解释完了,再回顾一下这个代码的结构,

这样我们就只负责数据和视图的逻辑,剩下的事情全部交给mvvm驱动去管理,mvvm负责了创建响应式数据、对事件和数据进行编译、对模版进行渲染以及视图更新。
