vue实现原理解析及一步步实现vue框架

深刻解析vue 1实现原理,并实现vue双向数据绑定模型 vueImitate,此模型(vueImitate)只适用于学习和了解vue实现原理;没法做为项目中使用,没有进行任何异常错误处理及各类使用场景的兼容;但经过此项目,可让你:
  • 深刻了解vue实现原理
  • 亲手一步一步本身实现vue相应功能,包括双向绑定、指令如v-model、v-show、v-bind等

总体效果以下:html

图片描述

下面咱们重头开始框架的实现,咱们知道,vue的使用方式以下:vue

var vm = new Vue({
    el: 'root',
    data() {
        return {
            message: 'this is test',
            number: 5,
            number1: 1,
            number2: 2,
            showNode: false
        }
    },
    methods: {
        add() {
            this.number1 += 1;
            this.number += 1;
        },
        show() {
            this.showNode = !this.showNode;
        }
    }
})

因而可知,vue为一个构造函数,而且调用时传入一个对象参数,因此主函数vueImitate能够以下,源码可见这里;并对参数进行对应的初始化处理:node

// init.js 
export default function vueImitate(options) {
    this.options = options || {}; 
    this.selector = options.el ? ('#' + options.el) : 'body'; // 根节点selector
    this.data = typeof options.data === 'function' ? options.data() : options.data; // 保存传入的data
    this.el = document.querySelectorAll(this.selector)[0]; // 保存根节点

    this._directives = [];
}

此时可使用new vueImitate(options)的方式进行调用,首先,咱们须要界面上展现正确的数据,也就是将下面页面进行处理,使其能够正常访问;git

图片描述

咱们能够参考vue的实现方式,vue将{{ }}这种绑定数据的方式转化为指令(directive),即v-text相似;而v-text又是如何进行数据绑定的呢?经过下面代码可知,是经过对文本节点从新赋值方式实现,源码见这里github

export default {
  bind () {
    this.attr = this.el.nodeType === 3
      ? 'data'
      : 'textContent'
  },
  update (value) {
    this.el[this.attr] = value
  }
}

那么,问题来了,若是须要按照上面的方式实现数据的绑定,咱们须要将如今的字符串{{number}}转化为一个文本节点,并对它进行指令化处理;这些其实也就是vue compile(编译)、link过程完成的,下面咱们就先实现上面功能需求;express

compile

整个编译过程确定从根元素开始,逐步向子节点延伸处理;数组

export default function Compile(vueImitate) {
    vueImitate.prototype.compile = function() {
        let nodeLink = compileNode(this.el),
            nodeListLink = compileNodeList(this.el.childNodes, this),
            _dirLength = this._directives.length;

        nodeLink && nodeLink(this);
        nodeListLink && nodeListLink(this);

        let newDirectives = this._directives.slice(_dirLength);

        for(let i = 0, _i = newDirectives.length; i < _i; i++) {
            newDirectives[i]._bind();
        }
    }
}

function compileNode(el) {
    let textLink, elementLink;
    // 编译文本节点
    if(el.nodeType === 3 && el.data.trim()) {
        textLink = compileTextNode(el);
    } else if(el.nodeType === 1) {
        elementLink = compileElementNode(el);
    }
    return function(vm) {
        textLink && textLink(vm);
        elementLink && elementLink(vm);
    }
}

function compileNodeList(nodeList, vm) {
    let nodeLinks = [], nodeListLinks = [];
    if(!nodeList || !nodeList.length) {
        return;
    }
    for(let i = 0, _i = nodeList.length; i < _i; i++) {
        let node = nodeList[i];
        nodeLinks.push(compileNode(node)),
        nodeListLinks.push(compileNodeList(node.childNodes, vm));
    }
    return function(vm) {
        if(nodeLinks && nodeLinks.length) {
            for(let i = 0, _i = nodeLinks.length; i < _i; i++) {
                nodeLinks[i] && nodeLinks[i](vm);
            }
        }
        if(nodeListLinks && nodeListLinks.length) {
            for(let i = 0, _i = nodeListLinks.length; i < _i; i++) {
                nodeListLinks[i] && nodeListLinks[i](vm);
            }
        }
    }
}

如上代码,首先,咱们经过定义一个Compile函数,将编译方法放到构造函数vueImitate.prototype,而方法中,首先主要使用compileNode编译根元素,而后使用compileNodeList(this.el.childNodes, this)编译根元素下面的子节点;而在compileNodeList中,经过对子节点进行循环,继续编译对应节点及其子节点,以下代码:性能优化

//  function compileNodeList
for(let i = 0, _i = nodeList.length; i < _i; i++) {
    let node = nodeList[i];
    nodeLinks.push(compileNode(node)),
    nodeListLinks.push(compileNodeList(node.childNodes, vm));
}

而后进行递归调用,直到最下层节点:而在对节点进行处理时,主要分为文本节点和元素节点;文本节点主要处理上面说的{{number}}的编译,元素节点主要处理节点属性如v-modelv-textv-showv-bind:click等处理;app

function compileTextNode(el) {
    let tokens = parseText(el.wholeText);
    var frag = document.createDocumentFragment();
    for(let i = 0, _i = tokens.length; i < _i; i++) {
        let token = tokens[i], el = document.createTextNode(token.value)
        frag.appendChild(el);
    }

    return function(vm) {
        var fragClone = frag.cloneNode(true);
        var childNodes = Array.prototype.slice.call(fragClone.childNodes), token;
        for(let j = 0, _j = tokens.length; j < _j; j++) {
            if((token = tokens[j]) && token.tag) {
                let    _el = childNodes[j], description = {
                    el: _el,
                    token: tokens[j],
                    def: publicDirectives['text']
                }
                vm._directives.push(new Directive(vm, _el, description))
            }
        }

        // 经过这儿将`THIS IS TEST {{ number }} test` 这种转化为三个textNode
        if(tokens.length) {
            replace(el, fragClone);
        }
    }    
}

function compileElementNode(el) {
    let attrs = getAttrs(el);
    return function(vm) {
        if(attrs && attrs.length) {
            attrs.forEach((attr) => {
                let name = attr.name, description, matched;
                if(bindRE.test(attr.name)) {
                    description = {
                        el: el,
                        def: publicDirectives['bind'],
                        name: name.replace(bindRE, ''),
                        value: attr.value
                    }
                } else if((matched = name.match(dirAttrRE))) {
                    description = {
                        el: el,
                        def: publicDirectives[matched[1]],
                        name: matched[1],
                        value: attr.value
                    }
                }
                if(description) {
                    vm._directives.push(new Directive(vm, el, description));

                }
            })
        }
    }
}

这里,先主要说明对文本节点的处理,咱们上面说过,咱们须要对{{number}}之类进行处理,咱们首先必须将其字符串转化为文本节点,如this is number1: {{number1}}这种,咱们必须转换为两个文本节点,一个是this is number1: ,它不须要进行任何处理;另外一个是{{number1}},它须要进行数据绑定,并实现双向绑定;由于只有转化为文本节点,才能使用v-text相似功能实现数据的绑定;而如何进行将字符串文本分割为不一样的文本节点呢,那么,就只能使用正则方式let reg = /\{\{(.+?)\}\}/ig;{{ number }}这种形式数据与普通正常文本分割以后,再分别建立textNode,以下:框架

function parseText(str) {
    let reg = /\{\{(.+?)\}\}/ig;
    let matchs = str.match(reg), match, tokens = [], index, lastIndex = 0;

    while (match = reg.exec(str)) {
        index = match.index
        if (index > lastIndex) {
          tokens.push({
            value: str.slice(lastIndex, index)
          })
        }
        tokens.push({
            value: match[1],
            html: match[0],
            tag: true
        })
        lastIndex = index + match[0].length
    }

    return tokens;
}

经过上面parseText方法,能够将this is number: {{number}}转化为以下结果:

图片描述

转化为上图结果后,就对返回数组进行循环,分别经过建立文本节点;这儿为了性能优化,先建立文档碎片,将节点放入文档碎片中;

// function compileTextNode
// el.wholeText => 'this is number: {{number}}'

let tokens = parseText(el.wholeText);
var frag = document.createDocumentFragment();
for(let i = 0, _i = tokens.length; i < _i; i++) {
    let token = tokens[i], el = document.createTextNode(token.value)
    frag.appendChild(el);
}

而在最后编译完成,执行linker时,主要作两件事,第一是对须要双向绑定的节点建立directive,第二是将整个文本节点进行替换;怎么替换呢?如最开始是一个文本节点this is number: {{number}},通过上面处理以后,在frag中实际上是两个文本节点this is number: {{number}};此时就使用replaceChild方法使用新的节点替换原始的节点;

