案例——《享阅·阅读器》解析

去年,用 canvas 实现了一个 txt 阅读器——《享阅·阅读器》。(直接叫阅读器有点笼统,并且实现的功能也简单,所以前面加个书城的名称作为前缀。ps:书城《享阅》也是一个简单的个人项目。)但直到现在才惊觉没有写相关文章。所以,这篇文章算是补缺,内容会写到 v0.3.0 版本。

软件介绍

市面上好用的阅读器很多,所以也并没有打算将这个阅读器做得多么成熟,毕竟个人能力有限。这只是一款简单的 txt 阅读器。可以单独使用,也可以配合书城使用。地址:https://github.com/tonyzhou1890/reader2

功能

  1. 支持txt文本
  2. 可以直接本地打开utf-8编码的txt文本
  3. 可以通过url参数address打开远程utf-8文本 比如:reader.tony93.top/?address=https://store.tony93.top/舞舞舞.txt
  4. 支持阅读进度记录
  5. 支持文本搜索
  6. 支持文本选择复制(仅 pc 端)

其他说明

  1. 排版只考虑了中文和英文,算法并没有考虑复杂情况
  2. 移动端因为字体库较少,所以字体切换没有效果
  3. 阅读进度存储在本地,至于是在indexDB还是localstorage取决于浏览器支持–用的库是localforage

截图

实现解析

技术栈

vue、element-ui、canvas、Web Worker、postMessage、axios、localforage、clipboard

项目结构

reader
│ index.html
│ package.json
│
├─build
│ build.js
│ ……
│
├─src
│ │ App.vue
│ │ main.js
│ │
│ ├─components
│ │ ├─ActionBar // 选择文字后的操作按钮,目前只有复制
│ │ │ index.vue
│ │ │
│ │ ├─Book // 文本渲染组件--应用的主要部分
│ │ │ index.vue
│ │ │
│ │ ├─ChapterList // 章节目录
│ │ │ index.vue
│ │ │
│ │ ├─JumpPage // 跳页
│ │ │ index.vue
│ │ │
│ │ ├─Local // 本地模式选择文件的页面
│ │ │ index.vue
│ │ │
│ │ ├─Menu // 菜单
│ │ │ index.vue
│ │ │
│ │ ├─Search // 搜索
│ │ │ index.vue
│ │ │
│ │ ├─Setting // 设置
│ │ │ index.vue
│ │ │
│ │ └─SvgIcon
│ │ index.vue
│ │
│ ├─icons
│ │ │ index.js
│ │ │ svgo.yml
│ │ │
│ │ └─svg
│ │ 404.svg
│ │ chapter.svg
│ │ jump.svg
│ │ search.svg
│ │ setting.svg
│ │
│ ├─styles
│ │ index.less
│ │ variables.less
│ │
│ └─utils
│ error.js
│ promiseWorker.js
│ pureUtils.js // 供 Web Worker 线程使用的函数,不涉及 DOM 操作
│ request.js
│ setting.js
│ storage.js // localforage 操作的封装
│ utils.js // 工具函数
│ utils.worker.js // Web Worker 线程,使用 worker-loader 插件加载
│
└─theme // element ui 主题
│ index.css
│ ……
│
└─fonts
element-icons.ttf
element-icons.woff

这个项目主要部分就两个。Vue 负责布局,组织各个模块。canvas 和相关算法(如果算的话)负责排版和渲染。如上文件结构,components/Book/index.vue、utils/utils.js、utils/pureUtils.js 这三个是项目的核心。

书本排版渲染流程

首先,通过 components/Book/index.vue 梳理一下大体流程。

