保姆级教程了解Vue的MVVM

902 阅读8分钟

文章目的

本文从Vue3的使用出发结合一个简单的案例来一步一步分析MVVM架构设计;

从这个简单修改值的响应式案例中我们可以了解到一些Vue背后到底做了一些什么操作,了解Vue的MVVM的工作流程(文章只说明大概做了什么操作具体细节操作例如验证等可以自己查阅源码)例如:

  1. 如何使用代理来进行数据劫持;
  2. Vue渲染完成后我们看到dom上的一些data-v-xx一些标记到底是干什么的;
  3. 模板上@click='fn(1)'的事件绑定方式如何简单的实现;
  4. ...

并且文章不会一次性把所有代码都进行展示出来,而是一步一步流程分析让大家能看的更轻松。希望能帮助大家更简单的理解Vue背后为我们做了什么。

图示流程

image.png

流程可以简单的分为 代理数据、 分析模板、 绑定数据、 绑定事件;下面开始一步一步分析

MVVM流程分析

1. 先复习一下Vue3代码的写法以及文件目录作用

image.png

所有功能都从MVVM/index.js入口文件进行引入,App()函数执行会返回一个根实例, 里面会有template模板 state数据和方法,createApp()函数将根实例挂载到对应的dom上。

// 由于需要这两个函数所以index.js需要导出这两个方法
import { createApp, useReactive } from './MVVM/index'

function App() {
    const state = useReactive({
        user: {
            name: 'is name'
        },
        num: 0
    })

    const add = (num) => state.num += num;
    const min = (num) => state.num -= num;
    const changeName = (name) => state.user.name = name;

    return {
        template: `
            <div>
                <p>{{ user.name }}</p>
                <span>{{ num }}</span> 
                <button onClick="add(1)">+</button>   
                <button onClick="min(1)">-</button>
                <button onClick="changeName('fyh')">change name</button>
            </div>
        `,
        state,
        methods: {
            add,
            min,
            changeName
        }
    }
}

createApp(App(), document.getElementById('app'));

main.js入口文件的使用方法基本完成, 现在根据需求进行完成内部功能, 由外往内一个一个点进行出发, 并且每个功能最好能抽离位一个函数方便日后进行维护。

2. createApp函数的基本逻辑

从上面的使用上看第一个传入的是一个根实例对象{ tempalte: '', state: {}, methods:{} }包含模板、数据和方法, 第二个为一个dom对象。 这个作用是渲染函数我们可以放在render.js文件中导出给index.js入口文件;

// MVVM/index.js 
export { createApp } from './redner';
export { useReactive } from './reactive';
// MVVM/render.js
export function createApp({template, state, methods}, dom) {
    // ... 就是把dom的innerHtml进行改变, 抽离一个render方法
    dom.innerHTML = render(template, state);
}

export function render(template, state) {
    // 此时模板需要进行两次处理, 一次处理绑定事件、一次处理绑定数据
    template = eventFormat(template);
    template =eventState(template, state);
    return template;
}

新建compiler/event和compiler/state两个js文件写方法分别处理模板上事件的绑定和数据的绑定逻辑, 方便以后维护;

3. 暂时补全所有函数(能让页面渲染出模板)

补全所有函数, 第一步先将模板渲染出来, 先把基本架子搭建完成,以下每一个代码块都是一个JS文件,上面的注释为文件名。

// index.js
import { createApp } from './render';
import { useReactive } from './reactive';
import { eventFormat } from './compiler/event';
import { eventState } from './compiler/state';
// reactive/reactive.js 响应式数据处理
export function useReactive(target) {
    return target;
}
// compiler/event.js 暂时模板直接返回
export function eventFormat(template) {
    return template;
}
// compiler/state.js 暂时模板直接返回
export function eventState(template) {
    return template;
}

这样我们就能将模板渲染到页面上了。图示:

image.png

4. 完成数据代理

数据代理的发步骤较多我们继续拆分单个函数文件,方便管理

a. 基本架构, 此时缺少baseHandler针对代理的get和set操作以及通用方法isObject()判断是否是一个对象

// reactive.js 新增一个函数来处理响应式逻辑

// 执行这个函数返回一个代理对象
export function useReactive(target) {
    return createReactObject(target, baseHandler)
}

// 返回代理对象
function createReactObject(target, baseHandler) {
    if(!isObject(target)) {
        return target;
    }
    const observer = new Proxy(target, baseHandler);
    return observer;
}

b. baseHandler 这个对象单独抽离出来, 放入到reactive/mutableHandles.js中.专门处理代理的get和set逻辑(逻辑之后填写)

// reactive/mutableHandles.js

const get = createGetter();
const set = createSetter();


function createGetter() {
    return function get() {

    }
}

function createSetter() {
    return function set() {
        
    }
}

const mutableHandles = {
    get,
    set
}

export {
    mutableHandles
}

c. 处理get逻辑函数

import { isObject } from '../shared/utils' // 一个工具函数判断是否是一个对象
import { useReactive } from "./reactive";

