万能面试题:手写一个Vue2的MVVM响应式原理

436 阅读10分钟

前言

emm...关于这道题之前已经被好几个面试官问过了,虽然是自己在自我介绍时提到过我了解vue响应式底层原理,但我总感觉没说清楚,所以现在有时间就把过程写下来,方便自己加深理解和口头表达。

正文

先提供一个简单的双向绑定的模板吧,也就是所谓的入口文件,接下来响应式就发生在这个模板中。

// index.html
<body>
    <div id="mvvm-app" style="text-align: center;margin-top: 100px;">
        <h2>{{title}}</h2>
        <button v-on:click="clickBtn">数据初始化</button>
    </div>
</body>
1.gif

下面是一个简单的vue2创建vue实例的过程,一个挂载点el:'#mvvm-app',一个简单的数据title: 'hello world,再加上一个简单的方法用来触发响应式机制从而替换页面上的hello world。即简单地使用按钮方法来实现MVVM。

import MVVM from './mvvm';

var vm = new MVVM({
    el: '#mvvm-app',
    data: {
        title: 'hello world'
    },
    methods: {
        clickBtn: function(e) {
            this.title = '你好'
        }
    }
})

其中MVVM是一个自定义的构造函数,该构造函数的主要功能:

(1) 把传入MVVM中的el,data,methods进行一些处理(发生在mvvm.js中);

(2) 进行一些数据监听(发生在observer.js);

(3) 把vue文件template模板编译成html文件(发生在compile.js);

(4) 在编译时对vue中特有的{{}},属性和指令等进行监听(发生在watcher.js中),并把这些监听全部存入一个管家中Dep(订阅与发布者设计模式),当数据发生改变时,就会触发在编译时存入管家中的监听,然后更新数据。

以上这些就是我们需要实现的函数了,一共四个模块mvvm.jsobserver.jscompile.jswatcher.js

mvvm.js

mvvm.js中,options就是 {el: '', data: '', methods: ''} 这些对象属性,我们需要把datamethodsoptions中取出,存入自己属性中方便后续进行一些操作。

observe(this.data) 是对data中的数据进行监听,通过defineProperty这个特有的方法来进行get读取操作,set修改设置操作。比如console.log(this.data.title)就触发了get操作,this.data.title = '你好'触发了set操作。

new Compile(options.el, this) 中,需要传入el和this(即vue实例),因为在浏览器中浏览器只能识别.html文件,而无法直接识别.vue文件,因此需要Compile()这个函数把.vue文件转换成.html文件,并且顺便给它作一个响应监听,即观察者(Watcher)。

// mvvm.js

import { observe } from './observer';
import Compile from './compile';

function MVVM(options) {
    this.data = options.data; // 存入data属性
    this.methods = options.methods; // 存入methods方法
    
    observe(this.data); // 对data中的数据进行响应式监听
    
    // 编译index.html文档中的vue模板
    new Compile(options.el, this); 
};

observer.js

接下来就是对this.data进行具体的监听操作了,先判断是否为对象或者已经没有值了,如果不满足,就说明传过来的value需要进行深层次的监听,对value中数据类型为object的进行递归

    // observer.js
    
    export const observe = (value) => {
        if (!value || typeof value !== 'object') { // 如果data中没有值或data不是对象而是函数
            return;
        }
        return new Observer(value);
    }
    
    function Observer(data) {
        this.data = data;
        this.walk(data);
    }

walk方法是先用 Object.keys(data)来把data对象中的属性一个一个遍历出来,因为data中的数据可能是个对象;然后传入defineReactive中进行监听,但需要考虑这个data里是否还包含了一个对象,所以需要再调用一下observer(data),直到这个属性不为object或没有值为止,然后再一个一个的给它们加上监听,其实实现的思想就是使用递归。

    // observer.js  接上文
    
    Observer.prototype = {
        walk: function(data) {
            var self = this;
            Object.keys(data).forEach(function(key) {
                self.defineReactive(data, key, data[key])
            })
        },
        defineReactive: function(data, key, val) {
            var childObj = observe(val); // 递归
            Object.defineProperty(data, key, {
                enumerable: true,
                configurable: true,
                get: function getter() {
                    return val;
                },
                set: function setter(newVal) {
                     // 如果值没变,就不需要修改
                    if (newVal === val) {
                        return ;
                    }
                    val = newVal;
                }
            })
        }
    }

最后,我们来对Object.definePropertygetset来进行详细地分析

