乐趣区

关于javascript:一比一手写迷你版vue彻底搞懂vue运行机制

前言

当初前端面试 Vue 中都会问到响应式原理以及如何实现的,如果你还只是简略答复通过 Object.defineProperty()来劫持属性可能曾经不够了。

本篇文章通过学习文档及视频教程 实现手写 一个繁难的 Vue 源码实现数据双向绑定,解析指令等。

几种实现双向绑定的做法

目前几种支流的 mvc(vm)框架都实现了单向数据绑定,而我所了解的双向数据绑定无非就是在单向绑定的根底上给可输出的元素 (input, textare 等) 增加了 change(input)事件,来动静批改 model 和 view,并没有多浅近,所以无需太过介怀是实现的单向或双向绑定。

实现数据绑定的做法有大抵如下几种:

发布者 - 订阅者模式(backbone.js)

脏值查看(angular.js)

数据劫持(Vue.js)

  • 发布者 - 订阅者模式

个别是通过 sub, pub 的形式来实现数据和试图的绑定坚听,更细数据办法通常做法是 vm.set(‘property’, value)
这种形式当初毕竟太 low 来,咱们更心愿通过 vm.property = value 这种形式更新数据,同时自动更新视图,于是有来上面两种形式。

  • 脏值查看

angular.js 是通过脏值检测的形式比照数据是否有变更,来决定是否更新视图,最简略的形式就是通过 setInterval()定时轮询检测数据变动,当然 Google 不会这么 low,angular 只有在制订的事件触发时进入脏值检测,大抵如下

* DOM 事件,臂如用户输出文本,点击按钮等(ng-click)
* XHR 响应事件($http)
* 浏览器 location 变更事件($location)
* Timer 事件($timeout, $interval)
* 执行 $diaest()或¥apply()
  • 数据劫持

Vue.js 则是通过数据劫持联合发布者 - 订阅者模式的形式,通过 Object.defineProperty() 来劫持各个属性的setter,getter,在数据变动时公布音讯给订阅者,触发相应的监听回调。

Vue 源码实现

index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title></title>
        <script type="text/javascript" src="./compile.js"></script>
        <script type="text/javascript" src="./observe.js"></script>
        <script type="text/javascript" src="./myvue.js"></script>
    </head>
    <body>
        <div id="app">
            <h2>{{person.name}} -- {{person.age}}</h2>
            <h3>{{person.sex}}</h3>
            <ul>
                <li>1</li>
                <li>2</li>
                <li>3</li>
            </ul>
            <div v-text="msg"></div>
            <div>{{msg}}</div>
            <div v-text="person.name"></div>
            <div v-html="htmlStr"></div>
            <input type="text" v-model="msg" />
            <button type="button" v-on:click="btnClick">v-on: 事件 </button>
            <button type="button" @click="btnClick">@事件 </button>
        </div>
        <script type="text/javascript">
            let vm = new Myvue({el: '#app',                data: {                    person: {                        name: '只会番茄炒蛋',                        age: 18,                        sex: '男'},                    msg: '学习 MVVM 实现原理',                    htmlStr: '<h1> 我是 html 指令渲染的 </h1>'
                },                methods: {btnClick() {console.log(this.msg)                    }                }            })        </script>
    </body>
</html>

第一步 – 实现一个指令解析器(Compile)

compile 次要做的事件是解析模板指令,将模板中的变量替换成数据,而后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,增加监听数据的订阅者,一旦数据有变动,收到告诉,更新视图,参考视频解说:进入学习

myvue.js

