从零开始构建JavaScript框架3——DOM遍历1

上一篇最后我们提到了我们接下来的工作就是给实例化对象添加方法,很明显这些方法肯定得添加到原型上,由此我们可以猜测我们接下来的代码可能大概是这个样:

mQuery.prototype.xxx = function(){};

mQuery.prototype.yyy = function(){};

mQuery.prototype.zzz = function(){};

我们暂且先按照这个思路来写,遇到问题时再想办法解决

本篇我们首先来看DOM遍历,首先需要明确我们要添加哪些方法:
prev() 寻找元素的前面一个节点,如果有多个元素则返回所有元素的前驱节点
next() 寻找元素的后面一个节点
siblings(isContainSelf) 寻找元素的所有兄弟节点,通过传入布尔值参数判断是否包含当前元素
children() 寻找元素的所有子节点
parent() 寻找元素的直接父级
parents(".js-spec-par") 沿着html结构一层一层往上找,直到找到.js-spec-par并返回

从第一个方法prev开始实现:

mQuery.prototype.prev = function(){
    // 注意此处的this是一个类数组,需要循环遍历
    var cur;
    var res = [];
    // 循环mQuery实例化对象里面的各项
    for (var i = 0; i < this.length; i++) {
        cur = this[i].previousSibling;
        // 为了兼容,使用previousSibling而没用previousElementSibling
        // 但是previousSibling会遍历到所有类型的节点
        // 因此开个循环,当得到文本节点时再存入结果集
        while(cur){
            if (cur.nodeType == 1) {
                res.push(cur);
                // 由于前驱节点只有一个,因此找到之后就停掉循环
                break;
            }
            // 转到下一节点继续
            cur = cur["previousSibling"];
        }
    }
    // 我们希望外部能够链式调用,即$(".div1").prev().click(function(){})
    // 此时拿到的res是一个数组,并不是一个真正的mQuery对象,因此需要将其转为mQuery对象
    for(var i = 0; i < res.length; i++){
        this[i] = res[i];
    }
    this.context = this.context || document;
    this.length = res.length;
};

有了获取前驱节点的思路,后继节点就很简单了

mQuery.prototype.next = function(){
    var cur;
    var res = [];
    for (var i = 0; i < this.length; i++) {
        cur = this[i].nextSibling;
        while(cur){
            if (cur.nodeType == 1) {
                res.push(cur);
                break;
            }
            cur = cur["nextSibling"];
        }
    }

    for(var i = 0; i < res.length; i++){
        this[i] = res[i];
    }
    this.context = this.context || document;
    this.length = res.length;
};

明显重复代码太多,因此接下来的工作必然是抽离公共部分
很显然一个调用previousSibling,一个调用nextSibling,因此肯定得作为参数传入

function _getSiblings(fnDir){
    var cur;
    var res = [];
    for (var i = 0; i < this.length; i++) {
        cur = this[i][fnDir];
        while(cur){
            if (cur.nodeType == 1) {
                res.push(cur);
                break;
            }
            cur = cur[fnDir];
        }
    }
    return res;
}

而通过数组得到原生对象这个操作,在init方法里面已经用到过一次了,因此我们计划在init、prev、next以及接下来要实现的siblings、children、parents等方法全都统一起来,因此单独抽成一个方法

function _getMQueryObjFromEleArray(aEle){
    for(var i = 0; i < aEle.length; i++){
        this[i] = aEle[i];
    }
    this.context = this.context || document;
    this.length = aEle.length;
    return this;
}

值得注意的是,getSiblings和getMQueryObjFromEleArray这两个方法里面都用到了this,对此我们在调用的时候用call或apply来调用以避免this指针出错的问题,这样代码就变为:

mQuery.prototype.prev = function(){
    _getMQueryObjFromEleArray.call(this, _getSiblings.call(this, "previousSibling"));
};
mQuery.prototype.next = function(){
    _getMQueryObjFromEleArray.call(this, _getSiblings.call(this, "nextSibling"));
};

再说一个小问题,在函数_getSiblings里面有这么一句:

if (cur.nodeType == 1) {

这句话的意思是判断当前节点的节点类型如果是元素节点则执行条件体里面内容,这里我们的写法并不好,因为人类并不擅长记忆数字对应的某个意义是什么,例如这里用1代表元素节点的意思,但是时间长了再看这段代码可能就忘了这个1是哪种节点了,因此我们采用枚举来解决该问题

var NODE_TYPE = {
    ELE: 1
};
if (cur.nodeType == NODE_TYPE.ELE) {

这样看起来就更加形象

接下来实现siblings方法,该方法接收一个布尔值参数,代表是否包含当前元素
我们的思路大概如下:
先获取当前节点之前的所有兄弟节点,放到一个数组aPrev中
再获取当前节点之后的所有兄弟节点,放到一个数组aNext中
再拼接起来处理成mQuery对象返回去
现在我们肯定想要尽量调用刚才封装好的方法,但是刚才封装好的方法有个特点就是只获取一个节点(前驱或后继),而我们目前要获取一组节点(当前节点之前/之后的所有兄弟节点)

while(cur){
    if (cur.nodeType == NODE_TYPE.ELE) {
        res.push(cur);
        break;
    }
    cur = cur[fnDir];
}

如果我们不传num的话(即num为undefined)则条件(num == undefined) || num--为真,将会把所有当前循环方向的元素收集起来。经过测试,当一个节点没有前驱节点或后继节点时访问previousSibling或nextSibling会返回null,在IE8中由于previousSibling或nextSibling只访问元素节点,因此循环次数会比其他浏览器少,但是并不影响效果。
这样我们的siblings方法就可以实现如下:

mQuery.prototype.siblings = function(isContainSelf){
    var aPrev = _getSiblings.call(this, "previousSibling");
    var aCur = [this[0]];
    var aNext = _getSiblings.call(this, "nextSibling");

    return _getMQueryObjFromEleArray.call(
        this, 
        aPrev.concat(isContainSelf ? aCur : [] ).concat(aNext)
    );
};

接下来是children方法,我们可以先获取到当前mQuery实例化对象里各个元素的第一个节点(firstChild),再获取该节点的所有兄弟节点,但是问题来了_getSiblings函数内部的this是按照mQuery实例化对象处理的,因此我们肯定得想办法传入mQuery实例化对象,以现在的情况而言,我们必须循环遍历当前mQuery实例化对象里面的各个元素,再把各个元素转成mQuery对象传入处理,而目前我们的代码中并没有把原生DOM转为mQuery对象的接口,而这个操作也是返回一个DOM集合,因此我们肯定放到init函数里面,将这种情况合并到创建元素返回DOM集合,获取元素返回DOM集合这些情况中:

mQuery.prototype.init = function(selector, context){
    // 此处需要判断selector传入的类型,先简单处理成typeof判断,下一篇我们会专门讨论这块内容
    if (typeof selector == "string") {
        ...
    } else if (selector.nodeType && selector.nodeType == NODE_TYPE.ELE) {
        // 如果是原生DOM元素
        _getMQueryObjFromEleArray.call(this, [selector]);
    }
};

接下来我们就可以利用该特性实现children方法了:

mQuery.prototype.children = function(){
    var aChildren = [];
    for(var i = 0; i < this.length; i++){
        // 我们得让_getSiblings里面的this依次指向当前mQuery对象的各项的firstChild,再分别获取其兄弟节点,再拼接起来
        aChildren.concat(_getSiblings.call($(this[i].firstChild), "nextSibling"));
    }
    return _getMQueryObjFromEleArray.call(this, aChildren);
};

这样虽然可以做到,但是我们可以发现在children中我们遍历了一遍mQuery实例化对象,在_getSiblings中又遍历了一遍mQuery实例化对象,而且在_getSiblings中遍历的那次是把原来的mQuery实例化对象里面的各项先拆解成一个个原生DOM对象,再构造成jQuery对象,这样做明显非常别扭。此时肯定会有一个疑问:为什么不能和prev和next一样在_getSiblings内部处理遍历呢,原因就在于兄弟元素访问的是同级别的节点,而子元素已经跨级别了,因此处理上肯定有区别,如果依然这样一意孤行的走下去,由于parent和parents也跨级别了,因此肯定会出现同样的问题。

想要解决该问题也很简单,我们只需要把循环遍历这部分代码拿到外面来,让_getSiblings只对单个原生DOM对象节点操作,这样的粒度就比较小了,但这样做的话问题又来了,我们当初把循环遍历mQuery实例化对象放在_getSiblings内部处理的原因就是为了避免写重复代码,现在要是拿出来在prev、next、siblings等方法里再各写一遍不又回到之前的问题中了么,这个问题不用着急,我们自有解决它的办法。

由于本次代码修改量比较大,因此我们单独抽出到另外一个文件中,将该版本代码留存一份,github代码地址为
https://github.com/zhaohuiziwo901/CourseExample/blob/master/JavascriptLibraryExplore/3-dom-traverse.html

修改过后的代码大概变成了下面的样子:

mQuery.prototype.prev = function(){
    var aRes = [];
    // 循环遍历mQuery实例化对象,取出每次遍历到的DOM对象传入_getSiblings中处理
    for(var i = 0; i < this.length; i++){
        aRes = aRes.concat(_getSiblings(this[i], "previousSibling", 1));
    }
    // 当_getMQueryObjFromEleArray函数用的多了起来之后,用call调用就会非常不便
    // 因此改为将当前实例化对象作为参数传入的方式更加简洁
    return _getMQueryObjFromEleArray(this, aRes);
};
mQuery.prototype.next = function(){
    var aRes = [];
    for(var i = 0; i < this.length; i++){
        aRes = aRes.concat(_getSiblings(this[i], "nextSibling", 1));
    }
    return _getMQueryObjFromEleArray(this, aRes);
};
mQuery.prototype.siblings = function(isContainSelf){
    var aRes = [];
    for(var i = 0; i < this.length; i++){
        // 找到当前节点的父节点,再获取第一个子节点之后的所有节点,自然就是获取所有的兄弟节点了
        aRes = aRes.concat(_getSiblings(this[i].parentNode.firstChild, "nextSibling"));
    }
    return _getMQueryObjFromEleArray(this, aRes);
};

// children的实现也就非常自然了
mQuery.prototype.children = function(){
    var aRes = [];
    for(var i = 0; i < this.length; i++){
        aRes = aRes.concat(_getSiblings(this[i].firstChild, "nextSibling"));
    }
    return _getMQueryObjFromEleArray(this, aRes);
};

// 对于_getSiblings函数,里面的for循环得去掉,mQuery实例化对象也得换成DOM原生对象:
// ele是新增的参数,该参数代表原生DOM对象
function _getSiblings(ele, fnDir, num){
    var cur;
    var res = [];

    // 如果传入的原生对象参数为空,直接返回空数组,代表没有找到
    if (!ele) {
        return res;
    }
    // cur就是
    cur = ele[fnDir];
    while(cur){
        if (cur.nodeType == NODE_TYPE.ELE) {
            res.push(cur);
            if ((num != undefined) && (--num == 0)) {
                break;
            }
        }
        cur = cur[fnDir];
    }
    return res;
}

最后再对_getMQueryObjFromEleArray做一些修改,有这样一种情况:
当传入的aEle参数是空数组时,虽然将length属性也变为0了,但是由于没走for循环,之前的mQuery实例化对象有元素时还是会有0 1 2...这些属性存储着这些DOM对象的,例如:
$(".div").children()
当$(".div")选到了3个元素,则$(".div")对象将有0 1 2这3个属性,当这3个元素都没有子元素时$(".div").children()应当返回空数组,但是由于目前我们的_getMQueryObjFromEleArray实现是每次在链式调用的时候操作的是第一次调用$(".div")时得到的对象,所以如果当某次链式调用需要返回空数组时aEle参数长度为0,此时将没有机会清空我们的$(".div")对象中的0 1 2各项值,因此需要判断出长度为0的情况手动清空:

function _getMQueryObjFromEleArray(oMQuery, aEle){
    // 判断出长度为0的情况手动清空
    if (aEle.length == 0) {
        for(var i = 0; i < oMQuery.length; i++){
            delete oMQuery[i];
        }
    }
    for(var i = 0; i < aEle.length; i++){
        oMQuery[i] = aEle[i];
    }
    oMQuery.context = this.context || document;
    oMQuery.length = aEle.length;
    return oMQuery;
}

当然这种处理方式不是特别好,由于每次链式操作都是操作的第一次得到的对象集合,当我们某一时刻还希望用到最初的这个对象集合时有可能发现它已经被改了,我们后期会有更好的方式,我们目前先着眼于解决当下遇到的问题

代码写到这里之后问题也很明显,首先我们早就预料到的问题,prev next siblings children里面每个方法的实现都有一个循环,而且这几个方法的实现方式有很多共同点,但又略有差异,我们必然要将其合并

接下来我们可以观察一下这几个实现的共同点到底是什么,其实它们的流程非常像,都是遍历一个元素集合,拿到里面的每个元素之后执行相应的操作,随后再把数组转成mQuery对象。注意刚才提到的拿到每个元素之后执行的操作是不同的,于是我们可以把不一样的地方单独存在某个地方,把一样的地方做统一处理

此处代码改动也比较大,因此我们再保留一个版本,github代码地址:https://github.com/zhaohuiziwo901/CourseExample/blob/master/JavascriptLibraryExplore/3-dom-tranverse-2.html

存储操作不一样的地方:

var oDomTraverse = {
    // 此处的ele就是将来循环到的mQuery中的每一项
    "prev": function (ele) {
        return _getSiblings(ele, "previousSibling", 1)
    },
    "next": function (ele) {
        return _getSiblings(ele, "nextSibling", 1)
    },
    "siblings": function (ele) {
        return _getSiblings(ele.parentNode.firstChild, "nextSibling")
    },
    "children": function (ele) {
        return _getSiblings(ele.firstChild, "nextSibling")
    }
};

json中的每一项都会对应mQuery的一个实例化方法,因此我们要遍历这个对象,给mQuery添加对应的方法
很自然的,以下代码就出来了

for(var attr in oDomTraverse){
    mQuery.prototype[attr] = function(){
        var aRes = [];
        for(var j = 0; j < this.length; j++){
            aRes = aRes.concat(oDomTraverse[attr](this[j]));
        }
        return _getMQueryObjFromEleArray(this, aRes);
    };
}

但是注意到在mQuery.prototype[attr]函数体里面我们也用到了attr:
aRes = aRes.concat(oDomTraverse[attr](this[j]));
这样一来,js中的经典bug就出来了,当我们在mQuery.prototype[attr]里面访问attr这个变量时,由于没有找到attr的定义,因此会沿着作用域向上查找,结果找到了
for(var attr in oDomTraverse)
中定义的attr,于是就会用该attr,但是重点来了,用这个attr的时候此attr非彼attr,由于for in循环已经结束,因此这个attr早就已经变成了for in循环的最后一项

可以通过如下程序进行简单的测试:

var obj = {
    z: 3,
    a: 1,
    b: 2
};
var newObj = {};
for(var attr in obj){
    newObj[attr] = function(){
        console.log(attr);
    };
}
newObj.a(); // b
newObj.b(); // b
newObj.z(); // b

造成这个问题的根本原因在于循环到的3个attr共享了外面一个作用域,那么我们解决这个bug的思路必然是给每次循环到的attr各给一个作用域,这样attr就可以用上当前作用域的attr,而不是去外面找
for(var attr in oDomTraverse)
里面定义的attr了

ES5之前想要搞一个新的作用域除了函数之外别无他法,因此很自然的,代码就变为

for(var attr in oDomTraverse){
    fnDomTraverse(attr);
}
function fnDomTraverse(attr){
    mQuery.prototype[attr] = function(){
        var aRes = [];
        for(var j = 0; j < this.length; j++){
            aRes = aRes.concat(oDomTraverse[attr](this[j]));
        }
        return _getMQueryObjFromEleArray(this, aRes);
    };
}

这样做确实可以了,不过可以想到,这样的套路我们肯定会用多次,但是我们每用一次就得定义一个新的函数名(fnDomTraverse)不仅污染了上级作用域,万一哪天不小心函数定义的名字重复了,就比较麻烦了,注意到我们之前定义的函数,当它们所处的作用域比较高时名字都比较长:_getMQueryObjFromEleArray
其中有一个原因就是为了避免重名,因此匿名函数就该登场了

for(var attr in oDomTraverse){
    function (attr){
        mQuery.prototype[attr] = function(){
            var aRes = [];
            for(var j = 0; j < this.length; j++){
                aRes = aRes.concat(oDomTraverse[attr](this[j]));
            }
            return _getMQueryObjFromEleArray(this, aRes);
        };
    }(attr);
}

但是这样写依然有问题,因为浏览器在解析js时会认为function开头的话就是在定义函数,而定义函数时必须得有名字,否则在语法分析时就会报错,于是
function (attr)
这里的括号就会报"Uncaught SyntaxError: Unexpected token ("的错误

js中除了函数定义之外还有函数表达式也可以创建一个新的作用域,只要不是以function关键字开头,都可以是函数表达式,因此就有了下面这种写法

for(var attr in oDomTraverse){
    (function (attr){
        mQuery.prototype[attr] = function(){
            var aRes = [];
            for(var j = 0; j < this.length; j++){
                aRes = aRes.concat(oDomTraverse[attr](this[j]));
            }
            return _getMQueryObjFromEleArray(this, aRes);
        };
    })(attr);
}

但是我们目前的代码依然存在attr和oDomTraverse这两个变量作用域太高,我们希望把这两个变量也干掉,因为以后的循环还有很多,每次循环都定义这样一个变量依然会存在污染全局的问题,而且循环条件里定义的变量通常都不会太长,比如var i = 0; var attr;等等,后面再来一个循环,变量的覆盖是肯定的。但问题是只要是循环,就得定义循环变量从而在每次循环的时候逐步改变该变量的值,慢慢接近终止条件,这也是原生循环的一大弊病,因此我们会在下一篇中分析如何在原生的循环上做增强。

在此先保留一份代码,github地址:https://github.com/zhaohuiziwo901/CourseExample/blob/master/JavascriptLibraryExplore/3-dom-tranverse-3.html

转载于:https://www.cnblogs.com/zhaohuiziwo901/p/7041694.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值