从零开始,做一个NodeJS博客(四):服务器渲染页面与Pjax

标签: NodeJS


0

一个星期没更新了 = =
一直在忙着重构代码,以及解决重构后出现的各种bug
现在CSS也有一点了,是时候把遇到的各种坑盘点一下了

1 听歌排行 API 修复与重构

1.1 修复

在加载云音乐听歌排行的时候,有时会出现一个奇怪的bug:json数据无法被解析。如下图:

JSON Parse ERROR

在刷新页面后,问题就会得到解决。此后无论怎么刷新,问题也不会出现。

过一段时间再次打开页面,会出现相同的问题,刷新之后也可以解决。此时换用其他各种浏览器,都不会出现问题;但一段时间之后仍会重现一次。。。

那肯定不是浏览器的锅了。把Response的内容复制出来看看。

JSON 片段

粘贴,格式化。VSCode报出了4个警告和一个错误;再仔细看一眼,哎,怎么中途截断了?难道是收到的请求不全?

返回去看看接收请求收到的JSON文件:没错啊,是全的。当然了,因为接下来刷新几次之后就不会在遇到此问题了。在本地测试中也发现,只有服务器启动之后的第一次访问,才会出现这个问题。

打下断点

找到输出的位置,在这里下断点,开始调试。

从server.js进来的时候,文件还没有被创建;到36行,建立请求;38行,绑定事件回调;49行,发送。

接收到数据,触发response事件,命中断点。

解压缩,输出,这时候检查一下输出的文件,0 KB。跑到下一步callback,传出文件名,这时候检查输出文件,0 KB

等下!怎么会是0 KB!这时文件还没有写入完成,就已经把文件名传给回调函数,然后开始读取了?!

然后就进入了各种不明所以的内部库调用,跳出之后,检查输出文件,37KB。这里才刚刚写入完成!自然,浏览器那边还是没法解析,传出来的数据还是不完整,即使输出文件已经是完整的了。

有没有联想到一些东西?是IO效率的问题,或者说,文件操作也是异步的,需要等待一个事件?

好,马上去查一下Stream的API文档,找到了Stream.Writablefinish事件。这个事件在所有数据写入完成之后被触发。好,要的就是你。

将代码修改如下:

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

之前我们的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靠前的部分,相当于变量初始化的位置,然后运行测试吧:

动态的404页面

好的,效果达到了。

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被改变了,内容也成功加载出来。可是如果现在后退的话,虽然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仓库的实时构建结果。

posted @ 2016-08-22 22:07  Rocket1184  阅读(2012)  评论(1编辑  收藏  举报