使用Disqus JS让Disqus评论在国内可用

Disqus在国内近几年一直访问不了,那么许多平常访问的游客甚至不知道博客有评论功能,这无疑是让人遗憾的事情。

要想让Disqus的功能可用,可以使用Disqus API + 反向代理的方法,前端模拟Disqus的评论界面,再使用一个服务器反向代理Disqus API的请求,那么就可以让评论可以在国内网显示出来。

笔者对Disqus JS项目早有所耳闻,但是一直没有精力去使用,今天正好将其调试好,然而碰到了一些我意想不到的问题,导致花了很长时间。

神奇的Disqus JS

Disqus JS是一个前端脚本和反向代理服务器结合的轻量化的显示Disqus评论的方案。其比起其他的Disqus反向代理方案的优点是可以使用serverless的纯反代功能的服务器(例如免费的Cloudflare Workers),这样的话就不用担心自己的云服务器可能会换的问题。当然,这也限制了其功能,让它在基础模式下不能够发表评论。

其实使用起来很简单,遵循Github 项目上的介绍,很快就能够完成。主要就是在网页中引入javascript脚本和CSS样式表,并初始化一个js类即可,从CDN引入资源如下。

1
2
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/disqusjs@1.3/dist/disqusjs.css">
<script src="https://cdn.jsdelivr.net/npm/disqusjs@1.3/dist/disqus.js"></script>

初始化的类的过程如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script>
function createDisqusJS() {
window.dsqjs = new DisqusJS({
shortname: '<%= theme.disqusjs.shortname %>',
siteName: '<%= theme.disqusjs.sitename %>',
identifier: '<%= page.path %>',
url: '<%= page.permalink %>',
title: '',
api: '<%= theme.disqusjs.api %>',
apikey: '<%= theme.disqusjs.apikey %>',
nocomment: '<%= theme.disqusjs.nocomment %>',
admin: '',
adminLabel: ''
});
}
createDisqusJS();
</script>

但是我却碰到了许许多多的坑。

Disqus JS与InstantClick的兼容

之前的博文中介绍过InstantClick这个基于PJAX机制的轻量Javascript框架,能够将网页变为单页式应用,减小跳转延迟。但是InstantClick的修改HTML DOM的行为会导致一些加载上的问题。

例如,我将引入js和css资源的过程与执行初始化的过程都放在<body>内部,理论上按顺序执行初始化应该在加载好js脚本之后,但是InstantClick在跳转到文章页面就先执行了初始化,而此时js脚本还未加载好,就会出现DisqusJS is undefined的错误。

解决这个错误倒也不难,只要知道了原因,就可以使用onload事件去执行初始化。因此我现在引入资源的过程如下,让css与js都异步加载。

1
2
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/disqusjs@1.3/dist/disqusjs.css" media="none" onload="if(media!='all')media='all'">
<script defer src="https://cdn.jsdelivr.net/npm/disqusjs@1.3/dist/disqus.js" onload="createDisqusJS()" disqusjs></script>

在古早版本中的Disqus JS中还有其他与PJAX机制兼容的问题,但是现在的版本已经对其进行了修复。

Disqus的identifiers机制

我在解决上了上面一个PJAX兼容性问题后,接着又碰到了下一个问题。我的评论区提示 “ 当前Thread尚未创建。是否切换至完整Disqus模式 ”。我便去Github项目的文档里找有没有解释,当时看到这条issue #38: Thread初始化失败 中作者回复:

Since DisqusJS is only a pure front-end project which doesn't require any server, so create thread at Disqus requires user manually switches to Disqus Mode.

意即,每个网页的Disqus评论是需要在Disqus的服务器中创建一个Thread来储存评论的,如果之前没有创建过,那么作为一个轻后端的项目在基础评论模式下Disqus JS是不会调用API去创建Thread的。

这话是没有问题的,但是问题在于我的Thread尚未创建的原因不是因为“这个网页”没有创建Thread,而是网页的identifier没有对应上,导致无法获取该网页的Thread(即使它存在)。

什么是identifiers呢?Disqus在JavaScript configuration variablesWhat is a Disqus identifier?中对Disqus如何区别不同的网页,即建立评论Thread与网页的对应关系的方法进行了介绍。总结而言,一个Thread真正unique的id,是它的thread id,而identifiers和urls都是用来对应到thread id的。如果我们保证identifier或url的唯一性,我们就可以通过identifier或url找到对应的唯一的评论Thread。如果identifiers不唯一(多个网页对应了一个identifier),那么就无法保证通过一个identifier对应一个网页。

