ComicsScroller: 如何用 react redux observable 刻出一個具有無限捲動功能支援多個網站的漫畫閱讀 chrome extension

這篇文章是我自己在開發 ComicsScroller 的心得,ComicsScroller 想要做的事情很簡單,希望以一個可以無限捲動漫畫圖片閱讀頁面來取代原本的一次只能 show 一張圖又很多廣告的原始網站,讓你用滑鼠滾輪就可以追完火影忍者者整套漫畫,除此之外,還另外提供漫畫訂閱跟閱讀歷史紀錄的功能,本篇文章會介紹整個專案上的概念架構跟我一步一步的優化無限捲動效能的過程,寫的有點繁瑣,建議讀者對 react redux 跟有用過 redux middleware生態系會比較容易理解。 有的時候文字上的解釋可能還是沒有直接看程式碼快,因此先把程式碼的連結放在這裡

https://github.com/zeroshine/ComicsScroller

ComicsScroller 的實作概念

基本上就是透過 chrome api 在 browser 開啟新分頁時檢查 url ,如果 url match regex 就跳轉至 extension 內建的 app.html,並把原本漫畫章節網站的資訊帶到 url 的 querystring 裡頭,如下圖

最後 js 再根據 querrystring 去原本的網站爬出這一話所有的圖片塞到網頁然後這樣就完成了

架構設計

就跟一般的 react app 的設計差不多,主要的 view 由 react 負責,redux 負責放置正規化後的漫畫章節圖片 url 等 state,眾多的非同步負責爬出漫畫章節圖片資訊的 ajax 包成 Rx.js 的 Observable 由 redux 的 middleware redux-observable 來管理,這樣的設計有擴充性的好處,不同的網站除了漫畫章節圖片資訊有不同的爬法,redux 跟 react 的渲染邏輯跟方法幾乎是一模一樣的,只要在 Observable 內部正確的操作 redux 的 action 就可以有一致的行為,初始化時從 querystring 判斷帶入不同網站的 epic 到 middleware 即可。

因為不收費也沒有錢架後端,因此資料都透過 chrome.storage.local 存放在local 處,這樣點擊 icon 的跳出來的 popup window 跟閱讀頁面就可以共享漫畫閱讀記錄等資料來源

Normalize State! 正規化 State 的好處

ComicsScroller 會記錄已經閱讀過的漫畫,訂閱過的漫畫,有更新通知的漫畫,所以一部漫畫的資訊可能會被三個地方的 Array 記錄著,所以不管是更新修改刪除都很容易造成資料不一致,因此我們可以把漫畫資訊放到一個 Object 用一個 id 記錄著,Array 的部份就記錄這些漫畫的 id 即可

另外用 Object 在資料的查找取得也比在 Array 快又方便,只要知道 key是什麼就可以了,比起 Array 一個一個 item 比較資料快上不少。

如何爬出漫畫圖片

圖片的取得必須要了解 img url 的命名規則,現在漫畫網站為了防止盜圖 url 都會加上 hash 的機制防止別的網站可以不費吹灰之力的大量盜圖,還有其他的防禦方式譬如說檢查 referer header 是不是原本的網站來的,這個部分可以從 chrome extension api 解掉,更複雜的還有 img src 不是靜態寫在 html 而是用 js 動態產生的,這樣就必須挖 js file 找出一些變數或者是 function 來 eval 執行他,需要 2 次以上的 ajax 才的找到圖片的 url

圖片的尺寸

圖片初始化狀態如下,在圖片尚未載入時會先用 init 的 div 撐出一個固定的空間

圖片載入完成以後會分三種型態,一般來說漫畫的圖片都是長大於寬的如下圖,這種圖片我會給與初始化一樣的固定高度

但是漫畫在為了一些要展現魄力的場景會用兩頁拼成一張圖,如下圖所示:

或者有些漫畫會仿造書本的方式把兩頁拼成一頁的圖片,

這種跨頁寬圖的高度超過 browser 頁高就會有閱讀不易的問題,往下滾動看完右下角還要往上滾動才能看到左上角的圖。

所以這樣的圖片為了最大利用網頁顯示空間在 css 我就會設定圖片的最高度為略小於頁高。

max-height: calc(100vh - 68px)

還有一些圖片是翻譯組打廣告招募成員用的,高度小於頁高,所以用原尺寸即可

比較麻煩的是圖片的尺寸是沒有辦法透過 url 知道的,所以必須等圖片載入後再根據其長寬來判斷型態決定要套用那一種 className

無限捲動 Infinite Scroll 與 lazy loading

因瀏覽器連線數量的限制,一次對於同一個 domain 的連線只有 6 個,大量圖片載入時,為了要使用者能夠先看到可視範圍內的圖片,利用 js 找到可視範圍附近的圖片才把 img src 給放上去。

無限捲動以 lazy loading 為基礎,在 redux 裡面記錄現在章節的 index ,然後再每次捲動的時候找到目前落在可視範圍的圖片所歸屬的章節 index,如果與 redux 記錄的 index 與之相同,就會撈下一個章節的圖片回來,然後把 redux 所記錄的章節 index - 1,這樣做就會保證讀到新的一話的時候,下一話的圖片也準備好了。

如何找到目前落在可視範圍的圖片

利用 web api element.getBoundingClientRect() 這個 function 會回傳這個 element 上下左右四個邊界距離瀏覽器上邊緣跟左邊緣的距離,如下圖

所以圖片如果落在可視範圍內其 top 或是 bottom 會在 window.innerHeight 跟 0 之間,因此我第一個版本就只要在捲動的時候找到所有的圖片跑一個 forloop 找到滿足這個條件的 element 就好了,但是代誌不是憨人想的那麼甘單阿,第一個版本就有很多使用者回報 CPU 飆高風扇狂轉 lag 的問題

