手写DOM库(封装DOM)

751 阅读12分钟

封装

封装就是把程序的数据、行为、功能结合起来,装成一个整体,然后给你一个接口,你就能直接用。即使你不知道里面到底是怎么构造和设计的,你也能轻松的去使用它。

被封装好的东西,通常要给出一个接口,别人才能去方便的使用它。

封装的目的是简化编程,同时也能增强程序的安全性。

举例

  1. 我们想打开家里的灯,我们不用知道内部电路是怎样设计的,电是怎样工作的,灯泡是如何设计的,怎么装上去的,我们只需要知道:按下开关,灯就亮了。这里电工师傅或装修公司帮我们设计好电路、安装好灯泡就相当于一个封装,而开关就相当于给出的接口。
  2. 笔记本电脑里面有CPU、显卡、主板、内存、硬盘等硬件,我们使用笔记本时,不用知道CPU、显卡是怎么装进去的,主板电路是怎么设计的,我们只需要知道:通过鼠标和键盘就能操作电脑。这里厂商把电脑各个硬件组装在一起,就相当于一个封装,而鼠标键盘通过USB接口就行操作电脑,就类似于接口。

DOM封装

原生的 DOM 操作语法通常都比较长,不容易记清楚,用起来也不方便,所以我们可以对 DOM 操作进行封装。

一些名词

  1. 库:我们编程时可以把一些好用的函数推荐给别人使用,当这种可以提供给别人使用的函数多起来了以后,就可以形成一个工具仓库。常见的JS库有:jQuery、Underscore等;
  2. API:Application Programming Interface,翻译为应用程序编程接口,简单的说就是接口。API往往是预先定义好的函数,比如很多库里就会暴露出的函数或者属性,即使你不知道源码、不理解内部工作机制的细节,也能直接调用;
  3. 框架:当库发展的越来越庞大,功能越来越丰富,并且很多方法需要去学习后才知道如何使用时,那么此时的库就可以叫做框架,比如 Vue 和 React;

注意:对于以上的概念,未必就一定要用固定的术语去一一对应,可能不同人有不同的叫法,所以理解意思即可,不用纠结叫法是怎样的。

用对象风格来封装DOM

对象风格,也叫命名空间风格。

为什么要对象风格呢?因为这里的封装始终围绕着我们提供的全局对象:window.dom;

使用的时候,也要通过dom加“点”把方法点出来;

所以在封装自己的DOM库之前,先把dom声明为全局对象;

声明全局对象

// 怎么样把一个对象声明为全局对象呢?
// 把对象挂载到window上

dom = window.dom

书写方式

// 1.
window.dom.create = function(){...}
window.dom.remove = function(){...}

// 2.
dom.create = function(){...}
dom.remove = function(){...}

// 3.
window.dom = {
	create:function(){...},
    remove:function(){...}
}

// 4.
window.dom = {
	create(){...},
    remove(){...}
}

以上几种写法都能达到效果,每个人也都有自己的习惯,我个人比较喜欢第四种书写方式,不过本文为了图方便,用:

function fn(){
	...
}

这种形式来写,真正试验或使用时,转换一下形式放到 window.dom 里即可;

创建节点

function create(string){
    return document.createElement(string);
}

这样创建一个元素就相对方便一些了,直接用dom.create('div')就能创建一个div元素;

create优化

上面的方法虽然实现了功能,但是还有一些缺陷,比如:

想设置标签里的内容的话,就要先创建div标签,再改div标签的innerText才能实现,能不能想个办法是使之一步到位呢?

比如这样写:

dom.create('<div>hi</div>')

改进思路:先用一个div当容器,然后把string参数赋值给容器div的innerHTML;

function create(string){
    const container = document.createElement('div');
    container.innerHTML = string;
    return container;
}

上面用的容器是div标签,但是不是所有标签都能放在div里的,比如td标签放在div标签里就不符合HTML语法;

那么有没有一个标签能存放任意的标签呢?

有,那就是 ** template **;

