前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >vue 随记(2):轮子是如何造成的

vue 随记(2):轮子是如何造成的

作者头像
一粒小麦
发布2020-07-16 22:21:00
8100
发布2020-07-16 22:21:00
举报
文章被收录于专栏:一Li小麦一Li小麦

读过《三体》的人都会对人列计算机印象深刻。

牛顿不知从什么地方掏出六面小旗.三白三黑,冯 • 诺伊曼接过来分给三名士兵,每人一白一黑,说:“白色代表0,黑色代表1。好,现在听我说,出,你转身看着入1和入2,如果他们都举黑旗,你就举黑旗,其他的情况你都举白旗,这种情况有三种:入l白,入2黑;入l黑,入2白;入1、入2都是白。”

为了预测恒纪元和乱纪元,故事里的冯诺依曼设计了人列计算机,不需要三千万个数学家。只需要三千万忠实的士兵,每个个体订阅相关单元的变化,忠实地反映状态即可。

实际上前端的MVVM就是一个精巧的状态机。

如果参考vue做一个简易版本的响应式框架,设计上应该分为四部分:

1、数据劫持器(Observer),对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者。

2、编译器Compile,对模板每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。

3、订阅器Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图。

4、mvvm入口函数,整合以上三者。

1. 数据劫持(Observer)

数据劫持在上一篇文章已经介绍过defineProperty和Proxy。本文先用defineProperty。

有一个没解决的问题是如何实现深度监听。做法是在遍历每层时加多一个递归就行了。不妨先删除之前的get/setState和watch。

代码语言:javascript
复制
/ 响应式
class Observer {
    constructor(data) {
        this.observe(data);
    }

    defineResponsive(data,key,val){
        this.observe(val);
        Object.defineProperty(data, key, {
            enumerable: true, // 可枚举
            configurable: false, // 不能再define
            get() {
                return val;
            },

            set(nick) {
                const old = data[key];
                val = nick;
                console.log(`${key}:${old}->${nick}`)
            }
        })
    }

    // 观察属性
    observe(data) {
        if (!data || typeof data !== 'object') {
            return;
        }

        // 取出所有属性遍历
        Object.keys(data).forEach(key => {
            this.defineResponsive(data, key, data[key]);
        });
    }
}

我们测试下:

代码语言:javascript
复制
const data = {
    name: 'djtao',
    info: {
        job: 'aaa'
    }
}

const res = new Observer(data)

data.name = 'djt'; // name:djtao->djt
data.info.job = 'bbb'; // job:aaa->bbb

那么深度监听就实现了。很明显,监听点就是在我们打log日志的地方,上一篇的watch方法也是写在这里。

现在用发布订阅模式改造,又该怎么通知watcher呢?

回顾上篇中计算器的案例,文章中的watch方法中,是调用了一个calc方法。把所有需要响应数据变化的地方全部写进去并更新。

代码语言:javascript
复制
const calc = r => {
    document.querySelector(`#r`).value = r;
    document.querySelector(`#square`).innerHTML = Math.pow(r, 2);
    document.querySelector(`#cube`).innerHTML = Math.pow(r, 3);
}

