概述
以起点为例,一个款好的阅读器排版引擎需要处理以下几个问题:
- 避头尾:某些标点不能出现在行的开头,例如“逗号”就不应该出现在行首。有些标点不能出现在行的结尾,例如“正引号”就不应该出现在行的结尾
- 压缩:如果出现连续的标点,例如冒号后面跟着引号,那么这两个标点不应该占据2个字符的位置,而应该合并起来占据一个字符的位置。
- 每行文字两端对齐:每一行的文字都可能出现中文、字母、数字、特殊符号等字符类型,每一行的文字的排版不可能刚好占满整行,每行文字的两端就会无法对齐。
- 每页段落底部对齐:每一个段落的行数不确定,每一段和每一行之间的间距也不确定,每一页的排版就会出现底部留白的情况。
以起点app为例子,能够发现,基本上每一页都是两端对齐+底部对齐
实现方式
CSS3 column多栏布局
CSS3 中的 column(多栏布局)是一种可以将文本内容或其他元素排列成多列显示的布局方式,就像报纸的排版一样。它可以让内容在水平方向上分布在多个列中,尤其适用于较长的文本段落或者元素列表。
- 原理很简单,固定视窗(overflow:hidden),通过设置容器transform来进行左右翻页。但是在第一页和最后一页位置的时候,无法预览章节上一页/下一页的内容。
- 页码计算:画个图,写个方程式就得出来了,具体和布局有关
- 平移动画:主要分为三个动作
- touchstart:记录最开始点击位置, 记录初始时间
- touchmove:记录偏移量,通过 transition 和 transform 设置翻页动画
- touchend:有三种情况
- 点击事件: 即没有偏移量,点中间唤出菜单,左右两边进行页面切换
- 短时间拖拽:即快速滑动页面,处理为页面切换操作
- 长时间拖拽:若拖拽距离大于1/3屏幕,处理成页面切换操作,否则处理成回弹
/** 有效拖拽时间(毫秒) */
const dragTime = 300;
/** 显示菜单 */
const showMenu = ref(false);
/** 触摸位置 */
let touchPosition = 0;
/** 触摸的时间 */
let touchTime = 0;
/** 是否开始触摸 */
let startTouch = false;
/** touch时原transLateX值 */
let currentTranslateX: number;
function onTouchStart(e: TouchEvent) {
startTouch = true;
const pageX = e.touches[0].pageX;
touchPosition = pageX;
touchTime = Date.now();
currentTranslateX = getReaderTranslateX();
}
function onTouchMove(e: TouchEvent) {
if (!startTouch) return;
if (showMenu.value) return;
const pageX = e.touches[0].pageX;
const slide = pageX - touchPosition;
if (slide < 0 && isLastPage.value) {
showToast("没有啦~");
return;
}
if (slide > 0 && isFirstPage.value) {
showToast("当前为第一页");
return;
}
readerSectionStyle.transition = "0s all";
readerSectionStyle.transform = `translateX(${slide + currentTranslateX}px)`;
}
function onTouchEnd(e: TouchEvent) {
if (!startTouch) return;
startTouch = false;
if (sliderWindowInfo.show) {
sliderWindowInfo.show = false;
}
if (showMenu.value) {
showMenu.value = false;
return;
}
const pageX = e.changedTouches[0].pageX;
const slideX = pageX - touchPosition;
const value = appOption.screenWidth / 3;
const now = Date.now();
// /** 返回原来位置 */
const backPosition = () => {
readerSectionStyle.transition = "0.4s all";
readerSectionStyle.transform = `translateX(${currentTranslateX}px)`;
};
// 点击事件
if (Math.abs(slideX) <= 0) {
if (pageX < value) {
// pre
pagePrev();
} else if (pageX > value * 2) {
// next
pageNext();
} else {
//点击中间
setType.value = "slider";
showMenu.value = true;
}
} else {
// 短时间拖拽和长时间长距离拖拽
if (now - touchTime < dragTime || (now - touchTime > dragTime && Math.abs(slideX) >= value)) {
// 拖拽距离大于 1/3
if (slideX < 0) {
// next
pageNext();
} else {
// pre
pagePrev();
}
} else {
backPosition();
}
}
}
注意
- uniapp打包成app后不能使用 document ,需要使用uni提供的api
- 但是 uni.createSelectorQuery拿不到元素的scrollWidth属性,我是放置了一个占位元素去拿到anchro元素的right值得到scrollWidth
- uni.createSelectorQuery是一个异步任务,我在实际使用过程中会出现right值的获取不准确的情况下,导致最终放弃了column布局方案。
- 如果一定要使用 column 布局方案,请使用renderjs
- 具体思路可以看看 地址 里
src/pages/reader/index_v1.vue
实现
<!-- html -->
<p id="anchro_last_p" style="width: 100%; height: 0"></p>
// js
function updatePageCount() {
getNodeInfo("#anchro_last_p").then((res) => {
setTimeout(() => {
const right = res?.right ?? 0;
const scrollWidth = right - bookOption.sizeInfo.lrPadding;
pageCount.value = (scrollWidth + 32) / (appOption.screenWidth - bookOption.sizeInfo.lrPadding * 2 + 32);
}, 0);
});
}
function getNodeInfo(selector: string): Promise<UniApp.NodeInfo | null> {
return new Promise((resolve) => {
const query = uni.createSelectorQuery();
query
.select(selector)
.boundingClientRect((data) => {
resolve((data as UniApp.NodeInfo) || null);
})
.exec();
});
}
缺点
- 依赖于平台排版渲染能力,小程序不支持,兼容性差。
- 所有排版均为平台自动渲染,只有一个容器,无法灵活的对每一页的内容做自定义处理。
- 长章节文本的渲染可能会出现性能问题。
- 翻页处理困难,基本上只能实现平滑翻页。其余翻页方式实现困难,强行实现只会带来更多的灾难。
- 灵活性差:比如每一页底部的留白空缺就无法处理。
优点
- 简单、快速,在产品功能简单的情况下可以使用
JS计算分页
思路:canvas.measureText
- 主要思路就是通过 measureText 去测量一段文本的宽度,再根据屏幕宽度去计算有多少行。根据行数、文字高度和间距计算出一页的高度,去分页就行了。
- 但是有下面几个需要注意的点:
- measureText 计算出来的宽度并不准确,比实际宽度偏小。measureText 计算结果和字体大小、字体类型、字体重量等很多因素有关。这一点影响其实不大,重点是 measureText 计算出来的宽度和浏览器的排版是没关系的。大致就相当于把十行文字全部放在一行,然后测量一行文字的宽度。
- 在浏览器文字的排版中,如果下一行首字符是以特殊字符开始,往往会自动把上一行的段尾的文字放到下一行的段首,会尽可能的处理这种避头尾的情况。但是这就会导致实际测量出来的段落刚好是两行,而实际看到的却是三行。即计算结果比实际宽度偏小的情况。
- 还有一些情况,比如说一个小写字符的宽度本身很小,但是如果在一个段落的段首,确可能占一整个中文汉字的的情况,具体和字体类型有关。
- uni提供了一个api: uni.createCanvasContext,可以创建一个canvas对象(document会有兼容问题)。但是uni.createCanvasContext.measureText 在app端会有严重的性能问题。造成严重的卡顿,应该避免使用!!!
- render 实践指南
- renderjs 网上教程很少,官网写的也很简略。并且不支持 vue3
setup script
语法。 - renderjs 使用可以看这里
- renderjs 网上教程很少,官网写的也很简略。并且不支持 vue3
- renderjs 使用需要注意的地方
- 最佳实践:以子组件的形式使用,如果放到一起的话,享受不到ts的支持,并且ts还会会报错,而且原页面逻辑不用动(天知道我最开始把我的vue setup重构成 setup() {}, 再重构成 export default {}, 最后又重构成 setup 期间经历了什么)
- vue3项目中,renderjs 没有提示,应该是vscode插件的问题,反正就是Vetur、Volar 之间的各种冲突(如果我没记错的话vue2使用的是Vetur,vue3使用的是Volar)
- render 里不可以使用 App、Page 的生命周期,也不能使用 uni 相关接口(如uni.setStorage)等。包括引用的函数里也不能存在uni相关API,不然运行到手机上会直接报错!!(如 import store from "@/store" 但是store里面使用了uni.setStorage,我当时被坑惨了- .-)
- 看具体思路可以看看 地址 里
src/pages/reader/components/Renderjs_v2.vue
实现
完整代码(写不动了,直接看代码把 - .-)
methods: {
loadChapter(newVal, oldValue, ownerInstance, instance) {
if (newVal?.data?.title && newVal?.data?.title) {
const { pageWidth, pageHeight, statusBarHeight, bookOption } = newVal.options;
this.pageWidth = pageWidth;
this.pageHeight = pageHeight;
this.statusBarHeight = statusBarHeight;
this.bookOption = bookOption;
const d1 = +new Date();
const chapterPageList = this.updateChapterList(newVal.data);
this.$ownerInstance.callMethod("updateContainerContent", { params: newVal, chapterPageList });
const d2 = +new Date();
console.log(d2 - d1, "total Spend Timer!!!!!");
}
},
updateChapterList(data: { title: string; content: string }) {
function customRound(num1: number, num2: number) {
const result = num1 / num2;
const integerPart = Math.floor(result); // 获取整数部分
const decimalPart = result - integerPart; // 获取小数部分
if (decimalPart > 3 / 4) {
return Math.ceil(result);
} else {
return Math.floor(result);
}
// return Math.floor(result);
}
/** 当前章节的段落列表 */
const contents = data.content
.split("\n")
.map((i) => i.trim())
.filter((i) => i);
/** 下边距 */
const margin = this.bookOption.sizeInfo.margin;
/** 容器实际宽度 */
const width = this.pageWidth - this.bookOption.sizeInfo.lrPadding * 2;
/** 实际一行能展示的宽度 */
const actualWidth = this.getActualWidth(width);
/** 计算的页数 */
let page = 0;
/** 是否第一页 */
let firstPage = true;
/** 一页的字体容器高度 */
let height = 0;
/** 一页的字体列表 */
let list = [];
/** 下一页超出的数据 */
let nextPageText = {
height: 0,
text: "",
};
// 先重置为一个空的数组,因为二维数组的值初始化是`undefined`
const chapterPageList: Array<{ title: string; content: Array<string>; breakLineIndex?: Array<number> }> = [];
// 软分段索引值
const breakLineIdx: Array<number> = [];
let i = 0;
while (i < contents.length) {
const item = contents[i];
let fontWidth = this.getTextWidth(item, actualWidth, breakLineIdx.includes(i)); // 使用 getTextWidth 来计算文本宽度
const row = Math.ceil(fontWidth / actualWidth);
const itemHeight = row * this.bookOption.sizeInfo.pLineHeight + margin;
if (firstPage) {
const w = this.getTextWidth(data.title, actualWidth, true, this.bookOption.sizeInfo.title);
const r = Math.ceil(w / actualWidth);
const h = r * this.bookOption.sizeInfo.tLineHeight + margin;
height += h;
firstPage = false;
}
// 把上一页超出的内容加到当前页中去
if (nextPageText.height) {
height += nextPageText.height;
list.push(nextPageText.text);
// 用完拼接好的页面记得清除
nextPageText.height = 0;
nextPageText.text = "";
}
// 处理长段落:如果当前段落不能完全放下,分为两部分
const containerHeight =
this.pageHeight -
this.bookOption.sizeInfo.infoHeight -
this.bookOption.sizeInfo.infoHeight -
this.bookOption.sizeInfo.tPadding -
this.bookOption.sizeInfo.bPadding -
this.statusBarHeight;
if (height - margin + itemHeight > containerHeight) {
// 当前段落超出页面,尝试将一部分放到下一页
const remainingSpace = containerHeight - height; // 剩余的可用空间
const allowRemainRow = customRound(remainingSpace, this.bookOption.sizeInfo.pLineHeight); // 可填充的行数
// console.log(
// `总空间 ${containerHeight} 第${page + 1}页剩余可用空间 ${remainingSpace} 可填充行数 ${allowRemainRow} 需要填充的字符串 ${item}`,
// );
if (allowRemainRow > 0) {
let currentText = ""; // 当前页已经填充的文本
let currentLineCount = 0; // 当前页已经填充的行数
let fontWidth = 0;
// 逐字符处理,直到文本的宽度大于可以填充的行数宽度
let remainingText = item; // 剩余的文本
while (remainingText.length > 0) {
fontWidth = this.getTextWidth(currentText + remainingText[0], actualWidth); // 加上一个字符的宽度
const totalWidth = actualWidth * allowRemainRow; // 当前页的总宽度
// 如果当前行的宽度超出剩余空间,就跳出循环
if (fontWidth > totalWidth) {
break;
}
currentText += remainingText[0]; // 将当前字符添加到当前页的文本
remainingText = remainingText.slice(1); // 剩余文本去掉第一个字符
}
// 当前页填满后,保存内容
list.push(currentText);
height += currentLineCount * this.bookOption.sizeInfo.pLineHeight + margin;
// 剩余部分的文本
const nextText = remainingText;
if (nextText) {
contents.splice(i + 1, 0, nextText);
breakLineIdx.push(i + 1);
}
// 保存当前页的内容
chapterPageList[page] = {
title: "",
content: list,
};
list = [];
height = 0;
page++; // 跳到下一页
i++;
continue; // 当前段落已经被拆分,跳过继续处理
}
}
// 如果当前段落没有超出页面,直接放到当前页
list.push(item);
height += itemHeight;
// 判断是否超出一页的高度
if (height - margin > containerHeight) {
list.pop();
nextPageText.height = itemHeight;
nextPageText.text = item;
chapterPageList[page] = {
title: "",
content: list,
};
list = [];
height = 0;
page++;
} else {
if (i === contents.length - 1) {
// 最后一页
chapterPageList[page] = {
title: "",
content: list,
};
}
}
i++;
}
// 最后一页的标题
chapterPageList[0].title = data.title;
if (breakLineIdx.length) {
let i = 0;
for (let index = 0; index < chapterPageList.length; index++) {
const page = chapterPageList[index];
if (!page.breakLineIndex) {
page.breakLineIndex = [];
}
page.content.forEach((ctx, pos) => {
if (breakLineIdx.includes(i)) {
page.breakLineIndex!.push(pos);
}
i++;
});
}
}
return chapterPageList;
},
// 获取文本的实际宽度
getTextWidth(
text: string,
actualWidth?: number,
isBreakLine: boolean = false,
fontSize: number = this.bookOption.sizeInfo.p,
) {
if (!this.canvas) {
const cvs = document.createElement("canvas");
this.canvas = cvs.getContext("2d")!;
}
this.canvas.font = `${fontSize}px PingFang SC`; // 设置字体大小
// 如果文本的第一个字符是半角特殊字符或全角特殊字符,就将它替换为一个中文字符
if (this.isSpecialCharacter(text.charAt(0))) {
text = "啊" + text.slice(1); // 用中文字符 '啊' 替换开头的字符
}
// 计算实际宽度
if (!actualWidth) {
return this.canvas.measureText(text).width;
}
// 如果不是软分行段首,即有两个字符缩进
if (!isBreakLine) {
text = "啊啊" + text;
}
let totalWidth = this.canvas.measureText(text).width;
if (totalWidth <= actualWidth) {
return totalWidth;
}
// 如果宽度大于实际容器宽度,判断是否有特殊符号,进行换行处理
// 这里实际是因为,如果下一行是以特殊字符开始,浏览器的排版会自动把上一行的段尾放到下一行的段首,会尽可能的处理以符号开始的情况
let currentLineWidth = 0;
let lines = [];
let textArr = text.split("");
let line = "";
// 循环处理每个字符并判断换行
for (let i = 0; i < textArr.length; i++) {
let str = line + textArr[i];
currentLineWidth = this.canvas.measureText(str).width;
// 如果当前行宽度超过了容器宽度
if (currentLineWidth <= actualWidth) {
// 尝试把中间的全角字符转换成一个中文字符
// 因为 canvas.measureText(str) 中带有全角,计算会不准确
str = str.replace(/[!“”‘’()、,.:;<>@@[]\^_`{}|~]/g, "啊");
currentLineWidth = this.canvas.measureText(str).width;
}
if (currentLineWidth > actualWidth) {
// 判断是否需要将特殊符号移到下一行
if (this.isSpecialCharacter(textArr[i])) {
lines.push(line.slice(0, -1)); // 将当前行最后一个字符挪到下一行
line = textArr[i - 1];
} else {
lines.push(line);
line = "";
}
currentLineWidth = 0; // 重置当前行宽度
}
line += textArr[i];
}
// 最后一行如果有剩余的文本,则加入
if (line.length > 0) {
lines.push(line);
}
if (lines.length > 1) {
totalWidth = actualWidth * (lines.length - 1) + this.canvas.measureText(lines.pop() || "").width;
}
return totalWidth;
},
// 判断字符是否是半角特殊字符或全角特殊字符
isSpecialCharacter(char: string) {
const halfWidthSpecialChars = /[!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~]/; // 半角特殊字符
const fullWidthSpecialChars = /[!“”‘’()、,.:;<>@[]\^_`{}|~]/; // 全角特殊字符
return halfWidthSpecialChars.test(char) || fullWidthSpecialChars.test(char);
},
getActualWidth(width: number) {
// 获取一行实际的宽度
let sizeWidth = 0;
let str = "啊";
while (sizeWidth <= width) {
str += "啊";
sizeWidth = this.getTextWidth(str);
}
return this.getTextWidth(str.slice(1));
},
},
- 这段代码只能初步实现分页计算:但是实际处理仍然不够完善,旨在于提供思路。比如:
while (remainingText.length > 0) {}
会每个文字计算一个宽度,这是一种很浪费性能的表现。直接通过actualWidth,计算每一行能容纳最大的汉字开始分割计算就行了。 - 主要思路其实就是:每一个段落高度(行数x高度)相加,如果大于容器高度。就把最后一个段落进行软分行,拆分一部分放到下一页里面。
- customRound 会进行空白填充,如果剩余空白太多,会尝试去多追加一行,即使会超出容器部分高度的情况下(避免留白太多)
- 性能较差(200ms以内):仅供参考,感兴趣的话喵一眼94行和248行(处理标点符号避头)就行了(ps:因为找到了有大佬现成的轮子,我就放弃优化了 - .-)
最终分页实现
- 来源于你不知道的阅读器排版引擎,处理得已经很完善了
- 在基础上我添加了一个空白填充功能的处理
两端对齐
text-align: justify;
会存在兼容问题(nvue不支持)- 每一个文字占一个元素:然后通过
justify-content: space-between;
两端对齐,兼容性好。
底部对齐
- 段落父元素:
display: flex; justify-content: space-between; flex-direction: column;
- 因为有尝试进行空白填充,所以最终留白的高度不会太多。两端对齐后肉眼不会有很明显的大间距。
JS计算翻页动画
思路
- 整个阅读器只加载三个页面:当前页,上一页,下一页。当前页数是第一页的时候,上一页内容为上一章最后一页的数据,当前页数是最后一页的时候,下一页内容为下一章第一页的数据。
- 切换页码的时候,动态修改三个页面的内容就行了。
- 翻页方式只是改变三个页面的布局和移动动画就行。
- 具体处理看 地址 里
src/pages/reader/index.vue
的实现
const pageTextList = ref<Array<{ title: string; content: PageList }>>([ { title: "", // "page-1", content: [],
},
{
title: "", // "page-2",
content: [],
},
{
title: "", // "page-3",
content: [],
},
]);
相关
- 耗时三个月,我高仿了一个起点小说阅读器
- 从零开始手撸一个阅读器--书源解析功能的实现(2)
- 从零开始手撸一个阅读器--换源功能的实现(3)
- 从零开始手撸一个阅读器--数据结构与数据缓存(4)
- 从零开始手撸一个阅读器--夜间/白天主题色切换(5)