// 工具类依据指令执行对应办法
const compileUtils = {
    /*     * node 以后元素节点     * expr 以后指令的 value     * vm 以后 Myvue 实例,      * eventName 以后指令事件名称     */

    // 因为指令绑定的属性有可能是原始类型, 也有可能是援用类型, 因而要取到最终渲染的值
    getValue(expr, vm) {// reduce() 办法对数组中的每个元素执行一个由您提供的 reducer 函数(升序执行),将其后果汇总为单个返回值。return expr.split('.').reduce((data, currentVal) => {return data[currentVal]
        }, vm.$data)
    },
    // input 双向数据绑定
    setValue(expr, vm, inputVal) {// reduce() 办法对数组中的每个元素执行一个由您提供的 reducer 函数(升序执行),将其后果汇总为单个返回值。return expr.split('.').reduce((data, currentVal) => {
            // 将以后扭转的值赋值
            data[currentVal] = inputVal
            console.log(data);
        }, vm.$data)
    },

    // 解决 {{person.name}}--{{person.age}} 这种格局的数据, 不更新值的时候会全副替换了
    getContentVal(expr, vm) {return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {// 获取 {{}} 中的属性
            return this.getValue(args[1], vm)
        })

    },
    // 这里简略就封装了几个指令办法
    text(node, expr, vm) {
        let value;
        // 解决 {{}} 的格局
        if (expr.indexOf('{{') !== -1) {value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
                // 绑定观察者
                new Watcher(vm, args[1], (newValue) => {// 解决 {{person.name}}--{{person.age}} 这种格局的数据, 不然更新值的时候会全副替换了
                    this.upDater.textUpDater(node, this.getContentVal(expr, vm))
                })
                // 获取 {{}} 中的属性
                return this.getValue(args[1], vm)
            })
        } else {new Watcher(vm, expr, (newValue) => {this.upDater.textUpDater(node, newValue)
            })
            // 获取以后要节点要更新展现的值
            value = this.getValue(expr, vm)
        }
        // 更新的工具类
        this.upDater.textUpDater(node, value)
    },
    html(node, expr, vm) {const value = this.getValue(expr, vm)
        // 绑定观察者
        new Watcher(vm, expr, (newValue) => {this.upDater.htmlUpDater(node, newValue)
        })
        // 更新的工具类
        this.upDater.htmlUpDater(node, value)
    },
    model(node, expr, vm) {const value = this.getValue(expr, vm)
        // 绑定观察者
        new Watcher(vm, expr, (newValue) => {this.upDater.modelUpDater(node, newValue)
        })
        node.addEventListener('input', (e) => {
            // 设置值
            this.setValue(expr, vm, e.target.value)
        })
        // 更新的工具类
        this.upDater.modelUpDater(node, value)
    },
    on(node, expr, vm, eventName) {
        // 获取以后指令对应的办法
        const fn = vm.$options.methods && vm.$options.methods[expr]
        // console.log(fn);
        node.addEventListener(eventName, fn.bind(vm), false)
    },
    // 更新的工具类
    upDater: {
        // v-text 指令的更新函数
        textUpDater(node, value) {node.textContent = value},
        // v-html 指令的更新函数
        htmlUpDater(node, value) {node.innerHTML = value},
        // v-model 指令的更新函数
        modelUpDater(node, value) {node.value = value}
    }
}

// Myvue
class Myvue {constructor(options) {
        this.$el = options.el;
        this.$data = options.data;
        this.$options = options;
        if (this.$el) {
            // 1. 实现一个数据观察者
            new Observe(this.$data)

            // 2. 实现一个指令解析器
            new Compile(this.$el, this)

            // 3. 实现 this 代理, 拜访数据能够间接通过 this 拜访
            this.proxyData(this.$data)
        }
    }
    proxyData(data) {for (const key in data) {
            Object.defineProperty(this, key, {get() {return data[key]
                },
                set(newValue) {data[key] = newValue
                }
            })
        }
    }
}

compile.js

// 指令解析器
class Compile {constructor(el, vm) {
        // 判断以后传入的 el 是不是一个元素节点
        // document.querySelector 返回与指定的选择器组匹配的元素的后辈的第一个元素。this.el = this.isElementNode(el) ? el : document.querySelector(el)
        this.vm = vm
        // 1. 匹配节点内容及指令替换相应的内容, 因为每次匹配替换会导致页面回流和重绘, 所以应用文档碎片对象
        // 获取文档碎片对象, 放入内存中会缩小页面的回流和重绘
        const fragment = this.node2Fragment(this.el)

        // 2. 编译模版
        this.compile(fragment)

        // 3. 追加子元素到根元素
        this.el.appendChild(fragment)

    }

    // 判断是否是元素节点
    isElementNode(node) {return node.nodeType === 1}

    // 将以后根元素中的所有子元素一层层取出来放到文档碎片中, 以缩小页面回流和重绘
    node2Fragment(el) {
        // 创立文档碎片对象
        const fragment = document.createDocumentFragment()
        let firstChild;
        // 将以后 el 节点对象的所有子节点追加到文档碎片对象中
        while (firstChild = el.firstChild) {fragment.appendChild(firstChild)
        }
        return fragment
    }

    // 编译模版, 解析指令
    compile(fragment) {
        // 1. 获取到所有的子节点, 以后获取的子节点数组是一个伪数组, 须要转为数组
        const childNodes = [...fragment.childNodes]
        childNodes.forEach(child => {
            // 判断以后节点是元素节点还是文本节点
            if (this.isElementNode(child)) {
                // 编译元素节点
                this.compileElement(child)
            } else {
                // 编译文本节点
                this.compileText(child)
            }
            // 递归遍历以后节点时候还有子节点对象
            if (child.childNodes && child.childNodes.length) {this.compile(child)
            }
        })

    }

    // 编译元素节点
    compileElement(node) {
        // 依据不同指令属性, 编译模版信息
        const attributes = [...node.attributes];
        attributes.forEach(attr => {
            // 通过解构将指令的 name 和 value 获取到
            const {
                name,
                value
            } = attr
            // 判断以后属性是指令还是原生属性
            if (this.isDirective(name)) {
                // 截取指令, 不须要 v -
                const directive = name.split('-')[1]
                // 因为指令格局有 v-text v-html v-bind: 属性 v-on: 事件等等, 依照 : 再次宰割
                const [dirName, eventName] = directive.split(':')
                // 更新数据, 数据驱动视图
                compileUtils[dirName](node, value, this.vm, eventName)
                // 删除有指令的标签上的属性
                node.removeAttribute('v-' + directive)
            } else if (this.isEventName(name)) { // 判断指令是以 @结尾绑定的事件
                // 截取指令, 不须要 @, 这里就省略解决里 @click.stop.prevent 等事件修饰符, 原理不难
                const eventName = name.split('@')[1]
                // 更新数据, 数据驱动视图
                compileUtils['on'](node, value, this.vm, eventName)
            }
        })
    }

