目标:用原生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'; } 复制代码
效果:
必备知识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方法里还能够得到任何你需要的参数。:
完整代码奉上,注意代码最后的小细节哦~
<!DOCTYPE html> <html> <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>
作者:Mr_无忧