用原生实现Vue3,真香~

12,837 阅读6分钟

目标:用原生js实现自定义组件,Vue3双向绑定

学前知识储备:

必备知识1,自定义元素(customElement)

废话不多,先上代码:

//html:
<user-card data-open="true"></user-card>

//javascript:
class Learn extends HTMLElement{
    constructor(props) {
        super(props);
        console.log(this.dataset);
        this.innerHTML = '这是我自定义的元素';
        this.style.border = '1px solid #899';
        this.style.borderRadius = '3px';
        this.style.padding = '4px';
    }
}
window.customElements.define('user-card',Learn);

效果: 在这里插入图片描述 解析:通过window.customElements方法可以创建自定义元素,里面的define方法就是用来指定自定义元素的名称,以及自定义元素对应的类。

这里有一个细节,自定义元素中间一定要用中划线隔开,不然是无效的。

这时候在这个类里面就可以定义元素里的所有内容了,这和Vue里面的组件已经比较类似了,有了这个基础之后我们再往里面去进行拓展就可以实现组件了。

必备知识2,Proxy

这家伙估计大家都知道,Vue3数据响应的核心,Vue2用的是Object.defineProperty; 很强大,很好用,先来个简单的代码:

let obj = {
    a:2938,
    b:'siduhis',
    item:'name'
}

obj = new Proxy(obj,{
    set(target, p, value, receiver) {
        console.log('监听到',p,'被修改了,由原来的:',target[p],'改成了:',value);
    }
});

document.onclick = ()=>{
    obj.item = 'newValue';
}

效果: 在这里插入图片描述 这个如果深入去讲的话有很多可以讲,比如说修改值的时候会触发set方法,读取值的时候会触发get方法等等,具体的大家去看看官网文档会更好。

必备知识3,事件代理

首先,我利用事件代理去处理组件中的事件,主要是写起来方便,拓展也很方便,先来看个最简单版本的事件代理:

//html
<ul class="list">
    <li class="item" data-first="true">这是第一个</li>
    <li class="item">2222</li>
    <li class="item">three</li>
    <li class="item" data-open="true">打开</li>
    <li class="item">这是最后一个</li>
</ul>

//javascript
let list = document.querySelector('.list');

list.onclick = function(ev){
    let target = ev.target;
    console.log('点击了'+target.innerHTML);
}

效果: 在这里插入图片描述 这是最简单版本,在ul身上绑定了点击事件,利用事件冒泡原理,点击任何一个li都会触发其父级ul的点击事件,通过ul的事件也可以反向找到被精确点击的li元素,从而把相应的li的内容打印出来,怎么样,很简单吧~

你可能注意到了上面代码中,有两个li的身上有data自定义属性,这个一会有用

再来看一个升级版本,在这里,可以通多判断li身上不同的属性,从而去执行不同的函数,这样的话就有点语法糖的意思了:

let eventfn = function(ev){
    let target = ev.target;
    let dataset = target.dataset;
    for(b in dataset){
        if(eventfn[b]){
            eventfn[b]({obj:target,parent:this});
        }
    }
}
eventfn.first = function(){
    console.log('点击了第一个,并且传了一些参数', arguments);
}
eventfn.open = function(){
    console.log('点击了打开');
}

list.onclick = eventfn;

在这里,我去获取了被点击元素的data属性,然后看看这个属性有没有对应的事件函数,如果有,则执行,并且传递一些参数进去,这个参数以后可能会用到,这是一个拓展点。到这里,我们事件处理基本就成型了

第一步,创建组件内容

思路分析:

  • 1, 内容最好是直接写在页面上,然后需要填数据的地方用{{}}包起来
  • 2, template标签可以用来包裹模板,并且不会被显示在页面上
  • 3, 在组件里复制template里的内容作为组件的内容,并且解析里面的{{}}
  • 4, 还需要解析里面的各种指令,比如data-open这代表一个open事件

在这里插入图片描述 这是效果图 上代码:

<template id="userCardTemplate">
    <style>
        .image {
            width: 100px;
        }

        .container {
            background: #eee;
            border-radius: 10px;
            width: 500px;
            padding: 20px;
        }
    </style>
    <img src="img/bg_03.png" class="image">
    <div class="container">
        <p class="name" data-open="true">{{name}}</p>
        <p class="email">{{email}}</p>
        <input type="text" v-model="message">
        <span>{{message}}</span>
        <button class="button">Follow</button>
    </div>
