# 前言

# ShokaX 和 Shoka

Shoka 是一款非常美观的 Hexo 博客主题,本人曾经也使用过该主题。但由于作者长期未更新(最近一次更新在两年前),同时主题本身存在着部分 BUG(例如 mermaid 图标不能显示,CDN 被污染等),于是催生出了 ShokaX 这样的二次开发版。
ShokaX 相比于 Shoka:

  • 改变了技术栈:Shoka 是 JS + Native + Nunjucks,ShokaX 是 TS + Vue 3 + Pug
  • 更改了大量难以访问的 CDN 链接
  • 允许通过注入 API 以实现自定义功能
  • PWA 支持
  • ...

# 存在的问题

由于 Shoka 主题中使用了大量的动画,在文章字数比较少的时候不会有什么问题,但一旦文章字数变多或者存在大量的 LaTeX,就会出现卡顿的现象。
例如该文章 clrs-book-note 大约包含 50k 个字,内部包含大量的 LaTeX,Lighthouse 统计大约有 45116 个 DOM 元素。在使用 Shoka 主题时,上下滚动会感受到明显的卡顿,鼠标点击的烟花动画更是只有个位数的帧率,且这种现象在移动端或是较旧的浏览器上会更加明显。

# ShokaX vs Shoka

测试文章采用 clrs-book-note
ShokaX 使用 v0.2.8,基础配置
Shoka 使用 v0.2.5,基础配置
浏览器版本:

  • Chrome 内核版本 114.0.5735.110
  • 360 极速浏览器 内核版本 86.0.4240.198

测试方式:尽可能以相同方式滑动网页,通过分析开发者工具中的性能选项卡以进行比较。

# Chrome

Shoka 主题如下图所示:
chrome-shoka
平均每个任务耗时约 25 毫秒
ShokaX 主题如下图所示:
chrome-shokax
平均每个任务耗时约 15 毫秒

可以看到,如今的 chrome 内核的优化已经非常好了,两者每次任务也仅仅相差 10 毫秒,可以说对于帧率没有什么影响。

# 360 极速浏览器

但在内核版本老一些的浏览器,如 360 极速浏览器中,情况就变得比较糟糕了:
Shoka 主题如下图所示:
360-shoka
可以看到几乎所有的任务都是长任务(带红色上标),且帧率非常的不稳定(顶部绿色部分),基本上保持在低位。
ShokaX 主题如下图所示:
360-shokax
相比于 Shoka,长任务少了很多,且帧率在非长任务阶段提升了不少,(但相比于 chrome 还是比较逆天,由此可见升级内核非常重要)

# 这是如何实现的?

# 减少无意义回流

众所周知,回流的代价远远大于重绘,所以尽可能减少回流非常重要。在 ShokaX/Shoka 中,移动端相比于桌面端有部分元素不用显示,在上下滚动页面时不需要对其进行修改,以此阻止回流的产生。
如以下代码在移动端不需要被执行:

backToTop.child('span').innerText = scrollPercent
$dom('.percent').changeOrGetWidth(scrollPercent)

所以可以将其改为:

if(backToTop.child('span').innerText !== scrollPercent) {
  backToTop.child('span').innerText = scrollPercent
}
if($dom('#sidebar').hasClass('affix') || $dom('#sidebar').hasClass('on')) {
  $dom('.percent').changeOrGetWidth(scrollPercent)
}

相关 PR:
#56

# 暂停动画

ShokaX/Shoka 中动画绝对是一个性能杀手,尤其是 ShokaX 在导航栏中引入了毛玻璃特效,导致性能进一步降低。但实际上这些动画只需要在可见区域内时播放就能可以了,而并不需要一直播放,造成不必要的性能损失。
几个比较重要(吃性能)的 CSS 动画:

  • 头图放大动画
  • 头图和文章交界处波浪动画(非常吃性能
  • 代码区向下 / 上展开箭头动画
  • 尾部樱花旋转动画

CSS 动画可以通过 animation-play-state 属性来控制其播放和暂停:

.stop-animation {
  animation-play-state: paused;
}

判断是否在可见区域可以有多种方法:

  • getBoundingClientRect() 函数获得 top 属性,但这样需要将其写在 scroll 监听回调中,且会造成回流。
const { top } = document.getElementById('main').getBoundingClientRect();
if (top >= 0) {
  document.querySelectorAll('#imgs .item').forEach(i => {
    i.classList.remove('stop-animation');
  })
} else {
  document.querySelectorAll('#imgs .item').forEach(i => {
    i.classList.add('stop-animation');
  })
}
  • IntersectionObserver 对象(强烈推荐),性能更好,但兼容性不如上一个方法(ShokaX 已经放弃了 EOL 浏览器的支持,所以没关系)
new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) {
    document.querySelectorAll('.parallax>use').forEach(i => {
      i.classList.remove('stop-animation');
    })
  } else {
    document.querySelectorAll('.parallax>use').forEach(i => {
      i.classList.add('stop-animation');
    })
  }
}, {
  root: null,
  threshold: 0.2
}).observe(document.getElementById('waves'));

但需要注意的是 IntersectionObserverroot 和其观察的对象必须是父子关系。
相关 PR:
#59

# 跳过渲染

content-visibility 是一个比较新的 CSS 属性,将其设置为 auto 后如果该元素与用户不相关,它会跳过元素渲染工作,这可以被运用在长列表和含有大量离线内容的渲染中,用以加快渲染速度,提升首屏性能。
但直接使用该属性可能会造成一些副作用 —— 滚动条会一直抽动,这是因为元素跳过渲染如果不指定其高度的话,高度就是 0。
所以需要配合 contain-intrinsic-size 这个属性一起使用:通过指定的元素大小(主要是高度)来确保未渲染子元素仍然占据空间,防止高度塌陷。但实际上有些时候高度是不能准确知道的,这时就需要尽可能估计其高度以获得最佳效果。所以这也是该属性存在的一个弊端:其只适合长表格等每行具有固定高度的元素。
Shoka/ShokaX 主题中文本主要是通过 <p></p> 包裹,其高度根据文字数量而发生改变,所以不适合使用该方法,但对于高度基本固定的元素,可以考虑采用如下的 CSS:

.waves {
  width: 100%;
  height: 15vh;
  margin-bottom: -.6875rem;
  min-height: 3.125rem;
  max-height: 9.375rem;
  position:relative;
  content-visibility: auto;
  contain-intrinsic-size: 100vw 15vh;
  +mobile() {
    height: 10vh;
    contain-intrinsic-size: 100vw 10vh;
  }
}

相关 PR:
#44

# 后记

本文所说的几种优化方法也只是冰山一角,后续 ShokaX 也许还可以?

  • 对于含有大量 LaTeX 公式的文章也许可以使用渲染成为 svg,以减少 DOM 元素数量?
  • 当前图片懒加载使用 lozad.js,在加载时会造成强制重排和布局切换,是否可以防止这一切?
  • ...