從零開始,做一個NodeJS博客(四):服務器渲染頁面與Pjax

分類:IT技術 時間:2016-10-14

標簽: 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倉庫的實時構建結果。


Tags: 瀏覽器 下一步 服務器 從零開始 音樂

文章來源:


ads
ads

相關文章
ads

相關文章

ad