目标:用原生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>
最后
放松一下咯~