如何开发高质量的Web阅读产品
作者:Sam
随着智能手机的普及,移动阅读成为越来越多人获得知识的选择,据统计移动阅读月活跃用户已突破3.2亿,2017年市场规模达到166亿,持续保持2位数增长,相比之下,2012年移动阅读市场仅为32.7亿,6年增长幅度超过5倍,让人惊叹。
正因如此,移动阅读市场巨头云集,各类阅读产品层出不穷。大部分优质阅读产品均为App版本,Web阅读产品虽多,但受限于之前的前端技术,体验与App相差甚远。近几年随着前端MVVM框架快速发展,Web阅读产品已经具备全面升级迭代的基础。
高质量的Web阅读产品应该至少具备书城、书架和阅读器三大模块,用户访问阅读器站点后,通过搜索+推荐+分类的方式找到自己感兴趣的电子书,查看详情后,将电子书加入书架,在书架中打开阅读器,读取电子书的内容进行阅读,功能结构如下图:
除此之外,高质量的阅读产品应提供流畅的阅读体验(切换流畅,不卡顿)和良好的兼容性(兼容PC端和移动端),能够接近原生App。
2个月前我在慕课网发布了免费课《快速入门Web阅读器开发》(课程地址:点击这里,源码地址:点击这里,体验地址:点击这里),尝试用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字体
- 支持电子书的离线存储,在无网络环境下也可以使用,实现免流量阅读
- 记录阅读的总时间
- 支持上一章和下一章的快速切换
- 支持阅读书签功能
- 支持全文搜索功能
- 实现更强大的主题切换功能,实现整个阅读器场景的切换,能够支持快速自定义场景开发
- 实现手势翻页操作
热门评论
6666666666666
可以添加翻页动画吗?
老师,我想问一下,必须每次加载整本epub嘛?如果摄影类书籍,图片会很多,那么文件就会非常大,流量问题怎么解决?