23

Vue源码分析之实现一个简易版的Vue

 4 years ago
source link: http://www.cnblogs.com/demonxian3/p/13546525.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

目标

参考 https://cn.vuejs.org/v2/guide/reactivity.html

使用 Typescript 编写简易版的 vue 实现数据的响应式和基本的视图渲染,以及双向绑定功能。

测试代码中,编写vue.js是本篇的重点,基本使用方法与常规的Vue一样:

<div id='app'>
    <div>{{ person.name }}</div>
    <div>{{ count }}</div>
    <div v-text='person.name'></div>
    <input type='text' v-model='msg' />
    <input type='text' v-model='person.name'/>
</div>

<script src='vue.js'></script>
<script>
let vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello vue',
        count: 100,
        person: { name: 'Tim' },
    }
});
vm.msg = 'Hello world';
console.log(vm);

//模拟数据更新
setTimeout(() => { vm.person.name = 'Goooooood'; }, 1000);
<script>

页面渲染结果如下

vQvMfi.png!mobile

实现的简易Vue需要完成以下功能

v-text
v-model

Vue当中有以下重要的组件

UNVNVbZ.png!mobile

  1. 初始化时通过 Object.defineProperty 代理Vue.data的数据方便操作, 访问 Vue.prop 等于访问 Vue.data.prop
  2. 通过 ObserverVue.data 里所有的数据及其子节点(递归)都进行捕捉,通过 getter setter 实现数据双向绑定
  3. 初始 Observergetter 中收集依赖(watcher观察者)在 setter 中发送通知 notify
  4. Watcher 中注册依赖 Dep

基层Vue

Vue 数据结构,这里只关注下面三个属性

字段 说明 $options 存放构造时传入的配置参数 $data 存放数据 $el 存放需要渲染的元素

实现Vue时,需要完成以下功能:

options
getter/setter
observer
compiler

类型接口定以

为保持灵活性,这里直接用any类型

interface VueData {
    [key: string]: any,
}

interface VueOptions {
    data: VueData;
    el: string | Element;
}

interface Vue {
    [key: string]: any,
}

Vue实现代码

class Vue {
    public $options: VueOptions;
    public $data: VueData;
    public $el: Element | null;

    public constructor(options: VueOptions) {
        this.$options = options;
        this.$data = options.data || {};
        if (typeof options.el == 'string') {
            this.$el = document.querySelector(options.el);
        } else {
            this.$el = options.el;
        }

        if (!this.$el) {
            throw Error(`cannot find element by selector ${options.el}`);
            return;
        }
        this._proxyData(this.$data);
    }

    //生成代理,通过直接读写vue属性来代理vue.$data的数据,提高便利性
    //vue[key] => vue.data[key]
    private _proxyData(data: VueData) {
        Object.keys(data).forEach(key => {
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return data[key];
                },
                set(newVal) {
                    if (newVal == data[key]) {
                        return;
                    }
                    data[key] = newVal;
                }
            })
        })
    }
}
  • 对于Vue的元数据均以 $ 开头表示,因为访问 Vue.data 会被代理成 Vue.$data.data ,即注入属性与元属性进行区分
  • $el 可以为选择器或Dom,但最终需要转成Dom,若不存在Dom抛出错误
  • _porxyData,下划线开头为私有属性或方法,此方法可以将 $data 属性注入到vue中
  • enumerable 为可枚举, configurable 为可配置,如重定以和删除属性
  • setter 中,如果数据没有发生变化则return,发生变化更新 $data

简单测试一下

let vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello vue',
        count: 100,
        person: { name: 'Tim' },
    }
});

Nbq6B3.png!mobile

上图中颜色比较幽暗的,表示注入到Vue的属性已成功设置了getter和setter

Observer

  • 负责把data选项中的属性转换成响应式数据
  • data中某个属性的值也是对象,需要递归转换成响应式
  • 数据发生变化时发送通知

Observer 实现代码

