《JavaScript框架設計(第2版)》之語言模組

非同步社群發表於2019-02-27

本文摘自:人民郵電出版社非同步圖書《JavaScript框架設計(第2版)》


試讀本書:www.epubit.com.cn/book/detail…

敲重點:
活動規則:試讀樣章,評論區留言說一下你對本書的一些感想,同時關注非同步社群專欄,並留言你想要得到的圖書。
活動時間:即日起-9月10日(活動獎項公告在9月11日)
贈書數量:1本 先到先得!
備註:可以選本書作為獎品也可以選擇其他圖書
更多好書可以來人郵社非同步社群檢視,申請下期活動:www.epubit.com.cn/

第2章 語言模組


1995年,Brendan Eich讀完了在程式語言設計中曾經出現過的所有錯誤,自己又發現了一些更多的錯誤,然後用它們創造出了LiveScript。之後,為了緊跟Java語言的潮流,它被重新命名為JavaScript。再然後,為了追隨一種皮膚病的時髦名字,這個語言又命名為ECMAScript。


上面一段話出自博文《程式語言偽簡史》。可見,JavaScript受到了多麼辛辣的嘲諷,它在當時是多麼不受歡迎。拋開偏見,JavaScript的確有許多不足之處。由於網際網路的傳播性及瀏覽器廠商大戰,JavaScript之父失去了對此門語言的掌控權。即便他想修復這些bug或推出某些新特性,也要所有瀏覽器廠商都點頭才行。IE6的市場獨佔性,打破了他的奢望。這個局面直到Chrome誕生,才有所改善。


但在IE6時期,瀏覽器提供的原生API數量是極其貧乏的,因此各個框架都創造了許多方法來彌補這缺陷。視框架作者原來的語言背景不同,這些方法也是林林總總。其中最傑出的代表是王者Prototype.js,把ruby語言的那一套方式或正規化搬過來,從底層促進了JavaScript的發展。ECMA262V6新增那一堆字串、陣列方法,差不多就是改個名字而已。


即便是瀏覽器的API也不能盡信,尤其是IE6、IE7、IE8到處是bug。早期出現的各種“JS庫”,例如遠古的prototype、中古的mootools,到近代的jQuery,再到大規模、緊封裝的YUI和Extjs,很大的一個目標就是為了填“相容性”這個“大坑”。


在avalon2中,就提供了許多帶compact命名的模組,它們就是專門用於修復古老瀏覽器的相容性問題。此外,本章也介紹了一些非常底層的知識點,能讓讀者更熟悉這門語言。


2.1 字串的擴充套件與修復


筆者發現指令碼語言都對字串特別關注,有關它的方法特別多。筆者把這些方法分為三大類,如圖2-1所示。



圖2-1


顯然以前,總是想著通過字串生成標籤,於是誕生了一些方法,如anchor、big、blink、bold、fixed、fontcolor、italics、link、small、strike、sub及sup。


剩下的就是charAt、charCodeAt、concat、indexOf、lastIndexOf、localeCompare、match、replace,search、slice、split、substr、substring、toLocaleLowerCase、toLocaleUpperCase、toLowerCase、toUpperCase及從Object繼承回來的方法,如toString、valueOf。


鮮為人知的是,數值的toString有一個引數,通過它可以轉換為進行進位制的數值,如圖 2-2所示。



圖2-2


但相對於其他語言,JavaScript的字串方法可以說是十分貧乏的,因此後來的ES5、ES6又加上了一堆方法。


即便這樣,也很難滿足開發需求,比如說新增的方法就遠水救不了近火。因此各大名庫都提供了一大堆操作字串的方法。我綜合一下Prototype、mootools、dojo、EXT、Tangram、RightJS的一些方法,進行比較去重,在mass Framework為字串新增如下擴充套件:contains、startsWith、endsWith、repeat、camelize、underscored、capitalize、stripTags、stripScripts、escapeHTML、unescapeHTML、escapeRegExp、truncate、wbr、pad,寫框架的讀者可以視自己的情況進行增減,如圖2-3所示。其中前4個是ECMA262V6的標準方法;接著9個發端於Prototype.js廣受歡迎的工具方法;wbr則來自Tangram,用於軟換行,這是出於漢語排版的需求。pad也是一個很常用的操作,已被收錄,如圖2-3所示。



圖2-3


到了另一個框架avalon2,筆者的方法也有用武之地,或者改成avalon的靜態方法,或者作為ECMA262V6的補丁模組,或者作為過濾器(如camelize、truncate)。


各種方法實現如下。


contains 方法:判定一個字串是否包含另一個字串。常規思維是使用正規表示式。但每次都要用new RegExp來構造,效能太差,轉而使用原生字串方法,如indexOf、lastIndexOf、search。


function contains(target, it) {
//indexOf改成search,lastIndexOf也行得通
return target.indexOf(it) != -1;
}複製程式碼

在Mootools版本中,筆者看到它支援更多引數,估計目的是判定一個元素的className是否包含某個特定的class。眾所周知,元素可以新增多個class,中間以空格隔開,使用mootools的contains就能很方便地檢測包含關係了。


function contains(target, str, separator) {
return separator ?
(separator + target + separator).indexOf(separator + str + separator) > -1 :
target.indexOf(str) > -1;
}複製程式碼

startsWith方法:判定目標字串是否位於原字串的開始之處,可以說是contains方法的變種。


//最後一個引數是忽略大小寫
function startsWith(target, str, ignorecase) {
var start_str = target.substr(0, str.length);
return ignorecase ? start_str.toLowerCase() === str.toLowerCase() :
start_str === str;
}複製程式碼

endsWith方法:與startsWith方法相反。


//最後一個引數是忽略大小寫
function endsWith(target, str, ignorecase) {
var end_str = target.substring(target.length - str.length);
return ignorecase ? end_str.toLowerCase() === str.toLowerCase() :
end_str === str;
}複製程式碼

2.1.1 repeat


repeat方法:將一個字串重複自身N次,如repeat("ruby", 2)得到rubyruby。


版本1:利用空陣列的join方法。


function repeat(target, n) {
return (new Array(n + 1)).join(target);
}複製程式碼

版本2:版本1的改良版。建立一個物件,使其擁有length屬性,然後利用call方法去呼叫陣列原型的join方法,省去建立陣列這一步,效能大為提高。重複次數越多,兩者對比越明顯。另外,之所以要建立一個帶length屬性的物件,是因為要呼叫陣列的原型方法,需要指定call的第一個引數為類陣列物件,而類陣列物件的必要條件是其length屬性的值為非負整數。


function repeat(target, n) {
return Array.prototype.join.call({
length: n + 1
}, target);
}複製程式碼

版本3:版本2的改良版。利用閉包將類陣列物件與陣列原型的join方法快取起來,避免每次都重複建立與尋找方法。


var repeat = (function() {
var join = Array.prototype.join, obj = {};
return function(target, n) {
obj.length = n + 1;
return join.call(obj, target);
}
})();複製程式碼

版本 4:從演算法上著手,使用二分法,比如我們將ruby重複5次,其實我們在第二次已得到rubyruby,那麼第3次直接用rubyruby進行操作,而不是用ruby。


function repeat(target, n) {
var s = target, total = [];
while (n > 0) {
if (n % 2 == 1)
total[total.length] = s;//如果是奇數
if (n == 1)
break;
s += s;
n = n >> 1;//相當於將n除以2取其商,或說開2二次方
}
return total.join('');
}複製程式碼

版本5:版本4的變種,免去建立陣列與使用jion方法。它的短處在於它在迴圈中建立的字串比要求的還長,需要回減一下。


function repeat(target, n) {
var s = target, c = s.length n
do {
s += s;
} while (n = n >> 1);
s = s.substring(0, c);
return s;
}
複製程式碼

版本6:版本4的改良版。


function repeat(target, n) {
var s = target, total = "";
while (n > 0) {
if (n % 2 == 1)
total += s;
if (n == 1)
break;
s += s;
n = n >> 1;
}
return total;
}複製程式碼

版本7:與版本6相近。不過在瀏覽器下遞迴好像都做了優化(包括IE6),與其他版本相比,屬於上乘方案之一。


function repeat(target, n) {
if (n == 1) {
return target;
}
var s = repeat(target, Math.floor(n / 2));
s += s;
if (n % 2) {
s += target;
}
return s;
}複製程式碼

版本8:可以說是一個反例,很慢,不過實際上它還是可行的,因為實際上沒有人將n設成上百成千。


function repeat(target, n) {
return (n <= 0) ? "" : target.concat(repeat(target, --n));
}複製程式碼

