手写DOM库

121 阅读5分钟

DOM提供的接口实在是不好用,于是今天我来手写一个DOM库

源代码链接:

github.com/Yue2167/DOM…

我们提供一个全局的window.dom对象

接下来分别就“增删改查”进行说明

一、增加元素、增加节点

dom.create(<div>hi</div>) 创建节点

dom api需要用document.createElement('div'), div.innerText = 'hi' 两句话

这里我们只需要一句话即可,dom.create(<div>hi</div>)

 //目标效果
    // 输入create("<div><span>你好</span></div>")
    // 自动创建好div和span
    //实现思路,直接把字符串写进InnerHTML
    // 用template是因为这个标签里可以容纳所有标签,
    // div标签里就不能放<tr></tr>标签,而template可以
    create(string) {
        const container = document.createElement("template")
        container.innerHTML = String.trim() // trim可以去掉多余空格
        return container.content.firstChild
    },

两个关键:

  1. template标签作为container,是因为这个标签可以容纳所有标签
  2. 用trim()去掉多余的空格,因为空格也算做节点,因此可能会干扰我们的结果

dom.after(node, node2) 用于新增弟弟

思路:由于dom只提供了Insertbefore操作,没有提供insertAfter操作, 所以我们需要转换思路

假设有两个节点 div1 --- > div3

我们想要再div1后面加一个div2, 这就等价于在div3前面插入div2

所以我们现有node.nextSibling获取当前节点的下一个节点,再insertBefore

after(node, newNode) {
        // 目标是在Node节点后面插入node2节点
        // 但是DOM只提供了insertBefore接口
        // 1 -> 3
        // 在1后面插入2, 等价于在3的前面插入2
        // 所以我们转换为在node的下一个节点的前面插入node2
        node.parentNode.insertBefore(newNode, node.nextSibling)
    },

dom.before(node, node2) 用于新增哥哥

思路:找到爸爸节点,然后insertBefore

before(node, newNode) {
        node.parentNode.insertBefore(newNode, node)
 },

dom.append(parent, child) 用于新增儿子

思路:找到爸爸节点,appendChild

append(parent, node) {
        parent.appendChild(node)
    },

dom.wrap(<div></div>)用于新增爸爸

wrap(node, newParent) {
        // 把Newparent 放到node前面
        // 把node append到newparent里
        // 目标: div1
        //        ↓----> div2
        // 变成  div1
        //        ↓----> div3
        //                ↓----> div2
  			// 先把div3 放到div2的前面,再div3.appendChild(div2)
        node.before.call(null, node, newParent)
        newParent.append(node)
    },

使用方法:

const div3 = dom.create('<div id="parent"></div>')
dom.wrap(test, div3)

二、删除节点

dom.remove(node) 用于删除节点

思路:找到爸爸节点, removeChild

 remove(node) {
        node.parentNode.removeChild(node)
        return node
    },

dom.empty(parent) 用于删除后代

很容易想到的一个思路是,用for循环遍历Parent.childNodes, 然后每次都remove

但是这里有一个坑是childNodes.length每次的Length会实时变化

所以这里我们用while循环

只要node还有子节点,就不断遍历

 // empty 把所有子节点删掉
    // 坑:childNodes.length每次的Length会变化
    empty(node) {
        // const {childNodes} = node 等价于const childNodes = node.childNodes
        const array = []
        let x = node.firstChild
        while (x) {
            array.push(dom.remove(node.firstChild))
            x = node.firstChild
        }
        return array
    },

三、修改

dom.attr(node, 'title', ?) 用于读写属性

这里用到了重载,即当函数的参数不一样时,做不一样的处理

当只有两个参数时,就读属性,返回属性

当有三个参数时,就修改属性

这里由于同时可以读写属性,用到了getter和setter设计模式

// 根据参数的个数,实现不同的函数,这叫函数的重载
    attr(node, name, value) {
        if (arguments.length === 3) {
            node.setAttribute(name, value)
        } else if (arguments.length === 2) {
            return node.getAttribute(name)
        }
    },

使用方法

// 修改 <div id="test" title="hi">test</div>
// #test的title属性值为 hello world
dom.attr(test, 'title', 'hello world')  //修改属性
const title = dom.attr(test, 'title')   //读属性
console.log(`title: ${title}`)

dom.text(node, ?) 用于读写文本内容

如果只有一个参数就读文本内容,返回node.innerText

如果有两个参数,就写文本内容 node.innerText = string

同时还对不同浏览器做了适配,这里运用了适配器模式

text(node, string) {
        if (arguments.length === 2) {
            // 适配不同浏览器
            if ('innerText' in node) { //ie
                node.innerText = string
            } else { // firefox / chrome
                node.textContent = string
            }
        } else if (arguments.length === 1) {
            if ('innerText' in node) { //ie
                return node.innerText
            } else { // firefox / chrome
                return node.textContent
            }
        }

    },

