从零开始,做一个NodeJS博客(四):服务器渲染页面与Pjax
标签: NodeJS
0
一个星期没更新了 = =
一直在忙着重构代码,以及解决重构后出现的各种bug
现在CSS也有一点了,是时候把遇到的各种坑盘点一下了
1 听歌排行 API 修复与重构
1.1 修复
在加载云音乐听歌排行的时候,有时会出现一个奇怪的bug:json数据无法被解析。如下图:
在刷新页面后,问题就会得到解决。此后无论怎么刷新,问题也不会出现。
过一段时间再次打开页面,会出现相同的问题,刷新之后也可以解决。此时换用其他各种浏览器,都不会出现问题;但一段时间之后仍会重现一次。。。
那肯定不是浏览器的锅了。把Response的内容复制出来看看。
粘贴,格式化。VSCode报出了4个警告和一个错误;再仔细看一眼,哎,怎么中途截断了?难道是收到的请求不全?
返回去看看接收请求收到的JSON文件:没错啊,是全的。当然了,因为接下来刷新几次之后就不会在遇到此问题了。在本地测试中也发现,只有服务器启动之后的第一次访问,才会出现这个问题。
找到输出的位置,在这里下断点,开始调试。
从server.js进来的时候,文件还没有被创建;到36行,建立请求;38行,绑定事件回调;49行,发送。
接收到数据,触发response
事件,命中断点。
解压缩,输出,这时候检查一下输出的文件,0 KB
。跑到下一步callback
,传出文件名,这时候检查输出文件,0 KB
。
等下!怎么会是0 KB
!这时文件还没有写入完成,就已经把文件名传给回调函数,然后开始读取了?!
然后就进入了各种不明所以的内部库调用,跳出之后,检查输出文件,37KB
。这里才刚刚写入完成!自然,浏览器那边还是没法解析,传出来的数据还是不完整,即使输出文件已经是完整的了。
有没有联想到一些东西?是IO效率的问题,或者说,文件操作也是异步的,需要等待一个事件?
好,马上去查一下Stream的API文档,找到了Stream.Writable
的finish
事件。这个事件在所有数据写入完成之后被触发。好,要的就是你。
将代码修改如下:
response.pipe(zlib.createGunzip()).pipe(output);
// wait for file operation
output.on('finish',() => {
fs.readFile(outputFileName, (err, data) => {
var buf = JSON.parse(data.toString())['/api/user/detail/76980626'].listenedSongs;
bufJSON = new Array();
buf.forEach((value, index) => {
if (index > 9) return;
bufJSON.push({ id: value.id, name: value.name, artistName: value.artists[0].name });
});
});
});
在等待文件操作完成之后才读取数据,而且读到数据后,只取出自己需要用到的部分,存在全局数组bufJSON
中当作缓存,顺便提高一下API响应速度。
1.2 重构
之前,API获取的听歌排行目标用户是写死在代码里的。可以写一个init()
函数,初始化它的获取目标用户。
function init(id) {
userId = id;
outputFileName = `netease_music_record_${id}.json`;
}
在写入请求body的时候,要把请求数据转化成QueryString的格式。Node.js提供的QueryString模块可以接受一个Obejct作为参数,输出字符串;不过可变值的多行字符串并不能作为对象的属性名。也就是说:
var postData = {
`/api/user/detail/${id}`: '{\'all\':true}'
}
是会报错的,对象属性名非法。这下我们就需要引入Map
这个数据类型了,只要是合法的字符串,就可以当作数据的键和值。像这样:
var req = http.request(options);
var qString = new Map();
qString[`/api/user/detail/${userId}`] = '{\'all\':true}';
req.write(qs.stringify(qString));
嗯,API的优化就说到这里了,代码都在文章最下方的Git仓库里,我也会时不时进行一些抽风似的重构,不可能一一讲述了。
2 服务器端页面渲染
说到动态页面,直接用JS在浏览器里操作不就行了,还关服务器什么事?这样虽然很方便,不过有一个弊端:不利于搜索引擎爬虫的索引。自己博客里写了这么多文章,当然希望更多的人可以通过搜索引擎找到,而不是整天放在那里无人问津吧。
好,那就来动态的构建一个404页面,可以显示当然服务器正在运行的Node版本。
之前我们的404页面是这样的。可现在Node.js的current版本已经到6.4.0了,就先从这里下手吧。
通过Node.jsAPI文档,了解到,要获取当前node版本号,只需要使用porcess.version
。如何吧这个版本号替换进404页面的html文件中去呢?我想到的方法是,把html中的版本号改成一段特殊的字符串,然后用正则表达式去唯一的匹配他。比如这样:
<p>Node.js - ${process.version}</p>
然后我们建立正则表达式,去匹配那个字符串。但千万不要在html文档的其他地方使用这个“占位符”,它会被全部替换成版本号。也可以再在后面加一些其他无意义内容,反正要避免正常的代码或文字与它重复。
fs.readFile(path.join(root, '/page/404.html'), (err, data) => {
var versionRegex = /\$\{process\.version\}/;
var nodeVersion = process.version;
var current404 = data.toString().replace(versionRegex, nodeVersion);
var page404 = fs.createWriteStream(path.join(root, '/page/current404.html'));
page404.end(current404, 'utf8');
});
读取文件,转换字符串,然后生成了新的current404.html
文件。之后发送404页面的响应也要改成发送刚刚生成的current404.html
。
把这段代码放在server.js
靠前的部分,相当于变量初始化的位置,然后运行测试吧:
好的,效果达到了。
3 使用 history.pushState(),改变 URL 并局部刷新页面
Ajax都很熟悉吧,Asynchronous Javascript And XML
,再加上pushState
,就变成了Pjax
。
没什么神秘的,history.pushState()
的作用就是,改变页面的URL,并将一个state
对象储存起来。这个state
对象是自己定义的。在事件window.onpopstate
的回调函数中,传入的参数的state
属性,是之前储存起来的state
对象。
简单来说,使用history.pushState()
,会改变当前页面的URL,但仅仅是改变,浏览器并不尝试去加载他,只是摆在那里;同时会将URL与传入的state
对象一起压入历史纪录栈中。当用户操作浏览器前进或后退时,如果操作后当前页面的URL是由history.pushState()
方法压入栈中的,那么页面将不会被重新加载,window.onpopstate
的回调函数会被执行。
有关更详细的介绍,请看操纵浏览器的历史记录 - DOM | MDN。
我的目的是,在用户单击了首页的标题文章标题时,URL改变,但以Ajax的方法从服务器加载文章内容,显示在页面上。而当用户直接访问这个URL时,又能提供完整文章浏览的页面。
为此,先要在主页上动动手脚,使得点击文章之后让他看起来像一个浏览页面:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Rocka's Node Blog</title>
</head>
<body>
<h1>Rocka's Node Blog</h1>
<hr>
<h3 id="index-article-title" style="display:none;">Title should be shown here.</h3>
<blockquote id="index-article-content" style="display:none;">Article should be shown here.</blockquote>
<h3 id="index-article-header">Blog Archive</h3>
<ul id="index-article-list"></ul>
<h3>Rcecntly Listened</h3>
<ul id="index-music-record"></ul>
</body>
</html>
新加入的元素被设置为了不显示,我们总不能在一个主页上就显示文章内容吧。在用户点击文章之后,再改变历史记录,同时变更页面的样式,让它看起来像一个文章浏览页面。于是,在loadArticleContent的success回调中,我们这样写:
function success(response) {
history.pushState({
originTitle: articleTitle,
type: 'archive',
originPathName: window.location.pathname
},
articleTitle,
`/archive/${articleTitle}`
);
// switch element visibility
showArticleContnet();
document.getElementById('index-article-title').innerText = articleTitle;
document.getElementById('index-article-content').innerText = response;
}
showAtricleContent
函数用来切换各种元素可见性,把#index-article-header
和#index-article-list
隐藏,#index-article-title
和#index-article-contnet
显示,这里就不展开写了。el.sytle.display='block'
或者'none'
就好。之后还会有一个showIndex
函数,都懂这个意思,看看就好。
还有就是history.pushState()
的三个参数,第一个是要压入的state
对象,第二个是名称,可以传入空字符串,或者当前文章名称,因为这个属性在现在并没由什么用处(MDN是这么说的!)。第三个就是要变成的URL了,规定好自己的URL地址。我这里用的是与文章文件相同位置的地址。
然后,看看效果:
URL被改变了,内容也成功加载出来。可是如果现在后退的话,虽然URL会变回去,但却不会产生任何效果。这时要给window.onpopstate
绑定回调函数:
window.onpopstate = (e) => {
if (e.state) {
loadArticleContent(e.state.originTitle);
} else {
showIndex();
}
}
这个e.state
是我们之前pushState
的时候压入历史记录栈中的,里面存储的是跳转到的标题。同样,如果没有state,应该是后退到了主页上,显示主页。
现在测试,点击,跳转了,后退,正常;前进,正常;后退,后退。。。。哎,不对啊,怎么退不回主页了?还记得loadArticleContent
吗?我们调用它的时候,直接使用了pushState
。但在window.onpopstate
的回调函数中,也是调用了它。这也就意味着,当我们操作页面前进时,又会有一条历史记录被压入栈中;然后再后退,又多了一条,每次后退,又会多一条。虽然我们的位置后退了,但在我们前面又增加了一条记录,这样永远也回不到主页。
所以,在加载文章内容时做出判断:如果此次加载来自历史记录操作(加一个参数就好),那么不再增加历史记录:
function loadArticleContent(articleTitle, fromState) {
function success(response) {
if (!fromState) {
history.pushState({
originTitle: articleTitle,
type: 'archive',
originPathName: window.location.pathname
},
articleTitle,
`/archive/${articleTitle}`
);
}
showArticleContent();
document.getElementById('index-article-title').innerText = articleTitle;
document.getElementById('index-article-content').innerText = response;
}
// other more operations......
// ......
}
window.onpopstate = (e) => {
if (!e.state) {
showIndex();
} else {
loadArticleContent(e.state.originTitle, true);
}
}
至此,在不刷新的前提下主页的操作正常了。
4 动态构建文章阅读页面
借助pushState
,我们时可以改变URL了,可是这个页面实际上是不存在的,一刷新就没了。如果别人想要收藏你的博客文章,不就很尴尬了。。。所以我们要动态的构建一个阅读页面出来。
刚才在处理首页的时候,把元素隐藏了一下就变成阅读界面了。这里先把首页复制一份,稍加改动,就变成了文章阅读页面view.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Rocka's Node Blog</title>
</head>
<body>
<h1>Rocka's Node Blog</h1>
<hr>
<h3 id="index-article-title">${article.title}</h3>
<blockquote id="index-article-content">${article.contnet}</blockquote>
<h3 id="index-article-header" style="display:none;">Blog Archive</h3>
<ul id="index-article-list" style="display:none;"></ul>
<h3>Rcecntly Listened</h3>
<ul id="index-music-record"></ul>
</body>
</html>
这里我把对应元素的内容也都换成了“占位符”,方便匹配。接下来,当用户请求文章页面的时候,就像生成404页面一样,先读取模板,然后将占位符用相应的数据替换。唯一不同的一点是,不要把输出后的文件缓存到当前目录,否则加载文章列表要读取文件的时候,会多出一些奇怪的东西。
在服务器启动监听端口之前,先把原始的文章阅读页面存入全局变量,也是相当于变量初始化吧:
fs.readFile(path.join(root, '/page/view.html'), (err, data) => {
// read origin page in advance
plainViewPage = data.toString();
});
之后每次请求时,只要复制存在全局变量里的字符串,然后修改副本:
fs.stat(filePath, (err, stats) => {
// no error occured, read file
if (!err && stats.isFile()) {
if (pathName.indexOf('/archive/') >= 0) {
var archiveRegex = /archive\/(.+)/;
var titleRegex = /\$\{archive\.title\}/;
var contentRegex = /\$\{archive\.content\}/;
var title = archiveRegex.exec(pathName)[1];
fs.readFile(path.join(root, pathName), (err, data) => {
var page = plainViewPage;
var page = page.replace(titleRegex, title);
var page = page.replace(contentRegex, data.toString());
response.end(page);
});
} else {
// normal file read
}
} else {
// file not found
}
});
现在问题来了:上一步pjax的时候,请求文章内容的URL已经是文章的“真实”URL了。如果再把这个URL分给文章页面,是否会产生冲突?
当然会了,不过我们有办法避免。在我们异步请求文章内容的时候是一个GET请求;浏览器刷新页面时也是。但在创建XMLHttpRequest
的时候,可以给它设置一个特殊的请求头,比如pushstate-ajax
之类的,用于区分动态加载和页面获取。值得注意的是,只有在请求open之后,send之前,才能设置请求头:
var request = new XMLHttpRequest();
request.onreadystatechange = () => {
if (request.readyState === 4) {
if (request.status === 200) {
// do sth with resopnse
} else {
// oops~~
}
}
}
request.open('GET', `/archive/${articleTitle}`);
// set special request header
request.setRequestHeader('pushstate-ajax', true);
request.send();
同样,在服务器端,也需要进行一些判断:
- 如果是正常的页面请求(没有特殊请求头),就要返回替换了文章内容的查看页面;
- 否则只需要返回文章内容:
if (request.method === 'GET') {
if (pathName.indexOf('/api/') >= 0) {
// api request
} else if (request.headers['pushstate-ajax']) {
// return article coontent only
} else {
fs.stat(filePath, (err, stats) => {
if (!err && stats.isFile()) {
if (pathName.indexOf('/archive/') >= 0) {
// return mixed view.html
} else {
// normal file
}
} else if (!err && pathName == '/') {
// goto index
} else {
// return currnet404.html
}
});
}
}
5
好了,今天就写到这里。其实我还落下了一次更新,现在的实际进度已经达到了,额,还是点开下面的App地址看一下吧,我也不好形容。我会抓紧把剩下的坑都填好的 ;)
仓库地址
GitHub仓库:BlogNode
主仓库,以后的代码都在这里更新。
HerokuApp:rocka-blog-node
上面GitHub仓库的实时构建结果。