第3章 语言模块
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,因此这也列入框架的工作范围。
本章主要是围绕着mass Framework的lang与lang_fix模块展开,可以到这里下载。
https://github.com/RubyLouvre/mass-Framework/blob/1.4/lang.js
https://github.com/RubyLouvre/mass-Framework/blob/1.4/lang_fix.js
3.1 字符串的扩展与修复
我发现脚本语言都对字符串特别关注,有关它的方法特别多。我把这些方法分为三大类。
第一类,与标签无关的实现: charAt、charCodeAt、concat、indexOf、lastIndexOf、localeCompare、match、replace,search、slice、split、substr、substring、toLocaleLowerCase、toLocaleUpperCase、toLowerCase、toUpperCase及从Object继承回来的方法,如toString、valueOf。
第二类,与标签有关的实现,都是对原字符串添加一对标签:anchor、big、blink、bold、fixed、fontcolor、italics、link、small、strike、sub、sup。
第三类是后来添加或未标准化的浏览器方法:trim、quote、toSource、trimLeft、trimRight。其中trim已经标准化,后四个是Firefox的私有实现。
我们再看ecma262v6(2012.6.15)打算要添加的方法:repeat、startsWith、endsWidth、contains。
再看伟大的Prototype.js添加的扩展:gsub、sub、scan、truncate、strip、stripTags、stripScripts、extractScripts、evalScripts、escapeHTML、unescapeHTML、parseQuery、toArray、succ、times、camelize、capitalize、underscore、dasherize、inspect、unfilterJSON、isJSON、evalJSON、include、startsWith、endsWith、empty、blank、interpolate。
其中gsub、sub、scan与正则相关,直接取自ruby的命名。
truncate是字符串截取,非常有用的方法,许多框架都有它的“微创新”。
strip即trim,已标准化。
stripTags去掉字符串中的标签对,非常有用。
stripScripts作为stripTags的补充,因为单单把script标签去掉,里面不该显示出来的script.text就暴露出来了。
extractScripts与evalScripts是抽取与执行字符串中的脚本,IE的innerHTML在某种情况下可以这样做,但其他浏览器不行,框架有责任屏蔽此差异性。
escapeHTML与unescapeHTML是对用户的输入输出操作进行转义,非常有用。
parseQuery基本上用于对URL的search部分进行操作,转换成对象,非常有用。
toArray原本也是ecma262v6打算要添加的方法,用于转换成数组,不过这个用户也易实现,因此被抛弃了。
succ是用于ObjectRange内部使用的。
times即ecma262v6的repeat方法。
Camelize、capitalize、underscore、dasherize这四个用于转换命名风格,非常有用。
inspect就是在两端加双引号,用于构建JSON,相当于Firefox的私有实现quote。
UnfilterJSON、isJSON、evalJSON与JSON相关。
include就是contains,与startsWith、endsWith成为ecma262v6的标准方法。
empty、blank是对空白进行判定,很简单的方法。
interpolate用于模板,其他框架通常称之为format或substitute。
Prototype.js这些有用的扩展会被其他框架抄去,我们查看哪些经常被抄,就知道哪些方法最有价值了。
Rirght.js 的字符串扩展:include、blank、camelize、capitalize、dasherize、empty、endsWith、evalScripts、extractScripts、includes、on、startsWith、stripScripts、stripTags、toFloat、toInt、trim、underscored。
Mootools的字符串扩展(只取原型扩展):test、contains、trim、clean、camelCase、hyphenate、capitalize、escapeRegExp、toInt、toFloat、hexToRgb、rgbToHex、substitute、stripScripts。
dojo的字符串扩展:rep、pad、substitute、trim。rep就是repeat方法.
EXT的字符串扩展:capitalize、ellipsis、escape、escapeRegex、format、htmlDecode、htmlEncode、leftPad、parseQueryString、trim、urlAppend。
qooxdoo的字符串扩展:format、hyphenate、pad、repeat、startsWidth、stripScripts、stripTags、toArray、trim、trimLeft、trimRight。
Tangram 的字符串扩展:decodeHTML、encodeHTML、escapeReg、filterFormat、format、formatColor、stripTags、toCamelCase、toHalfWidth、trim、wbr。
通过以上竞争对手分析,我在mass Framework为字符串添加如下扩展,各位写框架的朋友可以视自己的情况进行增减:contains、startsWith、endsWith、repeat、camelize、underscored、capitalize、stripTags、stripScripts、escapeHTML、unescapeHTML、escapeRegExp、truncate,wbr、pad。其中前四个 ecma262v6 的标准方法,接着九个发端于 Prototype.js 广受欢迎的工具方法,wbr 是来自Tangram,用于软换行,这出于汉语排版的需要。pad也是一个很常用的操作,被收纳。
下面是各种具体实现。
contains方法:判定一个字符串是否包含另一个字符串。常规思维,使用正则,但每次都要用new RegExp来构造,性能太差,转而使用原生字符串方法,如,ndexOf、lastIndexOf、 search。
function contains(target, it) { return target.indexOf(it) != -1; //indexOf改成search,lastIndexOf也行得通 }
在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; }
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的改良版,利用闭包将类数组对象与数组原型的jion方法缓存起来,省得每次都重复创建与寻找方法。
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在各浏览器的得分是最高的。
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; }
truncate 方法:用于对字符串进行截断处理,当超过限定长度,默认添加三个点号或其他什么的。
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标签,但这方法有缺陷,如里面有script标签,会把这些不该显示出来的脚本也显示出来。在Prototype.js中,它与strip、stripScripts是一组方法。
function stripTags(target) { return String(target || "").replace(/<[^>]+>/g, ''); }
stripScripts 方法:移除字符串中所有的 script 标签。弥补 stripTags 方法的缺陷。此方法应在stripTags之前调用。
function stripScripts(target) { return String(target || "").replace(/<script[^>]*>([\S\s]*?)<\/script>/img, '') }
escapeHTML 方法:将字符串经过 html 转义得到适合在页面中显示的内容,如将<替换为 <。
function escapeHTML(target) { return target.replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, """) .replace(/'/g, "'"); }
unescapeHTML:将字符串中的 html 实体字符还原为对应字符。
function unescapeHTML(target) { return target.replace(/"/g, '"') .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, "&") //处理转义的中文和实体字符 .replace(/&#([\d]+);/g, function($0, $1) { return String.fromCharCode(parseInt($1, 10)); }); }
escapeRegExp方法:将字符串安全格式化为正则表达式的源码。
function escapeRegExp(target) { return target.replace(/([-.*+?^${}()|[\]\/\\])/g, '\\$1'); }
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; }
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: "\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 ''; }); }
它支持两种传参方法,如果字符串的占位符为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"
quote 方法:在字符串两端添加双引号,然后内部需要转义的地方都要转义,用于接装 JSON的键名或模析系统中。
//http://code.google.com/p/jquery-json/ var escapeable = /["\\\x00-\x1f\x7f-\x9f]/g, meta = { '\b': '\\b', '\t': '\\t', '\n': '\\n', '\f': '\\f', '\r': '\\r', '"': '\\"', '\\': '\\\\' }; function quote(target) { if (target.match(escapeable)) { return '"' + target.replace(escapeable, function(a) { var c = meta[a]; if (typeof c === 'string') { return c; } return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4) }) + '"'; } return '"' + target + '"'; }
当然,如果浏览器已经支持原生JSON,我们直接用JSON.stringify就行了,另,FF在JSON发明之前,就支持String.prototype.quote与String.quote方法,我们在使用quote之前判定浏览器是否内置这些方法。
字符串好像没有大的浏览器兼容问题,有的话是IE6、IE7 不支持用数组中括号取它的每一个字符,需要用charAt来取;IE6、IE7、IE8不支持垂直分表符,因此有如下hack。
var isIE678= !+"\v1" ;
好了,我们来修复旧版本IE中的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同名。
function trim(str) { return str.replace(/^\s+/, '').replace(/\s+$/, ''); }
版本 3:截取方式取得空白部分(当然允许中间存在空白符),总共调用了四个原生方法。设计得非常巧妙,substring以两个数字作为参数。Math.max以两个数字作参数,search则返回一个数字。速度比上面两个慢一点,但基本比10之前的版本快!
function trim(str) { return str.substring(Math.max(str.search(/\S/), 0), str.search(/\S\s*$/) + 1); }
版本4:这个可以称得上版本2的简化版,就是利用候选操作符连接两个正则。但这样做就失去了浏览器优化的机会,比不上版本三。由于看来很优雅,许多类库都使用它,如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 str = str.replace(/^\s\s*/, ""), ws = /\s/, i = str.length; while (ws.test(str.charAt(--i))) return str.slice(0, i + 1); }
版本13:原作者@ialeafs称它为trimChunge,通过字符的charCodeAt值来判定是否为空白,速度也非常逆天,它仅次于版本10,快于版本11、12,不过此版本能处理的空白很有限。
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。