class Observer {
    constructor(data) {
        this.walk(data);
    }
    walk(data) {
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key]);
        });
    }
    defineReactive(obj, key, val) {
        //递归处理成响应式
        if (typeof val === 'object') {
            this.walk(val);
        }
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                //注意:这里val不可改成obj[key],会无限递归直至堆栈溢出
                return val;
            },
            set: (newVal) => {
                if (newVal == val) {
                    return;
                }
                //注意:这里newVal不可改成obj[key],会触发 getter
                val = newVal;
                if (typeof newVal == 'object') {
                    this.walk(newVal);
                }
            }
        });
    }
}
  • walk方法 用于遍历$data属性,传递给 defineReactive 做响应式处理
  • defineReactive 如果值为对象则递归调用 walk ,如果值为原生数据则设置getter和setter

说明

有的人可能会觉得 defineReactive(data, key, val) 中的形参val多此一举,因为 data[key] == val 也可以获取

其实不然,假设我们只传递了 defineReactive(data, key) 那么在 defineProperty 中 getter 和 setter 要是使用

data[key] 的方式访问值的话,在getter会无限触发 get() , 而在setter会触发一次 get() ,因为 data[key] 就是触发getter的方式

另外 defineProperty 内部引用了 defineReactive 的参数val,这里会产生闭包空间存储 val的值

defineReactive

Observer 引用

在上面编写的 Vue.constructor 中添加Observer的引用,并传入$data

//Vue.constructor
    public constructor(options: VueOptions) {
        this.$options = options;
        this.$data = options.data || {};
        if (typeof options.el == 'string') {
            this.$el = document.querySelector(options.el);
        } else {
            this.$el = options.el;
        }

        if (!this.$el) {
            throw Error(`cannot find element by selector ${options.el}`);
            return;
        }
        this._proxyData(this.$data);
        new Observer(this.$data);          //新增此行
    }

测试

重新打印vm可以看到 $data 里的成员也有getter和setter方法了

VBF3Yn.png!mobile

Compiler

  • 负责编译模板,解析指令 v-xxx 和插值表达式 {{var}}
  • 负责页面首次渲染
  • 当数据发生变化时,重新渲染视图

注意,为简化代码,这里的插值表达式,不处理复杂情况,只处理单一的变量读取

{{count + 2}} => 不进行处理

{{person.name}} => 可以处理

Util 辅助工具

为方便操作,我们需要提前编写几个简单的函数功能,并封装到 Util 类中静态方法里

class Util {
    static isPrimitive(s: any): s is (string | number) {
        return typeof s === 'string' || typeof s === 'number';
    }

    static isHTMLInputElement(element: Element): element is HTMLInputElement {
        return (<HTMLInputElement>element).tagName === 'INPUT';
    }

    //处理无法引用 vm.$data['person.name'] 情况
    static getLeafData(obj: Object, key: string): any {
        let textData: Array<any> | Object | String | Number = obj;

        if (key.indexOf('.') >= 0) {
            let keys = key.split('.');
            for (let k of keys) {
                textData = textData[k];
            }
        } else {
            textData = obj[key];
        }

        return textData;
    }

    static setLeafData(obj: Object, key: string, value: any): void {
        if (key.indexOf('.') >= 0) {
            let keys = key.split('.');

            for (let i = 0; i < keys.length; i++) {
                let k = keys[i];
                if (i == keys.length - 1) {
                    obj[k] = value;
                } else {
                    obj = obj[k];
                }

            }
        } else {
            if (obj[key]){
                obj[key] = value;
            }
        }
    }
}
  • isPrimitive

该函数用于判断变量是否为原生类型(string or number)

  • isHTMLInputElement

该函数用于判断元素是否为Input元素,用于后面处理 v-model 指令的双向绑定数据,默认 :value @input

  • getLeafData

因为key可能为 person.name , 如果直接中括号访问对象属性如 obj['person.name'] 无法等同于 obj.person.name

