带你揭开 Vue 响应式面纱,其实狠简单

# 前言

大家都知道Vue的设计思想就是视图View的状态和行为抽象化,让我们将视图UI和业务逻辑分开,即M-V-VM

MVVM的三大要素:

  1. 数据响应式:监听数据变化并在视图中更i性能
  2. 模板引擎:提供描述视图的模板语法
  3. 渲染:将模板转换成Html

今天目标:可创建自己的MyVue实例、数据响应式、双花括号及模板引擎的模拟实现。

# 实现 defineReactive

这里运用的是闭包的思想

// 数据响应式
function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        get() { // 取值时通过 get 函数 return 出去,则形成闭包
            return val;
        },
        set(newVal) {
            if (newVal !== val)
                val = newVal;
        }
    })
}

小试牛刀

简单的实现一个试图更新:时钟
这里需要再增加一个update函数,在set函数中设置新值时去更新Dom

// 数据响应式
function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        get() { // 取值时通过 get 函数 return 出去,则形成闭包
            return val;
        },
        set(newVal) {
            if (newVal !== val){
                val = newVal;
                update();
            }
        }
    })
}
// 更新视图
function update(){
    app.innerHtml = obj.foo;
}
const obj = {};
defineReactive(obj, 'foo', '')
obj.foo = new Date().toLocaleTimeString();
setInterval(() => {
    obj.foo = new Date().toLocaleTimeString();
}, 1000)

如上:小小时钟就实现了,这就是简单是数据驱动更新试图

# 实现 observe

在上面的例子中有个问题:就是每次只能实现一个属性的响应式,即

defineReactive(obj, 'foo', '')

如果有很多属性怎么办???

所以这时需要一个函数来遍历对象的所有属性,即observe函数

// 遍历属性
function observe(obj ) {
    if (typeof obj !== 'object' || obj === null) return

    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key])
    })
}

小试牛刀

const obj = { foo: '' };
observe(obj)
obj.foo = new Date().toLocaleTimeString();

setInterval(() => {
    obj.foo = new Date().toLocaleTimeString();
}, 1000)

如上:小时钟依然可以正常运行

写到这里大家可能发现一个问题:如果obj的值还是对象,则不能形成数据响应式,该怎么办呢???

其实很简单,就是利用递归去遍历,那在哪加上递归呢???

一般会选择在defineReactive中,如下:

// 数据响应式
function defineReactive(obj, key, val) {
    // 递归
    observe(val);
    // 响应式处理
    Object.defineProperty(obj, key, {
        get() {
            return val;
        },
        set(newVal) {
            if (newVal !== val) {
                  // 保证如果newVal是对象,再次做响应式处理
                  observe(val);
                  val = newVal;
            }
        }
    })
}

# 实现 set

之所以会有set函数是因在初始化遍历的时候,会遍历所有属性并做响应式处理,但在后来动态新增的属性是没有相应式的。

// 动态添加属性
function set(obj, key, val) {
    defineReactive(obj, key, val)
}

# 实现 new MyVue

准备工作到此咱们就准备结束了,今天咱们的主要任务就是实现一个自己的Vue实例,即如下:

{{count}}
const app = new MyVue({ el: '#appMyVue', data: { count: 0 } }) setInterval(() => { app.count++ })

我们首先分析下原理:

  1. new Vue()首先执行初始化,对data执行响应化处理,这个过程发生在Observe
  2. 同时对模板执行编译,找到其中动态绑定的数据,从data中获取并初始化试图,这个过程发生在Compile
  3. 由于data的某个key在一个视图中可能出现多次,多以每个key都需要一个Dep来管理多个Watcher
  4. 将来data中数据一旦发生变化,会首先找到对应的Dep,通知所有Watcher执行更新函数

# 实现 MyVue 类

class MyVue {
    constructor(options) {
        this.$options = options;
        this.$data = options.data;

        // data 响应式处理
        observe(this.data);
    }
}

为了更贴近源码,我们更改下observe方法,如下

function observe(obj ) {
    if (typeof obj !== 'object' || obj === null) return;

    new Observer(obj )
}

创建Observer观测类

// 根据传入的value的类型做形影的响应式处理
class Observer {
    constructor(value) {
        this.value = value;

        if (Array.isArray(value)) {
            // to do
        } else {
            this.walk(this.value)
        }
    };
    // 对象的响应式处理
    walk(obj) {
        // 遍历obj的key,做响应式处理
        Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]))
    }
}

为了方便观察,我们输出两个命令,如下:

// 数据响应式
function defineReactive(obj, key, val) {
    // 递归
    observe(val);
    // 响应式处理
    Object.defineProperty(obj, key, {
        get() {
            console.log('get', key, val);
            return val;
        },
        set(newVal) {
            if (newVal !== val) {
                console.log('set', key, val);
                // 保证如果newVal是对象,再次做响应式处理
                observe(val);
                val = newVal;
            }
        }
    })
}