// 模板部分和 import 部分就不放了
// ……
export default {
  name: 'Book',
  props: {
    width: { type: Number, required: true },
    height: { type: Number, required: true },
    text: { type: String, default: appSetting.title }, // 正文
    title: { type: String, default: '' }, // 标题/书名
    color: { type: String, required: true },
    background: { type: String, required: true },
    highlightBgc: { type: String, required: true },
    fontSize: { type: Number, required: true },
    lineHeight: { type: Number, required: true },
    fontFamily: { type: String, required: true },
    percent: { type: Number, default: 0 }, // 阅读进度百分比
    frontCoverPath: { type: String, default: '' },
    backCoverPath: { type: String, default: '' },
    renderChapter: { type: Boolean, default: true } // 是否分章排版
  },
  data() {
    return {
      ……
    }
  },
  computed: {
    // 可视区宽高
    windowSize() {
      let { width, height } = this
      return { width, height }
    },
    // 文本需要重新计算的相关属性
    propertiesToCalc() {
      let { lineHeight } = this
      return { lineHeight }
    },
    // 重新测量字符,重新分页
    propertiesToMeasure() {
      let { text, fontSize, fontFamily } = this
      return { text, fontSize, fontFamily }
    },
    // 页面需要重新绘制的相关属性
    propertiesToRender() {
      let { color, title } = this
      return { color, title }
    },
    // 显示内容
    showWhat() {
      if (this.frontCoverPath && this.page < 0) {
        return 'frontCover'
      } else if (this.backCoverPath && this.page >= this._bookData.pages.length) {
        return 'backCover'
      } else {
        return 'content'
      }
    },
    // 显示的 bookSize,这里重新定义一个 _bookSize 是为了解决封面封底显示问题
    // 显示封面封底的时候,应该只显示封面和封底,并且居中
    _bookSize() {
      if (this.showWhat === 'content' || this.full || this.single) {
        return this.bookSize
      } else {
        return [
          this.bookSize[0] - this.pageSize[0],
          this.bookSize[1]
        ]
      }
    },
    // 文字选择
    _selection() {
      return {
        startChar: this.selection.startChar,
        endChar: this.selection.endChar,
        single: this.single,
        full: this.full
      }
    }
  },
  watch: {
    // 监听可视区宽高
    windowSize: {
      handler() {
        this.calcBookSize()
      },
      immediate: true
    },
    // 监听需要重新计算的属性
    propertiesToCalc: {
      handler() {
        this.textToPage()
      }
    },
    // 监听需要重新测绘的属性
    propertiesToMeasure: {
      handler() {
        this.textToPage(true)
      }
    },
    // 监听需要重新绘制的属性
    propertiesToRender: {
      handler() {
        // 重绘文字前,清除文字选择
        this.clearSelection()
        this.renderPage()
        // 如果是双页,渲染第二页
        if (!this.single) {
          this.renderPage('two')
        }
      }
    },
    // 监听percent
    percent: {
      handler() {
        this.calcPage()
      },
      immediate: true
    },
    // 监听 page
    page: {
      handler() {
        // 重绘文字前,清除文字选择
        this.clearSelection()
        this.renderPage()
        // 如果是双页,渲染第二页
        if (!this.single) {
          this.renderPage('two')
        }
      }
    },
    // 监听 _selection
    _selection: {
      handler() {
        this.renderBgc()
        // 如果是双页,渲染第二页
        if (!this.single) {
          this.renderBgc('two')
        }
      }
    }
  },
  // ……
}
// ……

如代码所示,Book 组件会接收很多属性,根据不同属性的变化需要采取不同阶段的操作。这些阶段是:

  • calcBookSize(计算书本尺寸)
  • textToPage(分页)——这个阶段又有三个部分
    • measureChars(测量字符)
    • splitChapter(计算章节)
    • _textToPage(计算分页)
  • calcPage(计算页码)
  • renderPage(渲染页面)

下面,将按照操作流程解析具体实现细节。本来是准备按照技术点组织的,但也许按照流程会更合理。

计算书本尺寸

// Book/index.vue
export default {
  // ……
  methods: {
    // 计算书籍尺寸
    calcBookSize() {
      let _this = this
      if (!this.width) return
      let res = calcBookSize({ // 这个 calcBookSize 来自 utils/utils.js
        defaultPageSize: this.defaultPageSize,
        defaultPagePadding: this.defaultPagePadding,
        limit: this.limit,
        width: this.width,
        height: this.height,
        menuWidth: this.menuWidth
      })
      if (
        res.single === this.single &&
        res.full === this.full &&
        res.bookSize[0] === this.bookSize[0] &&
        res.bookSize[1] === this.bookSize[1]
      ) return

      ['full', 'single', 'pageSize', 'bookSize', 'pagePadding'].map(key => {
        _this[key] = res[key]
      })
      this._bookData.single = res.single
      this.textToPage()
    },
  }
  // ……
}

这里会调用 utils/utils.js——calcBookSize 进行计算,同时根据结果与已有数据比较判断是否需要重新排版渲染。因为排版计算量很大,所以这里的判读还是很有必要的。

// utils/utils.js
// ……
/**
 * 计算显示书籍尺寸
 * @param {Object} param 参数对象
 * param: {
 *    defaultPageSize: [width, height],  // 单页尺寸
 *    defaultPagePadding: [number, number],  // 页边距
 *    limit: [number, number],  // 切换显示模式--全屏阈值
 *    width: number,  // 可视区宽度
 *    height: number, // 可视区高度
 *    menuWidth: number,  // 菜单溢出宽度
 * }
 */
export function calcBookSize(param) {
  const res = {
    full: false,
    single: false,
    pageSize: [],
    bookSize: [],
    pagePadding: []
  }
  let pageRatio = param.defaultPageSize[0] / param.defaultPageSize[1]

  // 是否全屏
  if (param.width <= param.limit[0] || param.width <= param.limit[1]) {
    res.full = true
    res.single = true
    res.bookSize = res.pageSize = [param.width, param.height]
  } else {
    let tempWidth = param.width - 20 * 2
    let tempHeight = param.height - 20 * 2
    // 单页
    if (param.width < param.height || param.width < 1000) {
      res.single = true
      // 宽度不足
      if ((tempWidth - param.menuWidth) / tempHeight < pageRatio) {
        let tempPageWidth = tempWidth - param.menuWidth
        res.pageSize = [tempPageWidth, tempPageWidth / pageRatio]
        res.bookSize = [tempWidth, res.pageSize[1]]
      } else {
        // 高度不足
        res.pageSize = [tempHeight * pageRatio, tempHeight]
        res.bookSize = [res.pageSize[0] + param.menuWidth, tempHeight]
      }
    } else {
      // 双页
      // 宽度不足
      if ((tempWidth - param.menuWidth) / tempHeight < pageRatio * 2) {
        let tempPageWidth = (tempWidth - param.menuWidth) / 2
        res.pageSize = [tempPageWidth, tempPageWidth / pageRatio]
        res.bookSize = [tempWidth, res.pageSize[1]]
      } else {
        // 高度不足
        res.pageSize = [tempHeight * pageRatio, tempHeight]
        res.bookSize = [res.pageSize[0] * 2 + param.menuWidth, tempHeight]
      }
    }
  }

  // padding
  if (res.full) {
    res.pagePadding = bookSetting.fullPagePadding
  } else {
    res.pagePadding = [
      res.pageSize[0] * param.defaultPagePadding[0] / param.defaultPageSize[0],
      res.pageSize[1] * param.defaultPagePadding[1] / param.defaultPageSize[1]
    ]
  }

  return res
}
// ……

除了全屏模式(宽高低于 limit 相应的阈值),纸张的宽高比是固定的(A4纸比例)。当可视区宽度低于 1000px 的时候为单页模式,否则为双页模式。

分页

这一阶段的工作是根据给定的文本 text 、页面文字区域属性(宽高、字体族、字体大小、行高)和是否分章计算每一页应该分配哪些文字。

如上所言,这一阶段可以分为三个部分——测量字符、计算章节、计算分页。这三个部分计算量都很大,但只有计算章节和计算分页两个部分可以放到 Web Worker 中计算,因为测量字符涉及到 DOM 操作(使用到了 canvas 的 measureText 方法)。

测量字符

/**
 * 测量字符
 * @param {Object} param // 参数对象
 * param: {
 *    text: '', // 整个文本
 *    ctx: {},  // 绘图上下文
 * }
 * 返回值:{
 *    measures: {
 *      'a': {
 *        width: 12,
 *        height: 12
 *      }
 *    },
 *    textArray: ['a', 'b']
 * }
 */
