在segmentfault上阅读文章时有一个非常好的阅读体验:在文章右边有文章的提纲索引,可以快速了解文章的内容。点击对应的标题,可以对文章内容快速定位。同时处于阅读中段落的标题会实时高亮提示(这个功能在百度百科词条上也有),整个效果如下图:
为了改善各位老铁的阅读体验,我决定我的博客也得加上这个功能。当然这个功能已经上线了,各们老铁可以在我博客随便找篇markdown文章感受下(因为我博客支持3种文章类型:markdown文章,普通文章和富文本文章,目前此功能只在markdown文章实现了)。今天这篇文章主要是复盘一下实现过程。
既然要实现标题实时高亮,第一件事当然是把标题都给找出来,不然高亮谁去。这里讲两种找出markdown文章的所有标题方法:
熟悉Markdown语法的老铁都知道,Markdown中的标题都是以“#”这个符号开头的。所以要从md茫茫字符中找出标题的第一个正则可能会像这样:
/(#+)[^\n]*?\n/g
解释一下这个正则:以至少一个#开始,紧接非换行符外任意个字符进行惰性匹配,然后是一个换行符。看起来,的确是这么回事。先用最简单的mk测试一下:
# 标题1
## 标题2
## 标题3
### 标题4
这种md内容的确可以通过测试,但下面这种有code的情况下,就会有问题
# 标题1
``js
// ## 这是JS注释
console.log('hello')
``
## 标题2
## 标题3
### 标题4
在这个例子中,“## 这里JS注释”也被当成一个md标题了。那么如何避免这种情况呢?
由于md中,代码块种类繁多,且存在形式多种多样。这会当标题正则变得难以下手。这时候我们就不要硬碰硬了,要想到曲线救国:能不能先把md中代码块给去掉。因为代码块中肯定不包含标题,所以去掉对我们的结果没有任何影响。下面是去掉代码块的逻辑
var mkStr
.replace(/```/g, function () {
return '\f'
})
.replace(/\f[^\f]*?\f/g, function (match) {
return ''
})
解释一下:用\f这个字符对“`
”进行占位,然后再把占位块一起用空串替换。为什么要先占位来绕一下呢。原因还是代码块可能的情况太多,先用 \f 这个非常特殊的字符占位,就方便我们把代码块连根拔起了。
// 提取标题完整逻辑
var nav = []
mkContent
.replace(/```/g, function () {
return '\f'
})
.replace(/\f[^\f]*?\f/g, function (match) {
return ''
})
.replace(/\r|\n+/g, function (match) {
return '\n'
})
.replace(/(#+)[^\n]*?(?:\n)/g, function (match, m1, m2) {
var title = match
.replace('\n', '') // 去掉行尾换行符
.replace(/^#+/, '') // 去掉 #
.replace(/\([^)]*?\)/, '') // 去掉标题中可能存在的链接
nav.push(title)
})
在我折腾许多久,突然想到了专业的md处理库marked。其实我博客也是使用Marked把md文章转换成html进行显示的。果然,在mk的 github issues 中找到了办法
var nav = []
const marked = require('marked')
const renderer = new marked.Renderer()
renderer.heading = function (text, level) {
return nav.push(text)
}
marked(mkBStr, { renderer: renderer })
现在md的标题已经可以解析出来了。第二个问题就是如何把标题们按层级展示。我们知道,md标题前面的 # 越多,标题越小,md标题可以是1~6个 # 号。也就是说,最多可能有5个层级缩进。我们先看一个2级缩进的UI效果:
再看一个3级缩进UI效果:
层级展示的难点有:
但仔细观察不难发现,如何把一级标题作为根,那么所有的md标题就形成了一种树。作为前端同学,最熟悉的树当然是dom树了。我们把前面的标题数组转换成树。实现思想是:先抽象标题为一个节点对象,包含4个属性:
{
title: '标题', // 标题内容
level: 1, // 标题层级,即 # 的个数
index: 1, // 标题在文章中出现的索引
children: [
TitleNode // 子标题
]
}
# 标题1
``js
// ## 这是JS注释
console.log('hello')
``
## 标题2
## 标题3
### 标题4
上面md内容的完整标题树是这样:
[
{
"title": " 标题2",
"level": 2,
"children": [],
"index": 0
},
{
"title": " 标题3",
"level": 2,
"index": 1,
"children": [
{
"title": " 标题4",
"level": 3,
"children": [],
"index": 2
}
],
}
]
完整实现代码如下:
```js
function getMKTitles (mkContent) {
var nav = []
var navLevel = []
mkContent
.replace(/```/g, function () {
return '\f'
})
.replace(/\f[^\f]*?\f/g, function (match) {
return ''
})
.replace(/\r|\n+/g, function (match) {
return '\n'
})
.replace(/(#+)[^\n]*?(?:\n)/g, function (match, m1, m2) {
var title = match.replace('\n', '')
var level = m1.length
nav.push({
title: title.replace(/^#+/, '').replace(/\([^)]*?\)/, ''),
level: level,
children: []
})
if (navLevel.indexOf(level) === -1 && level !== 1) { // =1 为标题
navLevel.push(level)
}
})
// 去掉title
if (nav[0].level === 1) {
nav.shift()
}
var index = 0
nav = nav.map(item => {
item.index = index++
return item
})
navLevel = navLevel.sort()
var retNavs = []
var toAppendNavList
navLevel.forEach(level => {
toAppendNavList = find(nav, {
level: level
})
if (retNavs.length === 0) {
retNavs = retNavs.concat(toAppendNavList)
} else {
toAppendNavList.forEach(toAppendNav => {
toAppendNav = Object.assign(toAppendNav)
let parentNavIndex = getParentIndex(nav, toAppendNav.index)
return appendToParentNav(retNavs, parentNavIndex, toAppendNav)
})
}
})
// 同一级的Level不一样的时候,需要做顺序调整
retNavs = retNavs.map(navItem => {
var children = navItem.children || []
if (children.length === 0) {
return navItem
}
navItem.children = indexSort(navItem.children)
return navItem
})
return {
nav: retNavs, // 所有mk标题
navLevel: navLevel, // 所有标题层级
length: nav.length // 标题长度
}
}
function find (arr, condition) {
return arr.filter(item => {
for (var key in condition) {
if (condition.hasOwnProperty(key) && condition[key] !== item[key]) {
return false
}
}
return true
})
}
function findIndex (arr, condition) {
let ret = -1
arr.forEach((item, index) => {
for (var key in condition) {
if (condition.hasOwnProperty(key) && condition[key] !== item[key]) { // 不进行深比较
return false
}
}
ret = index
})
return ret
}
function getParentIndex (nav, endIndex) {
for (var i = endIndex - 1; i >= 0; i--) {
if (nav[endIndex].level > nav[i].level) {
return nav[i].index
}
}
}
function appendToParentNav (nav, parentIndex, newNav) {
// 先第一级里面找,找不到再去children中去找
let index = findIndex(nav, {
index: parentIndex
})
if (index === -1) {
let subNav
for (var i = 0; i < nav.length; i++) {
subNav = nav[i]
subNav.children.length && appendToParentNav(subNav.children, parentIndex, newNav)
}
} else {
nav[index].children = nav[index].children.concat(newNav)
}
}
function indexSort (navList) {
// 有子元素,检查level是否相同
var needSort = find(navList, {
level: navList[0].level
}).length !== navList.length
if (needSort === false) return navList
return navList.map(nav => {
return nav.index
}).sort().map(index => {
var nav = find(navList, {
index: index
})
if ((nav.children || []).length > 1) {
nav.children = indexSort(nav.children)
}
return nav[0]
})
}
export default getMKTitles
得到标题树后,就是进行UI展示了。进行UI展示相信不会难到大家了。这里介绍用vue递归组件的一种实现实现:
<template>
<ul class="nav-list">
<li v-for="(nav, index) in list" :key="index">
<a :href="nav.index | anchor" :class="{active: highlightIndex === nav.index}">{{nav.title}}</a>
<markdown-titles :list="nav.children" v-if="nav.children.length > 0"/>
</li>
</ul>
</template>
<script>
export default {
name: "markdown-titles",
props: {
list: {
type: Array,
required: true
}
}
}
</script>
<style lang="less">
.nav-list {
margin-left: 15px;
list-style: square;
padding-left: 0;
a {
display: block;
color: #333;
&:link, &:visited {
text-decoration: none;
}
&:hover {
text-decoration: underline;
}
&.active {
color: #FF4400;
font-weight: 600;
}
}
}
</style>
这个实现原理还是比较简单:利用浏览器的锚点功能。A 标签的href指向一个id,点击此 A 标签时,浏览器就会跳到对应的锚点处。
<a href="#titleAnchor-12"> 减小编译输出的文件体积</a>
<h2 id="titleAnchor-12">减小编译输出的文件体积</h2>
此于如何给md标题加上锚点id,一样可以使用Marked库
markdownHtml: (state) => {
const renderer = new marked.Renderer()
let index = -1
renderer.heading = function (text, level) {
return `<h${level} id="titleAnchor-${index++}">${text}</h${level}>`
}
return marked(state.markdown, { renderer: renderer })
}
到目前为此,就剩最后一个问题还没解决了:如何进行阅读时标题高亮。高亮的第一个问题就是:如何判断那一个文章段落处于阅读中。我的思路时:绑定 scroll 事件,在事件回调中遍历所有的md标题,找出距显示器屏幕上方的y值大于0且最小的标题,就是我们正在阅读的段落的标题。
scrollHandler() {
const idPrefix = 'titleAnchor-'
const distance = 20
let list = []
for (var i = 0; i < this.mkTitlesLen; i++) {
let dom = document.getElementById(`${idPrefix}${i}`)
let domTitle = document.querySelector(`a[href="#titleAnchor-${i}"]`)
list.push({
y: dom.getBoundingClientRect().top + 10, // 利用dom.getBoundingClientRect().top可以拿到元素相对于显示器的动态y轴坐标
index: i,
domTitle
})
}
let readingVO = list.filter(item => item.y > distance).sort((a, b) => {
return a.y - b.y
})[0] // 对所有的y值为正标的题,按y值升序排序。第一个标题就是当前处于阅读中的段落的标题。也即要高亮的标题
}
找到阅读段落的标题后,通过索引就可以找到要高亮的标题。高亮的实现就不多说了,无非加个 active 的样式 class。
这里之所以要提一下非md的标题导航,是因为是文章就有段落有标题。虽然md是一种很好的展示内容的文体形式,但就像前面所说的,还有富文本这种更丰富的内容展示形式。要对这种文章做标题导航,原理也差不多。只是找出标题的实现有些不一样,其它都是相同的。好了,关于标题导航就复盘到这了,希望看官们有所收获。如何有更好的实现思路,也欢迎与我交流。
发表评论: