Skip to content

MVVM

MVVM 由以下三个内容组成:

  • View: 界面
  • Model: 数据模型
  • ViewModel: 作为桥梁负责沟通 View 和 Model

在 JQuery 时期,如果需要刷新 UI 时,需要先取到对应的 DOM 再更新 UI,这样数据和业务的逻辑就和页面有强耦合。

在 MVVM 中,UI 是通过数据驱动的,数据一旦改变就会相应的刷新对应的 UI,UI 如果改变,也会改变对应的数据。这种方式就可以在业务处理中只关心数据的流转,而无需直接和页面打交道。ViewModel 只关心数据和业务的处理,不关心 View 如何处理数据,在这种情况下,View 和 Model 都可以独立出来,任何一方改变了也不一定需要改变另一方,并且可以将一些可复用的逻辑放在一个 ViewModel 中,让多个 View 复用这个 ViewModel。

在 MVVM 中,最核心的也就是数据双向绑定,例如 Angluar 的脏数据检测,Vue 中的数据劫持。

脏数据检测

当触发了指定事件后会进入脏数据检测,这时会调用 $digest 循环遍历所有的数据观察者,判断当前值是否和先前的值有区别,如果检测到变化的话,会调用 $watch 函数,然后再次调用 $digest 循环直到发现没有变化。循环至少为二次,至多为十次。

脏数据检测虽然存在低效的问题,但是不关心数据是通过什么方式改变的,都可以完成任务,但是这在 Vue 中的双向绑定是存在问题的。并且脏数据检测可以实现批量检测出更新的值,再去统一更新 UI,大大减少了操作 DOM 的次数。所以低效也是相对的,这就仁者见仁智者见智了。

数据劫持

Vue2 内部使用了 Object.defineProperty() 来实现双向绑定,通过这个函数可以监听到 setget 的事件。

js
function observe(obj) {
    // 判断类型
    if (!obj || typeof obj !== 'object') {
        return;
    }
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key]);
    });
}

function defineReactive(obj, key, val) {
    // 递归子属性
    observe(val);
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            console.log('get value');
            return val;
        },
        set: function reactiveSetter(newVal) {
            console.log('change value');
            val = newVal;
        }
    });
}


// test
var data = { name: 'jimco' };
observe(data);
let name = data.name;   // -> get value
data.name = 'aaa';      // -> change value

以上代码简单的实现了如何监听数据的 setget 的事件,但是仅仅如此是不够的,还需要在适当的时候给属性添加发布订阅:

html
<div>{{name}}</div>

在解析如上模板代码时,遇到 就会给属性 name 添加发布订阅:

js
// 通过 Dep 解耦
class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(sub) {
        // sub 是 Watcher 实例
        this.subs.push(sub);
    }
    notify() {
        this.subs.forEach(sub => {
            sub.update();
        });
    }
}
// 全局属性,通过该属性配置 Watcher
Dep.target = null;

class Watcher {
    constructor(obj, key, cb) {
        // 将 Dep.target 指向自己
        // 然后触发属性的 getter 添加监听
        // 最后将 Dep.target 置空
        Dep.target = this;

        this.cb = cb;
        this.obj = obj;
        this.key = key;
        this.value = obj[key]; // 触发 getter,添加 watcher 订阅

        Dep.target = null;
    }
    update() {
        // 获得新值
        this.value = this.obj[this.key];
        // 调用 update 方法更新 Dom
        this.cb(this.value);
    }
}

function update(value) {
    document.querySelector('div').innerText = value;
}


// test
var data = { name: 'jimco' };
observe(data);
// 模拟解析到 `{{name}}` 触发的操作
new Watcher(data, 'name', update);
// update Dom innerText
data.name = 'aaa';

接下来,对 defineReactive 函数进行改造:

js
function defineReactive(obj, key, val) {
    // 递归子属性
    observe(val);
    let dp = new Dep();
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            console.log('get value');
            // 将 Watcher 添加到订阅
            if (Dep.target) {
                dp.addSub(Dep.target);
            }
            return val;
        },
        set: function reactiveSetter(newVal) {
            console.log('change value');
            val = newVal;
            // 执行 watcher 的 update 方法
            dp.notify();
        },
    });
}

以上实现了一个简易的双向绑定,核心思路就是手动触发一次属性的 getter 来实现发布订阅的添加。

Proxy 与 Object.defineProperty 对比

Object.defineProperty 虽然已经能够实现双向绑定了,但是他还是有缺陷的。

  • 只能对属性进行数据劫持,所以需要深度遍历整个对象
  • 对于数组不能监听到数据的变化

虽然 Vue 中确实能检测到数组数据的变化,但是其实是使用了 hack 的办法,并且也是有缺陷的:

js
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
// hack 以下几个函数
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

methodsToPatch.forEach(function(method) {
  // 获得原生函数
  const original = arrayProto[method];

    def(arrayMethods, method, function mutator(...args) {
        // 调用原生函数
        const result = original.apply(this, args);
        const ob = this.__ob__;
        let inserted;
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args;
                break;
            case 'splice':
                inserted = args.slice(2);
                break;
        }
        if (inserted) ob.observeArray(inserted);
        // 触发更新
        ob.dep.notify();
        return result;
    });
});