</template>

第二步,开始写组件类

通过template的id获取到里面的内容,然后直接丢到组件里面,并且定义好数据:

class UserCard extends HTMLElement {
    constructor() {
        super();
        var templateElem = document.getElementById('userCardTemplate');
        var content = templateElem.content.cloneNode(true);
        this.appendChild(content);
        this._data = {
            name:'用户名',
            email:'yourmail@some-email.com',
            message:'双向'
        }
    }
}
window.customElements.define('user-card',UserCard);

这时候吧user-card这个元素往页面上丢,得到的效果就是这样的了: 在这里插入图片描述

第三步,解析

那么接下来要做的事情就是解析元素里面的子元素,看看里面是不是包含了{{}}这样的符号,并且要把中间的内容拿出来,和data里面的数据进行比对,如果对应上了,那就把数据填充到这个地方就可以了,说起来简单,做起来还是有一定难度的,这里面会用到正则匹配,于是我在class里写了这个么个方法:

compileNode(el){
    let child = el.childNodes;//获取到所有的子元素
    [...child].forEach((node)=>{//利用展开运算符直接转换成数组然后forEach
        if(node.nodeType === 3){//判断是文本节点,于是直接正则伺候
            let text = node.textContent;
            let reg = /\{\{\s*([^\s\{\}]+)\s*\}\}/g;
            //大概的意思就是匹配前面有两个{{,后面也有两个}}的这么一串文本
            if(reg.test(text)){//如果能找到这样的字符串
                let $1 = RegExp.$1;//那就把里面的内容拿出来,比如‘name’
                this._data[$1] && (node.textContent = text.replace(reg,this._data[$1]));//看看数据里面有没有name这么个东西,如果有,那就把数据里面name对应的值填到当前这个位置。
            };
        }
    })
}

把这个方法丢到constructor里面运行一下就可以了,得到效果: 在这里插入图片描述

第四步,实现数据视图绑定

到这里,还是只简单的把数据渲染到了页面上,如果数据再次发生变化,我们还没有找到通知机制让视图发生改变,怎么办呢? 这时候就需要用到Proxy了。这里还需要配合自定义事件,先来看Proxy部分,这里其实很简单,增加一个方法就可以了:

observe(){
    let _this = this;
    this._data = new Proxy(this._data,{//监听数据
        set(obj, prop, value){//数据改变的时候会触发set方法
            //事件通知机制,发生改变的时候,通过自定义事件通知视图发生改变
            let event = new CustomEvent(prop,{
                detail: value//注意这里我传了个detail过去,这样的话更新视图的时候就可以直接拿到新的数据
            });
            _this.dispatchEvent(event);
            return Reflect.set(...arguments);//这里是为了确保修改成功,不写其实也没关系
        }
    });
}

事件通知有了,但是需要在解析函数里面监听一下事件,以便视图及时作出改变:

compileNode(el){
    let child = el.childNodes;//获取到所有的子元素
    [...child].forEach((node)=>{//利用展开运算符直接转换成数组然后forEach
        if(node.nodeType === 3){//判断是文本节点,于是直接正则伺候
            let text = node.textContent;
            let reg = /\{\{\s*([^\s\{\}]+)\s*\}\}/g;
            //大概的意思就是匹配前面有两个{{,后面也有两个}}的这么一串文本
            if(reg.test(text)){//如果能找到这样的字符串
                let $1 = RegExp.$1;//那就把里面的内容拿出来,比如‘name’
                this._data[$1] && (node.textContent = text.replace(reg,this._data[$1]));//看看数据里面有没有name这么个东西,如果有,那就把数据里面name对应的值填到当前这个位置。

                //增加了事件监听,监听每一个匹配到的数据,并且再一次更新视图
                //注意这里的e.detail是上面observe里面的自定义事件传过来的
                this.addEventListener($1,(e)=>{
                    node.textContent = text.replace(reg,e.detail)
                })
            };
        }
    })
}

到这一步,我们就可以实现修改数据的时候,视图也发生改变了:

let card = document.querySelector('user-card');
document.onclick = function(){
    console.log('点击了');
    card._data.name = '新的用户名';
}

在这里插入图片描述

第五步,实现双向绑定

估计你也看到了,我在template里面写了一个输入框,并且输入框上面还带了一个属性:v-model="message" 所以估计你也猜到我要做什么了,怎么做呢? 其实很简单: 在解析内容的时候,判断一下input元素,并且看看它身上是不是有v-model属性,如果有,监听它的input事件,并且修改数据。

