很多小伙伴搞网站开发的时候很容易遇到网站中文乱码的问题,这时候就会各种百度,然后把提到的方法都试一遍。有些解决了,但也解决地莫名其妙。有些没有解决,甚至试遍了搜到的所有方法都未必成功,这极大打击了学习者的信心。
目前网上似乎也没有人比较系统地总结过Web应用在文字编码方面的问题,深受这个问题的困扰,于是我花了大概一整周的时间去测试研究总结。期间遇到了不少奇奇怪怪的问题,包括IDE软件本身的BUG等等,所幸的是以后自己再遇到乱码之类问题的时候就会系统地去排查了,不会再像大海捞针一样瞎搜solution。
本文旨在剖析网站开发过程中文字编码所参与的过程,大致理顺一个网页从最初生产到最后呈现的完整技术链,其中涉及到的所有编码环节,只要其中一个出错,都有可能导致最后的呈现出现乱码甚至更严重的问题。
一个HTML网页,往简单来说,无非就是一个文本文件,网页的编码问题可以从以下最根本的两个方面入手:
对网页使用的外联文件,如外联JavaScript文件、外联CSS文件等,甚至是网站后端所采用的代码文件,类似地同样可以从“写”与“读”这两个方面入手。始终坚持的原则就是,用什么编码,就用什么解码。
下文可能会涉及到Unicode、GB2312、UTF-8、UTF-16等编码的一些基本知识,这里就不详述了,有兴趣的可以去百度一下了解个大概即可。
生成一个网页,无非就两种办法。一是程序员手动码一个HTML文档,二是通过JSP、nodejs等相关技术动态生成一个网页。
先来说说程序员手动码出来的HTML文档。
通常我们写一个HTML文档的时候都会在文档中指定编码,例如:
以上任意一条都可以指定HTML文档使用的编码。
在css文件中,同样有类似语句。
@charset "UTF-8";
@charset "GB2312";
这些语句,在上面提到的两方面都会发挥作用。在保存文档的时候,IDE需要根据所指定编码去保存文档;在读取文档的时候,用户代理(如浏览器)需根据指定的编码去解析内容。
但是,需要注意的时,在文档中做了如此指定,并不代表这个HTML文档就一定会按照你所指定的编码去保存。例如你可能指定了UTF-8编码,最后HTML文档却有可能以GB2312编码的形式保存下来。
一些较为智能的IDE如Webstorm会识别这些指定,并按指定的编码去保存。但是据我这几天的测试,这些IDE也没有想象中的那么智能。
以我做测试用的2019.1 x64版本Webstorm为例,它可以理解但是却无法理解。看出区别了吗?后者charset后的等号两边多了两个空格即导致了无法识别。而且,如果一开始指定了UTF-16编码,IDE以UTF-16的编码保存了文件后,再修改指定的编码为UTF-8,IDE也不会把原先的UTF-16编码转换为UTF-8去保存。
对于一些完全不具备智能识别功能的IDE更是如此。因此,当出现问题时,最好用HEX模式打开查看一下,文档是否已真正按你所指定的编码去保存。
对于使用JSP、nodejs等相关技术动态生成的网页,问题倒是相对容易解决一点。以nodejs为例。
你可能在某些博文中见过以下类似语句:
res.writeHead(200, "OK", {'Content-Type': 'text/html charset=utf-8'});
这一句的作用仅仅是在HTTP报文头中声明了报文内容所用的编码,并不能决定报文内容真正所用的编码(当然一个正确的实现应该保持一致)。那么这个声明有什么用呢?这个我们后面再说。
真正决定写入文档编码的语句应该如下:
res.write('我是测试字符串', 'UTF-8');
顺便一提,由于nodejs不支持GB2312编码,可通过安装iconv-lite库实现编码转换。
res.write(iconv.encode('我是测试字符串', 'GB2312'));
其他动态生成技术大同小异,如JSP中是通过设置pageEncoding="UTF-8"的形式来实现。这里注意区分JSP代码文件本身所使用的编码和JSP的产品HTML文档所使用的编码,这里设置的是JSP的产品HTML文档所使用的编码,代码文件本身的编码是另一回事,这个我们稍后再谈。
总结一下,对于HTML文档,一定要保证生成或保存文档时所使用的编码与文档内声明的编码一致。在手工码HTML文档的时候,我们要充分利用IDE的智能识别给我们带来的便利,但是也不能过分依赖与相信IDE。
很多人可能会跟我最初一样非常好奇,当浏览器拿到一个HTML文档时,他对文档所使用的编码一无所知,又怎么能够解析出上面所说的各种编码声明进而用声明的编码方式进行解码呢?
上面我们有提到HTTP报文头中可以对报文内容的编码进行声明,某些用户代理或者一些较为旧版的浏览器可能会优先使用HTTP报文头声明的编码进行解码。我用目前较新版本的IE、Edge、Firefox、Chrome进行了测试,似乎现在的浏览器对HTTP报文头声明的编码都不太感冒。基本上现在浏览器都有自己的一套解析算法,浏览器尽量找到一种能解析出较多内容的编码方法,进而获得文档内所声明使用的编码方法。如果找不到文档内有声明,则保留浏览器所选择的解码方法。
即使找到了文档内有编码声明,浏览器也不一定会采用。如果浏览器采用声明的编码解析出的内容比浏览器选择的编码解析出的内容质量更差,那么声明的编码不采用。例如,浏览器检测出使用UTF-16编码可以解析出大部分内容,此时解析出了这么一句声明:。但是浏览器发现使用UTF-8去解析这个文档根本不知所云,因而浏览器会继续保持原来的UTF-16解码方式。
这个好与差没有一个统一的评判标准,也许浏览器本来选择了一个可以完全正确解析HTML文档的编码方法,但是使用文档内声明的编码方法(当然这个声明是错误的)解析出来却全是中文乱码,浏览器也有可能会采用声明的编码方法。例如一个以GB2312编码保存的HTML文档,文档内却写着UTF-8的编码声明,对于有汉字的HTML文档,往往就会显示为乱码。
因此,一定要保证生成文档所使用的编码与文档内所声明的编码完全一致,不一致只会导致诸多不确定的结果。个人的建议是所有HTML文档统一使用UTF-8编码,辣条都迈出国门了,难道你的网页还要用GB2312或者GBK这种局限于国内的编码方式?还有UTF-16这些个编码方式就更别拿出来贻笑大方了。
这里所说的源代码是指各种脚本代码(JavaScript、Python、PHP等)或各种编译型代码(C++、JAVA等)的源文件。分析的思路与上文分析HTML文档很相似,一方面生产代码文件的是各类IDE,另一方面,读取使用代码的是各类解析器、编译器。
原则还是一样,用什么编码去保存,就用什么去解码读取。一定要保证保存与读取使用同一个编码。
某些语言也像HTML一样支持编码声明,如Python,可以通过在文件头部使用声明 # -*- coding: UTF-8 -*-告诉IDE该用什么编码去保存源文件;其他的一些语言则是不支持编码声明的,如C++,保存源代码时要在IDE中进行专门的编码设置。注意,即使语言支持编码声明,也不要过分相信与依赖IDE,你的IDE可能不具备智能识别功能或者该功能不是很完善。
总而言之,对于支持编码声明的语言,与HTML文档一样,一定要保证声明的编码与实际保存的编码一致。
另一方面,解析器或编译器为源代码的消费者。由于这些东西都是给程序员用的,因此在解码方面自然不会有浏览器那么智能。一些编译器和解析器可能只支持源代码使用特定的一种或几种编码,如nodejs则要求输入的JavaScript源文件必须为UTF-8编码;Python3的解析器则可以识别文件头部声明的编码,进而用声明的编码去解析程序,若文件头部没有声明,则按默认编码UTF-8去解析,解析不了自然就会报错了;JAVA源代码在编译的时候可以通过-encoding参数传递源文件所使用的编码,从而告诉编译器应该用什么字符集去处理源代码。浏览器本身也是JavaScript的解析器,对于外联的JavaScript源文件,默认情况下按HTML文档当前所使用的编码去加载外联JavaScript源文件,也可以通过设置script元素的charset属性来使用指定的编码去加载。
很多从C或者C++入门的同学可能会比较了解string类的底层实现,GCC编译器默认情况下string类会保留原始文档中字符串对应的二进制串。如string s[] = "测试";这样一句,如果源码是以GB2312保存的,那么代码运行时字符串s在内存中的存在形式就是“测试”二字对应的GB2312编码(b2 e2 ca d4);而如果源码是以UTF-8保存的,则其在内存的存在形式就是对应的UTF-8编码(e6 b5 8b e8 af 95)。以UTF-8形式保存的字符串直接cout输出到控制台可能会以乱码的形式呈现,因为默认情况下windows cmd是以ANSI编码(在国内即为GBK编码)去解码输出的。将其通过文件输出流直接输出到txt文件,得到的文字编码是与源文件一致的。
另一方面,JavaScript语言的字符串类则是以Unicode编码的形式去存储每一个文字的。如“测”字的Unicode编码为27979,“”试“字的Unicode编码为35797。因此,解析器接受的编码与执行的源文件编码不一致时,可能会有不可预料的结果。例如,解析器要求接受UTF-8编码的源文件,却输入了GB2312编码的源文件,解析器遇到不符合UTF-8编码标准的字符串内容,就会转化为Unicode编码为65533的一个“?”字符保存到内存里,进而导致数据的丢失。
因此,我们不要尝试去依赖程序语言字符串的实现方式去保证源代码输入与内容输出保持一致的编码,这可能是一种依赖某个特定版本编译器或解析器的方法,不符合软件工程里面可移植的要求。
正确的做法应该是保持源文件的编码与编译器或解析器所要求接受的编码一致。其做法可以通过在文件头部的声明编码来和解析器进行协商,如Python;也可以在编译时告诉编译器该用什么编码去解码源文件,如JAVA在编译时使用-encoding指令指定源文件所使用的编码;对于仅支持某种特定编码的解析器,自然就应该手动保证源文件使用该特定编码了。
这里谈一谈我在测试研究的时候遇到的一些坑吧。
不知不觉写了这么多,也总算是把做了一周的实验测试结论都写得差不多了。以后再遇到各种乱码问题的时候应该就会系统地去排查而不会手忙脚乱了。
最后,如果文章有什么差错的话,希望大佬们可以评论批评指正。