dom.html(node, ?) 用于读写HTML内容

 html(node, string) {
        if (arguments.length === 2) {
            //修改
            node.innerHTML = string
        } else if (arguments.length === 1) {
            // 获取内容
            return node.innerHTML
        }
    },

dom.style(node, {color:'red'}) 用于修改style

大体思路:修改 node.style.color = 'red'

需要注意的是,这里会有三种情况使用style函数

  1. dom.style(node, 'color', 'red') 设置一个属性
  2. dom.style(node, 'color') 读一个属性
  3. dom.style(node, {'color': 'red', 'border': '1px solid black'}) 传一个对象,同时设置多个属性

所以我们需要对不同的参数进行处理,再次用到了适配器模式

首先我们需要判断参数的个数,

  • 如果是3,则是第一种情况,直接node.style.color = 'red'
  • 如果是2
    • 如果第二个参数是字符串
    • 判断一个东西是字符串的方法: 用typeof   xxxxx === 'string'
typeof name === 'string'
    • 然后直接return node.style[name]
    • 如果第二个参数是一个对象
    • 判断一个对象的方法:用instanceof : XXX instanceof Object === true
      • 然后遍历这个对象
      • 遍历对象的写法:
for (let key in object) {
   ......                  
}

实现代码:

//改样式
    style(node, name, value) {
        if (arguments.length === 3) {
            // dom.style(div, 'color', 'red')
            node.style[name] = value
        } else if (arguments.length === 2) {
            if (typeof name === 'string') {
                //dom.style(test, 'border')
                // 获取某个css属性
                return node.style[name]
            }

            if (name instanceof Object) {
                //dom.style(test, {border: '1px solid red', color: 'blue'})
                let object = name
                for (let key in object) {
                    // key : border / color
                    // node.style.border = ....
                    // node.style.color = ...
                    node.style[key] = object[key]
                }
            }
        }

    },

dom.class.add(node, 'blue') 用于添加class

大体思路:node.classList.add(class)

  class: {
        add(node, className) {
            node.classList.add(className)
        },
        remove(node, className) {
            node.classList.remove(className)
        },
        has(node, className) {
            return node.classList.contains(className)
        }
    },

注意:查找一个元素的classlist里是否有某一个class, 是contains 方法

dom.on(node, 'click', fn) 用于添加事件监听

 on(node, eventName, fn) {
        node.removeEventListener(eventName, fn)
    },

dom.off(node, 'click', fn) 用于删除事件监听

 off(node, eventName, fn) {
        node.removeEventListener(eventName, fn)
    },

四、查

dom.find('选择器', scope) 用于获取标签或标签们

在指定区域或者全局document里找HTML标签

大体思路:document.querySelector('selector')

 // 根据选择器获取元素
    find(selector, scope) {
        return (scope || document).querySelectorAll(selector)
    },

dom.parent(node) 用于获取父元素

parent(node) {
        return node.parentNode
},

dom.children(node) 用于获取子元素

children(node) {
        return node.children
},

dom.siblings(node) 用于获取兄弟姐妹元素

找到爸爸节点,然后删掉是我自己的元素

 siblings(node) {
        // 我父母所有孩子里不是我的,就是我的兄弟姐妹
        return Array.from(node.parentNode.children).filter(n => n !== node)
    },

dom.next(node) 用于获取弟弟

注意: while (x && x.nodeType !== 1) 表示如果x存在,且x不是HTML元素节点,就继续找下一个,直到找到一个节点是HTML元素节点

这样写的原因是,节点包括HTML标签、 文本、注释等,所以需要这样处理一下,保证我们找到的是一个HTML标签

next(node) {
        let x = node.nextSibling
        while (x && x.nodeType !== 1) {
           // x 不是想要的HTML标签
            x = x.nextSibling
        }
        return x
    },

dom.previous(node) 用于获取哥哥

 prev(node) {
        let x = node.previousSibling
        while (x && x.nodeType !== 1) {
            // x 不是想要的HTML标签
            x = x.previousSibling
        }
        return x

    }

dom.each(nodes, fn) 用于遍历所有节点

each(nodelist, fn) {
        for (let i = 0; i < nodelist.length; i++) {
            fn.call(null, nodelist[i])
        }
}

dom.index(node) 用于获取排行老几

index(node) {
        let nodeList = dom.children(dom.parent(node))
        let i
        for (i = 0; i < nodeList.length; i++) {
            console.log(nodeList[i])
            if (nodeList[i] === node) {
                return i
            }
        }
       return -1 //表示没找到
    }