如何在零JS程式碼情況下實現一個實時聊天功能❓

AlienZHOU發表於2019-05-20

引言

前段時間在 github 上看到了一個很“trick”的專案:用純 CSS(即不使用 JavaScript)實現一個聊天應用 —— css-only-chat。即下圖所示效果。

如何在零JS程式碼情況下實現一個實時聊天功能❓

在我們的印象裡,實現一個簡單的聊天應用(訊息傳送與多頁面同步)並不困難 —— 這是在我們有 JavaScript 的幫助下。而如果讓你只能使用 CSS,不能有前端的 JavaScript 程式碼,那你能夠實現麼?

原版是用 Ruby 寫的後端。可能大家對 Ruby 不太瞭解,所以我按照原作者思路,用 NodeJS 實現了一版 css-only-chat-node,對大家來說可能會更易讀些。

1. 我們要解決什麼問題

首先強調一下,服務端的程式碼肯定還是需要寫的,而且這部分顯然不能是 CSS。所以這裡的“純 CSS”主要指在瀏覽器端只使用 CSS。

回憶一下,如果使用 JavaScript 來實現上圖中展示的聊天功能,有哪些問題需要處理呢?

  • 首先,需要新增按鈕的click事件監聽,包括字元按鈕的點選與傳送按鈕的點選;
  • 其次,點選相應按鈕後,要將資訊通過 Ajax 的方式傳送到後端服務;
  • 再者,要實現實時的訊息展示,一般會建立一個 WebSocket 連線;
  • 最後,對於後端同步來的訊息,我們會在瀏覽器端操作 DOM API 來改變 DOM 內容,展示訊息記錄。

涉及到 JavaScript 的操作主要就是上面四個了。但是,現在我們只能使用 CSS,那對於上面這幾個操作,可以用什麼方式實現呢?

2. Trick Time

2.1. 解決“點選監聽”的問題

使用 JavaScript 的話一行程式碼可以搞定:

document.getElementById('btn').addEventListener('click', function () {
    // ……
});
複製程式碼

使用 CSS 的話,其實有個偽類可以幫我們,即:active。它可以選擇啟用的元素,而當我們點選某個元素時,它就會處於啟用狀態。

所以,對於上面動圖中的26個字母(再加上 send 按鈕),可以分配不同的classname,然後設定偽類選擇器,這樣就可以在點選該字母對應的按鈕時觸發命中某個 CSS 規則。例如可以對字元“a”設定如下規則用於“捕獲”點選:

.btn_a:active {
    /* …… */ 
}
複製程式碼

2.2. 傳送請求

如果有 JavaScript 的幫助,傳送請求只需要用個 XHR 即可,很方便。而對於 CSS,如果要想發一個請求的話有什麼辦法麼?

可以使用background-image屬性,將它指定為某個 URL,這樣前端就會向伺服器發起一個背景圖片的請求。之所以可以使用background-image屬性還因為:瀏覽器只有在該 CSS 選擇器規則被實際應用到 DOM 元素後才會實際發起background-image的請求。例如下面這個規則:

.btn_a:active {
    background-image: url('/keys/a');
}
複製程式碼

只有在字元“a”被點選後,瀏覽器才會向伺服器請求/keys/a這張“圖片”。而在伺服器端,通過判斷 URL 可以知道前端點選了哪個字元。例如,對於按鈕“b”會有如下規則:

.btn_b:active {
    background-image: url('/keys/b');
}
複製程式碼

這樣就相當於實現了在 URL(/keys/a/keys/b) 中“傳參”。

2.3. 實時訊息展示

實時的訊息展示,核心會用到一種叫“伺服器推”的技術。其中比較常見方式有:

  • 使用 JavaScript 來和服務端建立 WebSocket 連線
  • 使用 JavaScript 建立定時器,定時傳送請求輪詢
  • 使用 JavaScript 和服務端配合來實現長輪詢

但這些方法都無法規避 JavaScript,顯然不符合我們們的要求。其實還有一種方式,我在《各類“伺服器推”技術原理與例項》中也有提到,那就是基於 iframe 的長連線流(stream)模式。