template 英语翻译是模板,在HTML里就是内容模板标签,里面可以放任意的标签和内容。但是template标签里的内容并不会呈现在页面上,虽然在页面解析的时候,解析器会处理template标签的内容,但是不会去渲染它。

所以这里用template标签当容易相当的合适。

另外在js中template的第一层原型链里,有一个content属性,这个属性是只读的文档片段,就是标签里的内容片段,包含了模板标签里的所表示的DOM树。

这里我们把容器改为template标签:

function create(string){
    const container = document.createElement('template');
    container.innerHTML = string;
    return container.content.firstChild;
    // 虽然template不会在页面上渲染,但是我们要的还是我们输入的内容
    // 所以还是要用content.firstChild把template从返回结果里给去掉
}

有时在写代码时,可能会不注意多打一个空格,多按一个回车什么的,而空格和回车很可能会影响到生成的标签,怎么办?

可以使用trim()方法来去掉字符串两边的空格;

function create(string) {
   	const container = document.createElement('template');
   	container.innerHTML = string.trim();  
   	return container.content.firstChild;
}

// 用法示例:
dom.create('<div>你好</div>')

新增哥哥

在自己前面加一个节点,就相当于新增了一个哥哥,这里用到的是 insertBefore();

// parentNode.insertBefore(newNode,referenceNode)
// 为啥这里要用父节点来调用insertBefore呢?因为添个哥哥弟弟啥的不是你说了算的,得你父母说了才算
// newNode就是要新插入的节点,referenceNode就是参考节点,调用后newNode就会到reference前面

// html:
<div id="div1">
    <div id="div2">2</div>
  </div>

// js:
let div3 = document.createElement('div');
div3.innerText = '3';
div2.parentNode.insertBefore(div3,div2)

// 页面效果:
// html:
<div id="div1">
	<div>3</div>
    <div id="div2">2</div>
  </div>
  
// DOM封装:
function before(node,node2){
    	node.parentNode.insertBefore(node2,node)
}

// node是参考节点,node2是新生成的节点

// 用法示例:
dom.before(div1,div2)

新增弟弟

新增一个哥哥是insertBefore,那么新增弟弟是不是insertAfter?

然而并不是,因为没有insertAfter这个语法,但是我们可以用insertBefore来模拟出想要的效果:

// 这样可以实现把node2插入到node前面
parentNode.insertBefore(node2,node)

// 那么把node2插入到node的下一个节点的前面,不就是相当于把node2插入到node后面了吗
parentNode.insertBefore(node2,node.nextSibling)

说起来有点绕,画个图来表示一下:

// 所以DOM封装为:
function after(node,node2){
    	node.parentNode.insertBefore(node2,node.nextSibling)
}

// 用法示例:
dom.before(div1,div2)

新增儿子

// 原生DOM里新增一个子节点:
// parentNode.appendChild(node)
// 注意这里node是要事先创建好的,然后再调用

// DOM封装:
function append(parentNode,node){
    	parentNode.appendChild(node)
}

// 用法示例:
let div1 = dom.create('<div>1</div>')
let div2 = dom.create('<div>2</div>')
dom.append(div1,div2)

新增爸爸

// 思路:先把一个节点插入到自己前面,然后这个节点再appendChild()
// 这里的两个节点还是需要事先创建好的,然后再调用
function wrap(parent,node){
    	dom.before(node,parent)
        dom.append(parent,node)
}

// 用法示例:
let div1 = dom.create('<div>1</div>')
let div2 = dom.create('<div>2</div>')
dom.wrap(div1,div2)

删除节点

// 原生DOM删除一个节点有两种方式:
// 1.父节点删子节点(调用原型链 Node.prototype 里的removeChild)
parentNode.removeChild(childNode)

// 2.子节点自删(调用原型链 Element.prototype 里的remove)
node.remove()

// 其实第二种方法已经很简单了,不过如果用第一种方法的话,还是可以封装一下的:
function remove(node){
    	node.parentNode.removeChild(node)
}

// 考虑到有时虽然把一个节点从一个位置上移除了,但是可能之后还要用,或者有在其他地方的引用:
function remove(node){
    	node.parentNode.removeChild(node);
        return node;
}

// 用法示例:
dom.remove(div1)

删除后代节点

// 最简单的方法:直接删除所有的后代节点
function empty(node){
    	node.innerHTML = ''
}

// 还是上面的情况,有时虽然在一个位置把一个节点移除了,但是可能之后还要引用,或者在其他地方用到:
// 思路:每次删除所有子节点中的第一个,然后放到一个数组里,最后返回这个数组,就不会影响其他地方的引用了
function empty(node){
	const {childNodes} = node
    // 新语法,效果等价于 const childNodes = node.childNodes,有点类似于 i += 1 的感觉
    const array = []
    for(let i = 0;i < childNodes.length;i++){
    	dom.remove(childNodes[i])
        array.push(childNodes[i])
    }
    return array
}

// 但是实际测试下来发现不对:因为childNodes的长度是会实时改变的,所以结果肯定有错误
// 优化:
function empty(node){
	const {childNodes} = node
    const array = []
    let x = node.firstChild
    while(x){
    	array.push(dom.remove(node.firstChild)) // 前面在写remove的时候,要return就是这里要用到
        x = node.firstChild
    }
    return array
}

// 用法示例:
dom.empty(div1)

读、写属性

 // 原生DOM可以用setAttribute(name,value)来设置一个标签的属性,可以用getAttribute(name)来读取属性
 // 这里的 attribute 代表的是HTML文档内,标签的属性,name是指属性名,value是指属性值
 // 复习:js里的属性用property来表示
 
 // 读属性:
 function getAttr(node,name){
 	return node.getAttribute(name)
 }
 
 // 改属性:
 function setAttr(node,name,value){
 	node.setAttribute(name,value)
 }

重载

上面的读写属性还不够简洁,我想通过attr一个函数,能兼任读和改的工作,咋办呢?

function attr(node,name,value){
	if(arguments.length === 2){
    	return node.getAttribute(name)
    }else if(arguments.length === 3){
    	node.setAttribute(name,value)
    }
}

// 如果输入两个参数,就是想读属性
// 如果输入三个参数,就是想改属性

// 用法示例:
dom.attr(div1,'title') //读取div1的title属性
dom.attr(div1,'title','新标题') //把div1的title属性变为‘新标题’

这种根据不同的参数个数来写不同代码的方法,就叫重载;

说的再详细一点就是:重载是指不同的函数使用相同的函数名,但是函数的参数个数或类型不同,调用的时候根据函数的参数来区别不同的函数。

读、写文本内容

// 原生DOM里可以这样改变文本内容:
// node.innerText = 'xxx' 或 node.textContent = 'xxx'
// 当然如果没有赋值的话就是读取文本内容

// 所以我们可以这样封装:
function text(node,string){
	if(arguments === 2){
    	node.innerText = string
    }else if(arguments === 1){
    	return node.innerText
    }
}

适配问题

既然 innerText 和 textContent 效果一模一样,那为什么要设计两个类似的API出来呢?这里有个历史原因:

早期微软的IE称霸浏览器市场的时候,IE只支持用 innerText 来修改标签里的文本内容,其他的浏览器支持 textContent 来修改。众所周知,早期的前端开发人员被IE的兼容性折磨的很难受,就是类似的原因。所以这里要想一下适配问题:

function text(node,string){
	if(arguments === 2){
    	if('innerText' in node){
        	node.innerText = string
        }else{
        	node.textContent = string
        }
    }else if(arguments === 1){
    	if('innerText' in node){
        	return node.innerText
        }else{
        	return node.textContent
        }
    }
}

// 用法示例:
dom.text(div1) //读取div1里的文本内容
dom.text(div1,'你好') //改写div1里的文本内容

不过现在的浏览器,基本都是同时支持两个API的,用哪个都行,更重要是熟悉适配不同浏览器的思路;

读、写HTML内容

// 原生DOM修改html内容:
node.innerHTML = 'xxx' 
// 不赋值则为读取html内容

// 封装:
function html(node,string){
	if(arguments.length === 2){
    	node.innerHTML = string
    }else if(arguments.length === 1){
    	return node.innerHTML
    }
}

