简析一个polyfill

偶然看到了一个polyfill,是司徒正美写的一个polyfill,针对数组和对象等数据结构中一些低版本没有实现的方法的。最近没有什么业务不是在看公司SDK就是在看业务代码,又看到这个polyfill,正好可以复习一些基础知识。

开篇

(function (global) {
    var oProto = Object.prototype;
    var toString = oProto.toString;
    var hasOwnProperty = oProto.hasOwnProperty;
    function needFix(fn) {
        return !/native code/.test(fn);
    }

开篇,简写几个常用到的属性和方法,分别是Object的原型,toString()方法,和一个判断属性是否是某对象本身的hasOwnProperty()方法,一些遍历的方法总会涉及到是否会把对象原型上的属性也给拷贝或者复制出来的问题。

下面needFix()函数望名生义,判断一个方法是否需要修复,利用的方法非常精妙,利用原生方法在浏览器中不会被toString()输出,而只是返回"native code",如果返回的结果中有这个字符串,则证明该方法存在,否则不存在,需要fix。


若存在该方法不会把方法打印出来,只是输出"native code"

修复console

说道console,算是实习以来遇到的最印象深刻的坑,让我对IE的坑之奇葩有了较深刻的认识:在IE8下,如果你不打开开发者工具,window下是没有console这个对象的,只有打开了F12才会有这个方法。

当时发生的场景就是,我在一个抽奖功能里写了一个console.log,没有删去,因为调试页面的习惯,每次自测的时候都会打开控制台测,所以每次测试的时候功能都是正常的。页面完成后交给测试测,报说每次抽奖成功后都没有弹窗,但是我每次测都没有问题(IE8下),后来又是师兄来了一手在弹窗处的代码加try...catch,因为测试是在游戏大厅下测的,没法console,所以用了alert,最后测试弹出来这样的框:

TypeError:'console'未定义

既window下没有console这个对象!正好我在看代码的时候发现了里面有一句console.log(),问题就可想而知了。

当时真是,觉得又奇葩又无语又觉得有点意思,还有这种操作!

言归正传,所以当我看到作者写的这个修复console是非常印象深刻的:

//修复 console
    if (!global.console) {
        global.console = {};
    }
    var con = global.console; //有的浏览器拥有console对象,但没有这么多方法
    var prop, method;
    var dummy = function () { };
    var properties = ["memory"];
    var methods = ("assert,clear,count,debug,dir,dirxml,error,exception,group,groupCollapsed,groupEn" +
        "d,info,log,markTimeline,profile,profiles,profileEnd,show,table,time,timeEnd,time" +
        "line,timelineEnd,timeStamp,trace,warn").split(",");
    while ((prop = properties.pop())) {
        if (!con[prop]) {
            con[prop] = {};
        }
    }
    while ((method = methods.pop())) {
        if (!con[method]) {
            con[method] = dummy;
        }
    }
    // 修复console完毕!

修复的开始,作者用global.console做了判断,这是为了让polyfill可以跑在node和浏览器端做的两手准备,在node中的全局对象是global,在浏览器中也有全局对象global,自动指向window对象。

Chrome下,global === window // true

第一个声明var con = global.console对一些浏览器可能存在console但功能不全做了预防。第五个声明可以说是非常懒又非常取巧了,这么多个方法,如果写成数组,费时又费力,作者把方法们全部写成字符串,用逗号分隔,然后用字符串方法split()把字符串遇到逗号就分隔,用这种方法把所有方法放到了一个数组中。

细看这个实现方法才发现原来声明var properties = ["memory"]是因为memory是console对象的属性,而下面的一大串是console的方法。

console.memory用来记录js的堆大小,限制,和已经使用的堆内存

作者利用的方法是把数组中的元素一个个pop()出来(删除最后一个元素并返回它),并判断console对象中是否有这个属性/方法,没有就给一个空函数或者空对象,作者并不想提供console中对应的这些属性的功能,目的只是想在代码中使用了console后在老版本浏览器中能静默地失败而不报错。

作者的下一个polyfill觉得较难,放在最后讲,先跳过。


bind的polyfill

var fProto = Function.prototype;
    if (needFix(fProto.bind)) {
        fProto.bind = function () {
            var fn = this;
            var presetArgs = [].slice.call(arguments);
            var context = presetArgs.shift();        // 这一步既抽出了上下文,也得到了真正要传入的参数数组
            var curry = function () {
                for (var i = 0, n = arguments.length; i < n; i++) {
                    presetArgs.push(arguments[i]);
                }
                return fn.apply(context, presetArgs);
            };
            curry.name = "bound " + (fn.name || "anonymous");
            return curry;
        };
    }
Arguments转为纯数组

这里面第一个有意思的地方,就是第二个声明var persetArgs = [].slice.call(arguments):把arguments转化为纯数组,因为arguments是类数组,除了length属性,没有纯数组有的其他属性和方法。通过声明中的技巧把一个Arguments转化为纯数组。

类型判断返回 [object Arguments]

为了写这篇文章google了[].slice.call(arguments)的原理,算是明白为什么要这样调用了:
slice()是数组对象的方法,返回一份数组的拷贝,根据传参的不同返回原数组中指定位置的元素组成的新数组,如果什么都不传,就相当于浅拷贝了一份调用他的数组出来。

var arr  = [1, 2, 3];
var newArr = arr.slice();      
console.log(newArr);      // [1, 2, 3]

前面说过了Arguments类型没有数组上的方法,我们又知道call()可以改变调用对象的上下文,[].slice.call(arguments)就相当于是arguments调用了slice(),而不是数组的Arguments凭什么用call()就能调用数组上的方法呢,我的理解是,slice()可以接受的是“一类数据类型”,只要该类型可以把slice()调用后返回一个数组,就都有“资格”调用slice(),类似地,字符串,也可以调用数组的slice()方法,把字符串转化为数组。

var s = 'string';
[].slice.call(s)      // ["s", "t", "r", "i", "n", "g"]

接着,用shift()取出传入的第一个参数,是要bind的上下文。bind()不同于call()apply()bind()是返回一个绑定了新上下文的新函数,而且在绑定的时候可以传入参数,而在返回的函数上又可以传入参数,听起来是不是有柯里化的感觉,所以作者把最后返回的函数命名为curry可以说是非常形象了。
而据说面试中常会考到的:写一个bind的polyfill,实际原理就是返回一个调用了call()或者apply()的函数。只是还要多做一步是对新传入的参数也要放到返回的函数中。

修正 Array.prototype.splice
var arrayProto = Array.prototype;
  if (0 === [1, 2].splice(0).length) {
        var _splice = arrayProto.splice;
        arrayProto.splice = function (a) {
            var args = arrayProto.slice.call(arguments);
            if (typeof args[1] !== "number") {
                //IE6-8只能重写已经存在的索引值。比如aaa(1,2,3),只有三个参数,不能以arguments[3] =88来添加第4个参数
                args[1] = this.length;
            }
            return _splice.apply(this, args);
        };
    }

splice()是数组对象中最灵活的方法,可以删除、插入,删除后插入。
发现作者是用0 === [1, 2].splice(0).length去判断数组中是否含有splice()方法的,因为有意思的是,虽然在IE8及以下不支持ES5,但是splice()在这些浏览中是"存在“的,只是返回到结果不符合预期:

// 现代浏览器
[1, 2].splice(0)        // [1, 2]
// IE 8及以下
[1, 2].splice(0)        // [ ]

这样导致直接判断Array.prototype.splice会返回true,所以才使用这种方法判断数组对象是否存在splice()方法。
查了资料,发现旧版本的浏览器并不是没有splice(),只是旧版本浏览器要求第二个参数是必填的,只要填上第二个参数,就能返回正确的数组,所以作者的思路就是:判断是否有第二个参数,没有就让数组的长度等于第二个参数,意味着从start(开始的位置)截到最后。
作者考虑到IE6-8中arguments只能重写已存在的索引,所以把arguments转成了纯数组,再用apply()把数组传入splice()

这里面就有两个之前不知道的知识点。


支持 Array.prototype.forEach
    if (needFix(arrayProto.forEach)) {
        arrayProto.forEach = function (callback, thisArg) {
            var array = this;
            for (var i = 0, n = array.length; i < n; i++) {
                if (i in array) {
                    callback.call(thisArg, array[i], i, array);
                }
            }
        };
    }

forEach()除了第一个参数传入一个callback()之外,还允许第二个参数,用于改变callback()执行的上下文
forEach()的polyfill就是使用for遍历调用forEach()的数组,再把key、value、数组本身传入。
自己实现了一个用for...in循环的forEach,但是考虑到for...in会把数组定义的属性遍历出来,所以不建议用for...in操作数组。

支持 Array.isArray
   var toString = oProto.toString;
    if (needFix(Array.isArray)) {
        Array.isArray = function (arr) {
            return toString.call(arr) == "[object Array]";
        };
    }

很简单地利用Object.prototype.toString.call(arr)判断传入的参数是否是数组,toString在最上面已经声明了,为了方便阅读加到这里。

支持 Object.is
    if (needFix(Object.is)) {
        Object.is = function is(x, y) {
            if (x === y) {
                // Steps 1-5, 7-10 Steps 6.b-6.e: +0 != -0 Added the nonzero y check to make
                // Flow happy, but it is redundant
                return x !== 0 || y !== 0 || 1 / x === 1 / y;
            } else {
                // Step 6.a: NaN == NaN
                return x !== x && y !== y;
            }
        };
    }

ES6中的Object.is()用于判断两个对象是否完全相等,也可以用于普通类型的判断。
第一个判断不明白是为了预防什么情况才这样写的,应该是为了处理else中Object.is(NaN, NaN)要返回true的情况,我们知道,在JavaScript中,NaN是不等于NaN的,Object.is()让两个NaN的判断返回true。当参数x不等于它自身,且参数y也不等于它自身,我们可以知道这两个参数都是NaN

支持Object.create
if (needFix(Object.create)) {
    Object.create = function(obj) {
        function F () {};
        F.prototype = obj;
        return new F();
    }
}

利用原型式继承,返回一个继承了传入对象原型的对象。这样新对象就可以使用传入对象的方法和属性(一般不会使用原对象的引用型属性,因为修改引用类型会导致传入对象的引用类型也会被改变)。

支持 Object.assign(浅拷贝)

    if (needFix(Object.assign)) {
        Object.assign = function (target) {
            if (target === undefined || target === null) {
                throw new TypeError("Cannot convert undefined or null to object");
            }
            // Object.assign target不止可以是Obj,还可以是string,number,所以要对象化
            var output = Object(target);
            for (var index = 1; index < arguments.length; index++) {
                var source = arguments[index];
                if (source !== undefined && source !== null) {
                    for (var nextKey in source) {
                        if (source.hasOwnProperty(nextKey)) {
                            output[nextKey] = source[nextKey];
                        }
                    }
                }
            }
            return output;
        };
    }

第一个判断,Object.assign不允许传入nullundefined。若传入则报错。
ES6中的Object.assign不但可以传入对象,还可以接受Number,Boolean,String作为target,把传入的普通类型变成对象类型。所以作者把传入的target先对了“对象化”,把传入的target转为target类型。

var s = "string";
var newS = Object(s)
typeof newS        // "Object"

for循环从1开始,因为第一个参数是target,因为Object.assign是浅复制,所以只需要把传入的对象for...in遍历出来,因为for...in会把原型上的属性也遍历出来,所以加上hasOwnProperty判断,如果是对象自身的属性,就给对象增加这个键值,因为Object.assign对target上已经出现的属性是做覆盖处理,所以直接重写属性即可,最后返回一个新对象。

支持Object.keys
if (needFix(Object.keys)) {
        var hasDontEnumBug = !{ toString: null }.propertyIsEnumerable("toString");
        var dontEnums = [
            "toString",
            "toLocaleString",
            "valueOf",
            "hasOwnProperty",
            "isPrototypeOf",
            "propertyIsEnumerable",
            "constructor"
        ];

        Object.keys = function (obj) {
            // 对象化的对象仍然是对象,其他数据类型对象化后不再等于自身
            if (Object(obj) !== obj) {
                throw new TypeError("Object.keys called on non-object");
            }
            var result = [],
                prop,
                i;

            for (prop in obj) {
                // 只遍历出对象自身的属性
                if (hasOwnProperty.call(obj, prop)) {
                    result.push(prop);
                }
            }

            // 作者认为既然不可枚举的属性被重写了,就应该被枚举出来
            if (hasDontEnumBug) {
                for (i = 0; i < 7; i++) {
                    if (hasOwnProperty.call(obj, dontEnums[i])) {
                        result.push(dontEnums[i]);
                    }
                }
            }
            return result;
        };
    }
})(typeof window === "undefined" ? this : window);