export function measureChars(param) {
  let s = Date.now()
  // 将文本转成数组,防止四字节字符问题
  let textArray = Array.from(param.text)
  // 加上额外需要测量的字符
  let tempArr = textArray.concat([bookSetting.hyphen, '阅'])
  console.log('  tempArr:', Date.now() - s)

  let len = tempArr.length
  let measures = {}
  for (let i = 0; i < len; i++) {
    if (!measures[tempArr[i]]) {
      measures[tempArr[i]] = {
        width: param.ctx.measureText(tempArr[i]).width
      }
    }
  }
  // console.log('  Set:', Date.now() - s)
  // tab符、换行符特殊处理
  if (measures['\t']) {
    measures['\t'].width = measures['阅'].width * 2
  }
  if (measures['\r']) {
    measures['\r'].width = 0
  }
  if (measures['\n']) {
    measures['\n'].width = 0
  }
  console.log('measureChars:', Date.now() - s)
  return {
    textArray,
    measures
  }
}

如何获得字符的宽度是首先需要考虑的问题。在非等宽字体的情况下,标点符号和英文的宽度是不确定的,无法简单地使用字体大小估算。还好,canvas 有一个 measureText 方法可以获取字符的宽度。

为了减少测量次数,在测量之前,需要先去重。我这里使用了 for 循环,而不是 map 之类的高级遍历器。因为 for、while、do……while 之类的比 map 这类的高级遍历器效率高太多了,完全不是一个数量级的。此外,遍历之前先将字符串转换成了数组,这会造成性能损耗,但没办法,直接使用下标获取字符串中的某个字符是不准确的。(也许可以试下 for……of,这个遍历器也能正确处理 Unicode 字符,并且性能还可以。)变成数组之后又添加了两个字符:连字符“-”和“阅”。连字符是用来解决长单词换行的,而“阅”是为了计算制表符(tab)的宽度的。

计算章节

// utils/pureUtils.js
/**
 * 分章
 * @param {Object} param 参数对象
 * param {
 *    textArray: [], // 文本数组
 *    titleLineLength: 30 // 标题文本长度
 * }
 * @return {Array} 对象数组
 * 对象参考 checkChapter
 */
export function splitChapter(param) {
  let s = Date.now()
  const { textArray, titleLineLength } = param
  const len = textArray.length
  if (len === 0) {
    return []
  }
  const res = []
  // 第一行开始没有换行符,直接检测
  let checkTemp = null
  checkTemp = checkChapter({
    textArray,
    index: 0,
    titleLineLength
  })
  if (checkTemp) {
    res.push({
      ...checkTemp
    })
  }

  // 其余的从换行符后开始检测
  for (let i = 1; i < len; i++) {
    if (textArray[i] === '\n' && textArray[i + 1]) {
      checkTemp = checkChapter({
        textArray,
        index: i + 1,
        titleLineLength
      })
      if (checkTemp) {
        res.push({
          ...checkTemp
        })
      }
    }
  }
  console.log('splitChapter:', Date.now() - s)
  // 添加每个章节的 startIndex 和 endIndex
  res.map((item, index) => {
    item.startIndex = item.index
    item.endIndex = res[index + 1] ? res[index + 1].index - 1 : textArray.length - 1
  })
  return res
}

/**
 * 检测是否是章节标题--包括‘序言’之类的
 * @param {Object} param 对象参数
 * param {
 *    textArray: [], // 文本数组
 *    index: 1, // 行开始索引
 *    titleLineLength: 30 // 标题文本长度
 * }
 * @return {Boolean|Object}
 * 返回值:{
 *    index: 1, 行开始字符索引
 *    str: 'ddd', 标题字符串
 *    strArray: ['a'] 标题行
 * }
 */