也就是说这个案例中关键值r,有三个来自视图层的订阅者(wathcers:[input#r,div#sqaure,div#cube])。

所以接下来需要实现一个消息订阅器,考虑维护一个数组,用来收集订阅者,数据变动触发notify,再遍历调用订阅者的update方法。

代码语言:javascript
复制
**
 * 订阅器(依赖收集器)
 */
class Dep{
    constructor(){
        this.deps = []; 
    }

    // 添加依赖
    add(watcher){
        this.deps.push(watcher);
    }

    // 遍历通知驶入更新
    notify(){
        this.subs.forEach(wathcer => watcher.update());
    }
}

在Observer中,每set一次数据,都通知dep(dep.notify());每次get一次数据就把wather中的this放到deps中。

所以理论上,在defineProperty之前,new 出Dep,在监听点调用 dep 的 notify 方法就行了。

先来简单看Watcher的简单实现。

代码语言:javascript
复制
class Watcher{
    constructor(opts){
        // 实例化时,把自身缓存起来
        Dep.target = this;
    }

    update(){
        console.log('属性已更新')
    }
}


代码语言:javascript
复制
class Observer {
    // ...
    defineResponsive(data,key,val){

        const dep = new Dep();

        Object.defineProperty(data, key, {
            enumerable: true, 
            configurable: false, 
            get() {
                // 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,暂存watcher, 添加完移除
                Dep.target && dep.add(Dep.target);
                return val;
            },

            set(nick) {
                const old = data[key];
                val = nick;

                // 通知订阅者
                console.log(`${key}:${old}->${nick}`)
                dep.notify(nick)
            }
        })

        // 递归调用
        this.observe(val);
    }

    // ...

}

2 Compile (编译器)

写编译器可以单独新建一个Compile.js单独引入。关于这部分的理解不难,但是零碎的要点极多。表述出来更是不易。本章尝试都做出解释。

在设计代码的时候,需要根据理想效果来确定编译的方式。设想我们的计算器用vue来写,可能是这样的:

代码语言:javascript
复制
<!-- html -->
<div id="app">
    <!-- 事件,双向绑定 -->
    <button @click="minus">-</button> <input type="number" v-model="r"> <button @click="plus">+</button>
    <!-- 插值绑定 -->
    <p>r = <span>{{r}}</span></p>
    <!-- 计算属性 -->
    <p>r<sup>2</sup> = <span v-text="square"></span></p>
    <p>r<sup>3</sup> = <span v-text="cube"></span></p>
</div>

不妨就允许这样的代码直接写在html里,也就说编译器的目标是:至少要把这些语法写成的html转化为插值,指令,事件等。

此外,在更新指令或数据时,我们都希望编译器能拿到开发者写的具体业务逻辑比如method,定义的data等等——是时候考虑写下的mvvm入口了。模仿vue的使用方法,我希望它的简单用法是这样的:

代码语言:javascript
复制
// Vm/index.js
const vm = new Vm({
    ele: '#app',
    data: {
        r: 0
    },

    method: {
        plus() {
            this.data.r += 1;
        },
        minus() {
            this.data.r -= 1;
        }
    }
})

简单写个对象,把配置传进去:

代码语言:javascript
复制
/**
 * 总体入口
 */

class Vm {
    constructor(opts) {
        this.opts = opts;

        const { data } = this.opts;

        const res = new Observer(data);

        new Compile(this);
    }

}

2.1 编译基本流程

compile主要做的事情是解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图,如图所示:

代码语言:javascript
复制
读取模板,转化为dom碎片->编译节点(解析指令,事件)或文本(解析插值内容)->添加到dom中

因此构造函数为

代码语言:javascript
复制
class Compile {
    constructor(vm) {
        this.$el = document.querySelector(vm.opts.ele);
        this.$vm = vm;

        if (this.$el) {
            this.$fragment = this.node2Fragment(this.$el);
            this.compile(this.$fragment);
            this.$el.appendChild(this.$fragment);
        }
    }

    // ...
}

同时还需要若干通用方法,这些方法都全部放一个对象里维护。不妨命名为compileUtils

代码语言:javascript
复制
const compileUtils = {
    // 通用方法
}

2.2 node2Fragement

这段使用了原生dom方法Document.createDocumentFragment(),把真实dom转化为内存中的文档片段。方便进行编译。

DocumentFragments 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。在DOM树中,文档片段被其所有的子元素所代替。 因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。 ——MDN:https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createDocumentFragment

代码语言:javascript
复制
node2Fragment(el){
    console.log(el.firstChild)

    const fragment = document.createDocumentFragment();

    // 将原生节点拷贝到fragment
    let child;
    while (child = el.firstChild) {
        fragment.appendChild(child);
    }

    return fragment
}

你也许会难以理解这里有个奇怪的while循环:

代码语言:javascript
复制
while((child = el.firstChild)){  // 1
       fragment.appendChild(child)  // 2
}

1.在第一行的括号里面进行了两次操作:第一次就是赋值: child = el.firstChild;第二次就是判断child是否为空,即while(child).2.在第二行中,fragment就把el.firstChild(el.children[0])抽离了出来,此操作导致el.children[0]被抽出,在下次while循环执行firstChild = el.firstChild时读取的是相对本次循环的el.children[1],以此达到循环转移dom的目的

2.3 compile

Compile.compile负责具体的解析逻辑,并且与Watcher建立通信。同时负责更新具体的内容。

2.3.1 讨论元素节点和插值文本节点

文档碎片(el)从html中拿到后,就是对其子元素进行递归遍历。对于dom集合,可以使用Array.from方法,然后对遍历结果进行分类讨论:

1.如果是元素节点,执行compileElement(node);2.如果是插值文本节点,执行compileText(node);3.如果有子元素,则继续递归。

代码语言:javascript
复制
class Compile {
    // ...

    // 编译文档碎片
    compile(el) {
        const childNodes = el.childNodes;

        Array.from(childNodes).forEach(node => {
            if (compileUtils.isElement(node)) {
                console.log('编译元素节点', node.nodeName);
                this.compileElement(node);

            } else if (compileUtils.isInterpolation(node)) {
                // 双大括号
                console.log('编译插值文本', node.textContent);
                this.compileText(node);
            }

            if (node.childNodes && node.childNodes.length > 0) {
                this.compile(node);
            }
        });
    }

}

然后在Utils新增两个判断方法:

代码语言:javascript
复制
// 编译器通用方法
const compileUtils = {
    // 判断是否普通节点
    isElement(node){
        return node.nodeType == 1;
    },

    // 判断插值文本:是文本且符合双大括弧正则
    isInterpolation(node) {
        return node.nodeType == 3 && /\{\{(.*)\}\}/.test(node.textContent);
    },

    // ...
}
2.3.2 抽离指令,事件和文本

1.compileElement 抽取指令,事件2.compileText 抽取插值文本3.抽取之后根据抽取的内容丢给compileUtils对应的方法处理。4.“compileUtils对应的方法”,入参必须有vm(固定格式,node,exp[取值],vm)

代码语言:javascript
复制
class Compile {
    // ...

    // 编译元素节点
    compileElement(node) {
        let nodeAttrs = node.attributes;
        Array.from(nodeAttrs).forEach(attr => {
            // 获取属性
            const attrName = attr.name;
            // 获取等号后面的值
            const exp = attr.value;

            // 首先判断是事件还是命令
            if (compileUtils.isEvent(attrName)) {
                // 获取事件类型
                const eventType = attrName.substring(1);
                // 绑定事件,事件处理函数名就是exp
                compileUtils.eventHandler(node, eventType, exp, this.$vm);
            }

            if (compileUtils.isDir(attrName)) {
                // 获取指令名
                const dir = attrName.substring(2);
                // 在这里,exp是指令值。
                compileUtils[dir] && compileUtils[dir](node, exp, this.$vm);
            }
        })
    }


    // 编译文本节点
    compileText(node) {
        const exp = RegExp.$1.trim();
        compileUtils.text(node, exp,this.$vm);
    }

}

在utils补充对应的方法:

代码语言:javascript
复制
// 编译器通用方法
const compileUtils = {    
    //...

    // 判读是否事件:@开头
    isEvent(attrName) {
        return attrName.indexOf('@') == 0;
    },

    // 判读是否指令:v开头
    isDir(attrName) {
        return attrName.indexOf('v-') == 0;
    },

    // 事件处理函数
    eventHandler(node, eventType, exp) {
        console.log(node,`绑定了${eventType}事件,函数名为${exp}`);
    },

    // text指令
    text(node, exp) {
        console.log(node,`绑定了插值text指令,取值是${exp}`);
    },

    // model指令
    model(node,exp){
        console.log(node,`绑定了插值model指令,取值是${exp}`);
    }

此时可以测试‘编译’一下:

代码语言:javascript
复制
const c = new Compile(vm)

运行结果如下:

看来各个节点和事件指令都已被正确识别。到目前为止,编译器能够解析出插值变量,函数名,指令变量。基本上已经初具规模了。

2.4 响应与更新(通信接口)

上面遗留了两个问题,一个是具体的指令逻辑没写。另一个是除了解析渲染,编译器还要响应watcher。

代码语言:javascript
复制
// 编译器通用方法
const compileUtils = {
    // ...

    // 分派update更新方法,在此处初始化Watcher
    bind(node, exp, dir, vm) {
        var updaterFn = this[dir + 'Updater'];
        // 第一次初始化视图
        updaterFn && updaterFn(node, vm.opts.data[exp]);
        // 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher
        new Watcher(vm, exp, (value, oldValue) => {
            // 一旦属性值有变化,会收到通知执行此更新函数,更新视图
            updaterFn && updaterFn(node, value, oldValue);
        });
    },

如上,给compileUtils.text/compileUtils.model设计一个通用的处理方法bind。首先它会寻找方法库中的xxxUpdater方法并尝试执行(绑定初始数据)。另一方面,会new一个Watcher对象,当数据变化时,Watcher会通知Compile,调用xxxUpdater做出改变。

因此完善指令部分逻辑:

代码语言:javascript
复制
// text指令
    text(node, exp, vm) {
        console.log(node, `绑定了插值text指令,取值是${exp}`);
        this.bind(node, exp, 'text', vm);
    },

    // text更新
    textUpdater(node, value) {
        node.textContent = typeof value == 'undefined' ? '' : value;
    },


    // model指令
    model(node, exp,vm) {
        console.log(node, `绑定了model指令,取值是${exp}`);
        this.bind(node, exp, 'model', vm);
    },

    // model更新
    modelUpdater: function(node, value, oldValue) {
        node.value = typeof value == 'undefined' ? '' : value;
    }

来测试下吧!

代码语言:javascript
复制
const vm = new Vm({
    ele: '#app',
    data: {
        r: 0,
        square:123,
        cube:321
    },

    method: {
        plus() {
            // this.data.r += 1;
            console.log('+')
        },
        minus() {
            // this.data.r -= 1;
            console.log('-')
        }
    }
})

new Compile(vm);

至少初始化已经正常了。只是改变关键值,没能得到正确响应。

3.Watcher(订阅者)

现在我们已经知道Watcher就是做响应数据变化的事,订阅者作为Observer和Compile之间通信的桥梁,主要做的事情是:

1.Wathcer接受三个参数:vm,所监听的变量名key(也就是compile中的exp),回调方法callbacl(也就是Compile中的XXXupdater)。

2、自身必须有一个update()方法

3、待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。

代码语言:javascript
复制
/**
 * 订阅者
 */
class Watcher {
    constructor(vm,key,callback) {
        this.vm = vm;
        this.key = key;
        this.callback = callback;


        // 实例化时,把自身缓存起来
        Dep.target = this;
        // 读取vm值,从而触发get
        this.vm.opts.data[key];
        // 读取完之后清空
        Dep.target = null;
    }

    update() {
        console.log(this.callback)
        this.callback.call(this.vm,this.vm.opts.data[this.key]);
        console.log('属性已更新')
    }

}

构造函数代码中有一段操作,是读取data值触发了get。现在来回顾一下这个过程发生了什么:

代码语言:javascript
复制
/**
 * 数据劫持
 */
class Observer {
    // ...

    defineResponsive(data, key, val) {

        const dep = new Dep();

        Object.defineProperty(data, key, {
            enumerable: true, // 可枚举
            configurable: false, // 不能再define
            get() {
                // 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,暂存watcher, 添加完移除
                Dep.target && dep.add(Dep.target);
                return val;
            },

            set(nick) {
                const old = data[key];
                val = nick;

                // 通知订阅者
                console.log(`${key}:${old}->${nick}`)
                dep.notify();
            }
        })

        // 递归调用
        this.observe(val);
    }

通过Dep.target = watcherInstance标记订阅者是当前watcher实例,强行触发属性定义的getter方法,getter方法执行的时候,就会在属性的订阅器dep添加当前watcher实例,从而在属性值有变化的时候,watcherInstance就能收到更新通知。

4. 优化

4.1 数据代理

到目前为止,主体功能已经完成,但开发者读取data属性是比较困难的,我想把this.opts.r代理到this.r上面。

代码语言:javascript
复制
class Vm {
    constructor(opts) {
        this.opts = opts;

        const { data,computed } = this.opts;

        Object.keys(data).forEach(key=>{
           this.proxyData(key)
        });

        new Observer(data);

        new Compile(this);
    }

    // 代理
    proxyData(key){
        Object.defineProperty(this,key,{
            get(){
                return this.opts.data[key];
            },
            set(newVal){
                this.opts.data[key]=newVal;
            }
        })
    }
}

这样就可以直接使用使用this.r了。

4.2 生命周期

现在我想写一个created的生命周期,直接在vm构造函数加一段就行了。

代码语言:javascript
复制
class Vm {
    constructor(opts) {
        this.opts = opts;

        const { data,computed,created } = this.opts;

        Object.keys(data).forEach(key=>{
           this.proxyData(key)
        });

        new Observer(data);

        new Compile(this);
        // created 生命周期
        created && created.call(this);
    }

4.3 计算属性

因为做的是计算器,因此肯定推荐使用计算属性(computed)。像这样子

代码语言:javascript
复制
const vm = new Vm({
    ele: '#app',
    data: {
        r: 2,
    },

    method: {
        plus() {                    
            this.r += 1
        },
        minus() {
            this.r -= 1
        }
    },
    computed: {
        square() {
            return Math.pow(this.r,2);
        },

        cube(){
            return Math.pow(this.r,3);
        }
    },

    created(){
        // console.log('创建完毕',this.square)
    }
})

计算属性如果需要做,也要添加到响应式系统中,当初始化、关键数据变化时,先尝试读取data中的数据,如不成功,再尝试读取computed。

代码语言:javascript
复制
// vm.js

/**
 * 总体入口
 */

class Vm {
    constructor(opts) {
        this.opts = opts;

        const { data, computed, created } = this.opts;

        Object.keys(data).forEach(key => {
            this.proxyData(key);
        });

        // 计算属性
        this.initComputed();

        new Observer(data,this);

        new Compile(this);

        created && created.call(this);
    }

    // 代理
    proxyData(key) {
        Object.defineProperty(this, key, {
            get() {
                return this.opts.data[key];
            },
            set(newVal) {
                this.opts.data[key] = newVal;
            }
        })
    }

    // 计算属性
    initComputed() {
        const computed = this.opts.computed;

        if (!computed) {
            return
        }

        if (typeof computed === 'object') {
            Object.keys(computed).forEach(key => {
                const getter = computed[key];
                this.computedDep = new Dep();
                Object.defineProperty(this, key, {
                    enumerable: true,
                    configurable: true,
                    get:() => {
                        const value = getter.call(this);
                        Dep.target && this.computedDep.add(Dep.target);
                        // console.log('变了',value)
                        return value;
                    },

                    set: function () { }
                });
            });
        }
    }
}

在Observer中通知订阅者:

代码语言:javascript
复制
class Observer {
    constructor(data,vm) {
        // 现在需要拿到vm对象
        this.vm = vm;
        this.observe(data);
    }

    defineResponsive(data, key, val) {

        const dep = new Dep();
        const  _this = this;

        Object.defineProperty(data, key, {
            enumerable: true, // 可枚举
            configurable: false, // 不能再define
            get() {
                // ...
            },

            set(nick) {
                const old = data[key];
                val = nick;

                // 通知订阅者
                console.log(`${key}:${old}->${nick}`)
                dep.notify();
                // 计算属性的依赖收集器也要一并通知!
                _this.vm.computedDep.notify();
            }
        })

        // 递归调用
        this.observe(val);
    }

complieUtils的bind中,也增加尝试读取computed的逻辑:

代码语言:javascript
复制
// 编译器通用方法
const compileUtils = {

    // 分派update更新方法,在此处初始化Watcher
    bind(node, exp, dir, vm) {
        var updaterFn = this[dir + 'Updater'];

        if(vm.opts.computed[exp]){
            vm.opts.computed[exp] = vm.opts.computed[exp].bind(vm);
        }
        // 尝试读取computed
        let _value = vm.opts.data[exp] ? vm.opts.data[exp] : vm.opts.computed[exp]();

        updaterFn && updaterFn(node, _value);

        new Watcher(vm, exp, (value, oldValue) => {
            // 一旦属性值有变化,会收到通知执行此更新函数,更新视图
            updaterFn && updaterFn(node, value, oldValue);
        });

    },

最后,在watcher中,读取计算属性,并派发更新值:

代码语言:javascript
复制
/**
 * 订阅者
 */
class Watcher {
    constructor(vm,key,callback) {
        this.vm = vm;
        this.key = key;
        this.callback = callback;


        // 实例化时,把自身缓存起来
        Dep.target = this;
        // 读取vm值,从而触发get
        this.vm.opts.data[key];

        if(!this.vm.opts.data[key] && this.vm.opts.computed[key]){
            // 查找计算属性
            // 读取计算属性,触发get
            this.vm.opts.computed[key]();
        }


        // 读取完之后清空
        Dep.target = null;
    }

    update() {

        if(this.vm.opts.data[this.key]){
            // 普通data属性
            this.callback.call(this.vm,this.vm.opts.data[this.key]);

        }else if(this.vm.opts.computed[this.key]){
            // 计算属性
            this.callback.call(this.vm,this.vm.opts.computed[this.key]());
        }

    }

}

所以计算属性功能完成。

-----分割线----

现在有了自己的轮子,可以写个计算器了。

最终效果:

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-07-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 一Li小麦 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 数据劫持(Observer)
  • 2 Compile (编译器)
    • 2.1 编译基本流程
      • 2.2 node2Fragement
        • 2.3 compile
          • 2.3.1 讨论元素节点和插值文本节点
          • 2.3.2 抽离指令,事件和文本
        • 2.4 响应与更新(通信接口)
        • 3.Watcher(订阅者)
        • 4. 优化
          • 4.1 数据代理
            • 4.2 生命周期
              • 4.3 计算属性
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档