    // 编译文本节点
    compileText(node) {// node.textContent 获取文本并且匹配{{}} 模版字符串类型的
        const content = node.textContent
        if (/\{\{(.+?)\}\}/.test(content)) {compileUtils['text'](node, content, this.vm)
        }
    }

    // 判断以后属性是指令还是原生属性
    isDirective(attrName) {// startsWith() 办法用来判断以后字符串是否以另外一个给定的子字符串结尾,并依据判断后果返回 true 或 false。return attrName.startsWith('v-')
    }

    // 判断指令是以 @结尾绑定的事件
    isEventName(attrName) {return attrName.startsWith('@')
    }
}

第二步 – 实现一个数据监听器(Observer)

利用 Obeject.defineProperty()来监听属性变动 那么将须要 observe 的数据对象进行递归遍历,包含子属性对象的属性,都加上 setter 和 getter 这样的话,给这个对象的某个值赋值,就会触发 setter,那么就能监听到了数据变动。

observer.js

// 数据劫持
class Observe {constructor(data) {this.observe(data)
    }
    // 应用 object.defineProperty 监听对象, 数组临时不思考, 太简单
    observe(data) {if (data && typeof data === 'object') {// console.log(data);
            Object.keys(data).forEach(key => {this.defineReactive(data, key, data[key])
            })
        }
    }

    // 劫持属性
    defineReactive(obj, key, value) {
        // 递归遍历
        this.observe(value)
        // 创立依赖收集器
        const dep = new Dep()
        // console.log(dep);
        Object.defineProperty(obj, key, { // obj 为已有对象, key 为属性, 第三个参数为属性描述符
            enumerable: true, // enumerable:是否能够被枚举(for in),默认 false
            configurable: false, // 是否能够被删除,默认 false
            // 获取
            get() {// console.log(dep.target);
                // 订阅数据变动时, 往 Dep 中增加观察者
                Dep.target && dep.addSub(Dep.target)
                return value
            },
            // 设置
            set: (newValue) => {
                // 这里要留神新设置的值也须要劫持他的属性
                this.observe(newValue)
                if (newValue !== value) {value = newValue}
                // 告诉订阅器找到对应的观察者, 告诉观察者更新视图
                dep.notify()}
        })
    }
}

第三部 – 实现一个 Watcher 去更新视图

在初始化 myvue 实例的时候,通过 object。defineProperty()的 get 属性时去增加观察者,在 set 更改属性的时候去触发 notify()来调用 upDate 办法更新视图

// 观察者
class Watcher {constructor(vm, expr, cb) {
        this.vm = vm
        this.expr = expr
        this.cb = cb
        // 存储旧值
        this.oldValue = this.getOldValue()}
    // 获取旧值
    getOldValue() {
        // 在获取旧值的时候将观察者挂在到 Dep 订阅器上
        Dep.target = this
        const oldValue = compileUtils.getValue(this.expr, this.vm)
        // 销毁 Dep 上的观察者
        Dep.target = null
    }

    // 更新视图
    upDate() {
        // 获取新值
        const newValue = compileUtils.getValue(this.expr, this.vm)
        if (newValue !== this.oldValue) {this.cb(newValue)
        }
    }
}

// 订阅器
class Dep {constructor() {this.subs = []
    }
    // 收集观察者
    addSub(watcher) {this.subs.push(watcher)
    }
    // 告诉观察者去更新视图
    notify() {
        this.subs.forEach(watcher => {watcher.upDate()
        })
    }
}

面试题 - 论述你所了解的 MVVM 响应式原理

Vue 是采纳数据劫持配合发布者 - 订阅者模式,通过 Object.defineProperty 来 () 来劫持各个属性的 getter 和 setter,在数据发生变化的时候,公布音讯给依赖收集器,去告诉观察者,做出对应的回调函数去更新视图。

具体就是:MVVM 作为绑定的入口,整合 Observe,Compil 和 Watcher 三者,通过 Observe 来监听 model 的变动,通过 Compil 来解析编译模版指令,最终利用 Watcher 搭起 Observe 和 Compil 之前的通信桥梁,从而达到数据变动 => 更新视图,视图交互变动(input) => 数据 model 变更的双向绑定成果。

总结

本篇文章次要以 几种实现双向绑定的做法 实现 Observer实现 Compile实现 Watcher实现 MVVM这几个模块来论述了双向绑定的原理和实现。并依据思路流程渐进梳理解说了一些细节思路和比拟要害的内容点,当然必定有很多不欠缺的中央,然而对于如何实现双向数据绑定你必定有了更加粗浅的理解。

本篇文章也是通过查看 Vue 源码解析文章,以及 B 站相干视频总结进去的,俗话说好忘性不如烂笔头,本人即便照着抄一遍也能更加印象粗浅。

退出移动版