为了账号安全,请及时绑定邮箱和手机立即绑定

如何开发高质量的Web阅读产品

如何开发高质量的Web阅读产品

作者:Sam

随着智能手机的普及,移动阅读成为越来越多人获得知识的选择,据统计移动阅读月活跃用户已突破3.2亿,2017年市场规模达到166亿,持续保持2位数增长,相比之下,2012年移动阅读市场仅为32.7亿,6年增长幅度超过5倍,让人惊叹。
2012-2017年中国移动阅读市场规模及增长率

正因如此,移动阅读市场巨头云集,各类阅读产品层出不穷。大部分优质阅读产品均为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的数据结构
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端和移动端哦。

点击查看更多内容
27人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
Web前端工程师
手记
粉丝
1万
获赞与收藏
438

关注作者,订阅最新文章

阅读免费教程

感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消