封装
封装就是把程序的数据、行为、功能结合起来,装成一个整体,然后给你一个接口,你就能直接用。即使你不知道里面到底是怎么构造和设计的,你也能轻松的去使用它。
被封装好的东西,通常要给出一个接口,别人才能去方便的使用它。
封装的目的是简化编程,同时也能增强程序的安全性。
举例
- 我们想打开家里的灯,我们不用知道内部电路是怎样设计的,电是怎样工作的,灯泡是如何设计的,怎么装上去的,我们只需要知道:按下开关,灯就亮了。这里电工师傅或装修公司帮我们设计好电路、安装好灯泡就相当于一个封装,而开关就相当于给出的接口。
- 笔记本电脑里面有CPU、显卡、主板、内存、硬盘等硬件,我们使用笔记本时,不用知道CPU、显卡是怎么装进去的,主板电路是怎么设计的,我们只需要知道:通过鼠标和键盘就能操作电脑。这里厂商把电脑各个硬件组装在一起,就相当于一个封装,而鼠标键盘通过USB接口就行操作电脑,就类似于接口。
DOM封装
原生的 DOM 操作语法通常都比较长,不容易记清楚,用起来也不方便,所以我们可以对 DOM 操作进行封装。
一些名词
- 库:我们编程时可以把一些好用的函数推荐给别人使用,当这种可以提供给别人使用的函数多起来了以后,就可以形成一个工具仓库。常见的JS库有:jQuery、Underscore等;
- API:Application Programming Interface,翻译为应用程序编程接口,简单的说就是接口。API往往是预先定义好的函数,比如很多库里就会暴露出的函数或者属性,即使你不知道源码、不理解内部工作机制的细节,也能直接调用;
- 框架:当库发展的越来越庞大,功能越来越丰富,并且很多方法需要去学习后才知道如何使用时,那么此时的库就可以叫做框架,比如 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 的不同:
- 形式不同:(typeof sth) 和 (sth1 instanceof sth2)
- typeof 可以判断所有数据类型的变量,返回值是字符串,返回值有:number、string、symbol、bool、null、undefined、function、object,而instanceof仅适用于对象这一数据类型;
- typeof 用来判断丰富的对象实例时,只能返回一个'object'字符串。用instanceof用来判断对象,可以返回true或false;
- 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
}