该函数如果传递的键key中,若不包含点 . ,则直接返回 obj[key]。 若包含,则解析处理返回 obj.key1.key2.key3

  • setLeafData

同上, key为 person.name 时,设置 obj.person.name = value ,否则设置 obj.key = value

Complier 实现代码

class Compiler {
    public el: Element | null;
    public vm: Vue;

    constructor(vm: Vue) {
        this.el = vm.$el,
            this.vm = vm;
        if (this.el) {
            this.compile(this.el);
        }
    }

    compile(el: Element) {
        let childNodes = el.childNodes;
        Array.from(childNodes).forEach((node: Element) => {
            if (this.isTextNode(node)) {
                this.compileText(node);
            } else if (this.isElementNode(node)) {
                this.compileElement(node);
            }

            //递归处理孩子nodes
            if (node.childNodes && node.childNodes.length !== 0) {
                this.compile(node);
            }
        })
    }

    //解析插值表达式 {{text}}
    compileText(node: Node) {
        let pattern: RegExpExecArray | null;
        if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
            let key = pattern[1].trim();
            if (key in this.vm.$data && Util.isPrimitive(this.vm.$data[key])) {
                node.textContent = this.vm.$data[key];
            }
        }
    }

    //解析 v-attr 指令
    compileElement(node: Element) {
        Array.from(node.attributes).forEach((attr) => {
            if (this.isDirective(attr.name)) {
                let directive: string = attr.name.substr(2);
                let value = attr.value;
                let processer: Function = this[directive + 'Updater'];
                if (processer) {
                    processer.call(this, node, value);
                }

            }
        })
    }

    //处理 v-model 指令
    modelUpdater(node: Element, key: string) {
        if (Util.isHTMLInputElement(node)) {
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.value = value.toString();
            }

            node.addEventListener('input', () => {
                Util.setLeafData(this.vm.$data, key, node.value);
                console.log(this.vm.$data);
            })
        }
    }

    //处理 v-text 指令
    textUpdater(node: Element, key: string) {
        let value = Util.getLeafData(this.vm.$data, key);
        if (Util.isPrimitive(value)) {
            node.textContent = value.toString();
        }
    }

    //属性名包含 v-前缀代表指令
    isDirective(attrName: string) {
        return attrName.startsWith('v-');
    }

    //nodeType为3属于文本节点
    isTextNode(node: Node) {
        return node.nodeType == 3;
    }

    //nodeType为1属于元素节点
    isElementNode(node: Node) {
        return node.nodeType == 1;
    }
}
  • compile

用于首次渲染传入的 div#app 元素, 遍历所有第一层子节点,判断子节点 nodeType 属于 文本 还是 元素

若属于 文本 则调用 compileText 进行处理, 若属于 元素 则调用 compileElement 进行处理。

另外如果子节点的孩子节点 childNodes.length != 0 则递归调用 compile(node)

  • compileText

用于渲染插值表达式,使用正则 \{\{(.*?)\}\} 检查是否包含插值表达式,提取括号内变量名

通过工具函数 Utils.getLeafData(vm.$data, key) 尝试读取 vm.$data[key]vm.$data.key1.key2 的值

如果能读取成功,则渲染到视图当中 node.textContent = this.vm.$data[key];

  • compileElement

用于处理内置v-指令,通过 node.attributes 获取所有元素指令, Array.from() 可以使 NamedNodeMap 转成可遍历的数组

获取属性名,判断是否有 v- 前缀,若存在则进行解析成函数,解析规则如下

textUpdater()
modelUpdater()

可以通过尝试方法获取,如 this[directive + "Updater"] 若不为 undefined 说明指令处理函数是存在的

最后通过 call 调用,使得 this 指向 Compiler类实例

  • textUpdater

与 compileText 类似,尝试读取变量并渲染到Dom中

  • modelUpdate

除了尝试读取变量并渲染到Dom中,还需要设置 @input 函数监听视图的变化来更新数据