// 用法示例:
dom.html(div1) //读取div1标签里的HTML内容
dom.html(div1,'<span>你好</span>') //改写div1标签里的HTML内容

修改样式(style)

// 原生DOM读取、修改样式:
node.style.name
node.style.name = 'value'

// 封装:
function style(node,name,value){
	if(arguments.length === 3){
    	node.style[name] = value
    }else if(arugments.length === 2){
    	return node.style[name]
    }
}

// 用法示例:
dom.style(div1,'color') //读取div1标签的样式里的color的值
dom.style(div1,'color','red') //修改div1标签的样式里的color的值

// 有的人喜欢这样写:dom.style(div1,{color:'red'}),所以可以继续优化:
function style(node,name,value){
	if(arguments.length === 3){
    	node.style[name] = value
    }else if(arugments.length === 2){
    	if(typeof name === 'string'){
        	//如果输入的第二个参数的数据类型是字符串的话,就返回样式属性的值
            renturn node.style[name]
        }else if(typeof name === 'object'){
        	//如果输入的第二个参数的数据类型是对象的话,就执行下面的代码
        	const object = name //把name的值赋值给object变量
            for(let key in object){ //在object变量里放入一个key,代表属性值
            	node.style[key] = object[key]
            }
        }
    }
}
// 用法示例:
dom.style(div1,{color:'red'})
// 这里name、object和key有点绕,下面配一个图示就能看的容易一些了:

instanceof

用typeof可以判断一个输入的变量的数据类型,当判断的东西是对象时,我们还可以用instanceof;

instanceof 翻译是实例的意思,是一个对象运算符,用来判断一个对象是谁的实例,检测构造函数的prototype属性是否出现在某个实例对象的原型链上。所以也可以用来检测对象数据类型。

用法:obj1 instanceof obj2

如果 obj1 是 obj2 的实例的话,就返回true,不是的话,就返回false;

instanceof 和 typeof 的不同:

  1. 形式不同:(typeof sth) 和 (sth1 instanceof sth2)
  2. typeof 可以判断所有数据类型的变量,返回值是字符串,返回值有:number、string、symbol、bool、null、undefined、function、object,而instanceof仅适用于对象这一数据类型;
  3. typeof 用来判断丰富的对象实例时,只能返回一个'object'字符串。用instanceof用来判断对象,可以返回true或false;
  4. instanceof 可以对不同的对象实例进行判断,判断方法是根据对象的原型链依次向下查询,而 typeof 就不能;

所以之前的代码也可以用 instanceof 来改写:

function style(node,name,value){
	if(arguments.length === 3){
    	node.style[name] = value
    }else if(arguments.length === 2){
    	if(typeof name === 'string'){
            return node.style[name]
        }else if(name instanceof Object){
        	// 对比: typeof name === 'object'
        	const object = name 
            for(let key in object){
            	node.style[key] = object[key]
            }
        }
    }
}

添加、删除class

// 原生DOM对class的操作:
node.classList.add(className) //添加一个类
node.classList.remove(className) //移除一个类
node.classList.contains(className)//检测一个节点有没有某个类

// 封装:
windom.dom = {
	class:{
    	add(node,className){
        	node.classList.add(className)
        },
        remove(node,className){
        	node.classList.remove(className)
        },
        has(node,className){
        	return node.classList.contains(className)
        }
    }
}

// 用法示例:
dom.class.add(div1,'red')
dom.class.remove(div1,'red')
dom.class.has(div1,'red')

添加、删除事件监听

// 原生DOM的事件监听:
// 自身属性里的onclick:
node.onclick = function(){...} //添加一个鼠标点击事件
node.onclick = null //移除鼠标点击事件

// 原型链里的EventTarget.prototype里的方法:
node.addEventListener(click,function(){...}) //添加一个鼠标点击事件
node.removeEventListener(click,function(){...}) //移除鼠标点击事件

// 封装:
function on(node,eventName,fn){
	node.addEventListener(eventName,fn)
}
function off(node,eventName,fn){
	node.removeEventListener(eventName,fn)
}

