关于javascript:2021年让我们手写一个mini版本的vue2x和vue3x框架

5次阅读

共计 11198 个字符,预计需要花费 28 分钟才能阅读完成。

mini 版本的 vue.js2.X 版本框架

模板代码

首先咱们看一下咱们要实现的模板代码:

<div id="app">
    <h3>{{msg}}</h3>
    <p>{{count}}</p>
    <h1>v-text</h1>
    <p v-text="msg"></p>
    <input type="text" v-model="count">
    <button type="button" v-on:click="increase">add+</button>
    <button type="button" v-on:click="changeMessage">change message!</button>
    <button type="button" v-on:click="recoverMessage">recoverMessage!</button>
</div>

逻辑代码

而后就是咱们要编写的 javascript 代码。

const app = new miniVue({
    el:"#app",
    data:{
        msg:"hello,mini vue.js",
        count:666
    },
    methods:{increase(){this.count++;},
        changeMessage(){this.msg = "hello,eveningwater!";},
        recoverMessage(){console.log(this)
            this.msg = "hello,mini vue.js";
        }
    }
});

运行成果

咱们来看一下理论运行成果如下所示:

思考一下,咱们要实现如上的性能应该怎么做呢? 你也能够独自关上以上示例:

点击此处。

源码实现 -2.x

miniVue 类

