# epub阅读器的开发

入口:最终效果

# 阅读器基础功能:

  1. 屏幕适配(大屏两栏显示)
  2. 字号选择
  3. 背景色选择
  4. 选择目录
  5. 拖动进度条
  6. 翻页

# 涉及知识点

  • 阅读器

    • 阅读器工作原理:通过阅读器引擎(epub.js)将各种格式的电子书进行解析渲染到页面上,再添加一系列功能
  • epub.js

    • 通过epub.js解析电子书,创建Book对象
    • 通过renderTo方法实例化Rendition对象(负责电子书的渲染)
      • 可以得到Theme对象(负责样式和主题)
      themes.register(name,styles) //注册主题样式
      themes.select(name)  //切换主题
      
      1
      2
    • Location对象(负责电子书的定位,拖动进度条跳转)
    • Navigation对象(展示电子书目录,提供目录所在路径) 工作原理
  • vue.js

    • transition过渡
    • 组件化
    • class与style绑定
    • 父子组件通信
    • nextTick()方法
    • DOM操作
  • css

    • 伪类和伪元素
    • 定位
    • 过渡动画
    • flex布局
  • scss

    • @import
    • @function
    • @mixin :一个可以在整个样式表中重复使用的样式

# 起步

  • node -v     # v14.17.3

  • npm -v     # 6.14.13

  • vue -v     # @vue/cli 4.5.13

  • 创建项目

    • 因为Vue-cli 3.x和vue-cli 2.x使用了相同的 vue 命令,所以 vue-cli 2.x 被覆盖了。如果你仍然需要使用旧版本的 vue init 功能,你可以全局安装一个桥接工具
    npm install -g @vue/cli-init
    
    1
    vue init webpack ireader
    cd ireader
    npm run dev
    
    1
    2
    3
  • 下载依赖包

    npm install node-sass sass-loader --save-dev
    
    1
  • 下载epubjs

    npm install epubjs --save
    
    1
  • 引入font字体图标

  • viewport 配置

    # index.html
    <meta name="viewport"
      content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalabel=no">
    
    1
    2
    3
  • rem配置

    # App.vue
    // DOMContentLoaded事件动态设置根元素字体大小
    documnet.addEventListener('DOMContentLoaded', () => {
      const html = document.querySelector('html')
      html.style.fontSize = window.innerWidth / 10 + 'px'
    })
    
    1
    2
    3
    4
    5
    6
  • reset.scss:消除不同浏览器默认样式的不一致

  • global.scss:规定整个站点的公共样式、方法和参数

    @import 'reset';
    
    // 1rem = fontSize px
    // 2px = (2 / fontSize) rem
    
    $fontSize:37.5px;
    
    @function px2rem($px) {
      @return ($px/$fontSize)+rem;
    }
    
    @mixin center {
      display        : flex;
      justify-content: center;
      align-items    : center;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

# 电子书解析和渲染

import ePub from 'epubjs'
const DOWNLOAD_URL = '../static/2018_Book_AgileProcessesInSoftwareEngine.epub'

showEpub () {
    // 解析为Book对象
    this.book = ePub(DOWNLOAD_URL)
    // 通过renderTo实例化Rendition对象
    this.rendition = this.book.renderTo('read', {
      width: window.innerWidth,
      height: innerHeight
    })
    this.rendition.display()
  }
1
2
3
4
5
6
7
8
9
10
11
12
13

# 翻页功能

  • 添加浮层(包含左中右区域), 通过绑定this.rendition.prev及next方法实现左右区域翻页

# 标题栏和菜单栏的显示与隐藏

  • 在点击中间区域时触发事件,控制显示与隐藏
  • 添加过渡动画:用transition标签 (指定name) 包裹标题栏以及菜单栏,vue会为包裹的div动态添加6种class(enter,enter-to,enter-active,leave,leave-to,leave-active)
    <transition name="slide-down">
    <div>.......</div>
    </transition>
    
    1
    2
    3
    .slide-down-enter,
    .slide-down-leave-to {
      transform: translate3d(0, -100%, 0);
    }
    
    .slide-down-enter-to,
    .slide-down-leave,
    .slide-up-enter-to,
    .slide-up-leave {
      transform: translate3d(0, 0, 0);
    }
    
    .slide-down-enter-active,
    .slide-down-leave-active,
    .slide-up-enter-active,
    .slide-up-leave-active {
      transition: all 0.3s linear;
    }
    
    .slide-up-enter,
    .slide-up-leave-to {
      transform: translate3d(0, 100%, 0);
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23

Note:标题栏和菜单栏会添加很多功能,所以利用组件化的思想,对代码进行拆分

  • 父子组件通信
    • 将父组件中isTitleAndMenuShow数据传到子组件
    <!--父--> 
    <title-bar :isTitleAndMenuShow='isTitleAndMenuShow'></title-bar>
    
    <!--子--> 
    props: {
      isTitleAndMenuShow: {
        type: Boolean,
        default: false
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    • 父组件调用子组件中的方法
    // 父
    <menu-bar ref="menuBar"></menu-bar>
    this.$refs.menuBar.hideSetting()
    
    // 子
    hideSetting () {
      this.isSettingShow = false
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

# 字号设置

  • 字号设置窗口出现的时候会被菜单栏的阴影覆盖
  • 解决方法:当字号设置窗口出现时||标题栏和菜单栏消失时,将菜单栏的阴影取消
<div class="menu-wrapper"
      :class="{'hide-box-shadow':isSettingShow || !isTitleAndMenuShow}">
1
2
.menu-wrapper {
  &.hide-box-shadow {
    box-shadow: none;
  }
}
1
2
3
4
5
  • 字号设置窗口和菜单栏一并隐藏时过渡动画不够流畅
  • 原因:位移不同步(设置过渡动画时,字号设置窗口向上移动60px,菜单栏向上移动48px)
  • 解决方法:将向上移动距离设置为108px
.slide-up-enter,
.slide-up-leave-to {
  transform: translate3d(0, 100%, 0); // ==> translate3d(0, px2rem(108), 0)
}
1
2
3
4
  • 字号选择条的布局
    • 采用flex布局,左右两侧设置字号A图标,中间设置伸缩条(左侧横线+右侧竖线实现,字号范围12-24,递增2), 根据fontSizeList字号列表用v-for复制

# 主题背景色设置

提供四种主题背景色,设置栏提供了背景色的预览,选择一个会改变阅读器背景色,选中的label颜色会加深

背景主题切换使用epubjs的theme实现

this.themes.register(name,style)  // 注册主题
this.themes.select(name)  // 切换主题
1
2
  • 定义主题数组,遍历该数组注册主题
  • 定义默认主题defaultTheme,根据索引设置该变量,来切换主题
    themeList: [
          {
            name: 'default',
            style: {
              body: {
                'color': '#000',
                'background': '#fff'
              }
            }
          },
          {name: 'eye',...}
        ]
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    this.themeList.forEach((theme) => {
          this.themes.register(theme.name, theme.style)
        })
    
    1
    2
    3
    this.themes.select(this.themeList[index].name)
    this.defaultTheme = index
    
    1
    2

# 阅读进度功能

拖动进度条快速到达指定位置,初次打开如果没有加载完电子书,则提示在加载中;
进度条左侧(已读部分)颜色加深,手柄拖动时下方显示相应的百分比

使用epubjs的location实现

  • 通过epubjs钩子函数获取location对象
  • 定义bookAvailable变量保存图书是否加载完成
    this.book.ready.then(()=>{
      return this.book.locations.generate()
    }).then((result)=>{
      this.locations=this.book.locations
      this.bookAvailable=true
    })
    
    1
    2
    3
    4
    5
    6
  • 如果图书加载完成(bookAvailable===true),则可以拖动进度条;否则显示‘加载中’
    • 传递到子组件menuBar
      <!-- 父组件 Ebook -->
      <menu-bar :bookAvailable='bookAvailable'></menu-bar>
      
      1
      2
      // 子组件接收
      export default{
        props:{
          bookAvailable:Boolean
        }
      }
      
      1
      2
      3
      4
      5
      6
    • 子组件menuBar进度条中使用disabled属性来控制是否可拖动
      <!-- 子组件menuBar -->
      <input type='range' :disabled='!bookAvailable'>
      
      1
      2
    • 数值百分比显示信息
      <!-- 子组件menuBar -->
      <span>{{bookAvailable?  xxx+'%': 'Loading...'}}</span>
      
      1
      2
  • 当拖动进度条时,分别绑定input方法(实时获取进度条当前长度)和change方法(进度条松开后,跳转到指定电子书页面)
    <!-- 子组件menuBar -->
    <input type='range' @change='onProgressChange($event.target.value)' @input='onProgressInput($event.target.value)'>
    
    1
    2
    • 定义progress变量(初始为0),拖动进度条时给progress赋值,并根据progress值改变进度条样式(进度条手柄左侧为#999,右侧为#ccc)
    // <!-- 子组件menuBar -->
    onProgressInput(value){
      this.progress=value
      this.$refs.progress.style.backgroundSize=`${this.progress}% 100%`
    }
    
    1
    2
    3
    4
    5
    <!-- 子组件menuBar -->
    <input type='range' ref='progress' class='progress'>
    
    1
    2
    <!-- 子组件menuBar -->
    .progress{
      appearance: none;
      background: linear-gradient(#999, #999) no-repeat, #ccc;
      background-size: 0 100%;
    }
    
    1
    2
    3
    4
    5
    6
    • 松开进度条触发事件,并将当前进度条长度progress传递给父组件Ebook中的setProgress方法
    // <!-- 子组件menuBar -->
    onProgressChange(value){
      this.$emit('onProgressChange',value)
    }
    
    1
    2
    3
    4
    <!-- 父组件Ebook中需要向子组件传递函数 -->
    <menu-bar @setProgress='onProgressChange'></menu-bar>
    
    1
    2
    // <!-- 父组件Ebook -->
    onProgressChange(value){
      const percentage=value/100
      // 根据百分比值重新渲染电子书页面所在位置
      const location=percentage>0?this.locations.cliFromPercentage(percentage):0
      this.rendition.display(location)
    }
    
    1
    2
    3
    4
    5
    6
    7
  • 点击菜单栏的图标,显示相应的设置栏
    <!-- 子组件menuBar -->
    <span class="icon-a icon"
                @click="showSetting(0)">A</span>
    
    1
    2
    3
    // 子组件menuBar
    // 如果传入的index参数是3,则显示设置栏,否则显示目录栏
    // showTag控制显示哪个图标对应的设置栏
    showSetting (index) {
      if (index === 3) {
        // 如果是目录
        this.ifShowContent = true
      } else {
        this.ifSettingShow = true
      }
      this.showTag = index
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!-- 子组件menuBar -->
    <div class="setting-wrapper" v-show="isSettingShow">
     <div class="setting-font-size" v-if="showTag===0"></div>
     <div class="setting-theme" v-else-if="showTag===1"></div>
     <div class="setting-progress" v-else-if="showTag===2"></div>
    </div>
    
    1
    2
    3
    4
    5
    6

# 目录功能

点击目录图标弹出目录窗口,点击一个目录项跳转至电子书相应位置;
点击目录背景时,目录窗口会隐藏
初次进入时都会有一个加载过程
目录的显示隐藏都有过渡动画

使用epubjs的Navigation实现

  • 通过epubjs钩子函数获取navigation对象
    this.book.ready.then(()=>{
      this.navigation=this.book.navigation
    })
    
    1
    2
    3
  • 定义ifShowContent变量,点击目录图标弹出目录窗口子子组件
    <!-- 子组件menuBar -->
    <!-- 目录显示区域 -->
    <content-view v-show='ifShowContent'></content-view>
    <!-- 目录蒙版区域 -->
    <div class='content-mask' v-show='ifShowContent'></div>
    
    1
    2
    3
    4
    5
    <!-- 子子组件ContentView -->
    <div class="content">
      <div class="content-wrapper">
      </div>
    </div>
    
    1
    2
    3
    4
    5
  • 当图书加载完毕后显示目录,否则显示加载中. 需要从父组件中传入navigation对象和bookAvailable属性
    <!-- 子组件menuBar -->
    <!-- 目录显示区域 -->
    <content-view v-show='ifShowContent' :navigation='navigation'
                   :bookAvailable='bookAvailable'></content-view>
    
    1
    2
    3
    4
    <!-- 子子组件ContentView -->
    <div class="content">
     <div class="content-wrapper" v-if="bookAvailable">
       <div class="content-item"
            v-for="(item,index) in navigation.toc"
            :key="index">
            <span class="text">{{item.label}}</span>
       </div>
     </div>
     <div class="loading" v-else>Loading...</div>
    </div>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
  • 点击目录项触发跳转页面事件, 调用父组件的方法 (根据链接跳转到指定位置,并隐藏标题栏、菜单栏、设置栏、目录)
    <!-- 子子组件ContentView -->
    <div class="content-item" @click="jumpTo(item.href)>
    </div>
    
    jumpTo (href) {
        this.$emit('jumpTo', href)
      }
    
    1
    2
    3
    4
    5
    6
    7
    <!-- 子组件MenuBar -->
    <content-view @jumpTo='jumpTo'></content-view>
    
    jumpTo (href) {
        this.$emit('jumpTo', href)
      }
    
    1
    2
    3
    4
    5
    6
    <!-- 父组件Ebook -->
    <menu-bar @jumpTo='jumpTo'></menu-bar>
    
    jumpTo (href) {
        this.rendition.display(href)
        this.hideTitleAndMenu()
      },
    hideTitleAndMenu () {
        // 隐藏标题栏和菜单栏
        this.isTitleAndMenuShow = false
        // 隐藏菜单栏的设置栏
        this.$refs.menuBar.hideSetting()
        // 隐藏目录
        this.$refs.menuBar.hideContent()
      }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // <!-- 子组件MenuBar -->
    hideSetting () {
        this.ifSettingShow = false
    },
    hideContent () {
        this.ifShowContent = false
      }
    
    1
    2
    3
    4
    5
    6
    7