7

官网文档介绍《Vue.js》

MVVM

MVVM 和 MVC 是两种不同的软件设计模式

Vue 和 React 使用的是 MVVM 的设计模式,与传统的 MVC 不同,它通过数据驱动视图。MVVM 模式是组件化的基础。

MVVM

MVVM: Model-View-ViewModel,数据驱动视图

  • 各部分之间的通信,都是双向的
  • View 与 Model 不发生联系,通过 viewModel 传递

MVC

MVC: Model-View-Controller

  • View 传送指令到 Controller
  • Controller 完成业务逻辑后,要求 Model 改变状态
  • Model 将新的数据发送到 View,用户得到反馈

在 MVC 下,所有通信都是单向的

响应式原理

在不同的vue版本,实现响应式的方法不同:

  • vue2.0:Object.defineProperty
  • vue3.0:Proxy

Object.defineProperty

Vue 会遍历 data 所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter,每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

实现一个简单的响应式

function defineReactive(target, key, value) {
    // 深度监听(对象)
    Observer(value)
    // 核心API - 响应
    Object.defineProperty(target, key, {
        get: function() {
            return value
        },
        set: function(newVal) {
            if (value !== newVal) {
                // 深度监听(对象)
                Observer(newVal)

                value = newVal
                updateView()
            }
        }
    })
}

function updateView() {
    console.log('视图更新')
}

// 重新定义数组原型
const oldArrayProperty = Array.prototype;
// 创建新对象,原型指向 oldArrayProperty,再拓展新方法不会影响新原型
const arrProto = Object.create(oldArrayProperty)
const methods = ['push', 'pop', 'shift', 'unshift', 'splice']
methods.forEach( methodName => {
    arrProto[methodName] = function() {
        updateView(); // 视图更新
        oldArrayProperty[methodName].call(this, ...arguments) // 调用数组原型方法进行更新
    }
});

function Observer(target) {
    if (typeof target !== 'object' || target === null) {
        return target
    }
    // 深度监听(数组)
    if (Array.isArray(target)) {
        target.__proto__ = arrProto
    }

    for (key in target) {
        defineReactive(target, key, target[key])
    }
}

const data = {
    name: 'jack',
    age: 18,
    info: {
        address: '北京'
    },
    nums: [1, 2, 3]
}

// data 实现了双向绑定,深度监听
Observer(data)
 
// data.info.address = '上海' // 深度监听
// data.nums.push(4) // 监听数组

优势

  • 兼容性好,支持 IE9

不足

  • 无法监听数组的变化
  • 必须遍历对象的每个属性
  • 必须深层遍历嵌套的对象
  • 无法监听新增属性、删除属性
  • 需要在开始时一次性递归所有属性

Proxy

Proxy 是 es6 新增的内置对象,它用于定义基本操作的自定义行为。可用于运算符重载、对象模拟,对象变化事件、双向绑定等。

Proxy实现响应式

function reactive(target = {}) {
    if (typeof target !== 'object' || target === null) {
        // 非对象或数组,返回
        return target
    }

    // 代理配置
    const proxyConf = {
        get(target, key, receiver) {
            // 指处理本身(非原型的)属性
            const ownKeys = Reflect.ownKeys(target)
            if (ownKeys.includes(key)) {
                // 监听
            }
            const result = Reflect.get(target, key, receiver)
             // 在进行get的时候,再递归深度监听 - 性能提升
            return reactive(result)
        },
        set(target, key, value, receiver) {
            // 重复数据, 不处理
            if (value === target[key]) {
                return true
            }
            // 指处理本身(非原型的)属性
            const ownKeys = Reflect.ownKeys(target)
            if (ownKeys.includes(key)) {
                console.log('已有的key', key)
            } else {
                console.log('新增的key', key)
            }

            const result = Reflect.set(target, key, value, receiver)
            return result
        },
        deleteProperty(target, key) {
            const result = Reflect.deleteProperty(target, key)
            return result
        }
    }
    
    // 生成代理对象
    const observed = new Proxy(target, proxyConf)
    return observed
}

const data = {
    name: 'jack',
    age: 18,
    info : {
        city: 'beijing'
    }
}