当数据发生改变时,就会触发set操作这点非常的重要,贯穿整个响应式是,千万要记牢),而我们在set方法中向Dep这个管家添加一个触发更新模板数据的事件,当你修改了某个值时,就会触发这个事件,然后这个事件就把你修改值所在的模板进行重新编译,替换成新的值,从而实现了一个MVVM响应式。

 // get和set详情
 Dep.target = null; // 指向 get 的对象 templage 计算属性 生命周期 watch
 var dep = new Dep(); // 解耦,使用订阅发布者模式 pub sub
 
 get: function getter() {
     // 收集订阅者
    if (Dep.target) {
        dep.addSub(Dep.target); // 添加了订阅者
    };
    return val;

},
set: function setter(newVal) {
    // 如果值没变,就不需要修改
    if (newVal === val) {
        return ;
    }
    val = newVal;
    // 发布者告知 订阅者执行
    dep.notifiy();
}


export const Dep = function() {
    this.subs = []; // 订阅者数组
}

Dep.prototype = {
    addSub: function(sub) {
        this.subs.push(sub);
    },
    notifiy: function() {
        this.subs.forEach(function(sub){
            sub.update();
        }) 
    }
}

这样,就完整地完成了对data中的数据进行监听了。不过,这里只是对数据进行了一个响应式地功能,代码还不完整。你想想,当你修改数据,数据确实是完成了修改更新,但你浏览器中页面还没进行更新呢,因此需要你在set那里设置一个更新页面的函数,只要你一修改数据,就会触发set操作,然后在set中进行更新页面操作,就完成了真正地响应式了。所以接下来就是对编译模板那块进行细聊了。

compile.js

关于compile.js这块,由于我们是面向面试的,所以只需要对面试官说清楚大概地流程就行,如果时间充裕并且想理解地更加仔细,可以看看源码

首先引入watcher.js中的Watcher函数,Watcher的意思就是观察,用于观察vue模板中的数据有没有变化,进行相应响应式的更新。

然后就是找到挂载节点el的DOM元素,即<div id="mvvm-app></div>,方便把vue模板转化为正常模板时挂载到子节点下;以及初始化一个fragment,为了存储vue模板中的所有子节点。

// compile.js

// 注意这里引入了Watcher
import Watcher from './watcher';

function Compile(el, vm) {
    this.vm = vm; 
    this.el = document.querySelector(el)
    // 每次编译时都需要一个
    this.fragment = null; 
    this.init();
}

之后就是把vue模板转化为浏览器能够识别的正常html,由于篇幅问题且该篇文章是为了对响应式有个大致的理解,所以代码会有所省略,方便理解。

针对下述方法会单独拎出来讲解

// 接上文
// compile.js
Compile.prototype = {
  init: function() {},
  compileElement: function(el) {},
  compileText: function (node, exp) {},
  updateText: function(node, value) {}
}

export default Compile;
  

init()函数

通过nodeToFragment函数,来寻找el挂载节点的子节点,并把挂载el的子节点存入fragment中,该函数未写,理解功能就行。

通过compileElement函数,把vue模板编译成浏览器能识别的html,把自定义的v-on编译成js原生监听事件,把自定义的{{}}模板数据转成正常模板,

然后再通过appendChild把所有html模板加入el节点的子节点中

init: function() {
    if (this.el) {
        this.fragment = this.nodeToFragment(this.el); // 把挂载el的子节点存入fragment中
        // 编译子节点,把自定义的v-on编译成js原生监听事件,把自定义的{{}}模板数据转成正常模板
        this.compileElement(this.fragment); 
        this.el.appendChild(this.fragment); // 把编译成正常的模板后挂载到el节点上
    } else {
        console.log('DOM元素不存在')
    }
},

compileElement()函数

拿到子节点后,通过正则表达式进行判断节点是否为模板字符串{{}},如果是元素节点,先判断节点中有无自定义的事件v-on:click,然后通过compile函数把它转为原生js监听事件,使用addEventListener进行监听。

再判断节点是否为文本节点,即{{msg}}也是一个节点,类似<div>Hello</div>中的Hello,但这里面有div元素节点和Hello文本节点。如果是文本节点,就把模板数据{{msg}}转为正常文本

最后就是进行递归来检查节点是否有子节点,然后逐一把子节点进行转化。

compileElement: function(el) {
    var childNodes = el.childNodes;
    var self = this;
    [].slice.call(childNodes).forEach(function(node) {、
        var reg = /\{\{(.*)\}\}/;  // 正则判断是否为模板字符串{{}}
        var text = node.textContent; // 文本节点
        if (self.isElementNode(node)) { // 元素节点
            // 把自定义命令v-on:click转化为原生监听事件,使用addEventListener
            self.compile(node); 
        } else if (self.isTextNode(node) && reg.test(text)) {
            // 把模板数据{{msg}}转为正常文本
            self.compileText(node, reg.exec(text)[1]); 
        }

        // 递归,如果节点包含了子节点,那么继续编译
        if (node.childNodes && node.childNodes.length) {
            self.compileElement(node);
        }
    });
},

