和我同住的某人开始把翻译好的《Project Darkstar》中文文档上传至Blog了,在这里宣传转一下。
近日,在秦歌的blog中看到动态加载的内容,主要是说YUI3.0在动态加载js的一点小bug。当时想了个问题:如何侦听动态加载的内容被成功加载了,翻阅一番后记录下来。
Omar AL Zabir曾在他的Ensure library库中写过类似的东西,用以动态加载css和js文件。加载js时,基本伪代码是这样:
var script = document.createElement("script");
script.onreadystatechange = function() {
//
}
script.onload = function () {
//
}
script.setAttribute("src", "xxx.js");
document.head.appendChild(script);
原理就是创建一个script标签,设置src属性后附加到head区域。ie中侦听onreadystatechange,其它的侦听onload。不过有些较古老的浏览器对这两种方法都不支持(Omar举例为Safari 2),解决的办法是通过xhr加载js文件资源,再eval()出来。目前来看,似乎已没必要花费经历去为了旧版本兼容了。
凑巧,前几日Mootools官网也发布了一个延迟独立加载js的库Depender,它被包括在More中(貌似1.2.4.1还有些性能问题,我希望最终它能够被包含在Core中)。看看其核心实现:
new Element('script', {
src: scriptPath + (this.options.noCache ? '?noCache=' + new Date().getTime() : ''),
events: {
load: loaded,
readystatechange: function(){
if (['loaded', 'complete'].contains(this.readyState)) loaded();
},
error: error
}
}).inject(this.options.target || document.head);
亦是一样。不过在我测试过程中,ie的readystate的值在加载完成后都是loaded(ie6、7、8),并没有出现complete的情况,不知道mt为何要多判别它。
另外关于css动态加载,似乎并没有可行的侦听的方法。虽然ie中侦听onreadystatechange的值为complete,但是firefox 3.5和chrome里却侦听不到onload事件。不过一般情况下,样式的加载都是异步的,同步的需求很少。除非考虑到某个地方必须等样式加载成功后才去执行显示,否则没必要使用同步。
我能想到的解决办法就是通过xhr读取css文件内容,然后插入到style标签中来实现同步读取,不知道可不可行。
区分正则
想要高亮正则表达式的前提是区分它。由于正则表达式以/开头,因此这和除法、单行注释、多行注释会产生混淆。那么究竟该如何辨别呢?

注释的区别最为简单,因为注释拥有最高优先级。除非注释符出现在字符串内,否则一旦开始,编译器便立刻识别//和/*。
如图所示,当向前看字符为斜线时,根据规则由初始状态0进入状态1。如果接下来读入的字符是*,则进入状态2,并成为多行注释;倘若是/,则进入状态3,变成单行注释;而其它的情况么,则是正则除法皆有可能。
我们不妨设想一下:正则和除法有哪些区别?从语境上说,原则性的区别就是除法的前面是被除数,正则的前面不是。而成为被除数的可能无非是以下3种:数字、变量、括号内的运算结果。我不知道js解析引擎是如何做的,它可能相当得严谨(也简单,因为它会忽略空白和注释)。但是在web端语法高亮的需求中,由于假设输入的代码一定是正确的,所以可以“投机取巧”般地识别二者:
//检查当前除号是否是perl风格正则表达式开头
protected function isPerlReg():Boolean {
for (var j:int = index - 3; j > -1; j--) {
//先要忽略之前的单行注释
if (j == singleCommentEnd) {
j = singleCommentStart;
continue;
}
var cc:int = code.charCodeAt(j);
//忽略空白
if (Character.isBlank(cc) || Character.isLine(cc)) {
continue;
}
//前面也是一个除号的话防止是多行结尾注释,需跳过注释段继续向前找
else if (Character.isSlash(cc) && Character.isStar(code.charCodeAt(j - 1))) {
j = code.lastIndexOf("/*", j - 1);
}
//前面一个非空白字符若是字母数字下划线则为正则,否则除号
else if (Character.isLetterOrDigit(cc) || Character.isUnderline(cc) || Character.isDollar(cc) || Character.isRightParenthese(cc)) {
return false;
}
else {
break;
}
}
return true;
}
程序开始回溯,我们要看在斜线符号之前的“东西”到底是什么——当然,空白符是不算在内的,因此循环体内首先要做的就是忽略空白。假如前面是标识符的组成或者右括号的话,那么显然它是个被除数,斜线也应该被解读为除号;而如果是其它情况的话,斜线则是正则表达式的开头。
除此外还有个很大的难点,就是斜线前面插入了一段注释,这也需要忽略(js引擎无需考虑这些,因为一开始注释就被剔除了,所以它会相对简单一些)。目前jssc 5 beta 3版仅能识别出多行注释,对于单行注释尚未解决,这是下个版本需要注意的问题。
注:新版本已从语法层级上解决,方法为设置一个布尔标量,每次处理token时切换其正确状态,上述代码在新版本中已废弃。
识别正则
区分出来之后别是识别正则,根据特点我们画出这样的DFA:

我们假设当前/已经被识别为正则表达式的开头,那么接下来的任务便是寻找另外一个结尾/。
在状态1中首先要明确对待的就是转义符,因为它会转义掉下面一个符号,所以要单独为其区分出一个状态2。而状态2上的条件就很宽松了:无论输入什么都会被转义掉,因此遇到任何字符(any)都会回到状态1。
值得注意的是,字符集([])是个特殊之处,因为它里面可以省略转义符(既可以有也可以没有),为此需要画出状态3和状态4来对待它(字符集中出现未被转义的/也是合法的,你不可能把它当作正则表达式的结束符)。
当真正出现结束符后进入状态5,此时正则并没有完全结束,因为后面还可能跟有譬如i这样的flag。在状态5上多加种条件循环会本身便可解决问题(更严谨的做法应该是flag仅允许出现一次);而其它情况才是最终进入终结状态6。
//perl正则
protected function dealPerlReg():void {
var start:int = index - 2;
var cc:int;
outer:
while (index <= code.length) {
readch();
cc = peek.charCodeAt(0);
//转义符
if (Character.isBackslash(cc)) {
readch();
//后面是换行跳出
if (Character.isLine(peek.charCodeAt(0))) {
break;
}
}
//[括号
else if (Character.isLeftbracket(cc)) {
while (index <= code.length) {
readch();
cc = peek.charCodeAt(0);
//转义符
if (Character.isBackslash(cc)) {
readch();
if (Character.isLine(peek.charCodeAt(0))) {
break outer;
}
}
//]括号
else if (Character.isRightbracket(cc)) {
continue outer;
}
}
}
//行末尾
else if (Character.isLine(cc)) {
break;
}
//正则表达式/结束
else if (Character.isSlash(cc)) {
for (var i:int = 0; i < 3 && index <= code.length; i++) {
readch();
cc = peek.charCodeAt(0);
//是img中的一个,存入flag中,不分大小写
if(cc == 103 || cc == 105 || cc == 109 || cc == 71 || cc == 73 || cc == 77) {
//
}
//其它情况跳出
else {
break outer;
}
}
break;
}
}
//高亮
result.append(HighLighter.regular(HtmlEscape.encode(code.slice(start, index - 1))));
}
其它
其它符号的处理都很简单,不做任何高亮即可,只是要注意个别需要特殊编码的字符(如&变为&)。在编译器的词法分析中,每个符号都是有语法意义的,有时符号的长度还不止一个(<=就有2个符号)。幸运的是,在做语法高亮的情况下,这些全可以被“偷懒”掉。
下面贴一下c系列语言处理符号的代码,它相当得简单,可能唯一特殊之处就是其中对花括弧的深度处理了。
//抽象类中的处理符号方法
protected function dealSign():void {
result.append(HtmlEscape.encodeChar(peek));
readch();
}
//c系列语言继承后覆盖的方法,处理符号同时要计算深度
protected override function dealSign():void {
var cc:int = peek.charCodeAt(0);
if (Character.isLeftBrace(cc)) {
depth++;
}
else if (Character.isRightBrace(cc)) {
depth--;
}
super.dealSign();
}
在下一篇中,我将讲述jssc 5是如何做到嵌套高亮(html中内嵌css和js)的。
这是我第3次做拼图游戏了。算法简单得很,而且是最少的9宫拼图,拿到设计稿后不外几个小时搞定。
记得2008年7月份,刚毕业被EOL拉进PCHome的时候,工作所做的第一个就是flash游戏拼图(9、16、25宫)。那时为了研究出如何防止拼图死局,着实花了不少功夫(小时候玩的金山文曲星中就经常出现这个bug)。为此我特地发了个帖子求助数学大人,最终问题得以解决。
那么,什么是拼图死局呢?
以最简单的9宫拼图为例,拼图的最终目的是将随机的数块移动到如下状态:
1 2 3
4 5 6
7 8
第9格是空的,用以让玩家能够任意移动某一个数块。但是,初始随机情况可能会是这样:
1 2 3
4 5 6
8 7
这种情况下,想要变换到正确的结束状态是不可能的。至于为什么,繁琐的数学证明就不贴了。记得曾经《读者》杂志上刊登过这个东西,说是国外某位数学家悬赏1000w美金让人来拼这个拼图,结果无一人能做到。最后他才公布为何不能做到:因为这种情况的开始逆序和和目标逆序和不同。
数学定义:
n*n的方格中放入1,2,3,…,n-1及一个空格,在任何一种状态A下,
定义:f(i)=k,表示i放在第k个格子中,k按照从左至右,从上到下排序;
定义:g(i,j)=1,如果(f(i)-f(j))(i-j)<0;否则为0;
定义:F(A)=SUMg(i,j),对所有i,j求和;
当n=3时;
目标状态F(Aobj)=0,你给的状态F(Abegin)=1;
而移动空格只有2种方式:在同行中移动不改变F值,在不同行中移动F值+2,-2或不变;
利用F函数可以解决所有n为奇数的情况;
当n为偶数时,定义F1(A)=F(A)+空格所在行&1;处理方式一样。
当初始随机情况出现死局时,逆序和就会不相等,那么发现之后该怎么办呢?当然不会重新去随机一次,这太傻了。解开锁的钥匙很简单,随便交换同行的2个数块的位置即可。
那个时候写的一个产生9、16宫随机初始数块顺序的工厂(现在看来无需用工厂方法,一个类就能搞定):
//GenMatrixFactory
package matrix {
public class GenMatrixFactory {
public static function getArray(i:int):Array {
var m:Matrix;
switch(i) {
case 3:
m = new Matrix3();
break;
case 4:
m = new Matrix4();
break;
default:
throw new Error("none of matrix");
}
return m.getMatrix();
}
}
}
//Matrix
package matrix {
interface Matrix {
function getMatrix():Array;
}
}
//Matrix3
package matrix {
class Matrix3 implements Matrix {
private var gameArray:Array;
private var numList:Array;
public function Matrix3() {
initGameArray();
initNumList();
}
private function initGameArray():void {
//创建游戏拼图数组
gameArray = new Array(8);
for (var i:int = 0; i < gameArray.length; i++) {
gameArray[i] = 0;
}
}
private function initNumList():void {
//最终状态数组
numList = new Array(8);
for (var i:int = 1; i <= numList.length; i++) {
numList[i - 1] = i;
}
}
public function getMatrix():Array {
//获取最终状态逆序和
var reverse:int = getReverse();
//产生0~array长度的随机数填充array
var i:int = 0;
while (numList.length > 0) {
var rand:int = Math.floor(Math.random() * numList.length);
gameArray[i++] = numList.splice(rand, 1)[0];
}
return Validate.validata(gameArray, reverse);
}
private function getReverse():int {
var sum:int = 0;
for (var i:int = 0; i < numList.length - 1; i++) {
for (var j:int = i + 1; j < numList.length; j++) {
if ((i - j) * (numList[i] - numList[j]) < 0) {
sum++;
}
}
}
return sum;
}
}
}
//Matrix4
package matrix {
class Matrix4 implements Matrix {
private var gameArray:Array;
private var numList:Array;
public function Matrix4() {
initGameArray();
initNumList();
}
private function initGameArray():void {
//创建游戏拼图数组
gameArray = new Array(15);
for (var i:int = 0; i < gameArray.length; i++) {
gameArray[i] = 0;
}
}
private function initNumList():void {
//最终状态数组
numList = new Array(15);
for (var i:int = 1; i <= numList.length; i++) {
numList[i - 1] = i;
}
}
public function getMatrix():Array {
//获取最终状态逆序和
var reverse:int = getReverse();
//产生0~array长度的随机数填充array
var i:int = 0;
while (numList.length > 0) {
var rand:int = Math.floor(Math.random() * numList.length);
gameArray[i++] = numList.splice(rand, 1)[0];
}
return Validate.validata(gameArray, reverse);
}
private function getReverse():int {
var sum:int = 0;
for (var i:int = 0; i < numList.length - 1; i++) {
for (var j:int = i + 1; j < numList.length; j++) {
if ((i - j) * (numList[i] - numList[j]) < 0) {
sum++;
}
}
}
return sum;
}
}
}
//Validate
package matrix {
class Validate {
public static function validata(gameArray:Array, reverse:int):Array {
var sum:int = 0;
for (var i:int = 0; i < gameArray.length - 1; i++) {
for (var j:int = i + 1; j < gameArray.length; j++) {
if ((i - j) * (gameArray[i] - gameArray[j]) < 0) {
sum++;
}
}
}
//逆序和不同时死局,调整同行相邻两个
if (sum % 2 != reverse % 2) {
var temp:int = 0;
temp = gameArray[0];
gameArray[0] = gameArray[1];
gameArray[1] = temp;
}
return gameArray;
}
}
}
有穷自动机(状态图)
处理词法分析的关键在于建立正确的DFA(确定的有穷自动机),而DFA是如何由NFA(不确定的有穷自动机)构造而来,NFA又是如何从正则表达式演变,不在本文讨论范围之内。我们只需要关心如何去画好这个状态图就行了。
先从最简单的开始。现在来了一串源码,我们的解析器想要识别出关键字if,那么该怎么办呢?下面是它的状态图:

我们把每个圈称作一个状态,圈内的数字用以标识不同的状态,而双边的圈则是终结状态。这个状态图没画完,因为状态4和状态5不是终结状态,却没有转换规则。这不是主要讨论。
一般情况下,状态0被称之为初始状态。解析器会设置一个向前看的哨兵,从左到右遍历源码。假设当前字符是i,很显然,它就是状态0可接受的唯一字符。于是开始执行转换规则,状态0根据规则转换到状态1,继续读入下一个字符。
状态1可接受的字符有2种:f和other。这里other不是说字符串,而是指除f以外的所有字符。假如是other的情况,显然它不是我们想要的关键字if,所以转而进入状态4,进行其它处理——至于处理什么、如何处理,暂时不需要关心,后面会说到;而如果是字符f,这就符合我们的预期,状态1通过规则转换到状态2。
可能你认为已经结束了,但事实并非这么简单。if两个字母是读出了,但却未必是关键字,后面如果跟着一个字母s呢?ifs,它可能是定义的一个变量名,又可能是个方法名。然而不管是什么,总归不是关键字。所以状态2接受2种字符:数字、字母、下划线(js应该还有美元符号$,因为它也可作为变量名组成),这条路通往状态5,我们不管;其它的情况就好办了,空白也好回车也好括号也好,它一定是if关键字,由此进入状态3。
上面说了两个圈儿的是终结状态,识别出if关键字后,状态3就结束了。此时解析器会进行回退,把if后面读入的向前看字符放回源码流中,继续下一次循环。经过这一轮番的解释,相信你能对词法分析有了大概的印象吧。同理,其它的关键字、数字、字符串、注释……也是如此识别的。
或许有人要问:一种语言的关键字那么多,为每个关键字写处理程序岂不是要累死?没错,事实上编译器也不会这样做。上面那个例子只是为了简单起见所举,真正可行的办法是只建立标识符的DFA,将所有标识符识别出来,然后一一对比,看此标识符是否是个关键字。
识别标识符

这是识别js语言标识符的状态图。不难看出,状态3和4都是可能的终结状态(实际上基于最小化DFA数量的原则,它们应该被合并为一个)。在初始状态0时,接受字母、下划线和美元符号,如果读入的向前看字符是的话,那么进入状态1。
状态1接受2种情况:字母、数字、下划线和美元符号,很明显这是js标识符的组成规定。这种情况下会进入状态2,并且状态2也接受同样的情况返回本身状态(标识符的长度是不定的,因此状态2会循环本身),它上面的弧线即表达了这个意思。只有当状态2时读入的向前看字符不是字母、数字、下划线或美元符号任一种的时候,才会进入终结状态3。此时这个标识符也识别出来了,和前面一样,回退继续处理。
状态1还有个接受other的情况,这是当标识符仅由一个字符组成时才会发生的。
以下是jssc中处理ecmascript语言中标识符的源代码:
protected function dealWord():void {
var start:int = index - 1;
var cc:int;
//直到不是字母数字下划线美元符号为止
while (index <= code.length) {
readch();
cc = peek.charCodeAt(0);
if (Character.isLetterOrDigit(cc) || Character.isUnderline(cc) || Character.isDollar(cc)) {
//
}
else {
break;
}
}
//高亮
var res:String = code.slice(start, index - 1);
if (words.hasKey(res)) {
res = HighLighter.keyword(res);
}
result.append(res);
}
由于语法高亮毕竟还是有别于编译器的词法处理,所以为了简化功能和提高性能,很多地方还是不一样的。比如说上面代码中的index是个int型索引,用以保存向前看字符的下标,而peek则是当前的向前看字符。start保存着状态0时向前看字符的位置。循环体中不停地读入下一个字符(readch()方法),直到不是字母、数字、下划线或美元符号为止——这就是所谓的进入终结状态3——然后截取代码中从start到当前索引的代码。
之后的高亮处理先检查这段代码是否是保留字(words是个HashMap,预先保存着所有关键字),是的话高亮上颜色,不是则原封不动。
识别字符串

字符串的处理相对简单一些。js中单引号和双引号作用完全一样,所以用双引号来举例足够了。初始状态0,遇到引号后进入状态1。
在状态1中稍微有点绕人,如果遇到另外一个引号,那么自然这个字符串就结束了,进入终结状态2。但是要预防引号被转义的情况出现,所以当读到的向前看字符是\时,我们进入状态4,接着读入的任何字符都是被转义掉的,再返回状态1。最后需要注意的是弧线other,它依然回到状态1自己,这样就处理了不定长度的字符串格式。
插嘴一句:在windows的IE中,字符串跨行时所出现的可能不是标准的\n,而是\r\n,这在处理过程中会遇到麻烦。jssc 5对其多添加了一个状态来区分,但是更好的做法应该是在开头就把所有\r过滤掉。这可能稍微影响一点点性能。
//字符串处理,单引号或多引号、是否允许转义折行
protected function dealString(char:String = "\"", wrap:Boolean = false):void {
var start:int = index - 1;
var cc:int;
while (index <= code.length) {
readch();
cc = peek.charCodeAt(0);
//转义符
if (Character.isBackslash(cc)) {
readch();
//转义符后是回车继续读入下一个
if (Character.isEnter(peek.charCodeAt(0))) {
readch();
}
//不允许转义换行跳出
if (!wrap && Character.isLine(peek.charCodeAt(0))) {
break;
}
}
//行末尾未转义换行或找到结束跳出
else if (Character.isLine(cc) || char == peek) {
break;
}
}
//高亮
result.append(HighLighter.string(HtmlEscape.encode(code.slice(start, index), HighLighter.endTag + getNewLine() + HighLighter.stringStart())));
readch();
}
我想它很明了,无需多做解释。只是要注意最后高亮步骤中要将换行符给替换掉,变成html可显示的换行。
识别注释
本来注释在词法分析中是被扔掉的部分,但在语法高亮中却不能这样做。注释的识别和字符串一样简单:多行注释和字符串类似,以/*开头,*/结尾,它让人省心的地方就是不存在转义问题;而单行注释也一样,//开头直接到行末尾。这里直接附上源码,状态图就懒地画了。
//处理单行注释
protected function dealSingleComment():void {
var i:int = code.indexOf("\n", index);
index -= 2;
//找不到换行符说明是最后一行
if (i == -1) {
i = code.length;
}
//本行存入
result.append(HighLighter.comment(HtmlEscape.encode(code.slice(index, i))));
index = i;
readch();
}
//处理多行注释
protected function dealMultiComment():void {
depth++;
var i:int = code.indexOf("*/", index);
index -= 2;
//i为-1时直接注释到结尾
if (i == -1) {
i = code.length;
}
else {
i += 2;
}
result.append(HighLighter.comment(HtmlEscape.encode(code.slice(index, i), HighLighter.endTag + getNewLine() + HighLighter.commentStart())));
//调整索引和深度,并读取当前peek
index = i;
depth--;
readch();
}
识别数字
数字可能是最难处理的部分之一了,因为它包含小数。理想的方式是将整数和小数分开处理,jssc 5选择了比较复杂的方式——统一对待。不仅如此,我抽象出来一个方法,用它来尽可能地处理所有语言的数字。在java中,长整型可以以L结尾,浮点型可以以D或F结尾。这个问题想要统一解决也是相当得麻烦,所以建议大家不要像我一样,为节省并不起眼的程序大小而导致维护困难。

这是十进制数字的状态图,而js语言中E后面的+号还可以省略,这点需要额外注意(图上并没有显示出来)。下面是源码:
//处理数字
protected function dealNumber():void {
var cc:int = peek.charCodeAt(0);
var start:int = index - 1, i:int = 0;
var res:String;
//以0开头需判断是否16进制
if (Character.isZero(cc)) {
readch();
cc = peek.charCodeAt(0);
//0后面是x或者X为16进制
if (Character.isX(cc)) {
readch();
//寻找第一个非16进制字符,同时最大长度不能超过6(不包括开头的0x)
for ( ; i < 6 && index < code.length; i++) {
if (!Character.isDigit16(peek.charCodeAt(0))) {
break;
}
readch();
}
//高亮
result.append(HighLighter.number(code.slice(start, index - 1)));
return;
}
//其它非数字退出且不是小数点
else if (!Character.isDigit(cc) && !Character.isDecimal(cc)) {
result.append(HighLighter.number("0"));
return;
}
}
//先处理整数部分
while (Character.isDigit(peek.charCodeAt(0))) {
readch();
}
cc = peek.charCodeAt(0);
//整数后可能跟的类型L字母
if (Character.isLong(cc)) {
result.append(HighLighter.number(code.slice(start, index)));
readch();
return;
}
//小数部分
else if (Character.isDecimal(cc)) {
readch();
while (Character.isDigit(peek.charCodeAt(0))) {
readch();
}
//小数后可能跟的类型字母D、F
if (Character.isFloat(peek.charCodeAt(0))) {
readch();
res = code.slice(start, index - 1);
//防止.f出现
if (res.length > 2) {
res = HighLighter.number(res);
}
result.append(res);
return;
}
}
cc = peek.charCodeAt(0);
//指数E
if (Character.isExponent(peek.charCodeAt(0))) {
readch();
cc = peek.charCodeAt(0);
//+-号
if (Character.isAdd(cc) || Character.isMinus(cc)) {
readch();
}
//指数后面的数字
while (Character.isDigit(peek.charCodeAt(0))) {
readch();
}
}
//高亮
res = code.slice(start, index - 1);
//防止.e之类的出现,即第一个字符是小数点的情况下判断第二个字符是否非数字
if (!Character.isDecimal(res.charCodeAt(0)) && !Character.isLetter(res.charCodeAt(1))) {
res = HighLighter.number(res);
}
result.append(res);
}
其它
空白符的处理没什么难度,它不需要被高亮。而js中处理折叠主要是针对符号{和}。我设置了一个叫做depth的变量,用以保存深度(使用firebug的用户可以看到代码行li节点上的name属性,那就是深度变量)。每当遇到{时深度自增;而遇到}时深度自减。这样就做到了折叠功能——点击每行向下遍历,折叠掉深度比它大的行,遇到相等或小于的时候中断遍历。
至于难点中的难点——区分除号和正则,以及正则的状态图处理,这是下一篇要重点讲述的地方。

本博客所有文章均采用