export function checkChapter(param) {
  const { textArray, index, titleLineLength } = param
  const specialTitle = ['序言', '总序', '前言', '后记']
  let tempIndex = textArray.indexOf('\n', index)
  if (tempIndex > -1 && tempIndex < index + titleLineLength) {
    const strArray = arrayCopy(textArray, index, tempIndex)
    // 排除两边空字符的影响
    const str = strArray.join('').trim()
    if (str.length && (
      specialTitle.includes(str) ||
      // eslint-disable-next-line no-irregular-whitespace
      /^第?[0-9 一二三四五六七八九十百千万]+[章回篇节]([ \t ]\S+)*$/.test(str)
    )) {
      return {
        index,
        str,
        strArray
      }
    } else {
      return false
    }
  } else {
    return false
  }
}

自动分章是我非常需要的一个功能。因为阅读器只能解析 txt 格式的纯文本,如果没有章节导航,阅读会非常痛苦。

这个功能的实现并不是很复杂,简单来说就是格式匹配。如果一段文本满足以下条件,即视为标题。

  • 一行文本,30 个字以内。
  • 是“序言”、“总序”、“前言”、“后记”中的一种。
  • 或者,满足正则:/^第?[0-9 一二三四五六七八九十百千万]+[章回篇节]([ \t ]\S+)*$/。

这样的条件存在误判的情况,但也基本够用了。

计算分页

这一块的计算很繁琐,代码实现将近 300 行。所以,这里只贴一下核心的分析:

       /**
       * 普通字符(包括其余各种符号)处理(换行符之外的所有字符)
       * 1 有剩余空间(包括0)
       *    1.1 行首后置符号
       *      1.1.1 上一行最后一个字符(前一个字符)为换行符,不回溯换行
       *      1.1.2 最大回溯字符数以内,回溯换行
       *      1.1.3 超过最大回溯字符数,不回溯换行
       *      1.1.4 上一个字符为英文,不回溯换行
       *      1.1.5 判断:一行第一个字符,并且是后置字符,并且前一个字符有值且非换行符、非英文,并且回溯字符数以内有非后置字符
       *    1.2 行尾前置符号
       *      1.2.1 下一个字符为换行字符,不提前换行
       *      1.2.2 剩余空间小于下一个非换行字符,提前换行
       *      1.2.3 回溯字符数以内遇到非前置字符,非前置字符后提前换行
       *      1.2.4 判断:下一个字符非换行字符,且剩余空间小于下一个字符,然后函数回溯
       *    1.3 行尾英文字符
       *      1.3.1 下一个字符也是英文,回溯换行
       *      1.3.2 最大英文回溯字符以内,回溯换行
       *      1.3.3 超过最大英文回溯字符数,添加连字符换行
       *    1.3 其余情况,常规处理--改变当前行宽度,当前字符加入当前行
       * 2 没有剩余空间,换行
       */

其中,“后置符号”是指不能出现在行首的符号,同理,“前置符号”是指不能出现在行尾的符号。“后置符号”有这些——!),.:;?]}¨·ˇˉ―‖’”…∶、。〃々〉》」』】〕〗!"'),.:;?]`|}~¢。“前置符号”有这些——([{·‘“〈《「『【〔〖(.[{£¥。“最大回溯字符数”的设置是为了避免无限回溯的问题——比如输入不规范,全是前置符号。在实现的时候,最大回溯字符数和上一行字符数量,取小者。不能把上一行回溯没了。

也许该解释一下什么是“回溯换行”。“回溯换行”是指倒序取上一行符合相关规则的 n 个字符放到当前行。这个操作是为了让符号排版符合排版规则,也会导致上一行在字符不足的情况下换行(所以,渲染页面阶段要进行字符间距的调整)。因为不知道这个操作的术语,所以自创了这个概念。

计算页码

这里也不贴代码了,只说明一下解决方法。我是通过 (0, 1] 来表示进度的,对应正文的 1 ~ n 页,对应分页数组下标 0 ~ len – 1。进度小于等于 0 的时候,如果有封面,则为封面,否则为第一页;进度大于 1 的时候,如果有封底,则为封底,否则为最后一页。其余的按照百分比计算,双页的情况下要注意计算出的页码应该是在左边还是右边。

渲染页面

这个阶段可以分为两个步骤:layout 和 render。