node.addEventListener('input', () => {
    Util.setLeafData(this.vm.$data, key, node.value);
})

Complier 实例化引用

在 Vue.constructor 中引用 Compiler 进行首次页面渲染

//Vue.constructor
    public constructor(options: VueOptions) {
        this.$options = options;
        this.$data = options.data || {};
        if (typeof options.el == 'string') {
            this.$el = document.querySelector(options.el);
        } else {
            this.$el = options.el;
        }

        if (!this.$el) {
            throw Error(`cannot find element by selector ${options.el}`);
            return;
        }
        this._proxyData(this.$data);
        new Observer(this.$data);
        new Compiler(this);                  //新增此行
    }

测试代码

<div id='app'>
    <div>{{ person.name }}</div>
    <div>{{ count }}</div>
    <div v-text='person.name'></div>
    <input type='text' v-model='msg' />
    <input type='text' v-model='person.name'/>
</div>
<script src='vue.js'></script>
<script>

let vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello vue',
        count: 100,
        person: { name: 'tim' },
    }
})
</scirpt>

渲染结果

BJzMR3.png!mobile

至此完成了初始化数据驱动和渲染功能,我们修改 input 表单里的元素内容是会通过 @input 动态更新$data对应绑定 v-model 的数据

但是此时我们在控制台中修改 vm.msg = 'Gooooood' ,视图是不会有响应式变化的,因此下面将通过 WatcherDep 观察者模式来实现响应式处理

Watcher 与 Dep

Dep(Dependency)

rAZBBrI.png!mobile

实现功能:

  • 收集依赖,添加观察者(Watcher)
  • 通知所有的观察者 (notify)

Dep 实现代码

class Dep {
    static target: Watcher | null;
    watcherList: Watcher[] = [];

    addWatcher(watcher: Watcher) {
        this.watcherList.push(watcher);
    }

    notify() {
        this.watcherList.forEach((watcher) => {
            watcher.update();
        })
    }
}

Watcher

JveMRjZ.png!mobile

实现功能:

  • 当变化触发依赖时,Dep通知Watcher进行更新视图
  • 当自身实例化时,向Dep中添加自己

Watcher 实现代码

每个观察者Watcher都必须包含 update方法,用于描述数据变动时如何响应式渲染到页面中

class Watcher {
    public vm: Vue;
    public cb: Function;
    public key: string;
    public oldValue: any;

    constructor(vm: Vue, key: string, cb: Function) {
        this.vm = vm;
        this.key = key;
        this.cb = cb;

        //注册依赖
        Dep.target = this;

        //访问属性触发getter,收集target
        this.oldValue = Util.getLeafData(vm.$data, key);

        //防止重复添加
        Dep.target = null;
    }

    update() {
        let newVal = Util.getLeafData(this.vm.$data, this.key);

        if (this.oldValue == newVal) {
            return;
        }

        this.cb(newVal);
    }
}

修改 Observer.defineReactive

对于 $data 中每一个属性,都对应着一个 Dep,因此我们需要在$data初始化响应式时创建Dep实例,在getter 中收集观察者 Dep.addWatcher() , 在 setter 中通知观察者 Dep.notify()

defineReactive(obj: VueData, key: string, val: any) {
        let dep = new Dep();                        //新增此行,每个$data中的属性都对应一个Dep实例化

        //如果data值的为对象,递归walk
        if (typeof val === 'object') {
            this.walk(val);
        }
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                Dep.target && dep.addWatcher(Dep.target);       //检查是否有Watcher,收集依赖的观察者
                //此处不能返回 obj[key] 会无限递归触发get
                console.log('getter')
                return val;
            },
            set: (newVal) => {
                if (newVal == val) {
                    return;
                }
                val = newVal;
                if (typeof newVal == 'object') {
                    this.walk(newVal)
                }

                //发送通知
                dep.notify();                        //新增此行,$data中属性发送变动时发送通知
            }
        });
    }

