Vue原理浅析及手写简版Vue

281 阅读3分钟

1、Vue的工作机制

首先,结合上图,我们先来简单分析一下Vue的工作机制:

1.1、初始化

  • 在 new Vue() 后会调用_init()进行初始化,包括初始化生命周期、data, props, methods, computed与watch等。其中最重要的是通过Object.defineProperty设置setter与getter,用来实现【响应式】以及【依赖收集】。
  • 初始化之后调用 $mount 挂载组件,主要执行编译和首次更新。

1.2、编译

在非webpack的开发环境比如带着编译器的浏览器环境,编译要把template转化成render function,这个过程包括三个阶段:

  • parse:使用正则解析template中的vue的指令(v-xxx) 变量等等,形成抽象语法树AST
  • optimize:标记一些静态节点,用作后面的性能优化,在diff的时候直接略过
  • generate:把第一部分成的AST 转化,生成渲染函数 render function

渲染函数最终返回虚拟DOM.

1.3、虚拟DOM

Virtual DOM是react首创,Vue2开始支持,就是用javascript对象来描述dom结构【如下所示】,数据修改的时候,我们先修改虚拟dom中的数据,然后数组做diff,最后再汇总所有的diff,力求做最少的dom操作, 毕竟js里对比很快,而真实的dom操作太慢。

<div title="虚拟DOM示例" style="colir: red;" @click="test">
    <button>click me</button>
</div>
{
    tag: "div",
    props: {
      title: "虚拟DOM示例",
      style: { color: red },
      onClick: test
    },
    children: [
      {
        tag: "button",
        text: "click me"
      }
    ]
}

1.4、更新

执行render function的时候,会有一个依赖收集的过程,即通过getter把data中的数据添加到watcher. 当data中的数据被修改时,就会触发setter, 然后watcher会通知进行修改。Vue通过Diff算法对比新旧vdom树,得到最小修改,就是patch,最后patch()通过批量的真实的DOM操作将有变化的数据在对应DOM节点进行修改。

2、实现简版Vue

今天,我们要手写的简版Vue,如下图所示,主要关注【响应式】这个过程,即如何劫持监听data对象的所有属性,如何进行依赖收集,订阅数据变化,在数据变化时如何更新视图以及解析一些简单的Vue指令等。

// my-vue.js
class XVue {
    constructor(opts) {
        this.$opts = opts
        // 处理data选项
        this.$data = opts.data
        // 1 拦截并监听data中的所有属性
        this.observe(this.$data)
        // 在vue实例创建后就要开始执行首次编译
        new Compile(opts.el, this)

        // 执行created
        if (opts.created) {
            opts.created.call(this)
        }
    }
    // 1 拦截并监听data中的所有属性
    observe(dataObj) {
        // this.$data必须为对象,不考虑函数形式
        if (!dataObj || typeof dataObj !== 'object') return;
        // 1.1 遍历data中的所有属性
        Object.keys(dataObj).forEach(key => {
            // 1.2 为data中的所有属性设立响应式
            this.defineReactive(dataObj, key, dataObj[key])
            // 4.1 把this.$data.key代理到this.key上
            this.provyData(key)
        })
    }
    // 1.2 为data中的所有属性设立响应式
    defineReactive(obj, key, val) {
        // 递归
        this.observe(val)
        // 2.1  一个data中的key对应一个Dep
        const dep = new Dep()
        Object.defineProperty(obj, key, {
            get() {
                // 2.2 一个页面中对data的key的引用对应一个Watcher 
                // 将Dep.target添加到dep中
                Dep.target && dep.addDep(Dep.target)
                return val
            },
            set(newVal) {
                if (newVal !== val) {
                    val = newVal
                    // 2.3 执行每一个Watcher的update方法
                    dep.notify()
                }
            }
        })
    }
    // 4. 代理data对象
    provyData(key) {
        Object.defineProperty(this, key, {
            get() {
                return this.$data[key]
            },
            set(newVal) {
                this.$data[key] = newVal
            }
        })
    }
}
// 2. 收集依赖: 一个data中的key对应一个Dep
class Dep {
    constructor() {
        this.deps = []
    }
    addDep(dep) {
        this.deps.push(dep)
    }
    notify() {
        this.deps.forEach(dep => dep.update())
    }
}

// 3. 通知更新:一个页面中对data的key的引用对应一个Watcher[一个Dep数组可能有多个同名的Watcher]
class Watcher {
    constructor(vm, key, cb) {
        this.vm = vm
        this.key = key
        this.cb = cb
        // 将当前watcher实例指定到Dep静态属性target
        Dep.target = this
        // 触发getter,添加依赖
        this.vm[this.key]
        // 回收
        Dep.target = null
    }
    update() {
        this.cb.call(this.vm, this.vm[this.key])
    }
}
//compile.js
class Compile {
    constructor(el, vm) {
        // 1. 获取DOM
        this.$el = document.querySelector(el)
        this.$vm = vm
        if (this.$el) {
            // 提取宿主中模板内容到Fragment标签,dom操作会提⾼效率
            this.$fragment = this.node2Fragment(this.$el)
            // 编译模板内容,同时进⾏依赖收集
            this.compile(this.$fragment)
            // 把编译好的内容插入到根节点元素中
            this.$el.appendChild(this.$fragment)
        }
    }
    node2Fragment(el) {
        const fragment = document.createDocumentFragment()
        let child
        while ((child = el.firstChild)) {
            fragment.appendChild(child)
        }
        return fragment

    }
    compile(el) {
        // 2. 遍历子元素
        const childNodes = el.childNodes
        Array.from(childNodes).forEach(node => {
            // 3. 判断节点类型并执行响应的编译方法
            if(this.isElement(node)) {
                // 3.1 元素节点,查找x-, @
                const nodeAttrs = node.attributes
                Array.from(nodeAttrs).forEach(attr => {
                    const attrName = attr.name //属性名
                    const attrVal = attr.value //属性值
                    if (this.isDirective(attrName)) {
                        // 3.3 如果是指令: x-text,x-html,x-model
                        const dir = attrName.substring(2)
                        this['compile' + dir] && this['compile' + dir](node, this.$vm, attrVal)
                    }
                    if (this.isEvent(attrName)) {
                        // 3.4 如果是事件@
                        const dir = attrName.substring(1)
                        this.compileevent(node, this.$vm, attrVal, dir)
                    }
                })
            } else if (this.isInterpolation(node)) {
                // 3.2 插值文本{{xxx}}节点
                this.compileInterpolation(node)
            } 
            // 递归子节点
            if (node.childNodes && node.childNodes.length > 0) {
                this.compile(node)
            }
        })
    }
    // 3. 判断节点类型
    // 3.1 元素节点
    isElement(node) {
        return node.nodeType === 1
    }
    // 3.2 插值文本{{xxx}}节点
    isInterpolation(node) {
        return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
    }
    // 3.3 指令
    isDirective(attr) {
        return attr.indexOf('x-') === 0
    }
    // 3.4 事件
    isEvent(attr) {
        return attr.indexOf('@') === 0
    }

    // 4. 不同类型节点的编译方法
    // 4.1 编译插值文本{{}}
    compileInterpolation(node) {
        const exp = RegExp.$1
        this.update(node, this.$vm, exp, 'text')
    }
    // 4.2 编译指令x-text
    compiletext(node, vm ,dataKey) {
        this.update(node, vm, dataKey, 'text')
    }
    // 4.3 编译指令x-html
    compilehtml(node, vm, dataKey) {
        this.update(node, vm, dataKey, 'html')
    }
    // 4.3 编译指令x-model
    compilemodel(node, vm, dataKey) {
        // 指定input的value属性
        this.update(node, vm, dataKey, 'model')
        // 视图对模型响应:双向绑定
        node.addEventListener("input", e => {
            vm[dataKey] = e.target.value
        })
    }
    // 4.4 编译事件@
    compileevent(node, vm, dataKey, dir) {
        let fn = vm.$opts.methods && vm.$opts.methods[dataKey]
        if (dir && fn) {
            node.addEventListener(dir, fn.bind(vm))
        }
    }

    // 5. 公共的更新方法
    update(node, vm, dataKey, dir) {
        const updateFn = this[dir + 'Update']
        // 初始化
        updateFn && updateFn(node, vm[dataKey])
        // 依赖收集,即当data变化,更新视图
        new Watcher(vm, dataKey, function(newVal) {
            updateFn && updateFn(node, newVal)
        })
    }
    // 6. 不同类型节点的渲染方法
    // 6.1 文本节点
    textUpdate(node, val) {
        node.textContent = val
    }
    // 6.2 html节点x-html
    htmlUpdate(node, val) {
        node.innerHTML = val
    }
    // 6.3 双向数据绑定
    modelUpdate(node, val) {
        node.value = val
    }
}
<!--TestMyVue.html-->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>测试自己的简版Vue</title>
</head>
<body>
    <div id="app">
        <!-- 测试插值 -->
        <h1>{{name}}</h1>
        <p>{{name}}</p>
        <!-- 测试x-text -->
        <h2 x-text="age"></h2>
        <!-- 测试x-html -->
        <h3 x-html="html"></h3>
        <!-- 测试双向数据绑定x-model -->
        <input type="text" x-model="name" />
        <!-- 测试事件 -->
        <button @click="changeName">呵呵</button>
    </div>
    <script src='./compile.js'></script>
    <script src='./my-vue.js'></script>
    <script>
        const app = new XVue({
            el: '#app',
            data: {
                name: "我是name属性",
                age: 12,
                html: '<button @click="changeAge">这是一个按钮</button>'
            },
            created() {
                setTimeout(() => {
                    this.name = 'name属性发生变化了,视图更新'
                }, 1500)
            },
            methods: {
                changeName() {
                    this.name = 'name属性又变化了'
                },
                changeAge() {
                    this.age ++
                }
            }
        })
    </script>
</body>
</html>