计算分页的时候虽然计算了每一页、每一行有哪些字符,但出于性能考虑,并没有计算出每一个字符的位置。因此,渲染阶段就有了 layout 这个步骤。相比于一整本书,只计算当前显示页面的文字位置,计算量自不可相提并论。layout 需要注意的是,对于提前换行的,需要计算字符间距。(我这里用 completed 属性表示此行是否提前换行——虽然空间有剩余,但依然是完整的一行。)

// 如果是完整的一行,需要计算字间距,
    if (item.completed) {
      let charsWidth = 0
      item.chars.map(chara => {
        charsWidth += _params.measures[chara].width
      })
      letterSpacing = (_params.lineWidth - charsWidth) / (item.chars.length - 1)
    }

在 render 这个步骤中,需要注意的是 DPR 会导致页面模糊。解决方法如下:

// 首先清屏
// 采用这种方式清屏,是为了一并解决纸张大小变化的情况
textCtx.canvas.width = this.pageSize[0] * this.DPR
textCtx.canvas.height = this.pageSize[1] * this.DPR
// 画布缩放
textCtx.scale(this.DPR, this.DPR)

其他功能

Web Worker

先说一下 Web Worker 吧,因为上面“计算章节”和“计算分页”是在 worker 中进行的。

将计算密集型任务放到 worker 中可以解决 ui 线程阻塞导致页面“卡死”的问题。

在 webpack 中使用 Web Worker 需要先安装 worker-loader。这个 loader 可以将 *.worker.js 这样的文件处理成 worker。

// webpack 配置
{
  test: /\.worker\.js$/,
  use: { loader: 'worker-loader' }
},

对于 worker 的使用,我的想法是这样的:

  • 在一个文件中写一些不涉及到 DOM 操作的函数——pureUtils.js。
  • worker 线程引入上一步的文件——utils.worker.js。
  • 将 worker 线程的通信方式封装成 promise 形式方便调用——promiseWorker.js。
// utils.worker.js
import { textToPage, splitChapter } from '@/utils/pureUtils'

const utils = {
  textToPage,
  splitChapter
}

onmessage = (e) => {
  const { action, param, timestamp, _sign } = e.data
  if (typeof utils[action] === 'function') {
    const res = {
      action,
      result: utils[action](...param),
      timestamp,
      _sign
    }
    postMessage(res)
  } else {
    console.log('指定操作不存在')
  }
}
// promiseWorker.js
import Worker from '@/utils/utils.worker'

const quene = new Map()
const worker = new Worker()

worker.addEventListener('message', e => {
  if (!e.data || !e.data._sign) {
    console.error('worker 返回数据错误')
  } else {
    quene.get(e.data._sign).resolve({
      result: e.data.result,
      e
    })
    quene.delete(e.data._sign)
  }
})

export default (param) => {
  return new Promise((resolve, reject) => {
    const _sign = Date.now() * Math.random()
    quene.set(_sign, { resolve })
    worker.postMessage({
      ...param,
      _sign
    })
  })
}import Worker from '@/utils/utils.worker'
import { appSetting } from '@/utils/setting'

const workerNum = appSetting.threads // 线程数量
const quene = new Map()
const waiting = []
const workers = new Array(workerNum).fill(null).map((_, index) => {
  return (
    {
      index,
      worker: new Worker(),
      idle: true // 是否空闲
    }
  )
})

workers.map(item => {
  item.worker.addEventListener('message', e => {
    if (!e.data || !e.data._sign) {
      console.error('worker 返回数据错误')
      quene.get(e.data._sign).reject('worker 返回数据错误')
    } else {
      quene.get(e.data._sign).resolve({
        result: e.data.result,
        e
      })
      quene.delete(e.data._sign)
      item.idle = true
      // 尝试接受新任务
      assignJob()
    }
  })
})

/**
 * 将等待队列中的任务加入空闲线程
 */
function assignJob() {
  let idleWorker = null
  let waitingJob = null
  if (waiting.length) {
    idleWorker = workers.find(item => item.idle)
    if (idleWorker) {
      idleWorker.idle = false
      waitingJob = waiting.shift()
      quene.set(waitingJob._sign, waitingJob.p)

      idleWorker.worker.postMessage({
        ...waitingJob.job,
        _sign: waitingJob._sign
      })
    }
  }
}