修改 Compiler类,下面几个方法均添加实例化Watcher

每个视图对应一个Watcher,以key为关键字触发响应的Dep,并通过getter将Watcher添加至Dep中

class Compiler {
    //插值表达式
    compileText(node: Node) {
        let pattern: RegExpExecArray | null;
        if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
            let key = pattern[1].trim();
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.textContent = value.toString();
            }
            new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; });   //新增此行
        }
    }

    //v-model
    modelUpdater(node: Element, key: string) {
        if (Util.isHTMLInputElement(node)) {
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.value = value.toString();
            }

            node.addEventListener('input', () => {
                Util.setLeafData(this.vm.$data, key, node.value);
                console.log(this.vm.$data);
            })

            new Watcher(this.vm, key, (newVal: string) => { node.value = newVal; });  //新增此行
        }
    }

    //v-text
    textUpdater(node: Element, key: string) {
        let value = Util.getLeafData(this.vm.$data, key);
        if (Util.isPrimitive(value)) {
            node.textContent = value.toString();
        }

        new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; });   //新增此行
    }
}

至此本篇目的已经完成,实现简易版Vue的响应式数据渲染视图和双向绑定,下面是完整 ts代码和测试代码

实现简易版Vue完整代码

//vue.js
interface VueData {
    [key: string]: any,
}

interface VueOptions {
    data: VueData;
    el: string | Element;
}

interface Vue {
    [key: string]: any,
}

class Util {
    static isPrimitive(s: any): s is (string | number) {
        return typeof s === 'string' || typeof s === 'number';
    }

    static isHTMLInputElement(element: Element): element is HTMLInputElement {
        return (<HTMLInputElement>element).tagName === 'INPUT';
    }

    //处理无法引用 vm.$data['person.name'] 情况
    static getLeafData(obj: Object, key: string): any {
        let textData: Array<any> | Object | String | Number = obj;

        if (key.indexOf('.') >= 0) {
            let keys = key.split('.');
            for (let k of keys) {
                textData = textData[k];
            }
        } else {
            textData = obj[key];
        }

        return textData;
    }

    static setLeafData(obj: Object, key: string, value: any): void {
        if (key.indexOf('.') >= 0) {
            let keys = key.split('.');

            for (let i = 0; i < keys.length; i++) {
                let k = keys[i];
                if (i == keys.length - 1) {
                    obj[k] = value;
                } else {
                    obj = obj[k];
                }

            }
        } else {
            if (obj[key]){
                obj[key] = value;
            }
        }
    }
}

class Vue {
    public $options: VueOptions;
    public $data: VueData;
    public $el: Element | null;

    public constructor(options: VueOptions) {
        this.$options = options;
        this.$data = options.data || {};
        if (typeof options.el == 'string') {
            this.$el = document.querySelector(options.el);
        } else {
            this.$el = options.el;
        }

        if (!this.$el) {
            throw Error(`cannot find element by selector ${options.el}`);
            return;
        }
        this._proxyData(this.$data);
        new Observer(this.$data);
        new Compiler(this);
    }

    //生成代理,通过直接读写vue属性来代理vue.$data的数据,提高便利性
    //vue[key] => vue.data[key]
    private _proxyData(data: VueData) {
        Object.keys(data).forEach(key => {
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return data[key];
                },
                set(newVal) {
                    if (newVal == data[key]) {
                        return;
                    }
                    data[key] = newVal;
                }
            })
        })
    }
}

class Observer {
    constructor(data: VueData) {
        this.walk(data);
    }

    walk(data: VueData) {
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key]);
        });
    }

    //观察vue.data的变化,并同步渲染至视图中
    defineReactive(obj: VueData, key: string, val: any) {
        let dep = new Dep();


        //如果data值的为对象,递归walk
        if (typeof val === 'object') {
            this.walk(val);
        }
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                //收集依赖
                Dep.target && dep.addWatcher(Dep.target);
                //此处不能返回 obj[key] 会无限递归触发get
                console.log('getter')
                return val;
            },
            set: (newVal) => {
                if (newVal == val) {
                    return;
                }
                val = newVal;
                if (typeof newVal == 'object') {
                    this.walk(newVal)
                }

                //发送通知
                dep.notify();
            }
        });
    }
}