ES6提供的Object.keys作用是返回由传入对象的键组成的数组。原型上的属性的键不传入。但是如果是重写了的原型属性,比如重写了toString()valueOf()等属性,则应该被放入数组中。

可能是有些浏览器有即使原生方法被重写了也无法被for...in遍历出来的bug(for...in会遍历出原型上的属性但不会遍历出原生的属性),所以作者的第一个声明就是判断该浏览器是否有这样的bug。

!{ toString: null }.propertyIsEnumerable("toString");
这句话涉及到运算符的优先级问题,根据MDN中的优先级表,成员访问操作符(点操作符)优先级高于逻辑非运算符。
作者声明一个对象并重写对象上的toString(),再调用对象上的propertyIsEnumerable()方法判断该对象上的toString()是否可以被遍历,如果不行,则说明有上面提到的重写原生方法无法遍历的bug。
作者在最后一个判断做了处理:把对象上的七个原型方法放入一个数组,遍历这个数组做hasOwnProperty()判断,如果对象上有该方法,则手动放入最后返回的数组中。

回到Object.keys,作者的第二个判断目的是判断传入的参数是否是对象类型,一次省去了做判number,boolean,string的过程,因为普通类型被“对象化”后就不再等于它本身,而引用类型被“对象化”了仍然是个对象,由此可以判断传入的参数类型。这一手Object()的妙用真是666.作者一共使用了两次Object(),一次把普通类型对象化,一次用来判断传参是否是对象。
接着就是做for...in,循环出参数的key值,判是否是自身的属性,是就放入数组,接着就是最后的“重写的原生方法也要可遍历”问题。

作者的polyfill中还有一个是做JSON对象中stringify()parse()的polyfill,但是觉得实在很复杂,且贴上来看能说多少吧,有知道这个polyfill的小伙伴还望赐教。

//https://github.com/flowersinthesand/stringifyJSON/blob/master/stringifyjson.js
    function quote(string) {
        return (
            "\"" +
            string.replace(escapable, function (a) {
                var c = meta[a];
                return typeof c === "string"
                    ? c
                    : "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4);
            }) +
            "\""
        );
    }

    function f(n) {
        return n < 10 ? "0" + n : n;
    }

    function str(key, holder) {
        var i,
            v,
            len,
            partial,
            value = holder[key],
            type = typeof value;

        if (
            value &&
            typeof value === "object" &&
            typeof value.toJSON === "function"
        ) {
            value = value.toJSON(key);
            type = typeof value;
        }

        switch (type) {
        case "string":
            return quote(value);
        case "number":
            return isFinite(value) ? String(value) : "null";
        case "boolean":
            return String(value);
        case "object":
            if (!value) {
                return "null";
            }

            switch (toString.call(value)) {
            case "[object Date]":
                return isFinite(value.valueOf())
                    ? "\"" +
                            value.getUTCFullYear() +
                            "-" +
                            f(value.getUTCMonth() + 1) +
                            "-" +
                            f(value.getUTCDate()) +
                            "T" +
                            f(value.getUTCHours()) +
                            ":" +
                            f(value.getUTCMinutes()) +
                            ":" +
                            f(value.getUTCSeconds()) +
                            "Z\""
                    : "null";
            case "[object Array]":
                len = value.length;
                partial = [];
                for (i = 0; i < len; i++) {
                    partial.push(str(i, value) || "null");
                }

                return "[" + partial.join(",") + "]";
            default:
                partial = [];
                for (i in value) {
                    if (hasOwnProperty.call(value, i)) {
                        v = str(i, value);
                        if (v) {
                            partial.push(quote(i) + ":" + v);
                        }
                    }
                }

                return "{" + partial.join(",") + "}";
            }
        }
    }
    if (typeof JSON === "undefined") {
        var escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
            meta = {
                "\b": "\\b",
                "\t": "\\t",
                "\n": "\\n",
                "\f": "\\f",
                "\r": "\\r",
                "\"": "\\\"",
                "\\": "\\\\"
            };



        global.JSON = {
            stringify: function (value) {
                return str("", { "": value });
            },
            //http://www.cnblogs.com/fengzekun/p/3940918.html
            parse: function () {
                return new Function("return " + data)();
            }
        };
    }