运行我们的代码,如下:

const app = new MyVue({
    el: '#appMyVue',
    data: {
        count: 0
    }
});

setInterval(() => {
    app.count++
}, 1000)

问题:可以发现并没有如期去输出我们打印的那两条,即没有走到defineReactive方法里的Object.defineProperty中的getset方法,这是为什么呢?

原因就是:我们把数据放在了MyVue类中的$data上了,并没有放在实例上,换成如下访问方式,再试试看:

const app = new MyVue({
    el: '#appMyVue',
    data: {
        count: 0
    }
});

setInterval(() => {
    app.$data.count++
}, 1000)
1656991212558.png

这种方式虽然能实现,但如何能像Vue一样可直接在实例上访问data呢,即app.count的形式?

这里就需要通过代理的方式把数据代理到实例上,如下:

class MyVue {
    constructor(options) {
        this.$options = options;
        this.$data = options.data;

        // data 响应式处理
        observe(this.$data);

        // 数据代理
        proxy(this);
    }
}

// 将数据代理到实例上
function proxy(vm) {
    Object.keys(vm.$data).forEach(key => {
        Object.defineProperty(vm, key, {
            get() {
                return vm.$data[key];
            },
            set(val) {
                vm.$data[key] = val
            }
        })
    })
}

运行代码测试下

const app = new MyVue({
    el: '#appMyVue',
    data: {
        count: 0
    }
});

setInterval(() => {
    app.count++
}, 1000)

可以发现,正常输出没有问题 ~~~

# 实现 Comlile

我们已经实现了数据的响应式,接下来就是怎么把数据显示在页面上,即完成编译,这时我们需要实现一个新的类:Complie

// 解析模板
class Compile {
    constructor(el, vm) {
        this.$vm = vm;
        this.$el = document.querySelector(el);

        if (this.$el) {
            this.compile(this.$el)
        }
    }

    compile(el) {
        // 遍历el的所有子节点,判断他们的类型做相应的处理
        const childNodes = el.childNodes;
        const reg = /\{\{(.*)\}\}/;
        childNodes.forEach(node => {
            //  如果子元素是 元素
            if (node.nodeType === 1) {

            }
            // 如果子元素是 文本
            else if (this.isInter(node, reg)) {
                this.compileText(node, reg)
            }
            // 递归遍历
            if (node.childNodes) {
                this.compile(node, reg)
            }
        })
    };
    // 编译模板
    compileText(node, reg) {
        const regMap = reg.exec(node.textContent);
        node.textContent = this.$vm[regMap[1]];
    };
    // 判断是否为插值表达式
    isInter(node, reg) {
        return node.nodeType === 3 && reg.test(node.textContent);
    }
}

测试我们的代码


    

{{count}}

{{count}}

{{count}}

{{count}}

如我们所想,页面已经渲染了初始值,如下:

1657003007975.png

实现 my-text 和 my-html 指令

compile(el) {
    // 遍历el的所有子节点,判断他们的类型做相应的处理
    const childNodes = el.childNodes;
    const reg = /\{\{(.*)\}\}/;
    childNodes.forEach(node => {
        //  如果子元素是 元素
        if (node.nodeType === 1) {
            // 获得元素的所有指令
            const attrs = node.attributes;
            // 遍历所有属性,拿到我们想要的指令
            Array.from(attrs).forEach(attr => {
                // 属性名称
                const attrName = attr.name;
                // 属性值
                const exp = attr.value;
                // 判断得到我们的指令
                if (attrName.startsWith('my-')) {
                    // 得到指令对应的函数
                    const dir = attrName.substring(3);
                    // 如果函数存在,则执行
                    this[dir] && this[dir](node, exp)
                }
            })
        }
        // 如果子元素是 文本
        else if (this.isInter(node, reg)) {
            this.compileText(node, reg)
        }
        // 递归遍历
        if (node.childNodes) {
            this.compile(node, reg)
        }
    })
};
// 指令 my-text 处理函数
text(node, exp) {
    node.textContent = this.$vm[exp];
};
// 指令 myhtml 处理函数
html(node, exp) {
     node.innerHTML = this.$vm[exp];
};

问题是:虽然我们实现了模板编译和部分指令,但在定时器里更新count的时候,页面没有实时更新?

解决这个问题也很简单,就是接下来要实现依赖收集Watcherd

# 实现 Watcher

实现思路:

  1. defineReactive时为每一个key创建一个Dep实例
  2. 初始化视图时读取某个key并创建对应的Watcher
  3. 由于触发某个key对应的getter方法,边将对应的Watcher添加到key对应的Dep
  4. key更新,setter触发时,便可以通过对应的Dep通知管理所有的Watcher进行更新

# 实现 Watcher 与 Dep

这里首先得先改造一下Compile中的指令函数,增加一个update统一的处理函数,如下:

// 解析模板
class Compile {
    constructor(el, vm) {
        this.$vm = vm;
        this.$el = document.querySelector(el);

        if (this.$el) {
            this.compile(this.$el)
        }
    };

    compile(el) {
        // 遍历el的所有子节点,判断他们的类型做相应的处理
        const childNodes = el.childNodes;
        const reg = /\{\{(.*)\}\}/;
        childNodes.forEach(node => {
            //  如果子元素是 元素
            if (node.nodeType === 1) {
                // 获得元素的所有指令
                const attrs = node.attributes;
                // 遍历所有属性,拿到我们想要的指令
                Array.from(attrs).forEach(attr => {
                    // 属性名称
                    const attrName = attr.name;
                    // 属性值
                    const exp = attr.value;
                    // 判断得到我们的指令
                    if (attrName.startsWith('my-')) {
                        // 得到指令对应的函数
                        const dir = attrName.substring(3);
                        // 如果函数存在,则执行
                        this[dir] && this[dir](node, exp)
                    }
                })
            }
            // 如果子元素是 文本
            else if (this.isInter(node, reg)) {
                this.compileText(node, reg)
            }
            // 递归遍历
            if (node.childNodes) {
                this.compile(node, reg)
            }
        })
    };

    // 统一的处理函数
    update(node, exp, dir) {
        // 1. 初始化
        const fn = this[dir + 'Updater'];
        fn && fn(node, this.$vm[exp]);

        // 更新
    }

    // 指令 my-text 处理函数
    text(node, exp) {
        this.update(node, exp, 'text')
    };
    textUpdater(node, value) {
        node.textContent = value;
    };

    // 指令 myhtml 处理函数
    html(node, exp) {
        this.update(node, exp, 'html')
    };
    htmlUpdater(node, value) {
        node.innerHTML = value;
    };

    // 编译模板
    compileText(node, reg) {
        const regMap = reg.exec(node.textContent);
        this.update(node, regMap[1], 'text')
    };

    // 判断是否为插值表达式
    isInter(node, reg) {
        return node.nodeType === 3 && reg.test(node.textContent);
    }
}

测试代码,我们改造完成后页面依然渲染没有问题。

接下来我们要在update函数里,做统一的依赖收集操作,如下:

// 统一的处理函数
update(node, exp, dir) {
    // 1. 初始化
    const fn = this[dir + 'Updater'];
    fn && fn(node, this.$vm[exp]);

    // 更新
    new Watcher(this.$vm, exp, function(val) {
        fn && fn(node, val)
    })
}

创建Watcher

// 监听器:负责依赖更新
class Watcher {
    constructor(vm, key, updateFn) {
        this.vm = vm;
        this.key = key;
        this.updateFn = updateFn;
    };
    // 未来被 Dep 调用
    update() {
        // 执行实际的更新操作
        this.updateFn.call(this.vm, this.vm[this.key])
    }
}

问题:现在还没有依赖做关联 ???

创建依赖关联就需要大管家Dep了,如下

class Dep {
    constructor() {
        this.deps = [];
    }
    addDep(dep) {
        this.deps.push(dep)
    }
    notify() {
        this.deps.forEach(dep => dep.update());
    }
}

想要每个key和对应的Watcher对应起来,可以在defineReactive函数里做处理。这时还要在Watcher里做一次手动触发,以便收集依赖,如下:

// 监听器:负责依赖更新
class Watcher {
    constructor(vm, key, updateFn) {
        this.vm = vm;
        this.key = key;
        this.updateFn = updateFn;
        // 触发依赖收集
        Dep.target = this;
        this.vm[this.key]
        Dep.target = null;
    };
    // 未来被 Dep 调用
    update() {
        // 执行实际的更新操作
        this.updateFn.call(this.vm, this.vm[this.key])
    }
}

defineReactive函数创建依赖收集的关系, 如下:

// 数据响应式
function defineReactive(obj, key, val) {
    // 递归
    observe(val);
    // 创建Dep实例
    const dep = new Dep();
    // 响应式处理
    Object.defineProperty(obj, key, {
        get() {
            console.log('get', key, val);
            // 依赖收集
            Dep.target && dep.addDep(Dep.target);
            return val;
        },
        set(newVal) {
            if (newVal !== val) {
                console.log('set', key, val);
                // 保证如果newVal是对象,再次做响应式处理
                observe(val);
                val = newVal;
                // 更新
                dep.notify()
            }
        }
    })
}

这时再去测试我们的代码,发现已经可以实时更新了,是不是很神奇

今天我们只是实现了简版的Vue的数据响应式、依赖收集及编译模板,希望对大家的理解Vue能有所帮助,欢迎评论留言 ~~~

你可能感兴趣的:(带你揭开 Vue 响应式面纱,其实狠简单)