再次修改解析函数:

compileNode(el){
    let child = el.childNodes;
    [...child].forEach((node)=>{
        if(node.nodeType === 3){
            let text = node.textContent;
            let reg = /\{\{\s*([^\s\{\}]+)\s*\}\}/g;
            if(reg.test(text)){
                let $1 = RegExp.$1;
                this._data[$1] && (node.textContent = text.replace(reg,this._data[$1]));

                this.addEventListener($1,(e)=>{
                    node.textContent = text.replace(reg,e.detail)
                })
            };
        }else if(node.nodeType === 1){
            let attrs = node.attributes;
            if(attrs.hasOwnProperty('v-model')){//判断是不是有这个属性
                let keyname = attrs['v-model'].nodeValue;
                node.value = this._data[keyname];
                node.addEventListener('input',e=>{//如果有,监听事件,修改数据
                    this._data[keyname] = node.value;//修改数据
                });
            }

            if(node.childNodes.length > 0){
                this.compileNode(node);//递归实现深度解析
            }
        }
    })
}

第六步,处理事件

先来看看完整的组件代码:

class UserCard extends HTMLElement {
    constructor() {
        super();
        var templateElem = document.getElementById('userCardTemplate');
        var content = templateElem.content.cloneNode(true);
        this.appendChild(content);
        this._data = {//定义数据
            name:'用户名',
            email:'yourmail@some-email.com',
            message:'双向'
        }
        this.compileNode(this);//解析元素
        this.observe();//监听数据
        this.bindEvent();//处理事件
    }
    bindEvent(){
        this.event = new popEvent({
            obj:this,
            popup:true
        });
    }
    observe(){
        let _this = this;
        this._data = new Proxy(this._data,{
            set(obj, prop, value){
                let event = new CustomEvent(prop,{
                    detail: value
                });
                _this.dispatchEvent(event);
                return Reflect.set(...arguments);
            }
        });
    }
    compileNode(el){
        let child = el.childNodes;
        [...child].forEach((node)=>{
            if(node.nodeType === 3){
                let text = node.textContent;
                let reg = /\{\{\s*([^\s\{\}]+)\s*\}\}/g;
                if(reg.test(text)){
                    let $1 = RegExp.$1;
                    this._data[$1] && (node.textContent = text.replace(reg,this._data[$1]));

                    this.addEventListener($1,(e)=>{
                        node.textContent = text.replace(reg,e.detail)
                    })
                };
            }else if(node.nodeType === 1){
                let attrs = node.attributes;
                if(attrs.hasOwnProperty('v-model')){
                    let keyname = attrs['v-model'].nodeValue;
                    node.value = this._data[keyname];
                    node.addEventListener('input',e=>{
                        this._data[keyname] = node.value;
                    });
                }

                if(node.childNodes.length > 0){
                    this.compileNode(node);
                }
            }
        })
    }
    open(){
        console.log('触发了open方法');
    }
}

可以发现在这里面多了两个方法,一个是bindEvent,没错,这个就是用来处理事件的了,方法的代码在下面,结合着第三个必备知识点去看就能看懂了。

class popEvent{
    constructor(option){
        /*
        * 接收四个参数:
        * 1,对象的this
        * 2,要监听的元素
        * 3,要监听的事件,默认监听点击事件
        * 4,是否冒泡
        * */
        this.eventObj = option.obj;
        this.target = option.target || this.eventObj;
        this.eventType = option.eventType || 'click';
        this.popup = option.popup || false;
        this.bindEvent();
    }
    bindEvent(){
        let _this = this;
        _this.target.addEventListener(_this.eventType,function(ev){
            let target = ev.target;
            let dataset,parent,num,b;
            popup(target);
            function popup(obj){
                if(obj === document){ return false;}
                dataset = obj.dataset;
                num = Object.keys(dataset).length;
                parent = obj.parentNode;
                if(num<1){
                    _this.popup && popup(parent);
                    num = 0;
                }else{
                    for(b in dataset){
                        if(_this.eventObj.__proto__[b]){
                            _this.eventObj.__proto__[b].call(_this.eventObj,{obj:obj,ev:ev,target:dataset[b],data:_this.eventObj});
                        }
                    }
                    _this.popup && popup(parent);
                }
            }
        })
    }
}