const proxyData = reactive(data)

优势

  • Proxy 可以直接监听对象而非属性,可以监听新增/删除属性;
  • Proxy 可以直接监听数组的变化;
  • Proxy 有多达 13 种拦截方法,不限于 apply、ownKeys、deleteProperty、has 等等是 Object.defineProperty 不具备的;
  • Proxy 返回的是一个新对象,我们可以只操作新的对象达到目的,而 Object.defineProperty 只能遍历对象属性直接修改;

不足

  • 兼容性问题,而且无法使用 polyfill 抹平(es5 中没有可以模拟Proxy的函数/方法)

虚拟Dom

虚拟Dom 也就是 visual dom,常叫为 vdom。vdom 是实现 vue 和 react 的重要基石。

浏览器渲染

在了解 vdom 之前,了解一下浏览器的工作原理是很重要的。浏览器在渲染网页时,会有几个步骤,其中一个就是解析HTML,生成 DOM 树。以下面 HTML 为例:

<div>
  <h1>My title</h1>
  Some text content
  <!-- TODO: Add tagline -->
</div>

当浏览器读到这些代码时,会解析为对应的 DOM 节点树

每一个元素、文字、注释都是一个节点,众所周知,如果直接操作 dom 去更新,是非常耗费性能的,因为每一次的操作都会触发浏览器的重新渲染。Js 的执行相对来说是非常快的,于是,便出现了 vdom。

snabbdom

snabbdom是一个简洁强大的 vdom 库,易学易用。vue 是参考它实现的 vdom 和 diff 算法。可以通过 snabbdom 学习 vdom。

vdom

Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM,核心方法是createElement 函数。createElement 函数会生成一个虚拟节点,也就是 vNode,它会告诉浏览器应该渲染什么节点。vdom 是对由 Vue 组件树建立起来的整个 vnode 树的称呼。