這裡我們主要是借鑑了“長連線流”這種模式。讓我們的頁面永遠處於一個未載入完成的狀態。但是,由於請求頭中包含Transfer-Encoding: chunked,它會告訴瀏覽器,雖然頁面沒有返回結束,但你可以開始渲染頁面了。正是由於該請求的響應永遠不會結束,所以我們可以不斷向其中寫入新的內容,來更新頁面展示。

實現起來也非常簡單。http.ServerResponse類本身就是繼承自Stream的,所以只要在需要更新頁面內容時呼叫.write()方法即可。例如下面這段程式碼,可以每隔2s在頁面上動態新增 "hello" 字串而不需要任何瀏覽器端的配合(也就不需要寫 JavaScript 程式碼了):

const http = require('http');
http.createServer((req, res) => {
    res.setHeader('connection', 'keep-alive');
    res.setHeader('content-type', 'text/html; charset=utf-8');
    res.statusCode = 200;
    res.write('I will update by myself');

    setInterval(() => res.write('<br>hello'), 2000);
}).listen(8085);
複製程式碼

如何在零JS程式碼情況下實現一個實時聊天功能❓

2.4. 改變頁面資訊

在上一節我們已經可以通過 Stream 的方式,不借助 JavaScript 即可動態改變頁面內容了。但是如果你細心會發現,這種方式只能不斷“append”內容。而在我們的例子中,看起來更像是能夠動態改變某個 DOM 中的文字,例如隨著點選不同按鈕,“Current Message”後面的文字會不斷變化。

這裡其實也有個很“trick”的方式。下圖這個部分(我們姑且叫它 ChatPanel 吧)

如何在零JS程式碼情況下實現一個實時聊天功能❓

其實我們每次呼叫res.write()時都會返回一個全新的 ChatPanel 的 HTML 片段。於此同時,還會附帶一個<style>元素,將之前的 ChatPanel 設為display: none。所以看起來像是更新了原來的 ChatPanel 的內容,但其實是 append 了一個新的,同時隱藏之前的 ChatPanel。

2.5. 點選重複的按鈕

到目前為止,基本的方案都有了,但還有一個重要的問題:

在 CSS 規則中的background-image只會在第一次應用到元素時發起請求,之後就不會再向伺服器請求了。也就是說,用

.btn_a:active {
    background-image: url('/keys/a');
}
複製程式碼

這種規則,“a” 這個按鈕點過一次之後,下次再點選就毫無反應了 —— 即後端收不到請求了。

要解決這個問題有一個方法。可以在每次返回的新的 ChatPanel(ChatPanel 是啥我們們在上一節中提到了,如果忘了可以回去看下)裡,為每個字元按鈕都應用一套新的樣式規則,並設定新的背景圖 URL。例如我們第一次點選了“h”之後,返回的 ChatPanel 裡的按鈕“a”的classname會該成btn_h_a,對應的 CSS 規則改為:

.btn_h_a:active {
    background-image: url('/keys/h_a');
}
複製程式碼

再次點選“i”之後,ChatPanel 裡對應的按鈕的樣式規則改為:

.btn_hi_a:active {
    background-image: url('/keys/hi_a');
}
複製程式碼

2.6. 儲存

為了能夠儲存未傳送的內容(點選 send 按鈕之前的輸入內容),以及同步歷史訊息,需要有個地方儲存使用者輸入。同時我們還會為每個連線設定一個唯一的使用者 ID。在原版的 css-only-chat 中使用了 Redis。我在 css-only-chat-node 中為了簡便,直接儲存在了執行時的記憶體變數中了。

3. 最後

也許有朋友會問,這個 DEMO 有什麼實用價值麼?可以發展成一個可用的聊天工具麼?

好吧,其實我覺得沒有太大用。但是裡面涉及到的一些“知識點”到是瞭解下也無妨。我們每天面對那麼多無趣的需求,偶爾看看這種“有意思”的專案也算是放鬆一下吧。

最後,如果想看具體的執行效果,或者想了解程式碼的細節,可以看這裡:

  • css-only-chat-node:由於原版是 Ruby 寫的,所以實現了一個 NodeJS 版的便於大家檢視
  • css-only-chat:css-only-chat 的原版倉庫,使用 Ruby 實現

Just have fun! ?

相關文章