首先,不管三七二十一,既然是实例化一个mini-vue,那么咱们先定义一个类, 并且它的参数肯定是一个属性配置对象。如下:

 class miniVue {constructor(options = {}){// 后续要做的事件}
 }

当初,让咱们先初始化一些属性,比方 data,methods,options 等等。

// 在 miniVue 构造函数的外部
// 保留根元素, 能简便就尽量简便,不思考数组状况
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
this.$methods = options.methods;
this.$data = options.data;
this.$options = options;

初始化完了之后,咱们再来思考一个问题,咱们是不是能够通过在 vue 外部应用 this 拜访到 vue 定义的数据对象呢?那么咱们应该如何实现这一个性能呢?这个性能有一个业余的名词,叫做 代理(proxy)

代理数据

因而咱们来实现一下这个性能,很显著在这个 miniVue 类的外部定义一个 proxy 办法。如下:

//this.$data.xxx -> this.xxx;
//proxy 代理实例上的 data 对象
proxy(data){// 后续代码}

接下来,咱们须要晓得一个 api, 即Object.defineProperty,通过这个办法来实现这个代理办法。如下:

//proxy 办法外部
// 因为咱们是代理每一个属性,所以咱们须要将所有属性拿到
Object.keys(data).forEach(key => {
    Object.defineProperty(this,key,{
        enumerable:true,
        configurable:true,
        get:() => {return data[key];
        },
        set:(newValue){
            // 这里咱们须要判断一下如果值没有做扭转就不必赋值,须要排除 NaN 的状况
            if(newValue === data[key] || _isNaN(newValue,data[key]))return;
            data[key] = newValue;
        }
    })
})

接下来,咱们来看一下这个 _isNaN 工具办法的实现,如下:

function _isNaN(a,b){return Number.isNaN(a) && Number.isNaN(b);
}

定义好了之后,咱们只须要在 miniVue 类的构造函数中调用一次即可。如下:

// 构造函数外部
this.proxy(this.$data);

代理就这样实现了,让咱们持续下一步。

数据响应式观察者 observer 类

咱们须要对数据的每一个属性都定义一个响应式对象,用来监听数据的扭转,所以咱们须要一个类来治理它,咱们就给它取个名字叫Observer。如下:

class Observer {constructor(data){// 后续实现}
}

咱们须要给每一个数据都增加响应式对象,并且转换成 getter 和 setter 函数,这里咱们又用到了 Object.defineProperty 办法,咱们须要在 getter 函数中收集依赖,在 setter 函数中发送告诉,用来告诉依赖进行更新。咱们用一个办法来专门去执行定义响应式对象的办法,叫 walk,如下:

// 再次申明,不思考数组, 只思考对象
walk(data){if(typeof data !== 'object' || !data)return;
    // 数据的每一个属性都调用定义响应式对象的办法
    Object.keys(data).forEach(key => this.defineReactive(data,key,data[key]));
}

接下来咱们来看 defineReactive 办法的实现,同样也是应用 Object.defineProperty 办法来定义响应式对象,如下所示:

defineReactive(data,key,value){
    // 获取以后 this,以防止后续用 vm 的时候,this 指向不对
    const vm = this;
    // 递归调用 walk 办法,因为对象外面还有可能是对象
    this.walk(value);
    // 实例化收集依赖的类
    let dep = new Dep();
    Object.defineProperty(data,key,{
        enumerable:true,
        configurable:true,
        get(){
            // 收集依赖, 依赖存在 Dep 类上
            Dep.target && Dep.add(Dep.target);
            return value;
        },
        set(newValue){
            // 这里也判断一下
            if(newValue === value || __isNaN(value,newValue))return;
            // 否则扭转值
            value = newValue;
            // newValue 也有可能是对象,所以递归
            vm.walk(newValue);
            // 告诉 Dep 类
            dep.notify();}
    })
}

Observer类实现了之后,咱们须要在 miniVue 类的构造函数中实例化一下它, 如下:

// 在 miniVue 构造函数外部
new Observer(this.$data);

好的,让咱们持续下一步。

依赖类

defineReactive办法外部用到了 Dep 类,接下来,咱们来定义这个类。如下:

class Dep {constructor(){// 后续代码}
}

接下来,咱们来思考一下,依赖类外面,咱们须要做什么,首先依据 defineReactive 中,咱们很显著就晓得会有 add 办法和 notify 办法,并且咱们须要一种数据结构来存储依赖,vue 源码用的是队列,而在这里为了简单化,咱们应用 ES6 的 set 数据结构。如下:

// 构造函数外部
this.deps = new Set();

接下来,就须要实现 add 办法和 notify 办法, 事实上这里还会有删除依赖的办法,然而这里为了最简便,咱们只须要一个 addnotify办法即可。如下:

add(dep){
    // 判断 dep 是否存在并且是否存在 update 办法, 而后增加到存储的依赖数据结构中
    if(dep && dep.update)this.deps.add(dep);
}
notify(){
    // 公布告诉无非是遍历一道 dep,而后调用每一个 dep 的 update 办法,使得每一个依赖都会进行更新
    this.deps.forEach(dep => dep.update())
}

Dep 类算是完了,接下来咱们就须要另一个类。

Watcher 类

那就是为了治理每一个组件实例的类,确保每个组件实例能够由这个类来发送视图更新以及状态流转的操作。这个类,咱们把它叫做Watcher

class Watcher {
    // 3 个参数,以后组件实例 vm,state 也就是数据以及一个回调函数,或者叫处理器
    constructor(vm,key,cb){// 后续代码}
}

再次思考一下,咱们的 Watcher 类须要做哪些事件呢? 咱们先来思考一下 Watcher 的用法,咱们是不是会像如下这样来写:

// 3 个参数,以后组件实例 vm,state 也就是数据以及一个回调函数,或者叫处理器
new Watcher(vm,key,cb);

ok, 晓得了应用形式之后,咱们就能够在构造函数外部初始化一些货色了。如下:

// 构造函数外部
this.vm = vm;
this.key = key;
this.cb = cb;
// 依赖类
Dep.target = this;
// 咱们用一个变量来存储旧值,也就是未变更之前的值
this.__old = vm[key];
Dep.target = null;

而后 Watcher 类就多了一个 update 办法,接下来让咱们来看一下这个办法的实现吧。如下:

update(){
    // 获取新的值
    let newValue = this.vm[this.key];
    // 与旧值做比拟,如果没有扭转就无需执行下一步
    if(newValue === this.__old || __isNaN(newValue,this.__old))return;
    // 把新的值回调进来
    this.cb(newValue);
    // 执行完之后,须要更新一下旧值的存储
    this.__old = newValue;
}

编译类 compiler 类

初始化

到了这一步,咱们就算是齐全脱离 vue 源码了,因为 vue 源码的编译十分复杂,波及到 diff 算法以及虚构节点 vNode,而咱们这里致力于将其最简化,所以独自写一个 Compiler 类来编译。如下:

class Compiler {constructor(vm){// 后续代码}
}

留神: 这里的编译是咱们本人依据流程来实现的,与 vue 源码并没有任何关联,vue 也有 compiler,然而与咱们实现的齐全不同。

定义好了之后,咱们在 miniVue 类的构造函数中实例化一下这个编译类即可。如下:

// 在 miniVue 构造函数外部
new Compiler(this);

好的,咱们也看到了应用形式,所以接下来咱们来欠缺这个编译类的构造函数外部的一些初始化操作。如下:

// 编译类构造函数外部
// 根元素
this.el = vm.$el;
// 事件办法
this.methods = vm.$methods;
// 以后组件实例
this.vm = vm;
// 调用编译函数开始编译
this.compile(vm.$el);

compile 办法

初始化操作算是实现了,接下来咱们来看 compile 办法的外部。思考一下,在这个办法的外部,咱们是不是须要拿到所有的节点,而后比照是文本还是元素节点去别离进行编译呢?如下:

compile(el){
    // 拿到所有子节点(蕴含文本节点)let childNodes = el.childNodes;
    // 转成数组
    Array.from(childNodes).forEach(node => {
        // 判断是文本节点还是元素节点别离执行不同的编译办法
        if(this.isTextNode(node)){this.compileText(node);
        }else if(this.isElementNode(node)){this.compileElement(node);
        }
        // 递归判断 node 下是否还含有子节点,如果有的话持续编译
        if(node.childNodes && node.childNodes.length)this.compile(node);
    })
}

这里,咱们须要 2 个辅助办法,判断是文本节点还是元素节点,其实咱们能够应用节点的 nodeType 属性来进行判断, 因为文本节点的 nodeType 值为 3,而元素节点的 nodeType 值为 1。所以这 2 个辅助办法咱们就能够实现如下:

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

编译文本节点

接下来,咱们下来看 compileText 编译文本节点的办法。如下:

//{{count}}数据结构是相似如此的
compileText(node){// 后续代码}

接下来,让咱们思考一下,咱们编译文本节点,无非就是把文本节点中的 {{count}} 映射成为 0, 而文本节点不就是 node.textContent 属性吗?所以此时咱们能够想到依据正则来匹配 {{}} 中的 count 值,而后对应替换成数据中的 count 值,而后咱们再调用一次 Watcher 类,如果更新了,就再次更改这个 node.textContent 的值。如下:

compileText(node){// 定义正则,匹配 {{}} 中的 count
    let reg = /\{\{(.+?)\}\}/g;
    let value = node.textContent;
    // 判断是否含有{{}}
    if(reg.test(value)){// 拿到 {{}} 中的 count, 因为咱们是匹配一个捕捉组,所以咱们能够依据 RegExp 类的 $1 属性来获取这个 count
        let key = RegExp.$1.trim();
        node.textContent = value.replace(reg,this.vm[key]);
        // 如果更新了值,还要做更改
        new Watcher(this.vm,key,newValue => {node.textContent = newValue;})
    }
}

编译文本节点到此为止了,接下来咱们来看编译元素节点的办法。

编译元素节点

指令

首先,让咱们想一下,咱们编译元素节点无非是想要依据元素节点上的指令来别离执行不同的操作,所以咱们编译元素节点就只须要判断是否含有相干指令即可,这里咱们只思考了 v-text,v-model,v-on:click 这三个指令。让咱们来看看 compileElement 办法吧。

compileElement(node){
    // 指令不就是一堆属性吗,所以咱们只须要获取属性即可
    const attrs = node.attributes;
    if(attrs.length){Array.from(attrs).forEach(attr => {
            // 这里因为咱们拿到的 attributes 可能蕴含不是指令的属性,所以咱们须要先做一次判断
            if(this.isDirective(attr)){// 依据 v - 来截取一下后缀属性名, 例如 v -on:click,subStr(5)即可截取到 click,v-text 与 v -model 则 subStr(2)截取到 text 和 model 即可
                let attrName = attr.indexOf(':') > -1 ? attr.subStr(5) : attr.subStr(2);
                let key = attr.value;
                // 独自定义一个 update 办法来辨别这些
                this.update(node,attrName,key,this.vm[key]);
            }
        })
    }
}

这里又波及到了一个 isDirective 辅助办法,咱们能够应用 startsWith 办法,判断是否含有 v- 值即可认定这个属性就是一个指令。如下:

isDirective(dir){return dir.startsWith('v-');
}

接下来,咱们来看最初的 update 办法。如下:

update(node,attrName,key,value){// 后续代码}

最初,让咱们来思考一下,咱们 update 外面须要做什么。很显然,咱们是不是须要判断是哪种指令来执行不同的操作?如下:

//update 函数外部
if(attrName === 'text'){// 执行 v -text 的操作}else if(attrName === 'model'){// 执行 v -model 的操作}else if(attrName === 'click'){// 执行 v -on:click 的操作}

v-text 指令

好的,咱们晓得,依据后面的编译文本元素节点的办法,咱们就晓得这个指令的用法同后面编译文本元素节点。所以这个判断外面就好写了,如下:

//attrName === 'text' 外部
node.textContent = value;
new Watcher(this.vm,key,newValue => {node.textContent = newValue;})

v-model 指令

v-model 指令实现的是双向绑定,咱们都晓得双向绑定是更改输入框的 value 值,并且通过监听 input 事件来实现。所以这个判断,咱们也很好写了,如下:

//attrName === 'model' 外部
node.value = value;
new Watcher(this.vm,key,newValue => {node.value = newValue;});
node.addEventListener('input',(e) => {this.vm[key] = node.value;
})

v-on:click 指令

v-on:click 指令就是将事件绑定到 methods 内定义的函数,为了确保 this 指向以后组件实例,咱们须要通过 bind 办法扭转一下 this 指向。如下:

//attrName === 'click' 外部
node.addEventListener(attrName,this.methods[key].bind(this.vm));

到此为止,咱们一个 mini 版本的 vue2.x 就算是实现了。持续下一节,学习 vue3.x 版本的 mini 实现吧。

mini 版本的 vue.js3.x 框架

模板代码

首先咱们看一下咱们要实现的模板代码:

<div id="app"></div>

逻辑代码

而后就是咱们要编写的 javascript 代码。

const App = {
    $data:null,
    setup(){let count = ref(0);
        let time = reactive({second:0});
        let com = computed(() => `${ count.value + time.second}`);
        setInterval(() => {time.second++;},1000);
        setInterval(() => {count.value++;},2000);
        return {
            count,
            time,
            com
        }
    },
    render(){
        return `
            <h1>How reactive?</h1>
            <p>this is reactive work:${this.$data.time.second}</p>
            <p>this is ref work:${this.$data.count.value}</p>
            <p>this is computed work:${this.$data.com.value}</p>
        `
    }
}
mount(App,document.querySelector("#app"));

运行成果

咱们来看一下理论运行成果如下所示:

思考一下,咱们要实现如上的性能应该怎么做呢? 你也能够独自关上以上示例:

点击此处。

源码实现 -3.x

与 vue2.x 做比拟

事实上,vue3.x 的实现思维与 vue2.x 差不多,只不过 vue3.x 的实现形式有些不同,在 vue3.x,把收集依赖的办法称作是副作用effect。vue3.x 更像是函数式编程了,每一个性能都是一个函数,比方定义响应式对象,那就是 reactive 办法,再比方 computed,同样的也是 computed 办法 … 废话不多说,让咱们来看一下吧!

reactive 办法

首先,咱们来看一下 vue3.x 的响应式办法,在这里,咱们依然只思考解决对象。如下:

function reactive(data){if(!isObject(data))return;
    // 后续代码
}

接下来咱们须要应用到 es6 的proxyAPI,咱们须要相熟这个 API 的用法,如果不相熟,请点击此处查看。

咱们还是在 getter 中收集依赖,setter 中触发依赖,收集依赖与触发依赖,咱们都别离定义为 2 个办法,即 track 和 trigger 办法。如下:

function reactive(data){if(!isObject(data))return;
    return new Proxy(data,{get(target,key,receiver){
            // 反射 api
            const ret = Reflect.get(target,key,receiver);
            // 收集依赖
            track(target,key);
            return isObject(ret) ? reactive(ret) : ret;
        },
        set(target,key,val,receiver){Reflect.set(target,key,val,receiver);
            // 触发依赖办法
            trigger(target,key);
            return true;
        },
        deleteProperty(target,key,receiver){const ret = Reflect.deleteProperty(target,key,receiver);
            trigger(target,key);
            return ret;
        }
    })
}

track 办法

track 办法就是用来收集依赖的。咱们用 es6 的 weakMap 数据结构来存储依赖,而后为了简便化用一个全局变量来示意依赖。如下:

// 全局变量示意依赖
let activeEffect;
// 存储依赖的数据结构
let targetMap = new WeakMap();
// 每一个依赖又是一个 map 构造,每一个 map 存储一个副作用函数即 effect 函数
function track(target,key){
    // 拿到依赖
    let depsMap = targetMap.get(target);
    // 如果依赖不存在则初始化
    if(!depsMap)targetMap.set(target,(depsMap = new Map()));
    // 拿到具体的依赖,是一个 set 构造
    let dep = depsMap.get(key);
    if(!dep)depsMap.set(key,(dep = new Set()));
    // 如果没有依赖,则存储再 set 数据结构中
    if(!dep.has(activeEffect))dep.add(activeEffect)
}

收集依赖就这么简略,须要留神的是,这里波及到了 es6 的三种数据结构即 WeakMap,Map,Set。下一步咱们就来看如何触发依赖。

trigger 办法

trigger 办法很显著就是拿出所有依赖,每一个依赖就是一个副作用函数,所以间接调用即可。

function trigger(target,key){const depsMap = targetMap.get(target);
    // 存储依赖的数据结构都拿不到,则代表没有依赖,间接返回
    if(!depsMap)return;
    depsMap.get(key).forEach(effect => effect && effect());
}

接下来,咱们来实现一下这个副作用函数,也即 effect。

effect 办法

副作用函数的作用也很简略,就是执行每一个回调函数。所以该办法有 2 个参数,第一个是回调函数,第二个则是一个配置对象。如下:

function effect(handler,options = {}){const __effect = function(...args){
        activeEffect = __effect;
        return fn(...args);
    }
    // 配置对象有一个 lazy 属性,用于 computed 计算属性的实现,因为计算属性是懒加载的,也就是提早执行
    // 也就是说如果不是一个计算属性的回调函数,则立刻执行副作用函数
    if(!options.lazy){__effect();
    }
    return __effect;
}

副作用函数就是如此简略的实现了,接下来咱们来看一下 computed 的实现。

computed 的实现

既然谈到了计算属性,所以咱们就定义了一个 computed 函数。咱们来看一下:

function computed(handler){
    // 只思考函数的状况
    // 提早计算 const c = computed(() => `${ count.value}!`)
    let _computed;
    // 能够看到 computed 就是一个增加了 lazy 为 true 的配置对象的副作用函数
    const run = effect(handler,{ lazy:true});
    _computed = {
        //get 拜访器
        get value(){return run();
        }
    }
    return _computed;
}

到此为止,vue3.x 的响应式算是根本实现了,接下来要实现 vue3.x 的 mount 以及 compile。还有一点,咱们以上只是解决了援用类型的响应式,但实际上 vue3.x 还提供了一个 ref 办法用来解决根本类型的响应式。因而,咱们依然能够实现根本类型的响应式。

ref 办法

那么,咱们应该如何来实现根本类型的响应式呢? 试想一下,为什么 vue3.x 中定义根本类型,如果批改值,须要批改 xxx.value 来实现。如下:

const count = ref(0);
// 批改
count.value = 1;

从以上代码,咱们不难得出根本类型的封装原理,实际上就是将根本类型包装成一个对象。因而,咱们很快能够写出如下代码:

function ref(target){
    let value = target;
    const obj = {get value(){
            // 收集依赖
            track(obj,'value');
            return value;
        },
        set value(newValue){if(value === newValue)return;
            value = newValue;
            // 触发依赖
            trigger(obj,'value');
        }
    }
    return obj;
}

这就是根本类型的响应式实现原理,接下来咱们来看一下 mount 办法的实现。

mount 办法

mount 办法实现挂载,而咱们的副作用函数就是在这里执行。它有 2 个参数,第一个参数即一个 vue 组件,第二个参数则是挂载的 DOM 根元素。所以,咱们能够很快写出以下代码:

function mount(instance,el){effect(function(){instance.$data && update(instance,el);
    });
    //setup 返回的数据就是实例上的数据
    instance.$data = instance.setup();
    // 这里的 update 实际上就是编译函数
    update(instance,el);
}

这样就是实现了一个简略的挂载, 接下来咱们来看一下编译函数的实现。

update 编译函数

这里为了简便化,咱们实现的编译函数就比较简单,间接就将定义在组件上的 render 函数给赋值给根元素的innerHTML。如下:

// 这是最简略的编译函数
function update(instance,el){el.innerHTML = instance.render();
}

如此一来,一个简略的 mini-vue3.x 就这样实现了,怎么样,不到 100 行代码就搞定了,还是比较简单的。

具体文档收录网站。

正文完
 0