使用render方式创建组件能更直观看到 createElement 如何创建一个vnode(《render函数的约束》

  • createElement(标签名, 属性对象, 文本/子节点数组)
Vue.component('my-component', {
    props: {
        title: {
            type: String,
            default: '标题'
        }
    },
    data() {
        return {
            docUrl: 'https://cn.vuejs.org/v2/guide/render-function.html#%E5%9F%BA%E7%A1%80'
        }
    },
    render(createElement) {
        return createElement(
            'div', 
            {
                'class': 'page-container'
            }, 
            [
                createElement(
                    'h1', 
                    {
                        attrs: {
                            id: 'title'
                        }
                    },
                    this.title
                ),
                createElement(
                    'a', 
                    {
                        attrs: {
                            href: this.docUrl
                        }
                    },
                    'vue文档'
                )
            ]
        )
    }
})

上面方法,会生成一个 vnode 树(即AST 树)

将关键属性抽离出来后,可以看到一个类似于浏览器解析 Html 的节点树。这个结构会被渲染成真正的 Dom,并显示在浏览器上。

{
    "tag": "div",
    "data": {
        "class": "page-container"
    },
    "children": [
        {
            "tag": "h1",
            "data": {
                "attrs": {
                    "id": "title"
                }
            }
        },
        {
            "tag": "a",
            "data": {
                "attrs": {
                    "href": "https://cn.vuejs.org/v2/guide/render-function.html#%E5%9F%BA%E7%A1%80"
                }
            }
        }
    ]
}
初次渲染的时候,这个 AST 树会被存储起来,当监听到数据有改变时,将被用来跟新的 vdom 做对比。这个对比的过程使用的是diff算法。

diff算法

diff 算法是 vdom 中最核心、最关键的部分。vue 的 diff 算法处理位于 patch.js 文件中。

diff 即对比,是一个广泛的概念,不是 vue、react 特有的。如 linux diff 命令,git diff 等。

二叉树diff算法

原树 diff 算法需要经历每个节点遍历对比,最后排序的过程。如果有1000个节点,需要计算1000^3=10亿次,时间复杂度为O(n^3)。

很明显,直接使用原 diff 算法是不可行的。

vue中的diff算法

vue 将 diff 的时间复杂度降低为O(n),主要做了以下的优化:

  • 只比较同一层级,不跨级比较
  • tag 不相同,则直接删掉重建,不再深度比较
  • tag 和 key 两者都相同,则认为是相同节点,不再深度比较



模板编译

模板编译是指对 vue 文件内容的编译转换。Vue 的模板实际上被编译成了 render 函数,执行 render 函数返回 vnode。

with语句

在了解模板编译之前,需要先了解下with 语句。

with语句可以扩展一个语句的作用域链。将某个对象添加到作用域链的顶部,默认查找该对象的属性。

var obj = {a: 100};
// {} 内的自由变量,当做 obj 的属性来查找
with(obj) {
    console.log(a); // 100
    console.log(b); // ReferenceError: b is not defined
}
不被推荐使用,在 ECMAScript 5 严格模式中该标签已被禁止。

编译模板

当使用 template 模板的时候,vue 会将模板解析为 AST树(abstract syntax tree,抽象语法树),语法树再通过 generate 函数把 AST树 转化为 render 函数,最后生成 vnode 对象。

核心插件:vue-template-compiler

vue-template-compiler api:

  • compile(): 编译 template 标签内容,并返回一个对象
  • parseComponent(): 将单文件组件或*.vue文件解析成flow declarations
  • compileToFunctions(): 类似 compiler.compile,但直接返回实例化函数
  • ssrCompile(): 类似 compiler.compile ,将部分模板优化成字符串连接来生成特定于SSR的呈现函数代码
  • ssrCompileToFunctions(): 类似 compileToFunction , 将部分模板优化成字符串连接来生成特定于SSR的呈现函数代码
  • generateCodeFrame(): 将 template 标签内容高亮显示

举个栗子

template.js

const compiler = require('vue-template-compiler');
const template = '<p>{{message}}</p>'

console.log(compiler.compile(template))

执行

# 编译
node template.js

输出,返回一个这样的对象

{ 
    ast: { 
        type: 1,
        tag: 'p',
        attrsList: [],
        attrsMap: {},
        rawAttrsMap: {},
        parent: undefined,
        children: [ [Object] ],
        plain: true,
        static: false,
        staticRoot: false 
    },
    render: 'with(this){return _c(\'p\',[_v(_s(message))])}',
    staticRenderFns: [],
    errors: [],
    tips: [] 
}
使用 webpack 打包,在开发环境 vue-loader 实现了编译

render 中 _c 代表 createElement,其他的缩写函数说明:

function installRenderHelpers (target) {
  target._o = markOnce;
  target._n = toNumber;
  target._s = toString;
  target._l = renderList;
  target._t = renderSlot;
  target._q = looseEqual;
  target._i = looseIndexOf;
  target._m = renderStatic;
  target._f = resolveFilter;
  target._k = checkKeyCodes;
  target._b = bindObjectProps;
  target._v = createTextVNode;
  target._e = createEmptyVNode;
  target._u = resolveScopedSlots;
  target._g = bindObjectListeners;
  target._d = bindDynamicKeys;
  target._p = prependModifier;
}
vue-template-compiler 会针对模板中的各种标签、指令、事件进行提取拆分,分别处理。

组件渲染与更新

初次渲染

  1. 解析模板为 render 函数(或在开发环境已完成,vue-loader)
  2. 触发响应式,监听 data 属性 getter setter
  3. 执行 render 函数,生成 vnode
  4. path(elem, vnode)

更新过程

  1. 修改 data,触发 setter(此前在 getter 中已被监听)
  2. 重新执行 render 函数,生成 newVnode
  3. path(vnode, newVnode)

异步更新

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。

简单来说,事件循环会先执行完所有的宏任务(macro-task),再执行微任务(micro-task)。vue 将所有的更新都插入一个队列,当这个队列执行清空后再调用微任务。而 MutationObserver 、promise.then等都属于微任务(setTimeout属于宏任务)。

nextTick()

nextTick() 是更新后的回调函数,在 nextTick() 可以拿到最新 dom 元素。

验证

<template>
  <div class="hello">
    <ul ref="list">
        <li v-for="(item, index) in list" :key="index">
          {{item}}
        </li>
    </ul>
    
    <button @click="handleClick">点击</button>
  </div>
</template>

<script>
export default {
    data() {
        return {
        list: []
        }
    },
    watch: {
        list: {
        handler: function(val) {
            console.log('watch', val.length) // 3 - 仅触发一次
        },
        deep: true
        }
    },
    methods: {
        handleClick() {
            // 修改 3 次
            this.list.push(1)
            this.list.push(2)
            this.list.push(3)
            console.log('before>>', this.$refs.list.children.length) // 0 - 未更新
            this.$nextTick(() => {
                console.log('after>>', this.$refs.list.children.length) // 3 - 已更新
            })
        }
  }
}
</script>

源码分析

定义:nextTick (文件路径:vue/src/core/util/next-tick.js)

var callbacks = []; // 所有需要执行的回调函数
var pending = false; // 状态,是否有正在执行的回调函数

function flushCallbacks () { // 执行callbacks所有的回调
    pending = false;
    var copies = callbacks.slice(0);
    callbacks.length = 0;
    for (var i = 0; i < copies.length; i++) {
        copies[i]();
    }
}

var timerFunc; // 保存正在被执行的函数

/**
 * 延迟调用函数支持的判断
 * 1. Promise.then
 * 2. then、MutationObserver
 * 3. setImmediate
 * 4. setTimeout(fn, 0)
 * */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var p = Promise.resolve();
    timerFunc = function () {
        p.then(flushCallbacks);
        if (isIOS) { setTimeout(noop); }
    };
} else if (!isIE && typeof MutationObserver !== 'undefined' && ( isNative(MutationObserver) || MutationObserver.toString() === '[objectMutationObserverConstructor]')) {
    var counter = 1;
    var observer = new MutationObserver(flushCallbacks);
    var textNode = document.createTextNode(String(counter));
    observer.observe(textNode, {
        characterData: true
    });
    timerFunc = function () {
        counter = (counter + 1) % 2;
        textNode.data = String(counter);
    };
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = function () {
        setImmediate(flushCallbacks);
    };
} else {
    timerFunc = function () {
        setTimeout(flushCallbacks, 0);
    };
}