// 用法示例:
dom.on(div1,'click',function(){console.log('hi')})

function sayHi(){console.log('hi')}
dom.on(div1,'click',sayHi)
dom.off(div1,'click',sayHi)

// 用off的话,必须要提前把函数名字定好(使用具名函数,就是具有名字的函数),不然使用匿名函数是无效的:
// 错误示范:
dom.on(div1,'click',function(){console.log('hi')})
dom.off(div1,'click',function(){console.log('hi')})
// 这样写off是不生效的,点击了依然还会执行原来添加的功能

获取标签

// 原生DOM里寻找元素的办法:
doucment.getElementById('id')
document.getElementsByClassName('div')[0]
document.getElementsByClassName('red')[0]
document.querySelector('#id')
document.querySelectorAll('.red')[0]

// 封装:
function find(selector){
	return document.querySelectorAll(selector)
}

// 用法示例:
dom.find('#test')[0] //因为返回的是个伪数组,所以要记得在后面加下标

// 拓展:
// 假如我想在下面的代码里把p标签从div标签里找出来,如何实现?
<div id='test'>
	<p class='red'>文字</p>
</div>

// 实现:
function find(selector,scope){ //scope 范围的意思
	return (scope || document).querySelectorAll(selector)
}

// 用法示例:
let test = dom.find('#test')[0]
dom.find('.red',test)

获取父元素和子元素

这个比较简单,直接上代码:

// 获取父元素
function parent(node){
	return node.parentNode
}

// 获取子元素
function children(node){
	return node.children
}

// 用法示例:
dom.parent(div1)
dom.children(div1)

获取兄弟姐妹元素

// 原生DOM里用 node.parentNode.children 可以获取到兄弟姐妹元素,所以可以封装为:
function siblings(node){
	return node.parentNode.children
}

// 这样可以获取到所有的兄弟姐妹元素,但是里面也包括了自己,所以要把自己给排除出去,剩下的才是真正严格意义上的所有兄弟姐妹节点
// 这里的思路要用到数组的过滤方法:filter,但是返回的是一个伪数组,所以要先把返回结果变成数组再过滤
function siblings(node){
	return Array.from(node.parentNode.children).filter((n)=> n !== node)
    // 使用Array.from 使之变成数组
    // filter((n)=> n !== node) 意思是如果n不等于node,就留下来放到数组里,等于node就给过滤出去
}

// 用法示例:
dom.siblings(div1)

获取哥哥和弟弟元素

// 原生DOM获取下一个节点:node.nextSibling
// 原生DOM获取上一个节点:node.previousSibling
// 注意这里是节点,不是元素,因为获取到的可能是文本节点,而我们想获取的不是文本节点,所以在封装的时候要排除:

// 获取弟弟元素
function next(node){
	let x = node.nextSibling
    while(x && x.nodeType === 3){
    	// 当x存在时,并且节点类型为3,也就是文本节点时,就继续寻找下一个节点,直到不是文本节点为止
        x = x.nextSibling
    }
    return x
}

// 获取哥哥元素
function pervious(node){
	let x = node.previousSibling
    while(x && x.nodeType === 3){
    	x = x.previousSibling
    }
    return x
}

点击这里查看nodeType详细信息:nodeType MDN

遍历所有节点

function each(nodeList,fn){
	for(let i = 0;i<nodeList.length;i++){
    	fn.call(null,nodeList[i]) // this设置为空
    }
}

// 用法示例:
// html:
<div id="div1">
        <div id="div2">2</div>
        <div id="div3">3</div>
</div>

// js:
let divList = document.querySelector('#div1').children
dom.each(divList,(n) => n.style.color = 'red')

获取元素排行老几

// 思路:把给出元素的所有兄弟姐妹元素都找出来遍历循环,循环到自己的时候返回数字
function index(node){
	const list = dom.children(node.parentNode)
    let i //之所以在循环外面声明i,是因为用let声明时,作用域仅限于循环内,到外面就不能返回了
    for(i = 0;i<list.length;i++){
    	if(list[i] === node){
        	break;
        }
    }
    return i
}