最后,再次感叹写文章的好处,开始动手写文章才会去仔细看每个函数的实现,这样才能言之有物,才发现了函数中的实现细节,原来Object()可以这样用,原来arguments在IE8及以下不能被扩展,原来arguments转为纯数组的原理是这样的,原来重写原生方法会有不可遍历问题, 原来console有个memory属性,bind的实现有curry思想,IE8及以下有splice()方法,判断一个变量是NaN的方法,一些ES6的对象方法不但可以接收对象类型也可以接受普通类型......
还有一个好处,可以站在写文章的人的角度看问题,为什么有些人写文章的时候要这样写,思路是什么,这样对阅读别人的文章也能更好地理解。那些看起来很长的文章,其实很可能是我这种分块分析的,看起来很长,一段段看也不会有影响。贴代码的,看到代码就头大,看到运行截图就难受,其实就是一些运行结果,不过还是自己运行出来的结果看得舒服,知其然而知其所以然。

----------------end---------------------

参考文章:
### arrays - Explanation of [].slice.call in javascript? - Stack Overflow

### javascript - IE <= 8 .splice() not working - Stack Overflow

### 运算符优先级- JavaScript | MDN

推荐阅读更多精彩内容

  • 第2章 基本语法 2.1 概述 基本句法和变量 语句 JavaScript程序的执行单位为行(line),也就是一...
    枫叶appiosg阅读 2,956评论 0 13
  •   引用类型的值(对象)是引用类型的一个实例。   在 ECMAscript 中,引用类型是一种数据结构,用于将数...
    霜天晓阅读 650评论 0 1
  • 今天遇到了一个天坑,我这么相信微信,结果被微信偷偷坑死了一回。 微信小程序解密encryptedData数据字符串...
    oliwen阅读 1,717评论 0 1
  • 那些流浪在北京街头的歌手 那些坐在酒吧嘶声力竭的驻唱 那些民谣啊 一首一人一故事 生活是有诗意,有理想的 只不过,...
    樱菊桃杉阅读 66评论 0 3
  • “我用最合适的相貌出现在每个灵魂面前。在遇到下一个灵魂之前,我一直保持这样的相貌。我不知道自己遇到第一个灵魂之...
    DKoNg阅读 235评论 0 0