这是我第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,那么该怎么办呢?下面是它的状态图:

jssc-article-2-1

我们把每个圈称作一个状态,圈内的数字用以标识不同的状态,而双边的圈则是终结状态。这个状态图没画完,因为状态4和状态5不是终结状态,却没有转换规则。这不是主要讨论。

一般情况下,状态0被称之为初始状态。解析器会设置一个向前看的哨兵,从左到右遍历源码。假设当前字符是i,很显然,它就是状态0可接受的唯一字符。于是开始执行转换规则,状态0根据规则转换到状态1,继续读入下一个字符。

状态1可接受的字符有2种:fother。这里other不是说字符串,而是指除f以外的所有字符。假如是other的情况,显然它不是我们想要的关键字if,所以转而进入状态4,进行其它处理——至于处理什么、如何处理,暂时不需要关心,后面会说到;而如果是字符f,这就符合我们的预期,状态1通过规则转换到状态2。

可能你认为已经结束了,但事实并非这么简单。if两个字母是读出了,但却未必是关键字,后面如果跟着一个字母s呢?ifs,它可能是定义的一个变量名,又可能是个方法名。然而不管是什么,总归不是关键字。所以状态2接受2种字符:数字、字母、下划线(js应该还有美元符号$,因为它也可作为变量名组成),这条路通往状态5,我们不管;其它的情况就好办了,空白也好回车也好括号也好,它一定是if关键字,由此进入状态3。

上面说了两个圈儿的是终结状态,识别出if关键字后,状态3就结束了。此时解析器会进行回退,把if后面读入的向前看字符放回源码流中,继续下一次循环。经过这一轮番的解释,相信你能对词法分析有了大概的印象吧。同理,其它的关键字、数字、字符串、注释……也是如此识别的。

或许有人要问:一种语言的关键字那么多,为每个关键字写处理程序岂不是要累死?没错,事实上编译器也不会这样做。上面那个例子只是为了简单起见所举,真正可行的办法是只建立标识符的DFA,将所有标识符识别出来,然后一一对比,看此标识符是否是个关键字。

识别标识符

jssc-article-2-2

这是识别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,预先保存着所有关键字),是的话高亮上颜色,不是则原封不动。

识别字符串

jssc-article-2-3

字符串的处理相对简单一些。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结尾,浮点型可以以DF结尾。这个问题想要统一解决也是相当得麻烦,所以建议大家不要像我一样,为节省并不起眼的程序大小而导致维护困难。

jssc-article-2-4

这是十进制数字的状态图,而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属性,那就是深度变量)。每当遇到{时深度自增;而遇到}时深度自减。这样就做到了折叠功能——点击每行向下遍历,折叠掉深度比它大的行,遇到相等或小于的时候中断遍历。

至于难点中的难点——区分除号和正则,以及正则的状态图处理,这是下一篇要重点讲述的地方。

最近较多地使用到了960gs,我更喜欢把它的所有css文件压缩成一个,作为公共库被页面包括起来。期间倒是遇到几个让人抓狂的问题,导致莫名其妙的状况出现,还得我很长一段时间都不知道是为什么。最终目标锁定到压缩的960gs,经过严密排查才发觉症状所在!正好,引以为戒,自己动手修改了其中某些地方,也算加深了对一些浏览器之间差异的了解。

以下便是曾经遇到过的一个头疼问题:

先来看页面1:/wp-content/uploads/2009/11/ie-bgi-bug-1.html。非ie下是正常的,ie中背景图无法显示。仔细查看css,没啥大问题啊,难道是第8行默认设置(960gs就把所有元素的背景色设置为透明)搞的鬼?

把第8行删了,页面2一切恢复原状:/wp-content/uploads/2009/11/ie-bgi-bug-2.html。十分搞不懂这是为什么,或许是个很低级的问题吧,忘知道的人赐教。

再来看页面3:/wp-content/uploads/2009/11/ie-bgi-bug-3.html。依旧保持第8行默认样式,但为ol设置了一个高度,于是乎ie6完美呈现,其它的只显示一半。很简单ie6会自动撑开高度。

最后是页面4:/wp-content/uploads/2009/11/ie-bgi-bug-4.html。保持第8行默认样式,不为ol设置高度,但为ol的li设置了个宽度(width:1px;高度也一样),于是乎所有的浏览器都皆大欢喜。

这个bug原本的表现是在一个很复杂的页面中,为一个ul的li设置backgroundimage出现的。结果ie6下出现了闪烁,鼠标移入移出都会造成其不稳定的显示、消失或闪烁。我本以为是老bug了,借用document.execCommand便可搞定,哪知道根本不起作用。最后花了好多时间,才弄清楚。

高亮环境

说到为网页添加代码高亮功能,使用服务器端语言处理无疑是更高效、更兼容的做法(比如基于PHP的GeSHi)。然而这一方式主要面对的问题有三个:

  1. 加重了服务器负担,每次读/写都要将代码解析成正确显示的结果,而且代码过多时传输解析结果也会浪费一定的带宽。
  2. 增加语言种类或者维护升级时更新高亮程序相当费事,倘若支持热部署并且没有集群时还轻松些。
  3. 服务器存储的结果是原始的代码还是高亮后的结果?前者会使得每次读操作都要解析一下浪费资源;后者在修改原始代码的时候则会造成一定困难。引入缓存机制或许是个解决办法,但缓存本身就会增加技术维护量。

抛开服务器端,倘若这一切采用web端来做会是怎样的结果?

  1. 每次读时都要将代码解析成正确显示的结果,然而这一切是在客户端做的,与服务器无关,也不会浪费带宽。
  2. 维护高亮程序就是维护前端程序(js或者as等),成本要低得多。
  3. 服务器存储的是原始代码,不影响代码本身的修改,只需做简单的过滤即可,完全脱离了高亮逻辑。

也正是如此,web端语法高亮成为了主要的潮流。目前web端流行的编程语言无非js和as,silverlight尚需时日。其中js虽然有点兼容性问题,但在前端开发工程师手中早已有无数破解之道;as拥有跨平台、高性能以及良好的OOP支持(这里as主要指as3,下同),却未必如js那般近100%的支持。由此,现在能见到的web端高亮器几乎都是js写的,除了jssc(jssc前3版也是js写的,名称前缀即因此而来)。

对比参数

既然目标锁定了web端语法高亮器(废话,要不然文章标题不是白起了),那么衡量一款语法高亮程序就有了针对性。无非从以下几个方面进行对比:平台支持和兼容性支持语法种类程序体积大小解析速度(性能)解析结果正确性功能体验可扩展性

  1. 平台支持和兼容性

    无疑只有js和as的对比。

    兼容性不说,as生成abc字节码跨平台支持,只需有avm虚拟机(Flash Player的虚拟机)支持即可;js虽然在各个核心中表现不同,但对前端开发工程师来说并不构成问题。

    可到了平台支持上as就不完美了,毕竟它要依靠avm才能运行——这可能是用as来编写高亮程序唯一的弱点了,尽管Adobe吹嘘fp的普及率有97%;而js却在现代浏览器中得到普遍支持,除非客户端手动关闭它。

  2. 支持语法种类

    这个要看作者原意提供多少种语法支持了。提供的语法当然是越多越好,但同时出现的问题就是写程序的人必须通晓所有语法特性才行。不可能幻想一个高亮解析器能够自动对所有编程语言进行正确解析,除非掌握这门语言的基本语法知识。

    这对作者来说是个相当大的挑战,因为不可能一个人能够知晓所有编程语言的特点。良好的做法是设计好接口,让其它有兴趣的人参与进来,编写未实现的语法高亮程序。

  3. 程序体积大小

    这点其实和上面一条互斥,支持的语法越多自然体积会更大。可行的办法无非一方面尽可能减少程序本身大小,提升代码复用读;另一方面用按需装载或按需组合只加载用到的那一部分(JSI?)。

  4. 解析速度(性能)

    as在这方面有着先天性的优势。js则不一样了,依赖于引擎的不同,各浏览器的表现也不一致。而且,为了优化算法提高性能,js编写的高亮引擎往往需要牺牲一定的准确性来达到目的。

  5. 解析结果正确性

    这个上面提到了,最正确的做法无非是针对某种语言的词法分析。然而这样做势必对实现语言有着很大的性能要求,js目前还是远远不能够胜任的,它只能采取某些方法进行折中,使得结果尽量正确。当然那些难以高亮的代码很少有人去写。

  6. 功能体验

    行数、复制和折叠功能是最基本的。行数统计无需任何干预,html本身的ol节点就是顺序列表,自动支持行数;复制在各个浏览器下表现不一,最终还是需要通过flash player来实现(从这一点上说,无论任何前端高亮器其实都用到了js和as,只是侧重点不同);而折叠功能只能通过词法分析来准确实现,as的优势再次体现出来。

  7. 可扩展性

    和第2点一样,要增加语法种类同时就考验了程序的可扩展性。目前所有高亮器都有着不错的支持,js高亮器需要注意的是提取所有语言的逻辑共性,词法分析则要针对每一种语言编写lexer,提取同类语言的通用部分。

高亮解析方式

这可能是初心者最关注的问题之一了。我们最终的目的是正确将一段代码解析成能够在网页上显示的结果,因此如何实现这个解析器(我更习惯这样叫它,因为jssc就是基于词法分析的,倘若是其它方式,称呼为高亮器可能更准确一点)的逻辑,便成了核心。从2007年的jssc 1开始,我大抵尝试过3种基本模式,这也是目前前端高亮器中被广泛采用的。以下将一一叙述它们的优缺点:

  1. 正则模式

    说到高亮一段未知代码,可能你第一反应就是使用正则。没错,基于模式匹配来高亮程序代码,这个方法至今都被大部分人所采用,只是在它基础上进行了许多加工。考虑到如下js代码(以后例子都将以javascript举例):

    典型的编程语言主要需要高亮以下几个部分:关键字(保留字)数字(整数和浮点数)字符(字符串)注释

    其中关键字无法精确匹配,但每种语言对关键字的结构都有明确规定,如:以美元符号、下划线或者字母开头,后跟美元符号、下划线或者数字字母,如此用正则找出所有这种组合,再比较是否预留关键字即可;然而数字、字符、注释都具有一定的格式,采用正则很容易做到:

    /^[$_a-z][$_a-z0-9]+/i、/^\d*?(\.\d+?)?$/、/^(“|’).*?\1$/、/\/\/.*?\n/。

    这里面其实有很多问题,等到以后我们就会知道。

    /*这个例子很简单,只需用正则匹配出多行注释、单行注释、关键字、字符串和数字即可。*/
    for(var i = 0; i < 10; i++) {
      var s = "NO. " + i;
      alert(i); //循环10遍
    }
  2. 分区正则模式

    最大的问题来自于交叉混合。试问:引号中出现注释该怎么办?注释中出现关键字怎么办?字符串跨行怎么办?数字在字符串里怎么办?这些都还只是初级问题,就能把正则模式完美KO掉,因此高亮器不可能只采用正则匹配便解析出所有。

    仔细观察,问题的关键点在于交叉混合,只要把这个解决了,那么一切都如见天日。简单的做法便是在开头增加个预处理过程,将代码分成区块。

    我们遍历代码字符串,遇到引号(无论单双引号)便寻找下一个引号。找到后要看是否被转义?一般情况下回答是否,那么这个区块也被分隔出来;但总有需要的情况出现,此时我们忽略它继续循环往下找,直到找到没被转义的字符串为止。

    同样的,注释也是一样,不过注释有个好处是不存在转义问题。单行注释直接到行结尾,多行注释直接到结束注释符(*/)。

    /*这个例子只用正则是做不到了,注释中有"字符串",字符串里可以有数字或关键字。
    因此首先要做的是将代码分区,提出可能混淆的部分。*/
    for(var i = 0; i < 10; i++) {
      alert("alert 10 times, this is NO." + i);
    }
  3. 词法分析模式

    新的问题来了。perl风格的正则表达式该怎么办?回答依然是分区,不过这可能有点困难,因为你很难将跨行除法与正则表达式区分开来。当遇到一个除号时,它可能就是一个除号,也可能是一个正则表达式的开头。想弄清它就需要回溯,根据之前的语境来判别。另外perl风格的正则表达式的flag亦是一个难点,分区很难将它们包括进来。正则表达式的字符集([]内的特殊符号可以省略转义)允许省略转义符,它处理起来也是相当得麻烦。

    那么,有没有更好的解决办法呢?我们需要一个圣杯,来完美地实现解析要求。最终,埋藏在宝藏中的词法分析浮出水面。

词法分析概念

那么什么是词法分析?

简单来说,它是《编译原理》的基础入门知识。构造出来的编译器,在对我们平常书写的程序代码进行分析时,第一步所做的就是词法分析。具体的工作是,从左到右遍历源代码字符流,根据构词规则识别单词。比如如下代码:

var i = 0;

经过分析器后生成:

  1. var 关键字;
  2. i 变量名;
  3. = 赋值符号;
  4. 0 数字;
  5. ; 分号。

可以看出,词法分析将源代码切割为一个个最基本的逻辑单元(token),并去除了注释、空白等无用的符号。这和语法高亮很相似,借助其基本原理便可做出一个相当不错的解析器来。

jssc5正是采用了这种方法。

web端语法高亮器到底是什么时候开始流行的?

SyntaxHighlighter发表于2007年;SHJS的网站上写着copyright © 2007的字样;google-code-prettify的开源项目主页,最早的反馈亦是Mar 2007;就连jssc的雏形也是出生在2007年初的一堂《编译原理》实验课上。这一年,似乎成为高亮web代码的热潮期。

然而,一切仅仅是开始。随着Yahoo官方采用sh(SyntaxHighlighter),用js编写的它一夜成名。sh的确是目前所有已知web端语法高亮中最出色的一个,许多网站都在使用这家伙,它的地位可以称得上是霸主。不过,开源世界的代码永远是竞争激烈的,其它高亮器如雨后春笋般诞生,互相之间无不在攀比性能、功用、大小等等。记得在jssc 2发表的时候,还是个学生的我就把矛头直指sh,意欲一较高下。当然,结果就是另外一回事了。

时至今日,jssc历经5个版本,各方面都已发展至成熟。然而技术推动却一直只有我一个人,各种因素都有,技术门槛肯定是最重要的一个。于是,我决定开写一个系列文章来介绍web端语法高亮原理——不仅仅是帮助jssc的发展,更是为了共享经验、推动web端高亮技术的进步。本篇文章就是作为序言而写的。

以上即是简单的概念介绍,下面是系列文章的目录,链接不定时更新:

  1. web端语法高亮原理:走进jssc的世界(一)
  2. web端语法高亮原理:走进jssc的世界(二)
  3. web端语法高亮原理:走进jssc的世界(三)
  4. web端语法高亮原理:走进jssc的世界(四)
  5. web端语法高亮原理:走进jssc的世界(五)