class Compiler {
    public el: Element | null;
    public vm: Vue;

    constructor(vm: Vue) {
        this.el = vm.$el,
            this.vm = vm;
        if (this.el) {
            this.compile(this.el);
        }
    }

    compile(el: Element) {
        let childNodes = el.childNodes;
        Array.from(childNodes).forEach((node: Element) => {
            if (this.isTextNode(node)) {
                this.compileText(node);
            } else if (this.isElementNode(node)) {
                this.compileElement(node);
            }

            //递归处理孩子nodes
            if (node.childNodes && node.childNodes.length !== 0) {
                this.compile(node);
            }
        })
    }

    // {{text}}
    compileText(node: Node) {
        let pattern: RegExpExecArray | null;
        if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
            let key = pattern[1].trim();
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.textContent = value.toString();
            }
            new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; })
        }
    }

    //v-attr
    compileElement(node: Element) {
        Array.from(node.attributes).forEach((attr) => {
            if (this.isDirective(attr.name)) {
                let directive: string = attr.name.substr(2);
                let value = attr.value;
                let processer: Function = this[directive + 'Updater'];
                if (processer) {
                    processer.call(this, node, value);
                }

            }
        })
    }

    //v-model
    modelUpdater(node: Element, key: string) {
        if (Util.isHTMLInputElement(node)) {
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.value = value.toString();
            }

            node.addEventListener('input', () => {
                Util.setLeafData(this.vm.$data, key, node.value);
                console.log(this.vm.$data);
            })

            new Watcher(this.vm, key, (newVal: string) => { node.value = newVal; })
        }
    }

    //v-text
    textUpdater(node: Element, key: string) {
        let value = Util.getLeafData(this.vm.$data, key);
        if (Util.isPrimitive(value)) {
            node.textContent = value.toString();
        }

        new Watcher(this.vm, key, (newVal: string) => {
            node.textContent = newVal;
        });
    }

    isDirective(attrName: string) {
        return attrName.startsWith('v-');
    }

    isTextNode(node: Node) {
        return node.nodeType == 3;
    }

    isElementNode(node: Node) {
        return node.nodeType == 1;
    }
}

class Dep {
    static target: Watcher | null;
    watcherList: Watcher[] = [];

    addWatcher(watcher: Watcher) {
        this.watcherList.push(watcher);
    }

    notify() {
        this.watcherList.forEach((watcher) => {
            watcher.update();
        })
    }
}

class Watcher {
    public vm: Vue;
    public cb: Function;
    public key: string;
    public oldValue: any;

    constructor(vm: Vue, key: string, cb: Function) {
        this.vm = vm;
        this.key = key;
        this.cb = cb;

        //注册依赖
        Dep.target = this;

        //访问属性触发getter,收集target
        this.oldValue = Util.getLeafData(vm.$data, key);

        //防止重复添加
        Dep.target = null;
    }

    update() {
        let newVal = Util.getLeafData(this.vm.$data, this.key);

        if (this.oldValue == newVal) {
            return;
        }

        this.cb(newVal);
    }
}

测试代码

<div id='app'>
    <div>{{ person.name }}</div>
    <div>{{ count }}</div>
    <div v-text='person.name'></div>
    <input type='text' v-model='msg' />
    <input type='text' v-model='person.name'/>
</div>
    <script src='dist/main.js'></script>
<script>
let vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello vue',
        count: 100,
        person: { name: 'tim' },
    }
})

// vm.msg = 'Hello world';
console.log(vm);

setTimeout(() => { vm.person.name = 'Goooooood' }, 1000);
</scirpt>

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK