本文摘自:人民郵電出版社非同步圖書《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-zd])([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[^>]>([Ss]?)</script>/img, ``)
}複製程式碼
escapeHTML 方法:將字串經過html轉義得到適合在頁面中顯示的內容,如將“<
”替換為“<
”`。此方法用於防止XSS攻擊。
function escapeHTML(target) {
return target.replace(/&/g, `&`)
.replace(/</g, `<`)
.replace(/>/g, `>`)
.replace(/"/g, """)
.replace(/`/g, "'");
}複製程式碼
unescapeHTML方法:將字串中的html實體字元還原為對應字元。
function unescapeHTML(target) {
return String(target)
.replace(/'/g, ```)
.replace(/"/g, `"`)
.replace(/</g, `<`)
.replace(/>/g, `>`)
.replace(/&/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(`&`).split(`<`).join(`<`).split(`>`). join(`>`);
} else {
value = value.replace(/&/g, `&`).replace(/</g, `<`).replace(/>/g, `>`);
}
return value;
}複製程式碼
看情況是處理&
時出了分歧。但它們這麼做其實也不能處理所有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中加上wbr
:after { content: " 0200B" }
解決此問題。
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: "",
12: "f",
10: "
",
13: "
",
9: " "
}
// 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-x1fx22x5c]/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, `
`)
.replace(/x09/g, ` `)
.replace(/x0D/g, `
`)
+ `"`;
}複製程式碼
當然,如果瀏覽器已經支援原生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(/^ss複製程式碼/, ``).replace(/ss$/, ``);
}
……複製程式碼
版本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(/Ss複製程式碼$/) + 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((?:[Ss]S)?)s$/, `$1`);
}複製程式碼
版本9:這次是用懶惰匹配頂替非捕獲分組,在火狐中得到改善,IE沒有上次那麼瘋狂。
function trim(str) {
return str.replace(/^s複製程式碼([Ss]?)s$/, `$1`);
}複製程式碼
版本 10:筆者只想說,搞出這個的人已經不能用厲害來形容,而是專家級別了。它先是把可能的空白符全部列出來,在第一次遍歷中砍掉前面的空白,第二次砍掉後面的空白。全過程只用了indexOf與substring這個專門為處理字串而生的原生方法,沒有使用到正則。速度快得驚人,估計直逼內部的二進位制實現,並且在IE與火狐(其他瀏覽器當然也毫無疑問)都有良好的表現,速度都是零毫秒級別的,PHP.js就收納了這個方法。
Function trim(str) {
var whitespace = `
fx0bxa0u2000u2001u2002u2003
u2004u2005u2006u2007u2008u2009u200au200bu2028u2029u3000`;
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 = /^[suFEFFxA0]+|[suFEFFxA0]+$/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 |
” “, “x09”, “u0009”, <TAB>製表符,鍵盤tab鍵 |
U+000B |
“v”, “x0B”, “u000B”,<VT>垂直製表符 |
U+000C |
“f”, “x0C”, “u000C”,<FF>換頁符 |
U+000D |
” |
U+000A |
” |
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;
};
}複製程式碼
追求完美的話,還存在這樣一個版本,把裡面的加、減、乘、除都重新實現了一遍。
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-curry或recurry。它強調的是遞迴呼叫自身來補全引數。
與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();
}複製程式碼
[2] 在電腦科學中,柯里化(Currying)是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數且返回結果的新函式的技術。這個技術由Christopher Strachey以邏輯學家Haskell Curry命名的,儘管它是Moses Schnfinkel和Gottlob Frege發明的。patial,bind只是其一種變體。其用處有3:1.引數複用;2.提前返回;3.延遲計算/執行。