另外一个就是open方法,这个方法是干嘛用的呢?再回过头去看看template里面的代码:<p class="name" data-open="true">{{name}}</p>这一串是不是很熟悉,猜到我想做什么了么?

没错,实现事件指令

当点击含有自定义属性:data-open的元素的时候,就可以触发组件里的open方法,并且在open方法里还能够得到任何你需要的参数。: 在这里插入图片描述 点击用户名的时候,触发了open方法。

完整代码奉上,注意代码最后的小细节哦~

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>

    </style>
</head>


<body>

    <template id="userCardTemplate">
        <style>
            .image {
                width: 100px;
            }

            .container {
                background: #eee;
                border-radius: 10px;
                width: 500px;
                padding: 20px;
            }
        </style>
        <img src="img/bg_03.png" class="image">
        <div class="container">
            <p class="name" data-open="true">{{name}}</p>
            <p class="email">{{email}}</p>
            <input type="text" v-model="message">
            <span>{{message}}</span>
            <button class="button">Follow</button>
        </div>
    </template>
    <user-card data-click="123"></user-card>

    <script type="module">
        class popEvent{
        constructor(option){
            /*
            * 接收四个参数:
            * 1,对象的this
            * 2,要监听的元素
            * 3,要监听的事件,默认监听点击事件
            * 4,是否冒泡
            * */


            this.eventObj = option.obj;
            this.target = option.target || this.eventObj;
            this.eventType = option.eventType || 'click';
            this.popup = option.popup || false;
            this.bindEvent();
        }
        bindEvent(){
            let _this = this;
            _this.target.addEventListener(_this.eventType,function(ev){
                let target = ev.target;
                let dataset,parent,num,b;
                popup(target);
                function popup(obj){
                    if(obj === document){ return false;}
                    dataset = obj.dataset;
                    num = Object.keys(dataset).length;
                    parent = obj.parentNode;
                    if(num<1){
                        _this.popup && popup(parent);
                        num = 0;
                    }else{
                        for(b in dataset){
                            if(_this.eventObj.__proto__[b]){
                                _this.eventObj.__proto__[b].call(_this.eventObj,{obj:obj,ev:ev,target:dataset[b],data:_this.eventObj});
                            }
                        }
                        _this.popup && popup(parent);
                    }
                }
            })
        }
    }
    
    class UserCard extends HTMLElement {
        constructor() {
            super();
            var templateElem = document.getElementById('userCardTemplate');
            var content = templateElem.content.cloneNode(true);
            this.appendChild(content);
            this._data = {
                name:'用户名',
                email:'yourmail@some-email.com',
                message:'双向'
            }
            this.compileNode(this);
            this.observe(this._data);
            this.bindEvent();
            this.addevent = this.__proto__;
        }
        bindEvent(){
            this.event = new popEvent({
                obj:this,
                popup:true
            });
        }
        observe(){
            let _this = this;
            this._data = new Proxy(this._data,{
                set(obj, prop, value){
                    let event = new CustomEvent(prop,{
                        detail: value
                    });
                    _this.dispatchEvent(event);
                    return Reflect.set(...arguments);
                }
            });
        }
        compileNode(el){
            let child = el.childNodes;
            [...child].forEach((node)=>{
                if(node.nodeType === 3){
                    let text = node.textContent;
                    let reg = /\{\{\s*([^\s\{\}]+)\s*\}\}/g;
                    if(reg.test(text)){
                        let $1 = RegExp.$1;
                        this._data[$1] && (node.textContent = text.replace(reg,this._data[$1]));

                        this.addEventListener($1,(e)=>{
                            node.textContent = text.replace(reg,e.detail)
                        })
                    };
                }else if(node.nodeType === 1){
                    let attrs = node.attributes;
                    if(attrs.hasOwnProperty('v-model')){
                        let keyname = attrs['v-model'].nodeValue;
                        node.value = this._data[keyname];
                        node.addEventListener('input',e=>{
                            this._data[keyname] = node.value;
                        });
                    }

                    if(node.childNodes.length > 0){
                        this.compileNode(node);
                    }
                }
            })
        }
        open(){
            console.log('触发了open方法');
        }
    }

    window.customElements.define('user-card',UserCard);
    let card = document.querySelector('user-card');
    card.addevent['click'] = function(){
        console.log('触发了点击事件!');
    }
</script>
</body>
</html>

最后

放松一下咯~