function createGetter() {
    return function get(target, key, receiver) {
        let res = Reflect.get(target, key, receiver);
        // 如果属性还是一个对象则进行深度代理
        // 记住proxy代理操作不会影响原始数据,而是返回一个代理
        // 当我们读取这个引用属性的时候,返回的是一个新的proxy对象
        if (isObject(res)) {
            return useReactive(res);
        }
        return res;
    }
}

d.处理set函数

此时需要进行判断旧值和新值是否相等, 如果相等则不进行更新节约性能

function createSetter() {
    return function set(target, key, value, receiver) {
        const keyExist = isKeyExist(target, key),
              oldValue = target[key],
              res =  Reflect.set(target, key, value, receiver);

        if (!keyExist) {
            // 之前没有这个key则为增加
            console.log('响应式增加', key, value);
        } else if(!isEqual(value, oldValue)) {
            console.log('响应式修改', key, value);
        }

        return res;
    }
}

e. 数据代理整体代码逻辑

// shared/utils.js 工具函数
export function isObject(obj) {
    return typeof obj === 'object' && obj !== null;
}

export function isKeyExist(target, key) {
    return Object.prototype.hasOwnProperty.call(target, key);
}

export function isEqual(newVal, oldVal) {
    return newVal === oldVal;
}

// reactive/mutableHandles.js
import { isObject, isEqual, isKeyExist } from '../shared/utils'
import { useReactive } from "./reactive";

const get = createGetter();
const set = createSetter();


function createGetter() {
    return function get(target, key, receiver) {
        let res = Reflect.get(target, key, receiver);

        console.log('响应式读取', key);
        // 如果属性还是一个对象则进行深度代理
        if (isObject(res)) {
            return useReactive(res);
        }
        return res;
    }
}

function createSetter() {
    return function set(target, key, value, receiver) {
        const keyExist = isKeyExist(target, key),
              oldValue = target[key],
              res =  Reflect.set(target, key, value, receiver);

        if (!keyExist) {
            // 之前没有这个key则为增加
            console.log('响应式增加', key, value);
        } else if(!isEqual(value, oldValue)) {
            console.log('响应式修改', key, value);
        }

        return res;
    }
}

const mutableHandles = {
    get,
    set
}

export {
    mutableHandles
}

5. 处理模板函数绑定

我们经常在vue渲染的dom上看到v-data-xx, 就是Vue在绑定函数时候进行的标记, 因为@click='fn'的语法html是无法识别的, 必须有一个背后的逻辑来去实践。我们只需要在绑定函数的dom上打上标签并且收集处理事件然后根据标签对应事件即可

a. 在compiler/event.js使用正则替换模板, 并且在对应的dom上打上唯一标签收集事件处理函数

// 1. 匹配出绑定事件函数名
let reg_onClick = /onClick\=\"(.+?)\"/g;

// 2. 创建唯一标识id
function markId() {
    return new Date().getTime() + parseInt(Math.random() * 10000)
}

// { type: 处理事件, mark: 这个事件对应的dom上的data-mark属性(根据这个属性来绑定对应事件), handle: 执行的函数名 }
let event_pool = [];

export function eventFormat(template) {
    console.log(template);
    template = template.replace(reg_onClick, (node, key) => {
        let _mark = markId();
        event_pool.push({ mark: _mark, type: 'click', handle: key.trim() });
        return `data-mark=${_mark}`;
    })
    return template;
}

b. createApp渲染完dom后需要绑定事件

// render.js
import { eventFormat, bindEvent } from './compiler/event'
import { eventState } from './compiler/state'

export function createApp({template, state, methods}, dom) {
    dom.innerHTML = render(template, state);
    // 渲染dom后在进行绑定
    bindEvent(methods);
}

这里会获取所有的dom节点, 循环查找dom上是否有data-mark, 第二步根据dom上的data-mark的值和event_pool事件列表进行匹配并且绑定事件,然后根据event_pool事件上的handle使用正则匹配出函数和函数参数

// compiler/event.js

