需求背景
七猫中文网阅读器页面实现前后端分离,除了要保留原有页面功能(皮肤切换、字号、字体设置),还需要支持vip用户在登录后可以正常阅读vip章节。
功能实现
七猫中文网前后端分离项目是nuxt框架实现的服务端渲染。
一、支持vip章节的阅读
旧版本的阅读器:非vip章节展示全部内容,vip章节展示部分内容,直接通过html渲染展示;
新版本阅读器要支持vip章节的正常阅读,为了保证vip章节内容的安全性,做了内容防抓取的处理
- 隐藏接口请求
章节内容相关的接口请求置于服务器端,这样在浏览器端就看不到任何的接口请求;
但是这样的处理方式使得阅读器页面的交互必须做相应的调整,之前的阅读器页面是滚动加载下一章节的内容,现在是通过点击按钮‘下一章’、‘上一章’来更新页面路由并刷新页面来实现
- 章节内容加密
接口下发的章节内容是加密的,然后在浏览器端对内容进行解密之后渲染;
并且前端项目在打包时,采用了代码加密混淆插件,来隐藏解密章节内容的操作;但是要注意的是代码加密混淆插件会导致打包后的项目体积增加,因此要按照自己的需要进行配置; - 章节内容渲染
对于非vip章节,还是使用html的方式直接渲染,保证了页面的seo友好;
vip章节则采用了canvas绘制的方式,来防止页面内容被抓取;
二、canvas绘制文本
除了绘制章节内容外,还需要支持字号、字体以及文字颜色的设置;
<canvas id="myCanvas"></canvas>
//绘制文本需要的参数
let mycanvas;
let ctx;
let width=820;//必须值,canvas宽度
let textAlign="left";//对齐方式(left\right\center),相对于 fillText参数中的x轴坐标
let textBaseline="middle";// 当前文本基线 相对于fillText参数中的y轴坐标
let textArr=[];//绘制文本内容,根据段落划分的数组
let lineHeight=1.8;//行高
let paragraphSpace= 13;// 段落间距
let font = {
fontSize:'13px',//字号
fontFamily:'Microsoft YaHei,Songti SC', //字体
fontColor:'#222', //文字颜色
}
//全局变量
let ratio; //当前屏幕像素比
let indent;//当前设置下,一个文字的宽度
let wordLength;//一行展示最多展示的字数
let textHeight;//文字高度(根据像素比)
let pSpace;//段落间距(根据像素比)
章节内容需要根据段落划分为数组形式;
paragraphSpace段落间距,是实际显示的段落间距的一半,类似于padding-top和padding-bottom分别添加;
1、初始化canvas画布
function init_canvas(){
mycanvas = document.getElementById('myCanvas');
ctx = mycanvas.getContext('2d');
//Retina 屏适配,解决某些屏幕显示模糊的问题
ratio = get_pixel_ratio(ctx);
font.fontSize = Number(font.fontSize) * ratio;
pSpace = Number(paragraphSpace) * ratio;
textHeight = font.fontSize * lineHeight;
}
//Retina 屏适配,获取当前屏幕像素比
function get_pixel_ratio(context){
const backingStore = context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
context.backingStorePixelRatio || 1;
return (window.devicePixelRatio || 1) / backingStore;
}
在 canvas context 中存在一个 backingStorePixelRatio 的属性,该属性的值决定了浏览器在渲染canvas之前会用几个像素来来存储画布信息。
2、根据展示样式设置画布参数
function set_canvas_options(){
// 设置文本样式
ctx.font = `${font.fontSize}px ${font.fontFamily}`;
// 根据当前文本设置,获取一个文字的宽度
indent = ctx.measureText('宽').width;
// 根据文字宽度计算一行显示最大字数
wordLength = parseInt(Number(width) * ratio / indent);
// 计算画布高度
init_text_data('initHeight');
// 计算画布高度时,会修改canvas的绘制高度,其中设置的相关属性都会被重置,所以需要重新设置文本相关样式
ctx.font = `${font.fontSize}px ${font.fontFamily}`;
ctx.textAlign = textAlign;
ctx.fillStyle = font.fontColor;
ctx.textBaseline = textBaseline;
}
canvas画布的高度不能根据内容自适应,因此在绘制文字之前,先计算当前章节内容绘制需要的高度,并赋值给canvas,设置画布的高度;但是重新修改画布的高度,会导致之前设置的文本属性都会被重制,因此需要重复设置文本属性;
计算画布的高度有个前提条件,就是要根据当前设置的文字属性计算文本高度、计算一行可以展示字数,因此必须先设置文本属性,计算画布高度,赋值画布高度,最后再重新设置文本属性
3、绘制canvas
// 绘制canvas或者计算canvas高度 type:initHeight(计算canvas高度)
function init_text_data(type){
let startTop = 0; // 初始化绘制高度
let _canvasOffset = 0; // canvas偏移量
for (let i = 0; i < textArr.length; i++){//遍历章节段落
let _paragraphTxt = textArr[i];
//首行缩进的宽度:当内容是左对齐时,添加段落首行缩进,2个字符的宽度
let _showIndent = textAlign == 'left' ? indent * 2 : 0;
startTop += pSpace; //添加段落间距
// 循环绘制段落内的每一行文本
while (_paragraphTxt.length > 0){
const subIndex = get_sub_str_index(_paragraphTxt, wordLength,!!_showIndent);
const showTxt = _paragraphTxt.substr(0, subIndex);//当前行绘制的文本
_paragraphTxt = _paragraphTxt.substr(subIndex); // 截取当前段落剩下的文字
startTop += textHeight;//绘制高度增加
if (type != 'initHeight'){
draw_canvas(startTop,showTxt, _showIndent);
}
_showIndent = 0;//缩进只需要在段落首行,之后的都是默认0
}
startTop += pSpace;
}
if (type == 'initHeight'){
mycanvas.style.height = (startTop / ratio) + 'px'; // canvas实际显示高度
mycanvas.height = startTop; // canvas绘制高度
mycanvas.width = Number(width) * ratio; // canvas绘制宽度
}
}
// 根据对齐方式绘制文本
function draw_canvas (startTop, showTxt, _showIndent){
const _top = startTop - (textHeight / 2);
switch (textAlign){
case 'center':ctx.fillText(showTxt, _showIndent + (mycanvas.width / 2), _top); break;
case 'left':ctx.fillText(showTxt, _showIndent, _top); break;
case 'right':ctx.fillText(showTxt, _showIndent + mycanvas.width, _top); break;
}
}
/*
* 根据参数获取当前文本截取的位置
* params:绘制文本、一行显示字数、是否有缩进
*/
function get_sub_str_index (str, len, showIndent){
if (showIndent){ // 如果要显缩进最大显示字数-2
len -= 2;
}
const byteMaxLen = len * 2; // 最大字节数
let byteCount = 0;
let _subIndex = 0;
for (let i = 0; i < str.length; i++){
if (str.charCodeAt(i) < 256){ // 单字节
if (byteCount + 1 <= byteMaxLen){
byteCount += 1;
} else {
_subIndex = i;
break;
}
} else { // 双字节
if (byteCount + 2 <= byteMaxLen){
byteCount += 2;
} else {
_subIndex = i;
break;
}
}
}
上述已经基本实现适用canvas绘制文本,但还有一个优化点,就是避免特殊符号位于每行的首位,因此我们在判断当前行第一个是特殊符号时,把上一行的最后一个字放到当前行的首位。
//避免每一行的第一个字是特殊符号
const _pattern = new RegExp("[`~!@#$^&*()=|{}':;',\\[\\].<>《》/?~!@#¥……&*()——|{}【】‘;:”“'。,、? ]");
if (_subIndex && _pattern.test(str.charAt(_subIndex))){
_subIndex = _subIndex - 1;
}
return _subIndex || str.length;
}
三、皮肤设置
支持多套皮肤的切换,并且在用户设置皮肤后,下一次打开页面时,仍需要保留上一次的皮肤设置
多套皮肤的实现
通过配置scss变量,生成多套皮肤css文件,然后在页面根目录设置当前皮肤的主题来实现阅读器页面的皮肤切换
document.querySelector(':root').setAttribute('data-theme', 'default');
$themes:(
default:(
wrapper-bg:#E0E0E0,
...
),
blue:(
wrapper-bg:#CFD9E0,
...
),
yellow:(
wrapper-bg:#E3D9BC,
...
),
green:(
wrapper-bg:#DDEBD6,
...
),
red:(
wrapper-bg:#F3D8D8,
...
),
dark:(
wrapper-bg:#242121,
...
)
);
@each $var,$map in $themes{
html[data-theme=#{$var}],
.reader-layout-theme[data-theme=#{$var}]{
background-color: map-get($map,'wrapper-bg');
body{
background-color: map-get($map,'wrapper-bg');
}
}
}
...
当前选择皮肤的缓存
-
localstorage存储
当前选择的皮肤信息存储在localstorage,下次打开页面或者刷新页面时,从localstorage获取皮肤信息并设置当前页面皮肤;
存在问题:localstorage只能在浏览器端获取,而阅读器页面采用的是ssr渲染,这就导致用户先看到的是默认皮肤的阅读器页面,然后才是当前本地存储皮肤对应的页面;而且切换章节时都会存在页面刷新,就导致用户在阅读的过程中,一直伴随着这个问题,体验不友好; -
cookie存储
为了解决localstorage存储带来的问题,我们采用了cookie来存储当前皮肤信息,cookie支持在服务器端操作;
在服务器上获取到当前皮肤信息,并设置当前页面的皮肤,这样就很好的避免了页面皮肤闪烁的问题。
总结
当前七猫中文网阅读器还存在一些优化点,需要后续继续研究实现;
1、利用canvas绘制章节内容,是直接绘制了一整个章节的内容,当章节内容特别多的时候,绘制的速度就会比较慢,因此后续需要考虑将一个章节分为多个canvas依次绘制来提升页面的加载速度;
2、交互体验优化:在保证章节内容的安全性的前提下,通过滚动加载切换章节内容;