反观 Proxy 就没以上的问题,原生支持监听数组变化,并且可以直接对整个对象进行拦截,所以 Vue3.0 用 Proxy 替换了 Object.defineProperty

js
let onWatch = (obj, setBind, getLogger) => {
    let handler = {
        get(target, property, receiver) {
            getLogger(target, property);
            return Reflect.get(target, property, receiver);
        },
        set(target, property, value, receiver) {
            setBind(value);
            return Reflect.set(target, property, value);
        }
    }

    return new Proxy(obj, handler);
}

// test
let obj = { a: 1 };
let value;
let p = onWatch(
    obj,
    v => {
        value = v;
    },
    (target, property) => {
        console.log(`Get '${property}' = ${target[property]}`);
    },
);
p.a = 2;    // bind `value` to `2`
p.a;        // -> Get 'a' = 2

参考资料:Vue 响应式原理 - 如何监听 Array 的变化?

路由原理

前端路由实现起来其实很简单,本质就是监听 URL 的变化,然后匹配路由规则,显示相应的页面,并且无须刷新。目前单页面使用的路由就只有两种实现方式:

  • Hash 模式
  • History 模式

Hash 模式

Hash 模式是一种把前端路由的路径用井号 # 拼接在真实 url 后面的模式。当井号 # 后面的路径发生变化时,浏览器并不会重新发起请求,而是会触发 onhashchange 事件。

hash 的特点

  • hash 变化会触发网页跳转,即浏览器的前进和后退。
  • hash 可以改变 url(只能修改 # 后面的部分),但是不会触发页面重新加载(hash 的改变是记录在 window.history 中),即不会刷新页面。也就是说,所有页面的跳转都是在客户端进行操作。因此,这并不算是一次 http 请求,所以这种模式不利于 SEO 优化
  • hash 通过 window.onhashchange 的方式监听,借此实现无刷新跳转的功能
  • hash 永远不会提交到 server 端(可以理解为只在前端自生自灭)

History 模式

history API 是 H5 提供的新特性,允许开发者直接更改前端路由,即更新浏览器 URL 地址而不重新发起请求。

history 的 API

API说明
history.pushState(data, title [, url])主要用于往历史记录堆栈顶部添加一条记录。各参数解析如下:
data 会在 onpopstate 事件触发时作为参数传递过去;
title 为页面标题,当前所有浏览器都会忽略此参数;
url 为页面地址,可选,缺少时表示为当前页地址
history.replaceState(data, title [, url])更改当前的历史记录,参数同上;上面的 pushState 是添加,这个更改
history.state用于存储以上方法的 data 数据,不同浏览器的读写权限不一样
window.onpopstate响应 pushState 或者 replaceState 的调用

history 的特点

  • 新的 url 可以是与当前 url 同源的任意 url,也可以是与当前 url 一样的地址,但是这样会导致的一个问题是,会把重复的这一次操作记录到栈当中
  • 通过 history.state,添加任意类型的数据到记录中
  • 可以额外设置 title 属性,以便后续使用
  • 通过 pushState, replaceState 来实现无刷新跳转的功能

TIP

调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。

简单 Router 实现

js
class Router {
    constructor(options) {
        this.mode = options.mode || 'hash';
        this.routes = options.routes || [];

        const me = this;
        switch (me.mode) {
            case 'hash':
                window.addEventListener('hashchange', function (evt) {
                    let hash = location.hash.slice(1);
                    let route = me.routes.find(item => item.path.test(hash)) || {};

                    route.func && route.func();
                });
                break;
            case 'history':
                /**
                 * history.pushState, history.replaceState 不会触发
                 * popstate 事件,需做劫持处理
                 */
                function hisRewrite(type) {
                    let origin = history[type];

                    return function () {
                        let result = origin.apply(me, [...arguments]);
                        let e = new Event(type);
                        e.arguments = arguments;
                        window.dispatchEvent(e);

                        return result;
                    }
                }

                function changeCallback(evt) {
                    let pathname = location.pathname;
                    let route = me.routes.find(item => item.path.test(pathname)) || {};

                    route.func && route.func();
                }

                history.pushState = hisRewrite('pushState');
                history.replaceState = hisRewrite('replaceState');

                window.addEventListener('popstate', changeCallback);
                window.addEventListener('pushState', changeCallback);
                window.addEventListener('replaceState', changeCallback);

                break;
            default:
                break;
        }
    }

    push(route) {
        let mode = this.mode;

        switch (mode) {
            case 'hash':
                location.hash = route.name;
                break;
            case 'history':
                history.pushState(route.options, route.title, route.name);
                break;
        }
    }

    // replace() {}
}

// test
var router = new Router({
    routes: [
        {
            path: /123/,
            func: function () {
                console.log(123);
            }
        }
    ]
});

router.push({ name: '123' });