// compile.js
function compileTextNode(el) {
    let tokens = parseText(el.wholeText);
    var frag = document.createDocumentFragment();
    for(let i = 0, _i = tokens.length; i < _i; i++) {
        let token = tokens[i], el = document.createTextNode(token.value)
        frag.appendChild(el);
    }

    return function(vm) {
        var fragClone = frag.cloneNode(true);
        var childNodes = Array.prototype.slice.call(fragClone.childNodes), token;
        
        // 建立directive
        ......

        // 经过这儿将`THIS IS TEST {{ number }} test` 这种转化为三个textNode
        if(tokens.length) {
            replace(el, fragClone);
        }
    }    
}

// util.js
export function replace (target, el) {
  var parent = target.parentNode
  if (parent) {
    parent.replaceChild(el, target)
  }
}

替换后结果以下图:

图片描述

通过与最开始图比较能够发现,已经将this is number: {{number}} middle {{number2}}转化为this is number: number middle number2;只是此时,仍然展现的是变量名称,如number,number2;那么,咱们下面应该作的确定就是须要根据咱们初始化时传入的变量的值,将其进行正确的展现;最终结果确定应该为this is number: 5 middle 2;即将number替换为5、将number2替换为2;那么,如何实现上述功能呢,咱们上面提过,使用指令(directive)的方式;下面,就开始进行指令的处理;

Directive(指令)

对于每个指令,确定是隔离开的,互相不受影响且有本身的一套处理方式;因此,咱们就使用对象的方式;一个指令就是一个实例化的对象,彼此之间互不影响;以下代码:

export default function Directive(vm, el, description) {
    this.vm = vm;
    this.el = el;
    this.description = description;
    this.expression = description ? description.value : '';
}

在建立一个指令时,须要传入三个参数,一个是最开始初始化var vm = new vueImitate(options)时实例化的对象;而el是须要初始化指令的当前元素,如<p v-show="showNode">this is test</p>,须要建立v-show的指令,此时的el就是当前的p标签;而description主要包含指令的描述信息;主要包含以下:

// 源码见 './directives/text.js'
var text = {
  bind () {
    this.attr = this.el.nodeType === 3
      ? 'data'
      : 'textContent'
  },
  update (value) {
    this.el[this.attr] = value
  }
}

// 如,'{{number}}'
description = {
    el: el, // 须要建立指令的元素
    def: text, // 对指令的操做方法,包括数据绑定(bind)、数据更新(update),见上面 text
    name: 'text', // 指令名称
    value: 'number' // 指令对应数据的key
}

经过new Directive(vm, el, description)就建立了一个指令,并初始化一些数据;下面就先经过指令对界面进行数据渲染;全部逻辑就放到了_bind方法中,以下:

// directive.js
Directive.prototype._bind = function() {
    extend(this, this.description.def);
    if(this.bind) {
        this.bind();
    }

    var self = this, watcher = new Watcher(this.vm, this.expression, function() {
        self.update(watcher.value);
    })

    if(this.update) {
        this.update(watcher.value);
    }
}

// util.js
export function extend(to, from) {
    Object.keys(from).forEach((key) => {
        to[key] = from[key];
    })
    return to;
}