// 判断格式的正则
let reg_onClick = /onClick\=\"(.+?)\"/g; // 是否是绑定语法
let reg_fn_name = /(.*)\(/; // 获取绑定语法的函数名
let reg_fn_args = /\((.*)\)/; // 获取绑定语法函数的参数

// 结构
// / { type: 处理事件, mark: 这个事件对应的dom上的data-mark属性(根据这个属性来绑定对应事件), handle: 执行的函数名 }
let event_pool = [{mark: 1648003292308, type: 'click', handle: 'min(1)'}]

// 判断参数类型,因为 @click="fn('1')"与 @click="fn(1)" 函数的参数一个是字符串1一个是数字1必须要进行区分
const reg_check_str = /^[\'|\"].*?[\'|\"]$/; // 坚持是否是字符串
const reg_str = /[\'|\"]/g  // 去掉字符串的单或双引号
function checkType(str) {
    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 function bindEvent(methods) {
    // 1. 选择所有dom
    let allElement = document.querySelectorAll('*');
    let oItem;
    for(let i = 0; i < allElement.length; i++) {
        oItem = allElement[i]
        // 2. 查看所有dom上是否有mark, 如果有对比event_pool
        let dataMarkNum = Number(oItem.dataset.mark);
        if (dataMarkNum) {
            event_pool.forEach(event => {
                if (dataMarkNum === event.mark) {
                    // 给有mark的dom绑定点击事件, 间接触发绑定事件
                    oItem.addEventListener(event.type, function () {
                        // 根据event.handle来判断函数
                        console.log(event);
                        // 匹配当前dom需要的函数名
                        let fnName = event.handle.match(reg_fn_name)[1];
                        // 匹配当前函数的参数
                        let fnArgs = event.handle.match(reg_fn_args)[1];
                        fnArgs = checkType(fnArgs);
                        methods[fnName](fnArgs);
                    }, false);
                }
            })
        }
    }
}

渲染后图示: image.png

6. 处理模板数据

和绑定数据一样, 需要给dom打上标记, 但是state需要分两次标记, 第一次一次打上标签一次渲染值

// event/state.js

const reg_dom = /\<.*\>\{\{(.*?)\}\}\<\/.*\>/g; // 匹配有{{}}的dom
const reg_tag = /\<\/(.+)\>/; // 匹配有差值表达式的dom tagName
const reg_state = /\{\{(.*?)\}\}/g; // 匹配数据键名

// { mark: 唯一, state: ['key'] } 更新需要这个state_pool里面的值
let state_pool = [];


export function stateFormat(template, state) {
    // 1. 匹配出带有差值表达式的dom, 并且给每一个dom打上唯一的标签
    template = template.replace(reg_dom, (node, key) => {
        let _mark = parseInt(Math.random() * 10000),
            tagName = node.match(reg_tag)[1];

        // 第一步处理mark后面处理数据
        state_pool.push({mark: _mark});
        return `<${tagName} data-sMark="${_mark}">{{${key}}}</${tagName}>`
    })

    // 2. 处理模板数据, 由于可能出现类似count.num这样的语法需要以数组形式处理
    template = template.replace(reg_state, (node, key) => {
        let valArr = key.trim().split('.');
        let i = 0;
        let val;

        while (i < valArr.length) {
            val = state[valArr[i]];
            i++;
        }

        return val;
    });
    return template;
}

以上代码处理了初次渲染, 但是state_pool里面的值和mark没有匹配上, 下面需要进行匹配

let o = 0;
export function stateFormat(template, state) {
    template = template.replace(reg_dom, (node, key) => {
        let _mark = parseInt(Math.random() * 10000),
            tagName = node.match(reg_tag)[1];

        state_pool.push({mark: _mark});
        return `<${tagName} data-sMark="${_mark}">{{${key}}}</${tagName}>`
    })

    template = template.replace(reg_state, (node, key) => {
        let valArr = key.trim().split('.');
        let i = 0;
        let val;


        while (i < valArr.length) {
            val = state[valArr[i]];
            // .语法情况下需要读取下一个属性值
            if (i > 0) {
                let next = state[valArr[i - 1]],
                    key = valArr[i];
                val = next[key];
            }
            i++;
        }

        // 对应上state_pool里面的值, 这里为了方便使用索引值,其实对象应该更好
        // 结构 {mark: xxx, state: ['name', 'age']}
        state_pool[o].state = valArr;
        o++;

        return val;
    });
    return template;
}

处理后的dom

image.png

7. 数据更新时候处理

处理好state数据的编译和初次渲染,现在需要一个更新upDate函数更新页面, 这个函数在state数据set的时候进行执行.

// render.js
export function upDate(state_pool, key, value) {
    console.log(state_pool, key, value);
}

// reactive/mutableHandles.js 的set函数中执行
import { upDate } from "../render";
import { state_pool } from "../compiler/state";
function createSetter() {
    return function set(target, key, value, receiver) {
        const keyExist = isKeyExist(target, key),
              oldValue = target[key],
              res =  Reflect.set(target, key, value, receiver);

        if (!keyExist) {
            // 之前没有这个key则为增加
            console.log('响应式增加', key, value);
        } else if(!isEqual(value, oldValue)) {
            console.log('响应式修改', key, value);
            upDate(state_pool, key, value);
        }
        return res;
    }
}
// render.js upDate函数
 export function upDate(state_pool, key, value) {
    // 获取所有的dom
    let allElement = document.querySelectorAll('*');
    let oItem;

    // 注意state_pool state的值形式以数组出现 ['name', 'age']
    state_pool.forEach(item => {
        if (item.state[item.state.length - 1] === key) {
            for (let i = 0; i < allElement.length; i++) {
                oItem = allElement[i];
                let dom_mark = parseInt(oItem.dataset.smark);
                if (item.mark === dom_mark) {
                    oItem.innerHTML = value;
                }
            }
        }
    })
}

总结

以上就可以简单的实现Vue对于MVVM模型的流程使用, 相对于上一篇MVC的实现稍微复杂一些,但是如果能自己写下来对于理解Vue的工作逻辑会有很清晰的理解。