compileText函数

这里就是简单的把模板数据{{}}转化为正常数据,但重点是在这里新建了一个Watcher实例,用于观察当前转化的模板数据会不会发生变化,如果发生了变化,就会触发在observer.js中的Object.defineProperty中的get函数里的dep.notice()函数触发这个Watcher实例里的回调函数进行更新,响应式就大致完成了。这里你可以往上看看get函数并结合下面的Watch.js进行分析。

compileText: function (node, exp) {
    var self = this;
    // 这里会触发Object.defineProperty的get函数
    // 数据 this.vm.data[exp],拿到vue实例的初始数据,exp表示{{msg}}中的msg
    var initText = this.vm[exp]; 

    this.updateText(node, initText); 把{{}}转化成正常数据
    // 将这个节点添加到订阅者中,实例化订阅者
    // 注意,真正触发响应式机制,留个Watcher实例在这,方便后面修改时直接调用修改数据
    // 使用call调用了回调函数,并传回了新值
    new Watcher(this.vm, exp, function(value) {
        self.updateText(node, value);
    })
},
updateText: function(node, value) {
    node.textContent = typeof value == 'undefined' ? '' : value;
}

Watch.js

这里就是引入observer.js中创建的Dep,在一开始时就调用自身的get函数,用于存储模板中新建的那个Watcher实例,实例中的回调函数和拿到最新的值。

// 订阅发布者
// vdom js html
import { Dep } from './observer';

function Watcher(vm, exp, cb) {
    this.vm = vm;
    this.exp = exp;
    this.cb = cb;
    this.value = this.get(); 
};

在get函数中,一开始就会触发get函数,直接把this代表的watcher实例存入Dep中的target,并且在下一行代码的this.vm.data[this.exp]中会触发Object.defineProperty的get函数,把watcher实例存入dep中。所以当发生模板数据改变时,就会触发Object.defineProperty的set函数,然后就会执行watcher实例中的回调函数进行响应式更新数据。请结合上述的observer.js中的Object.defineProperty。

Watcher.prototype = {
    get: function() {
        Dep.target = this; // 缓存这个watcher实例
        // 注意,这里触发了一次Object.defineProperty的get函数,
        // 并且有了Dep.target,可以在Object.defineProperty的get函数中存储这个watcher实例
        var value = this.vm.data[this.exp]; // hello world 
        Dep.target = null;
        return value;
    },
    update: function() {
        this.run();
    },
    run: function() {
        var value = this.vm.data[this.exp];
        var oldVal = this.value;
        if (value !== oldVal) { // 更新
            this.value = value;
            // 使用call调用了回调函数,并传回了新值
            this.cb.call(this.vm, value, oldVal);
        }
    }
};

export default Watcher;

Webpack配置

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
    entry: path.join(__dirname, "/src/index.js"), // 入口文件
    output: {
        path: path.join(__dirname, "/dist"), //打包后的文件存放的地方
        filename: "bundle.js" //打包后输出文件的文件名
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /(node_modules|bower_components)/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
        ]
    },
    devServer: {
        port: 9000,
        hot: true,
        open: true
    },
    plugins: [new HtmlWebpackPlugin({
        template: 'index.html',
        filename: 'index.html'
    })],
    devtool: 'source-map'
}

自述

面对面试官时,大致可以描述为:

我举个场景吧,在点击按钮时触发文本发生响应式的更新,也就是当我点击更新按钮时,原本的‘你好’变为了英文的‘hello world’。

先是创建一个vue的模板,包含了一个挂载节点,文本{{msg}}和vue的v-on:click来响应式更新数据。

然后新建一个自定义vue的实例,包含了el,data和methods。整个响应式的过程我大致分为四个模块,mvvm.jsobserver.jscompile.jswatcher.js

先是在mvvm.js中解析el,data,methods,并把el,data,methods传入observer和compile,相当于一个中转站。

然后在observer.js中对数据使用Object.defineProperty的get和set进行数据监听

接着在compile.js中把vue模板数据{{}}和自定义vue的v-on:click事件转化为正常的数据和原生js的监听事件,但在把模板数据{{}}进行转化时,会在对应的模板数据中添加一个watcher观察者的实例进行观察模板数据有没有发生变化,如果发生了变化,就会触发observer.js中的Object.defineProperty的set函数,来调用留存在这里的watcher实例,从而触发watcher实例里的回调函数来更新数据。

总结

这里只是针对了点击事件时触发的响应式更新,因为我个人认为这是最好理解的。其他的比如表单的v-model等其他vue事件,可以在compile.js中进行相应的添加。

以上是我在回答面试官时的自述,非常地恳切希望大家有不同观点的讨论让我来进行修改。