方法首先将传入的指令操做方法合并到this上,方便调用,主要包括上面说的bindupdate等方法;其主要根据指令不一样,功能不一样而不一样定义;全部对应均在./directives/*文件夹下面,包括文本渲染text.js、事件添加bind.js、v-model对应model.js、v-show对应show.js等;经过合并之后,就执行this.bind()方法进行数据初始化绑定;可是,目前为止,当去看界面时,仍然没有将number转化为5;为何呢?经过查看代码:

export default {
  bind () {
    this.attr = this.el.nodeType === 3
      ? 'data'
      : 'textContent'
  },

  update (value) {
    this.el[this.attr] = value
  }
}

bind并无改变节点展现值,而是经过update; 因此,若是调用this.update(123),可发现有以下结果:

图片描述

其实咱们并非直接固定数值,而是根据初始化时传入的值动态渲染;可是目前为止,至少已经完成了界面数据的渲染,只是数据不对而已;
而后,咱们回头看下编译过程,咱们须要在编译过程去实例化指令(directive),并调用其_bind方法,对指令进行初始化处理;

// 见compile.js 'function compileTextNode'
let    _el = childNodes[j], description = {
    el: _el,
    name: 'text',
    value: tokens[j].value,
    def: publicDirectives['text']
}
vm._directives.push(new Directive(vm, _el, description));

// 见compile.js 'function compile'
let newDirectives = this._directives.slice(_dirLength);
for(let i = 0, _i = newDirectives.length; i < _i; i++) {
    newDirectives[i]._bind();
}

上面说了,目前尚未根据传入的数据进行绑定,下面,就来对数据进行处理;

数据处理

数据处理包括如下几个方面:

  • 数据双向绑定
  • 数据变化后,须要通知到ui界面,并自动变化
  • 对于输入框,使用v-model时,须要将输入内容反应到对应数据

数据双向绑定

须要实现双向绑定,就是在数据变化后可以自动的将对应界面进行更新;那么,如何监控数据的变化呢?目前有几种方式,一种是angular的脏检查方式,就是对用户因此操做、会致使数据变化的行为进行拦截,如ng-click$http$timeout等;当用户进行请求数据、点击等时,会对全部的数据进行检查,若是数据变化了,就会触发对应的处理;而另外一种是vue的实现方式,使用Object.definProperty()方法,对数据添加settergetter;当对数据进行赋值时,会自动触发setter;就能够监控数据的变化;主要处理以下, 源码见这里

export function Observer(data) {
    this.data = data;
    Object.keys(data).forEach((key) => {
        defineProperty(data, key, data[key]);
    })
}

export function observer(data, vm) {
    if(!data || typeof data !== 'object') {
        return;
    }

    let o = new Observer(data);
    return o;
}

function defineProperty(data, key, val) {
    let _value = data[key];
    let childObj = observer(_value);

    let dep = new Dep(); //生成一个调度中心,管理此字段的全部订阅者
    Object.defineProperty(data, key, {
        enumerable: true, // 可枚举
        configurable: false, // 不能再define
        get: function() {
            if (Dep.target) {
                dep.depend();
            }
            return val;
        },
        set: function(value) {
            val = value;
            childObj = observer(value);
            dep.notify();
        }
    })
}

Observer是一个构造函数,主要对传入的数据进行Object.defineProperty绑定;能够监控到数据的变化;而在每个Observer中,会初始化一个Dep的称为‘调度管理器’的对象,它主要负责保存界面更新的操做和操做的触发;

界面更新

在经过上面Observer实现数据监控以后,如何通知界面更新呢?这里使用了‘发布/订阅模式’;若是须要对此模式进行更深刻理解,可查看此连接;而每一个数据key都会维护了一个独立的调度中心Dep;经过在上面defineProperty时建立;而Dep主要保存数据更新后的处理任务及对任务的处理,代码也很是简单,就是使用subs保存全部任务,使用addSub添加任务,使用notify处理任务,depend做用会在下面watcher中进行说明:

// Dep.js

let uid = 0;
// 调度中心
export default function Dep() {
    this.id = uid++;
    this.subs = []; //订阅者数组
    this.target = null; // 有何用处?
}

// 添加任务
Dep.prototype.addSub = function(sub) {
    this.subs.push(sub);
}

// 处理任务
Dep.prototype.notify = function() {
    this.subs.forEach((sub) => {
        if(sub && sub.update && typeof sub.update === 'function') {
            sub.update();
        }
    })
}

Dep.prototype.depend = function() {
    Dep.target.addDep(this);
}

那么,处理任务来源哪儿呢?vue中又维护了一个watcher的对象,主要是对任务的初始化和收集处理;也就是一个watcher就是一个任务;而整个watcher代码以下, 线上源码见这里

export default function Watcher(vm, expression, cb) {
    this.cb = cb;
    this.vm = vm;
    this.expression = expression;
    this.depIds = {};

    if (typeof expression === 'function') {
        this.getter = expOrFn;
    } else {
        this.getter = this.parseGetter(expression);
    }

    this.value = this.get();
}

let _prototype = Watcher.prototype;

_prototype.update = function() {
    this.run();
}

_prototype.run = function() {
    let newValue = this.get(), oldValue = this.value;
    if(newValue != oldValue) {
        this.value = newValue;
        this.cb.call(this.vm, newValue);
    }
}

_prototype.addDep = function(dep) {
    // console.log(dep)
    if (!this.depIds.hasOwnProperty(dep.id)) {
        dep.addSub(this);
        this.depIds[dep.id] = dep;
    }
}

_prototype.get = function() {
    Dep.target = this;
    var value = this.getter && this.getter.call(this.vm, this.vm);
    Dep.target = null;
    return value;
}

_prototype.parseGetter = function(exp) {
    if (/[^\w.$]/.test(exp)) return; 

    var exps = exp.split('.');

    return function(obj) {
        let value = '';
        for (var i = 0, len = exps.length; i < len; i++) {
            if (!obj) return;
            value = obj[exps[i]];
        }
        return value;
    }
}

在初始化watcher时,须要传入vm(整个项目初始化时实例化的vueImitate对象,由于须要用到里面的对应数据)、expression(任务对应的数据的key,如上面的‘number’)、cb(一个当数据变化后,界面如何更新的函数,也就是上面directive里面的update方法);咱们须要实现功能有,第一是每一个任务有个update方法,主要用于在数据变化时,进行调用,即:

// 处理任务
Dep.prototype.notify = function() {
    this.subs.forEach((sub) => {
        if(sub && sub.update && typeof sub.update === 'function') {
            sub.update();
        }
    })
}

第二个是在初始化watcher时,须要将实例化的watcher(任务)放入调度中心depsubs中;如何实现呢?这里,使用了一些黑科技,流程以下,这儿咱们以expression为'number'为例:

一、在初始化watcher时,会去初始化一个获取数据的方法this.getter就是,可以经过传入的expression取出对应的值;如经过number取出对应的初始化时的值5;

二、调用this.value = this.get();方法,方法中会去数据源中取值,并将此时的watcher放入Dep.target中备用,并返回取到的值;

// watcher.js
_prototype.get = function() {
    Dep.target = this;
    var value = this.getter && this.getter.call(this.vm, this.vm);
    Dep.target = null;
    return value;
}

三、由于咱们在上面Observer已经对数据进行了Object.defineProperty绑定,因此,当上面2步取值时,会触发对应的getter,以下, 触发get函数以后,由于上面2已经初始化Dep.target = this;了,因此会执行dep.depend();,就是上面说的depend函数了:

// Observer.js
let dep = new Dep(); //生成一个调度中心,管理此字段的全部订阅者
Object.defineProperty(data, key, {
    enumerable: true, // 可枚举
    configurable: false, // 不能再define
    get: function() {
        if (Dep.target) {
            dep.depend();
        }
        return val;
    },
    set: function(value) {
        val = value;
        childObj = observer(value);
        dep.notify();
    }
})

三、触发dep.depend();以后,以下代码,会执行Dep.target.addDep(this);, 此时的this就是上面实例化的dep, Dep.target则对应的是刚刚1步中实例化的watcher,即执行watcher.addDep(dep);

// Dep.js
Dep.prototype.depend = function() {
    Dep.target.addDep(this);
}

四、触发watcher.addDep(dep),以下代码,若是目前还没此dep;就执行dep.addSub(this);,此时的this就是指代当前watcher,也就是1步时实例化的watcher;此时dep是步骤3中实例化的dep; 便是,dep.addSub(watcher);

// watcher.js
_prototype.addDep = function(dep) {
    // console.log(dep)
    if (!this.depIds.hasOwnProperty(dep.id)) {
        dep.addSub(this);
        this.depIds[dep.id] = dep;
    }
}

五、最后执行dep.addSub(watcher);,以下代码,到这儿,就将初始化的watcher添加到了调度中心的数组中;

// Dep.js
Dep.prototype.addSub = function(sub) {
    this.subs.push(sub);
}

那么,在哪儿去初始化watcher呢?就是在对指令进行_bind()时,以下代码,在执行_bind时,会实例化Watcher; 在第三个参数的回调函数里执行self.update(watcher.value);,也就是当监控到数据变化,会执行对应的update方法进行更新;

// directive.js
Directive.prototype._bind = function() {
    extend(this, this.description.def);
    if(this.bind) {
        this.bind();
    }
    var self = this, 
    watcher = new Watcher(this.vm, this.expression, function() {
        self.update(watcher.value);
    })
    if(this.update) {
        this.update(watcher.value);
    }
}

而前面说了,开始时没有数据,使用this.update(123)会将界面对应number更新为123,当时没有对应number真实数据;而此时,在watcher中,获取到了对应数据并保存到value中,所以,就执行this.update(watcher.value);,此时就能够将真实数据与界面进行绑定,而且当数据变化时,界面也会自动进行更新;最终结果以下图:

为何全部数据都是undefined呢?咱们能够经过下面代码知道, 在实例化watcher时,调用this.value = this.get();时,实际上是经过传入的key在this.vm中直接取值;可是咱们初始化时,全部值都是经过this.options = options || {}; 放到this.options里面,因此根本没法取到:

// watcher.js

_prototype.get = function() {
    Dep.target = this;
    var value = this.getter && this.getter.call(this.vm, this.vm);
    Dep.target = null;
    return value;
}
_prototype.parseGetter = function(exp) {
    if (/[^\w.$]/.test(exp)) return; 

    var exps = exp.split('.');

    return function(obj) {
        let value = '';
        for (var i = 0, len = exps.length; i < len; i++) {
            if (!obj) return;
            value = obj[exps[i]];
        }
        return value;
    }
}

那么,咱们如何能直接能够经过诸如this.number取到值呢?只能以下,经过下面extend(this, data);方式,就将数据绑定到了实例化的vueImitate上面;

import { extend } from './util.js';
import { observer } from './Observer.js';
import Compile from './compile.js';

export default function vueImitate(options) {
    this.options = options || {};
    this.selector = options.el ? ('#' + options.el) : 'body';
    this.data = typeof options.data === 'function' ? options.data() : options.data;
    this.el = document.querySelectorAll(this.selector)[0];

    this._directives = [];

    this.initData();
    this.compile();
}

Compile(vueImitate);

vueImitate.prototype.initData = function() {
    let data = this.data, self = this;

    extend(this, data);

    observer(this.data);
}

处理后结果以下:

图片描述

数据也绑定上了,可是当咱们尝试使用下面方式对数据进行改变时,发现并无自动更新到界面,界面数据并无变化;

methods: {
    add() {
        this.number1 += 1;
        this.number += 1;
    }
}

为何呢?经过上面代码可知,咱们其实observer的是vueImitate实例化对象的data对象;而咱们更改值是经过this.number += 1;实现的;其实并无改vueImitate.data.number的值,而是改vueImitate.number的值,因此也就不会触发observer里面的setter;也不会去触发对应的watcher里面的update;那如何处理呢?咱们能够经过以下方式实现, 完整源码见这里

// init.js
vueImitate.prototype.initData = function() {
    let data = this.data, self = this;

    extend(this, data);

    Object.keys(data).forEach((key) => {
        Object.defineProperty(self, key, {
            set: function(newVal) {
                self.data[key] = newVal;
            },
            get: function() {
                return self.data[key];
            }
        })
    })
        
    observer(this.data);
}

这里经过对vueImitate里对应的data的属性进行Object.defineProperty处理,当对其进行赋值时,会再将其值赋值到vueImitate.data对应的属性上面,那样,就会去触发observer(this.data);里面的setter,从而去更新界面数据;

至此,整个数据处理就已经完成,总结一下:

一、首先,在初始化vueImitate时,咱们会将初始化数据经过options.data传入,后会进行处理,保存至this.data中;

二、经过initData方法将数据绑定到vueImitate实例化对象上面,并对其进行数据监控,而后使用observerthis.data进行监控,在实例化Observer时,会去实例化一个对应的调度中心Dep

三、在编译过程当中,会建立指令,经过指令实现每一个须要处理节点的数据处理和双向绑定;

四、在指令_bind()时,会去实例化对应的watcher,建立一个任务,主要实现数据获取、数据变化时,对应界面更新(也就是更新函数的调用)、并将生成的watcher存储到对应的步骤2中实例化的调度中心中;

五、当数据更新时,会触发对应的setter,而后调用dep.notify();触发调度中心中全部任务的更新,即执行全部的watcher.update,从而实现对应界面的更新;

到目前为止,整个框架的实现基本已经完成。其中包括compile、linker、oberver、directive(v-model、v-show、v-bind、v-text)、watcher;若是须要更深刻的研究,可见项目代码; 能够本身clone下来,运行起来;文中有些可能思考不够充分,忘见谅,也欢迎你们指正;

相关文章
相关标签/搜索