在使用Disqus原生的js框架时,Disqus推荐在window.disqus_config变量中写明page.urlpage.identifierpage.title,这样可以帮助Disqus给网页创建唯一的identifier。如果没有配置disqus_config,那么Disqus会使用网页的Url作为page.url,并以此来索引Thread id。

我的Disqus使用的问题在于,原来主题中在使用Disqus时是使用下面的两行javascript脚本来注册window.disqus_config,而使用window.location的问题在于,在localhost上和在有域名的服务器上浏览网页时会产生不同的location,这就导致一个网页无法对应唯一的identifier和url。

1
2
this.page.url = window.location.toString()
this.page.identifier = window.location.pathname

我发现这个问题的方法,是发现使用Disqus API threads/list.json请求对应的identifier的thread列表时,会返回一堆thread而不是一个唯一的thread(原因就是identifier不唯一对应网页时,Disqus API会返回该shortname下的所有Thread),而Disqus JS认为这种情况下属于没有创建Thread。

知道了这个问题后,我去Disqus网站上把之前注册的网站删除(意味着删除了所有的Thread),重新建立我网站的评论系统。

你以为这样就能正常工作了吗? Too young.

Disqus API的中文编码

我在此时使用下面的代码来建立identifier,使用url_for是因为想给作为identifier的path前加个斜杠/。然而我万万没想到的是,这个行为让我花了很多的时间去寻找问题所在。

1
identifier: '<% url_for(page.path) %>'

url_for的功能远不止给page.path加个斜杠,他还会对url进行编码,相当于再调用javascript的encodeURI函数,但是我当时并不知道这个问题,还以为是Disqus JS或浏览器在发GET请求时自动把url给编码了。

我来分析一下对encodeURI(page.path)会产生什么问题。

在使用完整评论模式(第一次使用会自动创建Thread)时,创建的thread的identifier是encodeURI(page.path),然后Disqus的数据库里的identifier值就是encodeURI(page.path),即Disqus不会在创建Thread时对identifier解码。

但是在使用Disqus API查询Thread列表时,他会对GET的参数进行URL解码,即相当于调用decodeURI,于是在与数据库里的identifier匹配时,它拿decodeURI(encodeURI(page.path)) (也就是page.path)和数据库进行匹配,而数据库里只存了encodeURI(page.path),那么自然匹配是失败的,然后threads/list.json API就会返回所有的Thread。

经过我的测试,如果你的GET方法的参数为thread=$IDENT,Disqus API服务器会首先调用ident:$IDE = decodeURIComponent($IDENT),然后会在数据库里查询$IDE,但是像我说的那种情况,在数据库里的键值为encodeURI($IDE),自然是查不到的。

令我疑惑的是,Disqus在自己的网站上I'm receiving the message "We were unable to load Disqus."说自己不支持非ascii码作为url,但是我即使直接通过curl请求未编码的中文,也不存在问题。

所以我现在不会主动对path进行编码,而是靠浏览器帮我编码传输。

当然,这里有一点很关键,我们不会主动对page.path进行编码,而是靠浏览器帮我们进行了GET参数的自动编码。而不同操作系统的不同浏览器对GET参数自动编码时采取的方案是不同的,虽然说现在主流浏览器都是采取%符号+utf-8编码,但是也有IE浏览器是使用系统编码,详情可以参考关于URL编码,因此依赖浏览器还是不稳定的,理论上Disqus JS应该自己对GET的参数进行encodeURIComponent后再发送请求。

关于encode这点,我已经给Disqus JS提了Pull Request并合并进master了。

现在,理论上你即使用IE浏览器也可以看到评论框的基本模式(没有安全补丁的xp系统由于SSL证书问题可能打不开网页,IE8之前的浏览器也不太行)。

gulp-babel 对展开语法(Spread syntax)的错误转换

当我fork了Disqus JS的源码修改GET行为的参数编码时,我在本地测试上遇到了问题。该项目使用gulp生成部署时代码,使用gulp-babel精简javascript代码。我使用gulp-babel对整个javascript脚本进行精简后得到部署环境中的js文件,然后在本地服务器运行并用浏览器测试时,我发现插件不能正常工作了。奇怪的是,当不用gulp-babel精简时的原脚本是可以正常运行的。

那么基本可以确定是gulp-babel让脚本转换时出了问题,经过排查,我发现问题出在将HTMLCollection转换为Array的过程中。原代码是[...aTag],其中aTag为一个HTMLCollection,这里用了展开语法(Spread syntax),可以将可迭代的对象展开为数组,是ES6标准后来提出的语法。观察生成后的js文件,gulp-babel将对应的代码转换为了[].concat(aTag),如果要达到类似的效果,concat的参数只能是数组,而不会把可迭代对象展开,[].concat(aTag)的写法会把aTag作为一个Object追加为数组的元素。因此这两者不是等价的。

那么,我们应该使用什么来达到这一行为而且不会被gulp改变语义呢?我们目前的需求是将HTMLCollection转换为Array,而HTMLCollection为什么可以转换为数组?首先,它是一个array-like的对象,更详细地说,它有length属性,且可以用数字作索引,那么这样的对象就有转换为Array的条件。另外,HTMLCollection在ES6标准中也成为了一个可迭代对象,而可迭代对象也是可以通过一些方法转换为Array的。

下表是我总结的三种可以将其转换为数组的方法,其中Array.prototype.slice.call支持的参数是array-like的对象,且支持的浏览器最为广泛,我不知道有什么浏览器是不支持它的。Array.from支持array-like的对象和可迭代对象,功能最丰富,但是也是ES6标准开始才有的方法。Spread syntax也是ES6标准开始的语法,且由于会被gulp-babel错误转换直接否决。

由于我们这里的需求只是将HTMLCollection转换为Array,因此选择支持最广泛也没什么缺点(可能代码长了点)的Array.prototype.slice.call即可。

Parameter Array.prototype.slice.call Array.from Spread Syntax
Array-like objects with a length property and indexed elements Yes Yes No
Iterable objects No Yes Yes

当然,gulp-babel有插件支持对Spread syntax的转换, @babel/plugin-transform-spread可以完成该功能。

One More Thing

前面我们提到了IE浏览器在进行GET查询时对参数的编码行为和其他浏览器不同,那么我们不妨再谈一谈对IE浏览器兼容做的工作。

众所周知,IE11是不兼容ES6标准的,因此如果js代码中有ES6标准的用法都是不能直接运行于IE浏览器的,故我们如果要考虑IE浏览器,用到的ES6标准中的语法和新接口都需要使用polyfill的方法来进行兼容。

Disqus JS用到了ES6标准中的fetch API和Promise,这两者都是无法在IE中运行的。在Disqus JS的readme也提到了需要用polyfill的实现来代替。那么,如何判断当前浏览器是IE然后动态地加载polyfill代码呢?

我一开始使用对Request Header进行判断的检测方法,但是随着我对Feature detection的进一步了解,我知道了如果是想实现跨浏览器的支持,那么Feature detection比针对浏览器版本的检测更加有效和全面。例如,我当初只检测了IE,但如果用户用的是另一个也不支持ES6标准的古老浏览器呢?如果用户切换了Header呢?

针对浏览器的版本判断,总是需要考虑更多的内容,需要想得更加全面,也需要精确地了解哪个版本的浏览器不支持什么标准的什么特性,而这是极其消耗精力的。如果使用Feature detection,我们能够花更少的精力就做到跨浏览器一致性。

例如,我只需要使用下面的代码就能简单地判断浏览器是否支持fetch特性。同样的,不同的特性都可以进行判断而决定是否对某一功能使用polyfill。这样能够做更精细而全面的跨浏览器一致性行为。

1
2
3
4
5
6
if (window.fetch) {
// use fetch
}
else {
// use polyfill
}

时光飞逝,Windows XP于2001年发售,截止至今已经19年了,Windows 7是2009年发售的。不算Vista,至少2009年后基本没有多少新增个人Windows XP的用户了。理论上,一台PC用了11年还能不换零件坚持用简直是奇迹。(其实我家里还有两台Windows XP的PC)

根据statcounter的统计数据,全球范围内截止至2020年7月,Windows在桌面操作系统的市场份额是77.68%,其中XP又占Windows阵营中的0.82% (在中国是2.87%),也就是说Windows XP占所有桌面操作系统的0.64%,不知道其中有没有包括众多的工业用机。

不过我想,从人群划分来看,应该几乎没有使用Windows XP的用户会点击到这个页面吧。