為什麼這樣做 CPU 會飆高

原因主要有兩個:

第一,捲動的事件在捲動時會不斷的觸發 callback function。

第二,getBoundingClientRect 為了要取得 top down 等值會強迫瀏覽器進行昂貴的 reflow 計算。

這兩件事情同時發生在捲動時使得瀏覽器會進行超多次昂貴的 reflow 的計算,導致 CPU 用量瘋狂上升。

如何解決

第一,針對捲動造成頻繁 invoke 的 function 要做 throttle 或者是 debounce,debounce 指的是設定一段時間如果 function 第一次 invoke 後這段時間都沒有又被 invoke 才會執行,如果被 invoke 就會重新計時,throttle 則是設定一段時間,如果第一次被 invoke 後過這段時間被 invoke 都不會執行也不會重新計時,時間到了就會執行,這兩種方式都可以有效的避免過多的重複呼叫,透過 setTimeout 跟 cleanTimeout 來實現 throttle 跟 debounce 有點複雜,幸運的是,Rx.js 本身 Observable 本身就帶了 throttle 跟 debounce 的 operator,使得要做這件事情跟其他 async 整合在一起非常的方便。

第二,找出落在可視範圍內的圖片不需要循序遍歷所有的圖片,其實如果認真觀察一下,在 getBoundingClientRect 取的 top 如果比頁高大表示這張圖在可視範圍的下方,bottom 如果小於 0 表示這張圖在可視範圍的上方,利用這樣的特性就可以用 Binary Search 來找到落在可視範圍的圖片了,複雜度從 O(N) 變成 O(logN),即便是在大量圖片嵌入到你的網頁情況下可以有效的減少 reflow 的次數,學演算法還是有用的阿同學~~

當然也可以從另外一個角度 scrollTop 下手,直接利用捲軸高度 scrollTop 來與圖片的高度來計算出哪張圖片在可視範圍內,這樣瀏覽器只需要 reflow 一次取得 scrollTop 即可,從 getBoundingClientRect() 這個方法下手最大的好處是可以不需要知道圖片的高度依舊可以得到結果,由於圖片的高度不是寫死的,從上面圖片的尺寸章節可以得知不同的圖片型態會有不同的高度設定,所以用這個方法會讓我躲掉這個問題,這是我第二個版本的作法。

然而講一件欠揍的事情,最後我選擇用 scrollTop 的角度切入XD,getBoundingClientRect()的方式最後被我捨棄掉了,下面告訴你為什麼

控制 element 的數量

圖片的數量會隨著你閱讀的章節線性增加,嵌入大量的圖片這對瀏覽器記憶體跟計算都是一個負擔,在大部分圖片長度都超過 1000 px 的狀況下,很多圖片離可視範圍都很遠,我們其實可以只 render 可視範圍附近的圖片即可,這樣做就可以保持 DOM 的圖片數量可以保持在一定的數量底下而不會隨著閱讀章節而線性增加。

概念就是捲動時用捲動距離來決定增加或移除節點,有點類似 sliding window 的概念,但是這邊有一個大重點就是移除圖片節點的時候不能改變其他已經嵌入的圖片在容器的相對位置,否則會造成畫面跳動或是整個跳走,這裡參考 Twitter Mobile 的解法,我們把放置圖片的容器設定 paddingTop 跟 paddingBottom,當我們往下捲動時在上面移除一個節點的時候我們把圖片的高度加到 paddingTop ,在下面增加一個節點就把節點高度從 paddingBottom 扣除,往上捲動時則相反,利用 paddingTop 跟 paddingBottom 來彌補移除圖片的高度,在這樣的作法下前提就是必須要管理圖片的高度,既然都要管理圖片的高度了,因此就直接用 scrollTop 的角度切入捨棄 getBoundingClientRect() 的方法做 infinite Scroll 跟 lazy loading

所以實際上怎麼用 redux 完成這件事呢,我在 redux 中存放一個 imgList, 每一個 item 都會記錄其圖片的高度跟型態,在 img onLoad 的時候會觸發 action 根據不同圖片型態的 update 其值,redux 中另外兩個變數renderBeginIndex, renderEndIndex 用來決定 imgList 中哪個範圍的圖片要被渲染,這個部分在捲動的時候用 scrollTop imgList 裡面的圖片高度來計算出落在可視範圍內的圖片 index 然後上下一個範圍推算即可,容器的 padding 另外在 connect 的時候用 selector 算出來,因為這邊當捲動距離不大時候可能不會更新 renderBeginIndex, renderEndIndex,所以就可以用 reselect cache 計算的結果。

另外把圖片型態放在 redux 不是放在 Component 裡頭的 state 還有一個好處,Component 重新 mount 上去的時候 state 會重新初始化,可能會造成畫面不必要的跳動,redux store 本身就會 cache 所有的 state 所以不會有這個問題。

Future Work

目前會慢慢的把 jest 跟 flowtype 補上去,新的功能跟支援新的網站暫時不會繼續開發,也暫時不會支持 firefox,維持穩定跟修復 bug 為主,如果喜歡這個 Chrome Extension,或者是覺得這篇開發心得對你有所幫助,歡迎到 github 給顆星星給作者鼓勵一下,或者是到粉絲專頁按個讚給個支持。

Chrome Web Store 
https://chrome.google.com/webstore/detail/comics-scroller/mccpalfmlnjadfnojmphffidnbemnkec
FB 
https://www.facebook.com/ComicsScroller/
Github
https://github.com/zeroshine/ComicsScroller

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.