經測試,版本6在各瀏覽器的得分是最高的。


2.1.2 byteLen


byteLen方法:取得一個字串所有位元組的長度。這是一個後端過來的方法,如果將一個英文字元插入資料庫char、varchar、text型別的欄位時佔用一個位元組,而將一箇中文字元插入時佔用兩個位元組。為了避免插入溢位,就需要事先判斷字串的位元組長度。在前端,如果我們要使用者填寫文字,限制位元組上的長短,比如發簡訊,也要用到此方法。隨著瀏覽器普及對二進位制的操作,該方法也越來越常用。


版本 1:假設當字串每個字元的Unicode編碼均小於或等於255時,byteLength為字串長度;再遍歷字串,遇到Unicode編碼大於255時,為byteLength補加1。


function byteLen(target) {
var byteLength = target.length, i = 0;
for (; i < target.length; i++) {
if (target.charCodeAt(i) > 255) {
byteLength++;
}
}
return byteLength;
}複製程式碼

版本2:使用正規表示式,並支援設定漢字的儲存位元組數。比如用mysql儲存漢字時,是3個位元組數。


function byteLen(target, fix) {
fix = fix ? fix : 2;
var str = new Array(fix + 1).join("-")
return target.replace(/[^\x00-\xff]/g, str).length;
}複製程式碼

版本3:來自騰訊的解決方案。騰訊通過多子域名+postMessage+manifest離線proxy頁面的方式擴大localStorage的儲存空間。在這個過程中,我們需要知道使用者已經儲存了多少內容,因此就必須編寫一個嚴謹的byteLen方法。


/**
 複製程式碼 www.alloyteam.com/2013/12/js-…
計算字串所佔的記憶體位元組數,預設使用UTF-8的編碼方式計算,也可制定為UTF-16 UTF-8 是一種可變長度的 Unicode 編碼格式,使用1~4個位元組為每個字元編碼
000000 - 00007F(128個程式碼) 0zzzzzzz(00-7F) 1個位元組
000080 - 0007FF(1920個程式碼) 110yyyyy(C0-DF) 10zzzzzz(80-BF) 2個位元組 000800 - 00D7FF
00E000 - 00FFFF(61440個程式碼) 1110xxxx(E0-EF) 10yyyyyy 10zzzzzz 3個位元組
010000 - 10FFFF(1048576個程式碼) 11110www(F0-F7) 10xxxxxx 10yyyyyy 10zzzzzz 4個位元組
注: Unicode在範圍 D800-DFFF 中不存在任何字元 {@link <a onclick="javascript:pageTracker._trackPageview('/outgoing/zh.wikipedia. org/wiki/UTF-8');"
href="zh.wikipedia.org/wiki/UTF-8"…
UTF-16 大部分使用2個位元組編碼,編碼超出 65535 的使用4個位元組 000000 - 00FFFF 2個位元組
010000 - 10FFFF 4個位元組
{@link <a onclick="javascript:pageTracker._trackPageview('/outgoing/zh.wikipedia. org/wiki/UTF-16');" href="zh.wikipedia.org/wiki/UTF-16…
@param {String} str @param {String} charset utf-8, utf-16
@return {Number} /
function byteLen(str, charset){
var total = 0,
charCode,
i,
len;
charset = charset ? charset.toLowerCase() : '';
if(charset === 'utf-16' || charset === 'utf16'){
for(i = 0, len = str.length; i < len; i++){
charCode = str.charCodeAt(i);
if(charCode <= 0xffff){
total += 2;
}else{
total += 4;
}
}
}else{
for(i = 0, len = str.length; i < len; i++){
charCode = str.charCodeAt(i);
if(charCode <= 0x007f) {
total += 1;
}else if(charCode <= 0x07ff){
total += 2;
}else if(charCode <= 0xffff){
total += 3;
}else{
total += 4;
}
}
}
return total;
}複製程式碼

truncate方法:用於對字串進行截斷處理。當超過限定長度,預設新增3個點號。


function truncate(target, length, truncation) {
length = length || 30;
truncation = truncation === void(0) ? '...' : truncation;
return target.length > length ?
target.slice(0, length - truncation.length) + truncation : String(target);
}複製程式碼

camelize方法:轉換為駝峰風格。


function camelize(target) {
if (target.indexOf('-') < 0 && target.indexOf('') < 0) {
return target;//提前判斷,提高getStyle等的效率
}
return target.replace(/[-
][^-]/g, function(match) {
return match.charAt(1).toUpperCase();
});
}
複製程式碼

underscored方法:轉換為下劃線風格。


function underscored(target) {
return target.replace(/([a-z\d])([A-Z])/g, '$1複製程式碼
$2').
replace(/-/g, '').toLowerCase();
}
複製程式碼

dasherize方法:轉換為連字元風格,即CSS變數的風格。


function dasherize(target) {
return underscored(target).replace(/複製程式碼
/g, '-');
}複製程式碼

capitalize方法:首字母大寫。


function capitalize(target) {
return target.charAt(0).toUpperCase() + target.substring(1).toLowerCase();
}複製程式碼

stripTags 方法:移除字串中的html標籤。比如,我們需要實現一個HTMLParser,這時就要處理option元素的innerText問題。此元素的內部只能接受文字節點,如果使用者在裡面新增了span、strong等標籤,我們就需要用此方法將這些標籤移除。在Prototype.js中,它與strip、stripScripts是一組方法。


var rtag = /<\w+(\s+("[^"]"|'[^']'|[^>])+)?>|<\/\w+>/gi
function stripTags(target) {
return String(target || "").replace(rtag, '');
}複製程式碼

stripScripts 方法:移除字串中所有的script標籤。彌補stripTags方法的缺陷。此方法應在stripTags之前呼叫。


function stripScripts(target) {
return String(target || "").replace(/<script[^>]>([\S\s]?)<\/script>/img, '')
}複製程式碼

escapeHTML 方法:將字串經過html轉義得到適合在頁面中顯示的內容,如將“<”替換為“&lt;”`。此方法用於防止XSS攻擊。


    function escapeHTML(target) {
return target.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}複製程式碼

unescapeHTML方法:將字串中的html實體字元還原為對應字元。


function unescapeHTML(target) {
return String(target)
.replace(/&#39;/g, '\'')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}複製程式碼

注意一下escapeHTML和unescapeHTML這兩個方法,它們不但在replace的引數是反過來的,replace的順序也是反過來的。它們在做html parser非常有用的。但涉及瀏覽器,相容性問題就一定會存在。


在citojs這個庫中,有一個類似於escapeHTML的方法叫escapeContent,它是這樣寫的。


function escapeContent(value) {
value = '' + value;
if (isWebKit) {
helperDiv.innerText = value;
value = helperDiv.innerHTML;
} else if (isFirefox) {
value = value.split('&').join('&amp;').split('<').join('&lt;').split('>'). join('&gt;');
} else {
value = value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
return value;
}複製程式碼

看情況是處理&amp;時出了分歧。但它們這麼做其實也不能處理所有html實體。因此Prototype.js是建議使用原生API innerHTML, innerText來處理。


var div = document.createElement('div')

var escapeHTML = function (a) {
div.data = a
return div.innerHTML
}

var unescapeHTML = function (a) {
div.innerHTML = a
return getText(div)//相當於innerText, textContent
}

function getText(node) {
if (node.nodeType !== 1) {
return node.nodeValue
} else if (node.nodeName !== 'SCRIPT') {
var ret = ''
for (var i = 0, el; el = node.childNodes[i++]; ) {
ret += getText(el)
}
} else {
return ''
}
}複製程式碼

但這樣一來,它們就不能執行於Node.js環境中,並且效能也不好,於是人們發展出下面這些庫。


github.com/mathiasbyne…
github.com/mdevils/nod…複製程式碼

escapeRegExp方法:將字串安全格式化為正規表示式的原始碼。


function escapeRegExp(target) {
return target.replace(/([-.+?^${}()|[]\/\])/g, '\$1');
}
複製程式碼

2.1.3 pad


pad方法:與trim方法相反,pad可以為字串的某一端新增字串。常見的用法如日曆在月份前補零,因此也被稱之為fillZero。筆者在部落格上收集許多版本的實現,在這裡轉換為靜態方法一併寫出。


版本1:陣列法,建立陣列來放置填充物,然後再在右邊起擷取。


function pad(target, n) {
var zero = new Array(n).join('0');
var str = zero + target;
var result = str.substr(-n);
return result;
}複製程式碼

版本2:版本1的變種。


function pad(target, n) {
return Array((n + 1) - target.toString().split('').length).join('0') + target;
}複製程式碼

版本3:二進位制法。前半部分是建立一個含有n個零的大數,如(1<<5).toString(2),生成100000,(1<<8).toString(2)生成100000000,然後再截短。


function pad(target, n) {
return (Math.pow(10, n) + "" + target).slice(-n);
}複製程式碼

版本4:Math.pow法,思路同版本3。


function pad(target, n) {
return ((1 << n).toString(2) + target).slice(-n);
}複製程式碼

版本5:toFixed法,思路與版本3差不多,建立一個擁有n個零的小數,然後再截短。


function pad(target, n) {
return (0..toFixed(n) + target).slice(-n);
}複製程式碼

版本6:建立一個超大數,在常規情況下是截不完的。


function pad(target, n) {
return (1e20 + "" + target).slice(-n);
}複製程式碼

版本7:質樸長存法,就是先求得長度,然後一個個地往左邊補零,加到長度為n為止。


function pad(target, n) {
var len = target.toString().length;
while (len < n) {
target = "0" + target;
len++;
}
return target;
}複製程式碼

版本8:也就是現在mass Framework使用的版本,支援更多的引數,允許從左或從右填充,以及使用什麼內容進行填充。


function pad(target, n, filling, right, radix) {
var num = target.toString(radix || 10);
filling = filling || "0";
while (num.length < n) {
if (!right) {
num = filling + num;
} else {
num += filling;
}
}
return num;
}複製程式碼

在ECMA262V7規範中,pad方法也有了對應的代替品——padStart,此外,還有從後面補零的方法——padEnd


github.com/es-shims/es…複製程式碼

wbr方法:為目標字串新增wbr軟換行。不過需要注意的是,它並不是在每個字元之後都插入<wbr>字樣,而是相當於在組成文字節點的部分中的每個字元後插入<wbr>字樣。例如,aa<span> bb</span>cc,返回a<wbr>a<wbr><span>b<wbr>b<wbr></span>c<wbr>c<wbr>。另外,在Opera下,瀏覽器預設css不會為wbr加上樣式,導致沒有換行效果,可以在css中加上wbrafter { content: "\00200B" }解決此問題。


function wbr(target) {
return String(target)
.replace(/(?:<[^>]+>)|(?:&#?[0-9a-z]{2,6};)|(.{1})/gi, '$&<wbr>')
.replace(/><wbr>/g, '>');
}複製程式碼

format方法:在C語言中,有一個叫printf的方法,我們可以在後面新增不同型別的引數嵌入到將要輸出的字串中。這是非常有用的方法,因為JavaScript涉及大量的字串拼接工作。如果涉及邏輯,我們可以用模板;如果輕量點,我們可以用這個方法。它在不同框架中名字是不同的,Prototype.js叫interpolate;Base2叫format;mootools叫substitute


function format(str, object) {
var array = Array.prototype.slice.call(arguments, 1);
return str.replace(/\?#{([^{}]+)}/gm, function(match, name) {
if (match.charAt(0) == '\')
return match.slice(1);
var index = Number(name)
if (index >= 0)
return array[index];
if (object && object[name] !== void 0)
return object[name];
return '';
});
}複製程式碼

format方法支援兩種傳參方法,如果字串的佔位符為0、1、2這樣的非零整數形式,要求傳入兩個或兩個以上的引數,否則就傳入一個物件,鍵名為佔位符。


var a = format("Result is #{0},#{1}", 22, 33);
alert(a);//"Result is 22,33"
var b = format("#{name} is a #{sex}", {
name: "Jhon",
sex: "man"
});
alert(b);//"Jhon is a man"複製程式碼

2.1.4 quote


quote 方法:在字串兩端新增雙引號,然後內部需要轉義的地方都要轉義,用於接裝JSON的鍵名或模板系統中。


版本1:來自JSON3。


//avalon2
//github.com/bestiejs/js…
var Escapes = {
92: "\\",
34: '\"',
8: "\b",
12: "\f",
10: "\n",
13: "\r",
9: "\t"
}

// Internal: Converts 'value' into a zero-padded string such that its
// length is at least equal to 'width'. The 'width' must be <= 6.
var leadingZeroes = "000000"
var toPaddedString = function (width, value) {
// The '|| 0' expression is necessary to work around a bug in
// Opera <= 7.54u2 where '0 == -0', but 'String(-0) !== "0"'.
return (leadingZeroes + (value || 0)).slice(-width)
};
var unicodePrefix = "\u00"
var escapeChar = function (character) {
var charCode = character.charCodeAt(0), escaped = Escapes[charCode]
if (escaped) {
return escaped
}
return unicodePrefix + toPaddedString(2, charCode.toString(16))
};
var reEscape = /[\x00-\x1f\x22\x5c]/g
function quote(value) {
reEscape.lastIndex = 0
return '"' + ( reEscape.test(value)? String(value).replace(reEscape, escapeChar) : value ) + '"'
}

avalon.quote = typeof JSON !== 'undefined' ? JSON.stringify : quote複製程式碼

版本2:來自百度的etpl模板庫。


//github.com/ecomfe/etpl…
function stringLiteralize(source) {
return '"'
+ source
.replace(/\x5C/g, '\\')
.replace(/"/g, '\"')
.replace(/\x0A/g, '\n')
.replace(/\x09/g, '\t')
.replace(/\x0D/g, '\r')
+ '"';
}複製程式碼

當然,如果瀏覽器已經支援原生JSON,我們直接用JSON.stringify就行了。另外,FF在JSON發明之前,就支援String.prototype.quote與String.quote方法,我們在使用quote之前需要判定瀏覽器是否內建這些方法。


接下來,我們來修復字串的一些bug。字串相對其他基礎型別,沒有太多bug,主要是3個問題。


(1)IE6、IE7不支援用陣列中括號取它的每一個字元,需要用charAt來取。


(2)IE6、IE7、IE8不支援垂直分表符,於是誕生了var isIE678= !+"\v1"這個偉大的判定hack。


(3)IE對空白的理解與其他瀏覽器不一樣,因此實現trim方法會有一些不同。


前兩個問題只能迴避,我們重點研究第3個問題,也就是如何實現trim方法。由於太常用,所以相應的實現也非常多。我們可以一起看看,順便學習一下正則。


2.1.5 trim與空白


版本1:雖然看起來不怎麼樣,但是動用了兩次正則替換,實際速度非常驚人,這主要得益於瀏覽器的內部優化。base2類庫使用這種實現。在Chrome剛出來的年代,這實現是異常快的,但chrome對字串方法的瘋狂優化,引起了其他瀏覽器的跟風。於是正則的實現再也比不了字串方法了。一個著名的字串拼接例子,直接相加比用Array做成的StringBuffer還快,而StringBuffer技術在早些年備受推崇!


function trim(str) {
return str.replace(/^\s\s複製程式碼
/, '').replace(/\s\s$/, '');
}
……
複製程式碼

版本2:和版本1很相似,但稍慢一點,主要原因是它最先是假設至少存在一個空白符。Prototype.js使用這種實現,不過其名字為strip,因為Prototype的方法都是力求與Ruby同名。


<div class="se-preview-section-delimiter"></div>

…javascript
function trim(str) {
return str.replace(/^\s+/, '').replace(/\s+$/, '');
}複製程式碼

版本 3:擷取方式取得空白部分(當然允許中間存在空白符),總共呼叫了 4 個原生方法。設計非常巧妙,substring以兩個數字作為引數。Math.max以兩個數字作引數,search則返回一個數字。速度比上面兩個慢一點,但基本比10之前的版本快!


function trim(str) {
return str.substring(Math.max(str.search(/\S/), 0),
str.search(/\S\s複製程式碼
$/) + 1);
}複製程式碼

版本4:這個可以稱得上版本2的簡化版,就是利用候選操作符連線兩個正則。但這樣做就失去了瀏覽器優化的機會,比不上版本3。由於看來很優雅,許多類庫都使用它,如jQuery與Mootools。


function trim (str) {
return str.replace(/^\s+|\s+$/g, '');
}複製程式碼

版本 5:match 如果能匹配到東西會返回一個類陣列物件,原字元匹配部分與分組將成為它的元素。為了防止字串中間的空白符被排除,我們需要動用到非捕獲性分組(?:exp)。由於陣列可能為空,我們在後面還要做進一步的判定。好像瀏覽器在處理分組上比較無力,一個字慢。所以不要迷信正則,雖然它基本上是萬能的。


function trim(str) {
str = str.match(/\S+(?:\s+\S+)/);
return str ? str[0] : '';
}
複製程式碼

版本6:把符合要求的部分提供出來,放到一個空字串中。不過效率很差,尤其是在IE6中。


function trim(str) {
return str.replace(/^\s複製程式碼
(\S(\s+\S+))\s$/, '$1');
}
複製程式碼

版本7:與版本6很相似,但用了非捕獲分組進行了優點,效能較之有一點點提升。


function trim(str) {
return str.replace(/^\s複製程式碼
(\S(?:\s+\S+))\s$/, '$1');
}
複製程式碼

版本8:沿著上面兩個的思路進行改進,動用了非捕獲分組與字符集合,用“?”頂替了“”,效果非常驚人。尤其在IE6中,可以用瘋狂來形容這次效能的提升,直接秒殺FF3。


function trim(str) {
return str.replace(/^\s((?:[\S\s]\S)?)\s$/, '$1');
}
複製程式碼

版本9:這次是用懶惰匹配頂替非捕獲分組,在火狐中得到改善,IE沒有上次那麼瘋狂。


function trim(str) {
return str.replace(/^\s複製程式碼
([\S\s]?)\s$/, '$1');
}複製程式碼

版本 10:筆者只想說,搞出這個的人已經不能用厲害來形容,而是專家級別了。它先是把可能的空白符全部列出來,在第一次遍歷中砍掉前面的空白,第二次砍掉後面的空白。全過程只用了indexOf與substring這個專門為處理字串而生的原生方法,沒有使用到正則。速度快得驚人,估計直逼內部的二進位制實現,並且在IE與火狐(其他瀏覽器當然也毫無疑問)都有良好的表現,速度都是零毫秒級別的,PHP.js就收納了這個方法。


Function trim(str) {
var whitespace = ' \n\r\t\f\x0b\xa0\u2000\u2001\u2002\u2003\n\
\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u2028\u2029\u3000';
for (var I = 0; I < str.length; I++) {
if (whitespace.indexOf(str.charAt(i)) === -1) {
str = str.substring(i);
break;
}
}
for (I = str.length – 1; I >= 0; I--) {
if (whitespace.indexOf(str.charAt(i)) === -1) {
str = str.substring(0, I + 1);
break;
}
}
return whitespace.indexOf(str.charAt(0)) === -1 ? str : ‘’;
}複製程式碼

版本 11:實現10的字數壓縮版,前面部分的空白由正則替換負責砍掉,後面用原生方法處理,效果不遜於原版,但速度都非常逆天。


Function trim(str) {
str = str.replace(/^\s+/, '');
for (var I = str.length – 1; I >= 0; I--) {
if (/\S/.test(str.charAt(i))) {
str = str.substring(0, I + 1);
break;
}
}
return str;
}複製程式碼

版本12:版本10更好的改進版,注意說的不是效能速度,而是易記與使用方面。


Function trim(str) {
var m = str.length;
for (var I = -1; str.charCodeAt(++I) <= 32; )
for (var j = m – 1; j > I && str.charCodeAt(j) <= 32; j--)
return str.slice(I, j + 1);
}複製程式碼

但這還沒有完。如果你經常翻看jQuery的實現,你就會發現jQuery1.4之後的trim實現,多出了一個對xA0的特別處理。這是Prototype.js的核心成員·kangax的發現,IE或早期的標準瀏覽器在字串的處理上都有bug,把許多本屬於空白的字元沒有列為\s,jQuery在1.42中也不過把常見的不斷行空白xA0修復掉,並不完整,因此最佳方案還是版本10。


// Make sure we trim BOM and NBSP
var rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,
jQuery.trim = function( text ) {
return text == null ?
"" :
( text + "" ).replace( rtrim, "" );
}複製程式碼

下面是一個比較晦澀的知識點——空白字元。根據屈屈的博文[1],瀏覽器會把WhiteSpace和LineTerminator都列入空白字元。Ecma262 v5文件規定的WhiteSpace,如表2-1所示。


表2-1

Unicode編碼

說明

U+0020

" " "\x20", "\u0020", <SP>半形空格符,鍵盤空格鍵

U+0009

"\t", "\x09", "\u0009", <TAB>製表符,鍵盤tab鍵

U+000B

"\v", "\x0B", "\u000B",<VT>垂直製表符

U+000C

"\f", "\x0C", "\u000C",<FF>換頁符

U+000D

"\r", "\x0D", "\u000D",<CR>回車符

U+000A

"\n", "\x0A", "\u000A",<LF>換行符

U+00A0

"\xA0", "\u00A0",<NBSP>禁止自動換行空格符

U+1680

OGHAM SPACE MARK,歐甘空格

U+180E

Mongolian Vowel Separator,蒙古文母音分隔符

U+2000

EN QUAD

U+2001

EM QUAD

U+2002

EN SPACE,En空格。與En同寬(Em的1/2)

U+2003

EM SPACE,Em空格。與Em同寬

U+2004

THREE-PER-EM SPACE,Em 1/3空格

U+2005

FOUR-PER-EM SPACE,Em 1/4空格

U+2006

SIX-PER-EM SPACE,Em 1/6空格

U+2007

FIGURE SPACE,數字空格。與單一數字同寬

U+2008

PUNCTUATION SPACE,標點空格。與同字型窄標點同寬

U+2009

THIN SPACE,窄空格。Em 1/6或1/5寬

U+200A

HAIR SPACE,更窄空格。比窄空格更窄

U+200B

Zero Width Space,<ZWSP>,零寬空格

U+200C

Zero Width Non Joiner,<ZWNJ>,零寬不連字空格

U+200D

Zero Width Joiner,<ZWJ>,零寬連字空格

U+202F

NARROW NO-BREAK SPACE,窄式不換行空格

U+2028

<LS>行分隔符

U+2029

<PS>段落分隔符

U+205F

中數學空格。用於數學方程式

U+2060

Word Joiner,同U+200B,但該處不換行。Unicode 3.2新增,代替U+FEFF

U+3000

IDEOGRAPHIC SPACE,<CJK>,表意文字空格,即全形空格

U+FEFF

Byte Order Mark,<BOM>,位元組次序標記字元。不換行功能於Unicode 3.2起廢止


2.2 陣列的擴充套件與修復


得益於Prototype.js的ruby式陣列方法的侵略,讓Jser()前端工程師大開眼界,原來對陣列的操作也如此豐富多彩。原來JavaScript的陣列方法就是基於棧與佇列的那一套,像splice還是很晚加入的。讓我們回顧一下它們的用法,如圖2-4所示。



圖2-4



  • pop方法:出棧操作,刪除並返回陣列的最後一個元素。

  • push方法:入棧操作,向陣列的末尾新增一個或更多元素,並返回新的長度。

  • shift方法:出隊操作,刪除並返回陣列的第一個元素。

  • unshift方法:入隊操作,向陣列的開頭新增一個或更多元素,並返回新的長度。

  • slice方法:切片操作,從陣列中分離出一個子陣列,功能類似於字串的。


substring、slice和substr是“三兄弟”,常用於轉換類陣列物件為真正的陣列。



  • sort方法:對陣列的元素進行排序,有一個可選引數,為比較函式。

  • reverse方法:顛倒陣列中元素的順序。

  • splice方法:可以同時用於原陣列的增刪操作,陣列的remove方法就是基於它寫成的。

  • concat方法:用於把原陣列與引數合併成一個新陣列,如果引數為陣列,那麼它會把其第一維的元素放入新陣列中。因此我們可以利用它實現陣列的平坦化操作與克隆操作。

  • join方法:把陣列的所有元素放入一個字串,元素通過指定的分隔符進行分隔。你可以想象成字串split的反操作。

  • indexOf方法:定位操作,返回陣列中第一個等於給定引數的元素的索引值。

  • lastIndexOf方法:定位操作,同上,不過是從後遍歷。索引操作可以說是字串同名方法的翻版,存在就返回非負整數,不存在就返回−1。

  • forEach方法:迭代操作,將陣列的元素依次傳入一個函式中執行。Ptototype.js中對應的名字為each。

  • map方法:收集操作,將陣列的元素依次傳入一個函式中執行,然後把它們的返回值組成一個新陣列返回。Ptototype.js中對應的名字為collect。

  • filter方法:過濾操作,將陣列的元素依次傳入一個函式中執行,然後把返回值為true的那個元素放入新陣列返回。在Prototype.js中,它有3個名字,即select、filter和findAll。

  • some方法:只要陣列中有一個元素滿足條件(放進給定函式返回true),那麼它就返回true。Ptototype.js中對應的名字為any。

  • every方法:只有陣列中所有元素都滿足條件(放進給定函式返回true),它才返回true。Ptototype.js中對應的名字為all。

  • reduce方法:歸化操作,將陣列中的元素歸化為一個簡單的數值。Ptototype.js中對應的名字為inject。

  • reduceRight方法:歸化操作,同上,不過是從後遍歷。


為了方便大家記憶,我們可以用圖2-5搞懂陣列的18種操作。



圖2-5


由於許多擴充套件也基於這些新的標準化方法,因此筆者先給出IE6、IE7、IE8的相容方案,全部在陣列原型上修復它們。


[1, 2, , 4].forEach(function(e){
console.log(e)
});
//依次列印出1,2,4,忽略第2、第3個逗號間的空元素複製程式碼

reduce與reduceRight是一組,我們可以利用reduce方法建立reduceRight方法。


ap.reduce = function(fn, lastResult, scope) {
if (this.length == 0)
return lastResult;
var i = lastResult !== undefined ? 0 : 1;
var result = lastResult !== undefined ? lastResult : this[0];
for (var n = this.length; i < n; i++)
result = fn.call(scope, result, this[i], i, this);
return result;
}

ap.reduceRight = function(fn, lastResult, scope) {
var array = this.concat().reverse();
return array.reduce(fn, lastResult, scope);
}複製程式碼

接下來,我們看看主流庫為陣列增加了哪些擴充套件吧。


Prototype.js的陣列擴充套件:eachSlice、detect、grep、include、inGroupsOf、invoke、max、min、partition、pluck、reject、sortBy、zip、size、clear、first、last、compact、flatten、without、uniq、intersect、clone、inspect。


Rightjs的陣列擴充套件:include、clean、clone、compact、empty、first、flatten、includes、last、max、merge、min、random、reject、shuffle、size、sortBy、sum、uniq、walk、without。


mootools的陣列擴充套件:clean、invoke、associate、link、contains、append、getLast、getRandom、include、combine、erase、empty、flatten、pick、hexToRgb、rgbToHex。


EXT的陣列擴充套件:contains、pluck、clean、unique、from、remove、include、clone、merge、intersect、difference、flatten、min、max、mean、sum、erase、insert。


Underscore.js的陣列擴充套件:detect、reject、invoke、pluck、sortBy、groupBy、sortedIndex、first、last、compact、flatten、without、union、intersection、difference、uniq、zip。


qooxdoo的陣列擴充套件:insertAfter、insertAt、insertBefore、max、min、remove、removeAll、removeAt、sum、unique。


Tangram的陣列擴充套件:contains、empty、find、remove、removeAt、unique。


我們可以發現,Prototype.js那一套方法影響深遠,許多庫都有它的影子,全面而細節地囊括了各種操作,大家可以根據自己的需要與框架宗旨制訂自己的陣列擴充套件。筆者在這方面的考量如下,至少要包含平坦化、去重、亂序、移除這幾個操作,其次是兩個集合間的操作,如取並集、差集、交集。


下面是各種具體實現。


contains方法:判定陣列是否包含指定目標。


function contains(target, item) {
return target.indexOf(item) > -1
}複製程式碼

removeAt方法:移除陣列中指定位置的元素,返回布林值表示成功與否。


function removeAt(target, index) {
return !!target.splice(index, 1).length
}複製程式碼

remove方法:移除陣列中第一個匹配傳參的那個元素,返回布林值表示成功與否。


function remove(target, item) {
var index = target.indexOf(item);
if (~index)
return removeAt(target, index);
return false;
}複製程式碼

shuffle 方法:對陣列進行洗牌。若不想影響原陣列,可以先複製一份出來操作。有關洗牌演算法的介紹,可見下面兩篇博文。


《Fisher-Yates Shuffle》


《陣列的完全隨機排列》


function shuffle(target) {
var j, x, i = target.length;
for (; i > 0; j = parseInt(Math.random() i),
x = target[--i], target[i] = target[j], target[j] = x) {
}
return target;
}
複製程式碼

random方法:從陣列中隨機抽選一個元素出來。


function random(target) {
return target[Math.floor(Math.random() 複製程式碼
target.length)];
}複製程式碼

flatten方法:對陣列進行平坦化處理,返回一個一維的新陣列。


function flatten(target) {
var result = [];
target.forEach(function(item) {
if (Array.isArray(item)) {
result = result.concat(flatten(item));
} else {
result.push(item);
}
});
return result;
}複製程式碼

unique方法:對陣列進行去重操作,返回一個沒有重複元素的新陣列。


function unique(target) {
var result = [];
loop: for (var i = 0, n = target.length; i < n; i++) {
for (var x = i + 1; x < n; x++) {
if (target[x] === target[i])
continue loop;
}
result.push(target[i]);
}
return result;
}複製程式碼

compact方法:過濾陣列中的null與undefined,但不影響原陣列。


function compact(target) {
return target.filter(function(el) {
return el != null;
});
}複製程式碼

pluck方法:取得物件陣列的每個元素的指定屬性,組成陣列返回。


function pluck(target, name) {
var result = [], prop;
target.forEach(function(item) {
prop = item[name];
if (prop != null)
result.push(prop);
});
return result;
}複製程式碼

groupBy方法:根據指定條件(如回撥物件的某個屬性)進行分組,構成物件返回。


function groupBy(target, val) {
var result = {};
var iterator = $.isFunction(val) ? val : function(obj) {
return obj[val];
};
target.forEach(function(value, index) {
var key = iterator(value, index);
(result[key] || (result[key] = [])).push(value);
});
return result;
}複製程式碼

sortBy方法:根據指定條件進行排序,通常用於物件陣列。


function sortBy(target, fn, scope) {
var array = target.map(function(item, index) {
return {
el: item,
re: fn.call(scope, item, index)
};
}).sort(function(left, right) {
var a = left.re, b = right.re;
return a < b ? -1 : a > b ? 1 : 0;
});
return pluck(array, 'el');
}複製程式碼

union方法:對兩個陣列取並集。


function union(target, array) {
return unique(target.concat(array));
}複製程式碼

intersect方法:對兩個陣列取交集。


function intersect(target, array) {
return target.filter(function(n) {
return ~array.indexOf(n);
});
}複製程式碼

diff方法:對兩個陣列取差集(補集)。


function diff(target, array) {
var result = target.slice();
for (var i = 0; i < result.length; i++) {
for (var j = 0; j < array.length; j++) {
if (result[i] === array[j]) {
result.splice(i, 1);
i--;
break;
}
}
}
return result;
}複製程式碼

min方法:返回陣列中的最小值,用於數字陣列。


function min(target) {
return Math.min.apply(0, target);
}複製程式碼

max方法:返回陣列中的最大值,用於數字陣列。


function max(target) {
return Math.max.apply(0, target);
}複製程式碼

基本上就這麼多了,如果你想實現sum方法,可以使用reduce方法。我們再來抹平Array原生方法在各瀏覽器的差異,一個是IE6、IE7下unshift不返回陣列長度的問題,一個splice的引數問題。unshift的bug很容易修復,可以使用函式劫持方式搞定。


if ([].unshift(1) !== 1) {
var _unshift = Array.prototype.unshift;
Array.prototype.unshift = function() {
_unshift.apply(this, arguments);
return this.length; //返回新陣列的長度
}
}複製程式碼

splice在一個引數的情況下,IE6、IE7、IE8預設第二個引數為零,其他瀏覽器為陣列的長度,當然我們要以標準瀏覽器為準!


下面是最簡單的修復方法。


if ([1, 2, 3].splice(1).length == 0) {
//如果是IE6、IE7、IE8,則一個元素也沒有刪除
var _splice = Array.prototype.splice;
Array.prototype.splice = function(a) {
if (arguments.length == 1) {
return _splice.call(this, a, this.length)
} else {
return _splice.apply(this, arguments)
}
}
}複製程式碼

下面是不利用任何原生方法的修復方法。


Array.prototype.splice = function(s, d) {
var max = Math.max, min = Math.min,
a = [], i = max(arguments.length - 2, 0),
k = 0, l = this.length, e, n, v, x;
s = s || 0;
if (s < 0) {
s += l;
}
s = max(min(s, l), 0);
d = max(min(isNumber(d) ? d : l, l - s), 0);
v = i - d;
n = l + v;
while (k < d) {
e = this[s + k];
if (e !== void 0) {
a[k] = e;
}
k += 1;
}
x = l - s - d;
if (v < 0) {
k = s + i;
while (x) {
this[k] = this[k - v];
k += 1;
x -= 1;
}
this.length = n;
} else if (v > 0) {
k = 1;
while (x) {
this[n - k] = this[l - k];
k += 1;
x -= 1;
}
}
for (k = 0; k < i; ++k) {
this[s + k] = arguments[k + 2];
}
return a;
}複製程式碼

一旦有了splice方法,我們也可以自行實現pop、push、shift、unshift方法,因此你明白為什麼這幾個方法是直接修改原陣列了吧?瀏覽器廠商的思路與我們一樣,大概也是用splice方法來實現它們!


var ap = Array.prototype
var _slice = sp.slice;
ap.pop = function() {
return this.splice(this.length - 1, 1)[0];
}

ap.push = function() {
this.splice.apply(this,
[this.length, 0].concat(_slice.call(arguments)));
return this.length;
}

ap.shift = function() {
return this.splice(0, 1)[0];
}

ap.unshift = function() {
this.splice.apply(this,
[0, 0].concat(_slice.call(arguments)));
return this.length;
}複製程式碼

陣列的空位


上面是一個forEach例子的演示,實質上我們通過修復原型方法的手段很難達到ecmascript規範的效果。緣故在於陣列的空位,它在JavaScript的各個版本中都不一致。


陣列的空位是指陣列的某一個位置沒有任何值。比如,Array建構函式返回的陣列都是空位。


Array(3) // [, , ,]複製程式碼

上面的程式碼中,Array(3)返回一個具有3個空位的陣列。


注意,空位不是undefined,而是一個位置的值等於undefined,但依然是有值的。空位是沒有任何值,in運算子可以說明這一點。


0 in [undefined, undefined, undefined] // true
0 in [, , ,] // false複製程式碼

上面的程式碼說明,第一個陣列的0號位置是有值的,第二個陣列的0號位置是沒有值的。


ECMA262V5對空位的處理,已經很不一致了,大多數情況下會忽略空位。比如,forEach()、filter()、every()和some()都會跳過空位;map()會跳過空位,但會保留這個值;join()和toString()會將空位視為undefined,而undefined和null會被處理成空字串。


[,'a'].forEach((x,i) => log(i)); // 1
['a',,'b'].filter(x => true) // ['a','b']
[,'a'].every(x => x==='a') // true
[,'a'].some(x => x !== 'a') // false
[,'a'].map(x => 1) // [,1]
[,'a',undefined,null].join('#') // "#a##"
[,'a',undefined,null].toString() // ",a,,"複製程式碼

ECMA262V6則是明確將空位轉為undefined。比如,Array.from方法會將陣列的空位轉為undefined,也就是說,這個方法不會忽略空位。


Array.from(['a',,'b']) // [ "a", undefined, "b" ]複製程式碼

擴充套件運算子(...)也會將空位轉為undefined。


[...['a',,'b']] // [ "a", undefined, "b" ]複製程式碼

copyWithin()會連空位一起拷貝。


[,'a','b',,].copyWithin(2,0) // [,"a",,"a"]複製程式碼

fill()會將空位視為正常的陣列位置。


new Array(3).fill('a') // ["a","a","a"]複製程式碼

for...of迴圈也會遍歷空位。


let arr = [, ,];
for (let i of arr) { console.log(1); }
// 1
// 1複製程式碼

上面的程式碼中,陣列arr有兩個空位,for...of並沒有忽略它們。如果改成map方法遍歷,那麼空位是會跳過的。


entries()、keys()、values()、find()和findIndex()會將空位處理成undefined。


[...[,'a'].entries()] // [[0,undefined], [1,"a"]]
[...[,'a'].keys()] // [0,1]
[...[,'a'].values()] // [undefined,"a"]
[,'a'].find(x => true) // undefined
[,'a'].findIndex(x => true) // 0複製程式碼

由於空位的處理規則非常不統一,所以建議避免出現空位


2.3 數值的擴充套件與修復


數值沒有什麼好擴充套件的,而且JavaScript的數值精度問題未修復,要修復它們可不是一兩行程式碼了事。先看擴充套件,我們只把目光集中於Prototype.js與mootools就行了。


Prototype.js為它新增8個原型方法:Succ是加1;times是將回撥重複執行指定次數toPaddingString與上面提到字串擴充套件方法pad作用一樣;toColorPart是轉十六進位制;abs、ceil、floor和abs是從Math中偷來的。


mootools的情況:limit是從數值限定在一個閉開間中,如果大於或小於其邊界,則等於其最大值或最小值;times與Prototype.js的用法相似;round是Math.round的增強版,新增了精度控制;toFloat、toInt是從window中偷來的;其他的則是從Math中偷來的。


在ES5shim.js庫中,它實現了ECMA262V5提到的一個內部方法toInteger。


// es5.github.com/#x9.4
// jsperf.com/to-integer
var toInteger = function(n) {
n = +n;
if (n !== n) { // isNaN
n = 0;
} else if (n !== 0 && n !== (1 / 0) && n !== -(1 / 0)) {
n = (n > 0 || -1) Math.floor(Math.abs(n));
}
return n;
};
複製程式碼

但依我看來都沒什麼意義,數值往往來自使用者輸入,我們一個正則就能判定它是不是一個“數”。如果是,則直接Number(n)!


基於同樣的理由,mass Framework對數字的擴充套件也是很少的,3個獨立的擴充套件。


limit 方法:確保數值在[n1,n2]閉區間之內,如果超出限界,則置換為離它最近的最大值或最小值。


function limit(target, n1, n2) {
var a = [n1, n2].sort();
if (target < a[0])
target = a[0];
if (target > a[1])
target = a[1];
return target;
}複製程式碼

nearer方法:求出距離指定數值最近的那個數。


function nearer(target, n1, n2) {
var diff1 = Math.abs(target - n1),
diff2 = Math.abs(target - n2);
return diff1 < diff2 ? n1 : n2
}複製程式碼

Number下唯一需要修復的方法是toFixed,它是用於校正精確度,最後的數會做四捨五入操作,但在一些瀏覽器中並沒有這樣幹。想簡單修復的可以這樣處理。


if (0.9.toFixed(0) !== '1') {
Number.prototype.toFixed = function(n) {
var power = Math.pow(10, n);
var fixed = (Math.round(this 複製程式碼
power) / power).toString();
if (n == 0)
return fixed;
if (fixed.indexOf('.') < 0)
fixed += '.';
var padding = n + 1 - (fixed.length - fixed.indexOf('.'));
for (var i = 0; i < padding; i++)
fixed += '0';
return fixed;
};
}複製程式碼

追求完美的話,還存在這樣一個版本,把裡面的加、減、乘、除都重新實現了一遍。


github.com/es-shims/es…


toFixed方法實現得如此艱難其實也不能怪瀏覽器,計算機所理解的數字與我們是不一樣的。眾所周知,計算機的世界是二進位制,數字也不例外。為了儲存更復雜的結構,需要用到更高維的進位制。而進位制間的換算是存在誤差的。雖然計算機在一定程度上反映了現實世界,但它提供的頂多只是一個“幻影”,經常與我們的常識產生偏差。比如,將1除以3,然後再乘以3,最後得到的值竟然不是1;10個0.1相加也不等於1;交換相加的幾個數的順序,卻得到了不同的和。JavaScript不能免俗。


console.log(0.1 + 0.2)
console.log(Math.pow(2, 53) === Math.pow(2, 53) + 1) //true
console.log(Infinity > 100) //true
console.log(JSON.stringify(25001509088465005)) //25001509088465004
console.log(0.1000000000000000000000000001) //0.1
console.log(0.100000000000000000000000001) //0.1
console.log(0.1000000000000000000000000456) //0.1
console.log(0.09999999999999999999999) //0.1
console.log(1 / 3) //0.3333333333333333
console.log(23.53 + 5.88 + 17.64)// 47.05
console.log(23.53 + 17.64 + 5.88)// 47.050000000000004複製程式碼

這些其實不是bug,而是我們無法接受這事實。在JavaScript中,數值有3種儲存方式。


(1)字串形式的數值內容。


(2)IEEE 754標準雙精度浮點數,它最多支援小數點後帶15~17位小數,由於存在二進位制和十進位制的轉換問題,具體的位數會發生變化。


(3)一種類似於C語言的int型別的32位整數,它由4個8 bit的位元組構成,可以儲存較小的整數。


當JavaScript遇到一個數值時,它會首先嚐試按整數來處理該數值,如果行得通,則把數值儲存為31 bit的整數;如果該數值不能視為整數,或超出31 bit的範圍,則把數值儲存為64位的IEEE 754浮點數。


聰明的讀者一定想到了這樣一個問題:什麼時候規規矩矩的整數會突然變成捉摸不定的雙精度浮點數?答案是:當它們的值變得非常龐大時,或者進入1和0之間時,規矩矩矩的整數就會變成捉摸不定的雙精度浮點數。因此,我們需要注意以下數值。


首先是1和0;其次是最大的Unicode數值1114111(7位數字,相當於(/x41777777);最大的RGB顏色值16777215(8位數字,相當於#FFFFFF);最大的32 bit整數是147483647(10位數字,即Math.pow(2,31)-1``);最少的32位bit整數 -2147483648,因為JavaScript內部會以整數的形式儲存所有Unicode值和RGB顏色;再次是2147483647,任何大於該值的資料將儲存為雙精度格式;最大的浮點數9007199254740992(16位數字,即Math.pow(2,53)),因為輸出時類似整數,而所有Date物件(按毫秒計算)都小於該值,因此總是模擬整數的格式輸出;最大的雙精度數值1.7976931348623157e+308,超出這個範圍就要算作無窮大了。


因此,我們就看出緣由了,大數相加出問題是由於精度的不足,小數相加出問題是進位制轉算時產生誤差。第一個好理解,第二個,主要是我們常用的十進位制轉換為二進位制時,變成迴圈小數及無理數等有無限多位小數的數,計算機要用有限位數的浮點數來表示是無法實現的,只能從某一位進行截短。而且,因為內部表示是二進位制,十進位制看起來是能除盡的數,往往在二進位制是迴圈小數。


比如用二進位制來表示十進位制的0.1,就得寫成2的冪(因為小於1,所以冪是負數)相加的形式。若一直持續下去,0.1就成了0.000110011001100110011…這種迴圈小數。在有效數字的範圍內進行舍入,就會產生誤差。



綜上,我們就儘量避免小數操作與大數操作,或者轉交後臺去處理,實在避免不了就引入專業的庫來處理。



2.4 函式的擴充套件與修復


ECMA262V5對函式唯一的擴充套件就是bind函式。眾所周知,這是來自Prototype.js,此外,其他重要的函式都來自Prototype.js。


Prototype.js的函式擴充套件包括以下幾種方法。



  • argumentNames:取得函式的形參,以字串陣列形式返回。未來的Angular.js也是通過此方法實現函式編譯與DI(依賴注入)。

  • bind:劫持this,並預先新增更多引數。

  • bindAsEventListener:如bind相似,但強制返回函式的第一個引數為事件物件,這是用於修復IE的多投事件API與標準API的差異。

  • curry:函式柯里化,用於一個操作分成多步進行,並可以改變原函式的行為。

  • wrap:AOP的實現。

  • delay:setTimeout的“偷懶”寫法。

  • defer:強制延遲0.01s才執行原函式。

  • methodize:將一個函式變成其呼叫物件的方法,這也是為其類工廠的方法鏈服務。


這些方法每一個都是別具匠心,影響深遠。


我們先看bind方法,它用到了著名的閉包。所謂閉包,就是一個引用著外部變數的內部函式。比如下面這段程式碼。


var observable = function(val) {
var cur = val;//一個內部變數
function field(neo) {
if (arguments.length) {//setter
if (cur !== neo) {
cur = neo;
}
} else {//getter
return cur;
}
}
field();
return field;
}複製程式碼

上面程式碼裡面的field函式將與外部的cur構成一個閉包。Prototype.js中的bind方法只要依仗原函式與經過切片化的args構成閉包,而讓這方法名符其實的是curry,使用者最初的傳參,劫持到返回函式修正this的指向。


Function.prototype.bind = function(context) {
if (arguments.length < 2 && context == void 0)
return this;
var method = this, args = [].slice.call(arguments, 1);
return function() {
return
method.apply(context, args.concat.apply(args, arguments));
}
}複製程式碼

正因為有這東西,我們才方便修復IE多投事件API和attachEvent回撥中的this問題,它總是指向window物件,而標準瀏覽器的addEventListener中的this則為其呼叫物件。


var addEvent = document.addEventListener ?
function(el, type, fn, capture) {
el.addEventListener(type, fn, capture)
} :
function(el, type, fn) {
el.attachEvent("on" + type, fn.bind(el, event))
}複製程式碼

ECMA262V5對其認證後,唯一的增強是對呼叫者進行檢測,確保它是一個函式。順便總結一下。


(1)call是obj.method(a,b,c)到method(obj,a,b,c)的變換。


(2)apply是obj.method(a,b,c)到method(obj, [a,b,c])的變換,它要求第2個引數必須存在,一定是陣列或Arguments這樣的類陣列,NodeList這樣具有爭議性的內容就不要亂傳進去了。因此jQuery對兩個陣列或類陣列的合併是使用jQuery.merge,放棄使用Array.prototype.push.apply。


(3)bind就是apply的變種,它可以劫持this物件,並且預先注入引數,返回後續執行方法。


這3個方法是非常有用,我們可以設法將它們“偷”出來。


var bind = function(bind) {
return{
bind: bind.bind(bind),
call: bind.bind(bind.call),
apply: bind.bind(bind.apply)
}
}(Function.prototype.bind)複製程式碼

那怎麼用它們呢?比如我們想合併兩個陣列,直接呼叫concat,方法如下。


var a = [1, [2, 3], 4];
var b = [5,6];
console.log(b.concat(a)); //[5,6,1,[2,3],4]複製程式碼

使用bind.bind方法則能將它們進一步平坦化。


var concat = bind.apply([].concat);
console.log(concat(b, a)); //[1,3,1,2,3,4]複製程式碼

又如切片化操作,它經常用於轉換類陣列物件為純陣列的。


var slice = bind([].slice)
var array = slice({
0: "aaa",
1: "bbb",
2: "ccc",
length: 3
});
console.log(array)//[ "aaa", "bbb", "ccc"]複製程式碼

更常用的操作是轉換arguments物件,目的是為了使用陣列的一系列方法。


function test() {
var args = slice(arguments)
console.log(args)//[1,2,3,4,5]
}
test(1, 2, 3, 4, 5)複製程式碼

我們可以將hasOwnProperty提取出來,判定物件是否在本地就擁有某屬性。


var hasOwn = bind.call(Object.prototype.hasOwnProperty);
hasOwn({a:1}, "a") // true
hasOwn({a:1}, "b") // false複製程式碼

使用bind.bind就需要多執行一次。


var hasOwn2 = bind.bind(Object.prototype.hasOwnProperty);
hasOwn2({a:1}, "b")() // false複製程式碼

上面bind.bind的行為其實就是一種curry,它給了你再一次傳參的機會,這樣你就可以在內部判定引數的個數,決定繼續返回函式還是結果。這在設計計算器的連續運算上非常有用。從這個角度來看,我們可以得到一個資訊,bind著重於作用域的劫持,curry在於引數的不斷補充。


我們可以編寫一個 curry,當所有步驟輸入的引數個數等於最初定義的函式的形參個數時,就執行它。


function curry(fn) {
function inner(len, arg) {
if (len == 0)
return fn.apply(null, arg);
return function(x) {
return inner(len - 1, arg.concat(x));
};
}
return inner(fn.length, []);
}

function sum(x, y, z, w) {
return x + y + z + w;
}
curry(sum)('a')('b')('c')('d'); // => 'abcd'複製程式碼

不過這裡我們假定使用者每次都只傳入一個引數,所以我們可以改進一下。


function curry2(fn) {
function inner(len, arg) {
if (len <= 0)
return fn.apply(null, arg);
return function() {
return inner(len - arguments.length,
arg.concat(Array.apply([], arguments)));
};
}
return inner(fn.length, []);
}複製程式碼

這樣就可以在中途傳遞多個引數,或不傳遞引數。


curry2(sum)('a')('b', 'c')('d'); // => 'abcd'
curry2(sum)('a')()('b', 'c')()('d'); // => 'abcd'複製程式碼

不過,上面的函式形式有個更帥氣的名稱,叫self-curryrecurry。它強調的是遞迴呼叫自身來補全引數。


與curry相似的是partial。curry的不足是引數總是通過push的方式來補全,而partial則是在定義時所有引數已經都有了,但某些位置上的引數只是個佔位符,我們接下來的傳參只是替換掉它們。部落格上有篇文章《Partial Application in JavaScript》專門介紹了這個內容。


Function.prototype.partial = function() {
var fn = this, args = Array.prototype.slice.call(arguments);
return function() {
var arg = 0;
for (var i = 0; i < args.length && arg < arguments.length; i++)
if (args[i] === undefined)
args[i] = arguments[arg++];
return fn.apply(this, args);
};
}複製程式碼

它是使用undefined作為佔位符。


var delay = setTimeout.partial(undefined, 10);
//接下來的工作就是代替掉第一個引數
delay(function() {
alert("this call to will be temporarily delayed.");
})複製程式碼

有關這個佔位符,該部落格的評論列表中也有大量的討論,最後確定下來是使用作為變數名,內部還是指向undefined。筆者認為這樣做還是比較危險的,框架應該提供一個特殊的物件,比如Prototype在內部使用$break = {}作為斷點的標識。我們可以用一個純空物件作為partial的佔位符。


var  = Object.create(null)複製程式碼

純空物件沒有原型,沒有toString、valueOf等繼承自Object的方法,很特別。在IE下我們可以這樣模擬它。


var 複製程式碼 = (function() {
var doc = new ActiveXObject('htmlfile')
doc.write('<script><\/script>')
doc.close()
var Obj = doc.parentWindow.Object
if (!Obj || Obj === Object)
return
var name, names =
['constructor', 'hasOwnProperty', 'isPrototypeOf'
, 'propertyIsEnumerable', 'toLocaleString', 'toString', 'valueOf']
while (name = names.pop())
delete Obj.prototype[name]
return Obj
}())複製程式碼

我們繼續回來講partial。


function partial(fn) {
var A = [].slice.call(arguments, 1);
return A.length < 1 ? fn : function() {
var a = Array.apply([], arguments);
var c = A.concat();//複製一份
for (var i = 0; i < c.length; i++) {
if (c[i] === ) {//替換佔位符
c[i] = a.shift();
}
}
return fn.apply(this, c.concat(a));
}
}
function test(a, b, c, d) {
return "a = " + a + " b = " + b + " c = " + c + " d = " + d
}
var fn = partail(test, 1,
, 2, _);
fn(44, 55)// "a = 1 b = 44 c = 2 d = 55"複製程式碼

curry、partial的應用場景在前端世界[2]真心不多,前端講究的是即時顯示,許多API都是同步的,後端由於IO操作等耗時長,像Node.js提供了大量的非同步函式來提高效能,防止堵塞。但是過多非同步函式也必然帶來回撥巢狀的問題,因此我們需要通過curry等函式變換,將套嵌減少到可以接受的程度。這個我會在第13章講述它們的使用方法。


函式的修復涉及apply與call兩個方法。這兩個方法的本質就是生成一個新的函式,將原函式與使用者傳參放到裡面執行而已。在JavaScript建立一個函式有很多辦法,常見的有函式宣告和函式表示式,次之是函式構造器,再次是eval、setTimeout……


Function.prototype.apply || (Function.prototype.apply = function (x, y) {
x = x || window;
y = y ||[];
x.apply = this;
if (!x.
apply)
x.constructor.prototype.apply = this;
var r, j = y.length;
switch (j) {
case 0: r = x.
apply(); break;
case 1: r = x.apply(y[0]); break;
case 2: r = x.
apply(y[0], y[1]); break;
case 3: r = x.apply(y[0], y[1], y[2]); break;
case 4: r = x.
apply(y[0], y[1], y[2], y[3]); break;
default:
var a = [];
for (var i = 0; i < j; ++i)
a[i] = "y[" + i + "]";
r = eval("x.apply(" + a.join(",") + ")");
break;
}
try {
delete x.
apply ? x.apply : x.constructor.prototype.apply;
}
catch (e) {}
return r;
});

Function.prototype.call || (Function.prototype.call = function () {
var a = arguments, x = a[0], y = [];
for (var i = 1, j = a.length; i < j; ++i)
y[i - 1] = a[i]
return this.apply(x, y);
});複製程式碼

2.5 日期的擴充套件與修復


Date構造器是JavaScript中傳參形式最豐富的構造器,大致分為4種。


new Date();
new Date(value);//傳入毫秒數
new Date(dateString);
new Date(year, month, day /, hour, minute, second, millisecond/);複製程式碼

其中第3種可以玩多種花樣,個人建議只使用“2009/07/12 12:34:56”,後面的時分秒可省略。這個所有瀏覽器都支援。此構造器的相容列表可見下文。


dygraphs.com/date-format…複製程式碼

若要修正它的傳參,這恐怕是個大工程,要整個物件替換掉,並且影響Object.prototype.toString的型別判定,因此不建議修正。ES5.js中有相關原始碼,大家可以看這裡。


github.com/kriskowal/e…複製程式碼

JavaScript的日期是抄自Java的java.util.Date,但是Date這個類中的很多方法對時區等支援不夠,且不少都是已過時的。Java程式設計師也推薦使用calnedar類代替Date類。JavaScript可選擇的餘地比較少,只能湊合繼續用。比如:對屬性使用了前後矛盾的偏移量,月份與小時都是基於0,月份中的天數則是基於1,而年則是從1900開始的。


接下來,我們為舊版本瀏覽器新增幾個ECMA262標準化的日期方法吧。


if (!Date.now) {
Date.now = function() {
return +new Date;
}
}
if (!Date.prototype.toISOString) {
void function() {
function pad(number) {
var r = String(number);
if (r.length === 1) {
r = '0' + r;
}
return r;
}

Date.prototype.toJSON =
Date.prototype.toISOString = function() {
return this.getUTCFullYear()
+ '-' + pad(this.getUTCMonth() + 1)
+ '-' + pad(this.getUTCDate())
+ 'T' + pad(this.getUTCHours())
+ ':' + pad(this.getUTCMinutes())
+ ':' + pad(this.getUTCSeconds())
+ '.' + String((this.getUTCMilliseconds() / 1000).toFixed(3)).slice(2, 5)
+ 'Z';
};

}();
}複製程式碼

IE6和IE7中,getYear與setYear方法都存在bug,不過這個修復起來比較簡單。


if ((new Date).getYear() > 1900) {
Date.prototype.getYear = function() {
return this.getFullYear() - 1900;
};
Date.prototype.setYear = function(year) {
return this.setFullYear(year); //+ 1900
};
}複製程式碼

至於擴充套件,由於涉及本地化,許多日期庫都需要改一改才能用,其中以dataFormat這個很有用的方法較為特別。筆者先給一些常用的擴充套件吧。


傳入兩個Date型別的日期,求出它們相隔多少天。


function getDatePeriod(start, finish) {
return Math.abs(start 1 - finish 1) / 60 / 60 / 1000 / 24;
}複製程式碼

傳入一個Date型別的日期,求出它所在月的第一天。


function getFirstDateInMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}複製程式碼

傳入一個Date型別的日期,求出它所在月的最後一天。


function getLastDateInMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
}複製程式碼

傳入一個Date型別的日期,求出它所在季度的第一天。


function getFirstDateInQuarter(date) {
return new Date(date.getFullYear(), ~~(date.getMonth() / 3) 3, 1);
}
複製程式碼

傳入一個Date型別的日期,求出它所在季度的最後一天。


function getFirstDateInQuarter(date) {
return new Date(date.getFullYear(), ~~(date.getMonth() / 3) 複製程式碼
3 + 3, 0);
}複製程式碼

判斷是否為閏年。


function isLeapYear(date) {
return new Date(this.getFullYear(), 2, 0).getDate() == 29;
}
//EXT
function isLeapYear2(date) {
var year = data.getFullYear();
return !!((year & 3) == 0 && (year % 100 || (year % 400 == 0 && year)));
}複製程式碼

取得當前月份的天數。


function getDaysInMonth1(date) {
switch (date.getMonth()) {
case 0:
case 2:
case 4:
case 6:
case 7:
case 9:
case 11:
return 31;
case 1:
var y = date.getFullYear();
return y % 4 == 0 && y % 100 != 0 || y % 400 == 0 ? 29 : 28;
default:
return 30;
}
}

var getDaysInMonth2 = (function() {
var daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

function isLeapYear(date) {
var y = date.getFullYear();
return y % 4 == 0 && y % 100 != 0 || y % 400 == 0;
}
return function(date) { // return a closure for efficiency
var m = date.getMonth();

return m == 1 && isLeapYear(date) ? 29 : daysInMonth[m];
};
})();

function getDaysInMonth3(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
}複製程式碼




[1] imququ.com/post/bom-an…

[2] 在電腦科學中,柯里化(Currying)是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數且返回結果的新函式的技術。這個技術由Christopher Strachey以邏輯學家Haskell Curry命名的,儘管它是Moses Schnfinkel和Gottlob Frege發明的。patial,bind只是其一種變體。其用處有3:1.引數複用;2.提前返回;3.延遲計算/執行。

相關文章