如何開發高質量的Web閱讀產品
隨著智慧手機的普及,移動閱讀成為越來越多人獲得知識的選擇,據統計移動閱讀月活躍使用者已突破 3.2億 ,2017年市場規模達到 166億 ,持續保持2位數增長,相比之下,2012年移動閱讀市場僅為32.7億,6年增長幅度超過5倍,讓人驚歎。

正因如此,移動閱讀市場巨頭雲集,各類閱讀產品層出不窮。大部分優質閱讀產品均為App版本,Web閱讀產品雖多,但受限於之前的前端技術,體驗與App相差甚遠。近幾年隨著前端MVVM框架快速發展,Web閱讀產品已經具備全面升級迭代的基礎。
高質量的Web閱讀產品應該至少具備書城、書架和閱讀器三大模組,使用者訪問閱讀器站點後,通過搜尋+推薦+分類的方式找到自己感興趣的電子書,檢視詳情後,將電子書加入書架,在書架中開啟閱讀器,讀取電子書的內容進行閱讀,功能結構如下圖:

除此之外,高質量的閱讀產品應提供流暢的閱讀體驗(切換流暢,不卡頓)和良好的相容性(相容PC端和移動端),能夠接近原生App。
2個月前我在慕課網釋出了免費課《快速入門Web閱讀器開發》(課程地址:點選這裡,原始碼地址:點選 ofollow,noindex">這裡 ,體驗地址:點選這裡),嘗試用Vue.js+Webpack開發一個高質量的閱讀器,事實證明,Vue.js可以為移動閱讀帶來突破性的提升。下面為大家介紹閱讀器的實現原理和開發過程,本文重點介紹ePub電子書的實現過程,閱讀器的原理如下圖:

電子書解析
ePub電子書的解析過程非常複雜,幸好FuturePress(官網點選這裡)幫我們解決了這個問題,FuturePress是加州大學伯克利分校的一個跨學科專案,他們推出的epubjs庫專門用於解決電子書的解析和渲染等複雜問題,安裝過程非常簡單
npm install epubjs --save 複製程式碼
藉助epubjs可以極大地降低閱讀器的開發難度,使得個人開發者完成一個複雜的閱讀產品成為可能。電子書的解析過程如下:
// 引入epubjs庫 import Epub from 'epubjs' // 設定全域性的ePub物件 global.ePub = Epub // 電子書的下載地址,這裡提供一個測試電子書的地址,大家可以替換為自己感興趣的電子書 const url = 'http://www.youbaobao.xyz/epub/History/2018_Book_TheCostOfInsanityInNineteenth-.epub' // 解析電子書 this.book = new Epub(url) 複製程式碼
這裡的book變數就是解析後的電子書物件
電子書渲染
電子書的渲染過程非常簡單,通過 Book.renderTo()
方法即可實現
this.rendition = this.book.renderTo('reader', { width: window.innerWidth, height: window.innerHeight }) 複製程式碼
renderTo的第一個引數是div的id,閱讀器會自動生成dom並掛載到指定div下,我們需要通過id來匹配div,第二個引數可以指定閱讀器的寬高,執行完畢後會生成Rendition物件,閱讀器的渲染需要使用Rendition物件,渲染過程是呼叫Rendition的display()方法,他會返回一個Promise物件,以便我們對渲染後的過程進行操作
this.rendition.display().then(() => { // 定義渲染完成後的操作 ... }) 複製程式碼
翻頁操作也非常簡單,只需要呼叫 Rendition.prev()
和 Rendition.next()
方法即可,這兩個方法同樣會返回Promise物件
// 上一頁 this.rendition.prev() // 下一頁 this.rendition.next() 複製程式碼
字號設定
字號設定是閱讀器必不可少的功能,使用者希望能夠自主改變閱讀器的字號大小,這個功能需要通過epubjs的Themes物件實現,Themes物件提供了fontSize()方法,傳入實際字號即可快速修改字號大小
// 獲取Themes物件 this.themes = this.rendition.themes // 設定字號大小 this.themes.fontSize(16) 複製程式碼
主題設定
主題設定功能可以讓我們改變閱讀器的字型和背景色,進而修改閱讀器的樣式,通過 Themes.register()
方法註冊主題, Themes.select()
方法切換主題,主題允許實時切換
// 註冊主題,body表示修改body標籤的樣式 this.themes.register('night', { body: { 'color': '#fff', 'background': '#000' }, img: { 'width': '100%' } }) // 切換主題 this.themes.select('night') 複製程式碼
進度設定
閱讀過程中,我們會希望快速切換到自己想要瀏覽的位置,通常會採用兩種方法:拖動進度條快速定位和通過目錄切換,本節我們將介紹拖動進度條的切換方式,進度條可以藉助HTML5新增的input range控制元件實現
<input type="range" :value="progress" max="100" min="0" step="1" @change="onProgressChange($event.target.value)" @input="onProgressInput($event.target.value)"> 複製程式碼
- 將input標籤的type屬性指定為range設定一個滑塊控制元件
- range繫結值為progress,max指定progress最大值為100,min指定progress最小值為0,step指定按照1的幅度進行增長,比如移動滑塊1格,progress就會增長1
- @change綁定了input的change事件,即修改完成後觸發的事件,$event.target.value可以獲取到最新的progress值
- @input綁定了修改過程事件,拖動滑塊即會觸發
閱讀進度修改的原理就是根據progress的值動態改變閱讀器的位置,要改變閱讀位置,首先需要進行分頁,可以通過epubjs的Locations物件實現
// Book物件的鉤子函式ready this.book.ready.then(() => { // 執行分頁 return this.book.locations.generate() }).then(result => { // 獲取Locations物件 this.locations = this.book.locations }) 複製程式碼
完成分頁後,我們可以通過 Locations.cfiFromPercentage()
方法獲取百分比對應的EpubCFI,EpubCFI用於解決電子書的定位問題,它可以定位到電子書中任意一個字元,這一點在後續文章中會詳細講解,將EpubCFI直接傳入 Rendition.display()
方法,即可跳轉到百分比所對應的電子書位置
// 將百分比轉化為小數形式 const percentage = progress / 100 // 獲取百分比對應的EpubCFI const location = percentage > 0 ? this.locations.cfiFromPercentage(percentage) : 0 // 跳轉到百分比對應的電子書位置 this.rendition.display(location) 複製程式碼
翻頁時,我們還可以反過來,獲取當前所在位置的百分比,首先通過 Rendition.currentLocation()
獲取當前的位置資訊,通過 currentLocation.start.cfi
提取本頁開始位置的EpubCFI,將這個值傳入 Locations.percentageFromCfi()
方法,獲取當前頁的百分比
this.rendition.next().then(() => { const currentLocation = this.rendition.currentLocation() const progress = this.locations.percentageFromCfi(currentLocation.start.cfi) }) 複製程式碼
電子書目錄
epubjs為我們提供了Navigation物件管理電子書目錄,獲取方法如下:
this.book.loaded.navigation.then(nav => { this.navigation = nav }) 複製程式碼
Navigation的資料結構如下:

Navigation.toc
表示電子書的目錄結構,toc中的每一個元素對應一個目錄,toc.href表示目錄的路徑,將這個值傳入
Rendition.display()
即可完成目錄的渲染
// 跳轉到第一章 this.rendition.display(this.navigation.toc[0].href) // 跳轉到第二章 this.rendition.display(this.navigation.toc[1].href) 複製程式碼
接下來要解決目錄的展示問題, Navigation.toc
是一個巢狀的陣列結構,他的資料結構如下:

toc包含一個subitems屬性,subitems也是一個數組,結構與toc相同,舉一個例子:
const toc = [ { id: '1', subitems: [ { id: '2', subitems: [ { id: '3', subitems: [] } ] }, { id: '4', subitems: [] } ] }, { id: '5', subitems: [] } ] 複製程式碼
該目錄表示的含義為:最外層是id為1和5的目錄,id為1的目錄下包含id為2和4的目錄,id為2的目錄下包含id為3的目錄,而最終呈現的效果應該為:
目錄1 目錄2 目錄3 目錄4 目錄5 複製程式碼
一級目錄不縮排,二級目錄縮排兩格,三級目錄縮排四格,以此類推。如果我們直接採用toc的原始結構進行解析,不僅實現過程複雜(需要實現多層巢狀迴圈),而且執行的效能也會下降(多層迴圈降低效能),同時代碼不利於閱讀也不利於後期維護
<div v-for="toc in navigation"> <div v-for="subitems in toc"> <div v-for="subitems2 in subitems"> </div> </div> </div> 複製程式碼
我們可以轉換一下思路,如果提供一個一維陣列,裡面包含一個層級屬性,那麼實現的難度將大大降低,轉化後的一維目錄的資料結構應該如下:
toc = [ { id: '1', level: '0' }, { id: '2', level: '1' }, { id: '3', level: '2' }, { id: '4', level: '1' }, { id: '5', level: '0' } ] 複製程式碼
這樣問題就轉變為如何將巢狀的陣列結構轉變為一維陣列,es6提供了擴充套件運算子 ...
,可以非常有效地解決這個問題,先實現一個最簡單的場景,定義如下陣列:
const a = [ { id:1, subitems: [ { id:2, subitems:[] }, { id:3, subitems:[] } ] } ] // 生成新陣列的一維陣列 // [{id:1}, {id:2}, {id:3}] console.log([a[0], ...a[0].subitems]) 複製程式碼
通過以上方法我們可以實現將一個樹狀的物件轉變為一個一維陣列,接下來我們要對上面示例中的toc陣列進行遍歷
toc.map(item => [item, ...item.subitems]) 複製程式碼
此時得到的結果為一個二維陣列的陣列:
[ [ { id: '1' }, { id: '2' }, { id: '4' } ], [ { id: '5' } ] ] 複製程式碼
可以先用擴充套件運算子 ...
把陣列展開,然後一個空陣列把他們連線起來
[].concat(...toc.map(item => [item, ...item.subitems])) 複製程式碼
此時就可以得到一個一維陣列了
[ { id: '1' }, { id: '2' }, { id: '4' }, { id: '5' } ] 複製程式碼
這樣的做法針對二級目錄的結構是沒問題的,但是會發現三級目錄沒有展開,針對三級目錄需要這樣實現:
[].concat(...toc.map(item => [ item, ...[].concat(...item.subitems.map(sub => [sub, ...sub.subitems] )) ] )) 複製程式碼
輸出結果為:
[ { id: '1' }, { id: '2' }, { id: '3' },{ id: '4' }, { id: '5' } ] 複製程式碼
這裡明顯地進行了迭代呼叫,所以可以採用迭代演算法進行優化
function flatten(arr) { return [].concat(...arr.map(v => [v, ...flatten(v.subitems)])) } 複製程式碼
接下來需要判斷目錄的層次, Navigation.toc
中提供了parent欄位用於判斷父級的id,如果parent欄位為null,則為頂級目錄,加入parent後的示例資料如下:
const toc = [ { id: '1', 'parent': null }, { id: '2', 'parent': '1' }, { id: '3', 'parent': '2' }, { id: '4', 'parent': '1' }, { id: '5', 'parent': null } ] 複製程式碼
判斷層級的演算法比較簡單,我們需要應用迭代演算法,判斷上層目錄是否為null,如果上層目錄為null,則迭代終止,如果不為null,則一直追溯,在追溯的過程中記錄層級的變化,每判斷一次,層級加1,具體演算法實現如下:
// 查詢某一個目錄的層級 function find(item, v = 0) { const parent = toc.filter(it => it.id === item.parent)[0] return !item.parent ? v : (parent ? find(parent, ++v) : v) } // 呼叫 toc.forEach(item => { item.level = find(item) }) 複製程式碼
運算後,toc的結果如下:
[ { id: '1', 'parent': null, level: 0 }, { id: '2', 'parent': '1', level: 1 }, { id: '3', 'parent': '2', level: 2 }, { id: '4', 'parent': '1', level: 1 }, { id: '5', 'parent': null, level: 0 } ] 複製程式碼
通過以上兩步,實現多級目錄佈局就非常容易實現了
<div v-for="(item, index) in flatten(navigation)" :key="index" :style="{marginLeft: (item.level * 10) + 'px'}" @click="rendition.display(item.href)"> <span>{{item.label}}</span> </div> 複製程式碼
擴充套件
通過以上內容我們應用Vue.js+epubjs快速實現了一個簡單的閱讀器,它可以滿足最基本的閱讀需求,但是使用者需求在不斷變化和增長,它要求Web閱讀器能夠支援更強大的功能,如:
- 將閱讀設定進行離線儲存,不必每次訪問閱讀器再重新設定一遍
- 針對不同電子書提供不同的配置方案,每個電子書的配置可以不同
- 提供字型設定功能,能夠支援從網際網路上獲取新奇的Web字型
- 支援電子書的離線儲存,在無網路環境下也可以使用,實現免流量閱讀
- 記錄閱讀的總時間
- 支援上一章和下一章的快速切換
- 支援閱讀書籤功能
- 支援全文搜尋功能
- 實現更強大的主題切換功能,實現整個閱讀器場景的切換,能夠支援快速自定義場景開發
- 實現手勢翻頁操作
以上功能在App閱讀器中比較普遍,但是在Web閱讀器中卻並不多見,要實現這些功能需要對Vue.js和epubjs有深入地理解和應用,近期我在慕課網推出的實戰課程中詳細講解了這些知識點的實現方法,感興趣的同學可以點選這裡進行了解。課程以微信讀書作為藍本,高度還原了App的功能和互動水準,不僅介紹了閱讀器的實現,還詳細講解了一個成熟閱讀產品必須包含的:書城和書架功能。想直接體驗產品的同學可以點選這裡,同時支援PC端和移動端哦。