function nextTick (cb, ctx) {
    var _resolve;
    callbacks.push(function () {
        if (cb) {
        try {
            cb.call(ctx);
        } catch (e) {
            handleError(e, ctx, 'nextTick');
        }
        } else if (_resolve) {
            _resolve(ctx);
        }
    });
    if (!pending) {
        pending = true;
        timerFunc();
    }
    // $flow-disable-line
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(function (resolve) {
        _resolve = resolve;
        })
    }
}

监听变化:update (文件路径:vue/src/core/observer/watcher.js)

// update 默认是异步的
update () {
    /* istanbul ignore else */
    if (this.lazy) {
        this.dirty = true
    } else if (this.sync) {
        /*同步则执行run直接渲染视图*/
        this.run()
    } else {
        /*异步推送到观察者队列中,下一个tick时调用。*/
        queueWatcher(this)
    }
}

队列监听:queueWatcher (文件路径:vue/src/core/observer/scheduler.js)

let waiting = false // 是否刷新
let flushing = false // 队列更新状态

// 重置
function resetSchedulerState () {
    index = queue.length = activatedChildren.length = 0
    has = {}
    if (process.env.NODE_ENV !== 'production') {
        circular = {}
    }
    waiting = flushing = false
}

export function queueWatcher (watcher: Watcher) {
    const id = watcher.id
    if (has[id] == null) {
        has[id] = true
        if (!flushing) {
            // 未更新,则加入
            queue.push(watcher)
        } else {
            // 已更新过,把这个watcher再放到当前执行的下一位, 当前的watcher处理完成后, 立即会处理这个最新的
            let i = queue.length - 1
            while (i > index && queue[i].id > watcher.id) {
                i--
            }
            queue.splice(i + 1, 0, watcher)
        }
        // waiting 为false, 等待下一个tick时, 会执行刷新队列
        if (!waiting) {
            waiting = true

            if (process.env.NODE_ENV !== 'production' && !config.async) {
                flushSchedulerQueue()
                return
            }
            // 执行视图更新
            nextTick(flushSchedulerQueue)
        }
    }
}

YanniLi
56 声望4 粉丝