/**
 * @param job {
 *  action: '',
 *  param: {},
 *  timestamp // 可选
 * }
 */
export default (job) => {
  job.timestamp = job.timestamp || Date.now()
  return new Promise((resolve, reject) => {
    const _sign = Date.now() * Math.random()
    waiting.push({
      _sign,
      job,
      p: { resolve, reject }
    })
    // 分配线程
    assignJob()
  })
}// promiseWorker.js
import Worker from '@/utils/utils.worker'

const quene = new Map()
const worker = new Worker()

worker.addEventListener('message', e => {
  if (!e.data || !e.data._sign) {
    console.error('worker 返回数据错误')
  } else {
    quene.get(e.data._sign).resolve({
      result: e.data.result,
      e
    })
    quene.delete(e.data._sign)
  }
})

export default (param) => {
  return new Promise((resolve, reject) => {
    const _sign = Date.now() * Math.random()
    quene.set(_sign, { resolve })
    worker.postMessage({
      ...param,
      _sign
    })
  })
}
// main.js
import Vue from 'vue'
import promiseWorker from '@/utils/promiseWorker'

// worker 线程挂到 Vue 上,方便调用
Vue.prototype._worker = promiseWorker
// Book/index.vue
// ……
// 在 worker 中计算章节
this._worker({
  action: 'splitChapter',
  param: [param]
})
  .then(res => {
    this.getChapters(res.e)
    _textToPage(this)
  })
// ……

文本选择复制

因为是 canvas 实现的电子书,所以选择文字之类的基本功能也只能自己实现。这一功能我拖了很长时间才开始编码,因为比较麻烦,特别是移动端,需要实现放大镜和游标。实际上,我也只是实现了 pc 端的文本选择复制。

先梳理一下思路:

  • mousedown 事件,记录为开始点。
  • mousemove 事件,记录为结束点,即时计算选中区域并绘制。
  • mouseup 事件,记录为选择结束,显示工具栏(目前只有复制功能)。

这里的重点是,如何根据开始点和结束点计算选中区域。因为所有的行和文字位置都已经计算出来了,所以,只需要根据两点的位置找到开始和结束字符的位置,然后,两个字符之间所有行的区域就是需要绘制选中背景的区域。为了绘制方便,选中背景是在另一层 canvas 上。

文字的复制功能是通过 clipboard 这个包实现的。

import Vue from 'vue'

import Clipboard from 'clipboard'

Vue.prototype.Clipboard = Clipboard
// 复制
handleCopy() {
  let text = arrayCopy(this._bookData.textArray, this.selection.startChar, this.selection.endChar).join('')
  const el = document.getElementById('action-copy-el')
  el.dataset.clipboardAction = 'copy'
  el.dataset.clipboardText = text
  const clipboard = new this.Clipboard('#action-copy-el')
  clipboard.on('success', () => {
    this.$message.success('复制成功')
  })
  clipboard.on('error', (e) => {
    this.$message.success('复制失败')
    console.log(e)
  })
  this.clearSelection()
}

注意事项

  • 其他页面打开阅读器的通信问题
    应该等阅读器页面给原页面发出可以发送信息的通知才向阅读器发送信息。关于 postMessage
  • 计算后的文本数据不需要响应式
    一本书往往几十万字,作为一个字符串还行,但如果是几十万长度的数组,其响应式性能惨不忍睹。所以,文本测量、分章、分页、排版等阶段计算后的数据是不需要响应式的。我是在 Vue 原型和 window 上都挂载了。
  • 移动端不存在诸多字体
    相比于 pc 端,移动端字体选择很少。所以设置部分的字体在移动端可以忽略。如果是纯英文,可以通过 font-face 解决,但 UTF-8 就算了。
  • 应用性能
    我想,密集计算这块应该还有可以优化的部分,但提升应该也不会很大了。难道要用 WebAssembly ?算了,反正我不用这软件。写这款软件完全是因为想写这么一款软件。并且,目前来说性能还可以,几十万字的文本完全不在话下。