「什么是锚点」
- 锚点是一种超级链接,能快速将访问者带到指定位置。
- 在 HTML 中, 一个带有 id 的 DOM 节点,就是一个锚点,通过在 url 的 hash 部分携带 id 就可以链接到该锚点。
「markdown的页内锚点跳转方式」
-
首先要定义一个锚
- 用
<span id="jump1>锚点1</span>
可以定义一个锚 - 在markdown中标题也会转化为锚(带id的标签),具体转化规则下面讲
- 标题即
# 标题一(这是h1)
、## 标题二(这是h2)
这样的语法
- 标题即
- 用
-
然后要定义一个引用方式
-
方式1:用a标签
<a href="#jump1">跳转到锚点1</a>
-
方式2:用markdown的语法
[跳转到锚点1](#jump1)
- 上面这个语法最后到html中其实会被渲染为方式1中的a标签
<a href="#jump1">跳转到锚点1</a>
-
-
总结
- 锚可以是
带id的标签
或者是markdown的标题语法
- 引用锚点可以用
href属性为目标锚点id的a标签
或者是markdown的引用语法
- 锚可以是
「问题背景」
- 我们在使用现代框架开发 SPA 的时候都会使用能和框架集成的路由管理器, 例如: vue-router, react-router等等。
- 而有时候为了兼容一些情况, 例如: 旧浏览器(IE 8/9) 、markdown跳转等. 我们会使用 Hash 的路由模式来实现前端路由.
- 这种时候, 因为 hash 被路由占据了, 锚点功能就会和路由冲突.
- 比如在一个组件库文档中用了hashRouter,这个时候要使用markdown中的锚点跳转改变了hash,就会跳转到404页面
「解决思路」
- 锚点跳转的实现原理是 在点击a标签时会检查a标签的href,然后查找页面上是否有对应id的元素,如果有,就将这个元素滚动到可视区域。
- 那由于我们的
#
已经被占用,我们无法利用a标签的默认跳转,所以需要我们来进行手动跳转- 在解析了 markdown 之后,对所有能链接到锚点的元素附加点击事件,阻止默认事件,让页面滚动到锚点所在位置。
- 滚动使用scrollIntoViewAPI
「实现」
-
function anchorChange() { // 获取的装载文章的 DOM 节点content. const postBody = document.getElementById('content'); // 拿到当前content内所有的a标签, 判断是否是本页面的跳转. postBody?.querySelectorAll("a").forEach((a: HTMLAnchorElement) => { // 跳转的页面 host 需和本页面一致, 并且带有 hash. if (a.hostname === window.location.hostname && !!a.hash) { // 为每一个符合本页跳转的a标签添加点击事件,组织默认跳转,用scrollIntoView实现跳转. a.addEventListener('click', e => { // 阻止默认跳转行为 e.preventDefault(); // 获取a标签的href const anchor = decodeURIComponent(a.hash.replace('#','')) // 拿到id为 a标签的href 的标签,然后用scrollIntoView跳转到那个标签,behavior:'smooth'是平滑滚动 postBody?.querySelector(`#${anchor}`)?.scrollIntoView({behavior: 'smooth'}); }); } }) }
- 上述代码实现了一个方法
对a标签的跳转行为改为手动跳转
,这个方法在每次文档内容更新的时候调用
(新文档会有新的a标签,所以要重新调用)- 调用后就可以为新文档内的所有a标签进行处理
- 调用示例
-
<span id="jump1">锚点1</span> [跳转到锚点1](#jump1) 或者 <a href="#jump1">跳转到锚点1</a>
- 在经过
anchorChange函数
处理过后的a标签就可以正确跳转到对应的锚点而不会进入404页面啦
-
「标题锚点的使用与问题」
- 上面我们说过,锚点可以用
带id的标签
或者是markdown的标题语法
声明 - 那下面我们来举个例子用
markdown的标题语法
声明锚点并跳转-
# button按钮 标题语法的引用的话,就将标题内容放到(#xxx)中 或者放到a标签的href中 [跳转到button按钮](#button按钮) <a href="#button按钮">跳转到button按钮</a>
- 我们看渲染后的html:
- 我们可以看到,标题是被渲染为
以内容为id的标签
- 而下面的引用语法则是被渲染为
href值为括号内 内容的a标签
- 其中a标签的href会
将某些字符的每个实例替换为表示字符的
UTF-8编码的一个、两个、三个或四个转义序列
- 其中a标签的href会
-
- 我们再举一个例子:
-
# Button按钮 [跳转到Button按钮](#Button按钮)
- 我们会发现无法跳转了,让我们再来看看渲染后的html:
- 观察上图,我们可以发现,标题在将内容转化为id的时候,把大写字母变小写了。
所以,标题内容转id并不是完全把内容复制过去的
-
「markdown标题内容 转ID 的规则」
- 大写字母转小写
- 比如:
# BUTTON
- 会转化成:
<h1 id="button">BUTTON</h1>
- 比如:
- 前后空格去除
- 比如:
# BUTTON
- 会转化成:
<h1 id="button">BUTTON</h1>
- 比如:
- 内容中间的空格会转化成 -
- 比如:
# BUTTON 按钮
- 会转化成:
<h1 id="button--按钮">BUTTON 按钮</h1>
- 比如:
- 特殊字符去除
-
以下特殊字符会被去除:
-
!$^()+={}|[]:"';<>?,./@#%……&*——+=“”‘’~`
-
-
比如:
# BUTTON 按钮;<>?,
-
会被转化成:
<h1 id="button--按钮">BUTTON 按钮;<>?,</h1>
-
- 现在我们知道了
markdown标题内容转id的规则
了,接下来我们在anchorChange函数
中对拿到的href进行处理,让它符合转化后的id
「重新实现」
-
function anchorChange() { // 获取的装载文章的 DOM 节点content. const postBody = document.getElementById('content'); // 拿到当前content内所有的a标签, 判断是否是本页面的跳转. postBody?.querySelectorAll("a").forEach((a: HTMLAnchorElement) => { // 跳转的页面 host 需和本页面一致, 并且带有 hash. if (a.hostname === window.location.hostname && !!a.hash) { // 为每一个符合本页跳转的a标签添加点击事件,组织默认跳转,用scrollIntoView实现跳转. a.addEventListener('click', e => { // 阻止默认跳转行为 e.preventDefault(); // markdown标题内容转id规则:前后空格去除,空格转-,特殊字符去除,大写字母转小写; const anchor = decodeURIComponent(a.hash.replace('#','')) .trim() .replace(/\s/g, '-') .replace(/[\!\$\^\(\)\+\=\{\}\|\[\]\\\:\"\'\;\<\>\?\,\.\/\@\#\%……\&\*——\+\=“”‘’\~\`]/g, '') .toLowerCase(); postBody?.querySelector(`#${anchor}`)?.scrollIntoView({behavior: 'smooth'}); }); } }) }
- 上方代码中
- 通过
decodeURIComponent
对字符的转义序列进行转回- 比如将
%E6%8C%89%E9%92%AE
转回按钮
- 比如将
- 通过
trim()
对前后空格进行去除 - 通过
replace(/\s/g, '-')
将内容间的空格转成-
- 通过
replace(/[\!\$\^\(\)\+\=\{\}\|\[\]\\\:\"\'\;\<\>\?\,\.\/\@\#\%……\&\*——\+\=“”‘’\~\
]/g, '')`将特殊字符去除 - 最后通过
toLowerCase()
将所有大写字母转为小写
- 通过
- 至此,a标签的href值处理已经完成。
id属性的限制 以及 querySelector的局限
- 写完上面的代码后,我本以为大功告成了,但是在我随便测试打出一个字符串
345@4h
时,我发现又无法跳转了😭 - 经过查阅资料:
在HTML5之前,根据HTML4和XHTML的规范,
id
属性的值确实不能以数字开头。它们必须以字母开头,后面可以跟字母、数字、下划线、连字符和其他一些字符。这一限制是由于文档对象模型(DOM)使用HTML的id
属性值来创建JavaScript可访问的属性,而在JavaScript中变量名不能以数字开头。然而,HTML5放宽了这一限制,允许
id
属性的值以数字开头。但是,即使如此,在使用CSS选择器和一些JavaScript方法时,以数字开头的id
仍然可能导致问题。
使用querySelector时对 以数字开头的id 的处理
- 当你使用
document.querySelector
或document.querySelectorAll
方法,如果选择器是以数字开头的id
,那么你需要在选择器字符串前加上转义字符\
,因为CSS选择器规范要求标识符(包括元素的类名、id
和属性名)不能以未转义的数字开头 - 例如:
<div id="123">Some content</div>
- 使用
querySelector
选取上面的div
时,你需要这样写:let element = document.querySelector("#\\31 23");
- 在这个选择器中,
\3
表示转义序列的开始,1
是要转义的字符,而后面紧跟的23
是id
的其余部分。 - 注意,转义序列后面必须跟一个空格或其它分隔符(在这里是数字),因此
\31
转义了数字1
,紧跟的23
被当作普通字符处理。
- 在这个选择器中,
- 因此,对
anchorChange函数
再做一些处理
function anchorChange() {
// 获取的装载文章的 DOM 节点content.
const postBody = document.getElementById('content');
// 拿到当前content内所有的a标签, 判断是否是本页面的跳转.
postBody?.querySelectorAll("a").forEach((a: HTMLAnchorElement) => {
// 跳转的页面 host 需和本页面一致, 并且带有 hash.
if (a.hostname === window.location.hostname && !!a.hash) {
// 为每一个符合本页跳转的a标签添加点击事件,组织默认跳转,用scrollIntoView实现跳转.
a.addEventListener('click', e => {
// 阻止默认跳转行为
e.preventDefault();
// markdown标题内容转id规则:前后空格去除,空格转-,特殊字符去除,大写字母转小写;
const anchor = decodeURIComponentSafe(a.hash.replace('#',''))
.trim()
.replace(/\s/g, '-')
.replace(/[\!\$\^\(\)\+\=\{\}\|\[\]\\\:\"\'\;\<\>\?\,\.\/\@\#\%……\&\*——\+\=“”‘’\~\`]/g, '')
.toLowerCase();
if(!isNaN(+anchor[0])) {
// 如果anchor开头是数字,则需要加转义符:\\3 ,否则querySelector会报错
let newAnchor = '\\3' + anchor[0] + ' ' + anchor.slice(1);
postBody?.querySelector(`#${newAnchor}`)?.scrollIntoView({behavior: 'smooth'});
} else {
postBody?.querySelector(`#${anchor}`)?.scrollIntoView({behavior: 'smooth'});
}
});
}
})
}
decodeURIComponent使用时对%的处理
-
在上面的代码中,如果我们直接在标题中打入%,如
# abk%kk
,那么会直接报错- 因为
decodeURIComponent
无法处理%kk
,所以会直接报错
- 因为
-
因此我们需要对我们手动输入的% 与 浏览器转义的%xx去进行区分处理
function decodeURIComponentSafe(uri: string) {
var out = new String(),
arr,
i = 0,
l,
x;
// 匹配uri中%开头的字符,如果%后有两位字符,则匹配 %及%后两位字符,否则匹配单个字符
arr = uri.match(/%[^%]{2}|%|[^%]+/g);
console.log(arr);
// nowstr用来储存解码失败的一段字符(因为可能有多个转义序列才能成功转义为一个字符)
let nowstr = ''
// 用一个map来储存解码失败对应的下一个字符(如果存在的话),避免顺序错乱
let map = new Map()
// 这个index用来记录map的键,防止map的键重复
let index = 0
for (l = arr ? arr.length : 0; i < l; i++) {
if(!arr) {
break
}
try {
// 如果nowstr不为空,则尝试解码,若成功则 赋值给x 并清空nowstr
if(nowstr.length) {
x = decodeURIComponent(nowstr)
nowstr = ''
// 这里处理完nowstr之后,当前这个arr[i]就被跳过了,所以把i-1抵消掉这次处理
i--
} else {
// 为空则尝试解码arr[i],若成功则赋值给x
x = decodeURIComponent(arr[i])
}
} catch (e) {
// 如果是三个字符,证明上方三个字符解码失败,需要后方字符添加才可继续解码,故添加到nowstr中
if (arr[i][0] === '%' && arr[i].length === 3) {
nowstr += arr[i];
x = ''
} else {
// 如果是单个字符,匹配到%的话将其替换为%25
let flag = arr[i].replace(/%(?![^%]{2})/g, "%25")
// 如果nowstr不存在,则可以直接解码并拼接到out中,不需要担心顺序错乱
if(!nowstr.length) {
x = decodeURIComponent(flag)
} else {
// 如果nowstr存在,则将nowstr的后三位为map的键,flag为map的值(因为nowstr的后三位一定为flag字符的上一位)
if(map.has(nowstr.slice(-3))) {
// 如果存在这个键,那么拼接上index
// 给原来的值加上index
let val = map.get(nowstr.slice(-3))
map.set(nowstr.slice(-3) + index, val)
map.delete(nowstr.slice(-3))
index++
// 再添加上当前值
map.set(nowstr.slice(-3) + index, flag)
index++
} else {
// 否则直接添加
map.set(nowstr.slice(-3), flag)
}
// 不拼接单个字符,等nowstr对应的字符处理完才拼接上,避免顺序错乱
x = ''
}
}
}
// out加入每个解码成功的字符
out += x;
}
// 循环结束后,若nowstr不为空,则尝试解码,若成功则赋值给x并清空nowstr
while(nowstr.length > 0) {
let x = ''
// 声明两个指针,用于截取nowstr
let start = 0,end = 3
// 要保留一个完整序列供 nowAnchor截取 以及 长度判断
let copyNowstr = nowstr
while(start <= copyNowstr.length && end <= copyNowstr.length) {
let nowAnchor = copyNowstr.slice(start, end)
try {
// 尝试解码nowstr中的nowAnchor,如果成功下方加上后面的单个字符再解码赋给x;失败则跳到catch
x += decodeURIComponent(nowAnchor)
// 如果成功解码,那么寻找map中是否有该 转义序列 对应的单个字符,如果有则拼接上
for(let [key,value] of map.entries()) {
// 这是对key的处理,因为如果key有多个重复值,会在后面加上index,要去掉
let newkey = ''
if(key.length == 4) {
newkey = key.slice(0,3);
} else {
newkey = key
}
// 如果key等于当前截取的部分,则说明找到了对应的单个字符
if(newkey === nowAnchor.slice(-3)) {
x += decodeURIComponent(value)
// 这是要截取的长度,初始值为3,因为indexOf一段字符串拿到的是字符串第一位的位置,而newkey的长度是3
let slicelen = 3
slicelen += value.length
// 匹配nowstr中的第一个key,然后在其后面加上value(为什么可以匹配第一个,因为map的遍历是有序的)
nowstr = nowstr.replace(new RegExp(newkey), (match) => match + value);
// nowstr将前面处理过的部分截取掉
nowstr = nowstr.slice(Number(nowstr.indexOf(newkey+value))+slicelen);
// 将已经添加过的单个字符从map中删除
map.delete(key);
// 匹配到一个就可以退出循环了
break;
} else {
// 如果map中没有对应的单个字符,也要删掉已经添加的nowAnchor
nowstr = nowstr.slice(nowAnchor.length);
// 记住要break,要不然就会一直被截取
break;
}
}
// 成功解码,证明当前start到end之间的字符串是有效的转义序列,两个都要下移
start = end
end = start + 3
} catch(e) {
// 如果解码失败,则end指针往下移动三位,继续循环尝试解码
end += 3
// 如果end与start相差超过12(因为最多只有4个转义序列),则证明start往下三位是一个无效的转义序列
if(end - start > 12) {
// 拿出这个无效的转义序列,并在map中寻找它后面有没有单个字符,如果有则拼接上
let sratrAnchor = copyNowstr.slice(start,start+3)
for(let [key,value] of map.entries()) {
// 这是对key的处理,因为如果key有多个重复值,会在后面加上index,要去掉
let newkey = ''
if(key.length == 4) {
newkey = key.slice(0,3);
} else {
newkey = key
}
// 如果key等于当前截取的部分,则说明找到了对应的单个字符
if(newkey === sratrAnchor.slice(-3)) {
// 这是要截取的长度,初始值为3,因为indexOf一段字符串拿到的是字符串第一位的位置,而newkey的长度是3
let slicelen = 3
slicelen += value.length
// 匹配nowstr中的第一个key,然后在其后面加上value(为什么可以匹配第一个,因为map的遍历是有序的)
nowstr = nowstr.replace(new RegExp(newkey), (match) => match + value);
// 结果字符串截取拼接上 已经完成解码并已添加其后字符的字符串
x += nowstr.slice(0, Number(nowstr.indexOf(newkey+value))+slicelen);
// nowstr将前面处理过的部分截取掉
nowstr = nowstr.slice(Number(nowstr.indexOf(newkey+value))+slicelen);
// 将已经添加过的单个字符从map中删除
map.delete(key);
// 匹配到一个就可以退出循环了
break;
}
}
// start往下移三位,end重置到start+3
start += 3
end = start + 3
}
}
}
// 如果循环结束了,还有剩余,证明剩下的都是无效转义序列
// 需要从前往后遍历,将所有无效转义序列(并查询是否有对应的单个字符)拼接起来
for(let i = 0;i < nowstr.length;i+=3) {
// 截取一个转义序列
let nowAnchor = nowstr.slice(i, i+3)
x += nowAnchor
for(let [key,value] of map.entries()) {
// 这是对key的处理,因为如果key有多个重复值,会在后面加上index,要去掉
let newkey = ''
if(key.length == 4) {
newkey = key.slice(0,3);
} else {
newkey = key
}
if(newkey === nowAnchor.slice(-3)) {
x += decodeURIComponent(value)
break;
}
}
}
// 遍历完成,清空nowstr退出循环
nowstr = ''
out += x;
}
return out;
}
- 上方这个函数是在
decodeURIComponent
时对手动输入的%
进行安全的处理 - 现在我们在
anchorChange
中对其进行应用-
function anchorChange() { // 获取的装载文章的 DOM 节点content. const postBody = document.getElementById('content'); // 拿到当前content内所有的a标签, 判断是否是本页面的跳转. postBody?.querySelectorAll("a").forEach((a: HTMLAnchorElement) => { // 跳转的页面 host 需和本页面一致, 并且带有 hash. if (a.hostname === window.location.hostname && !!a.hash) { // 为每一个符合本页跳转的a标签添加点击事件,组织默认跳转,用scrollIntoView实现跳转. a.addEventListener('click', e => { // 阻止默认跳转行为 e.preventDefault(); // markdown标题内容转id规则:前后空格去除,空格转-,特殊字符去除,大写字母转小写; const anchor = decodeURIComponentSafe(a.hash.replace('#','')) .trim() .replace(/\s/g, '-') .replace(/[\!\$\^\(\)\+\=\{\}\|\[\]\\\:\"\'\;\<\>\?\,\.\/\@\#\%……\&\*——\+\=“”‘’\~\`]/g, '') .toLowerCase(); if(!isNaN(+anchor[0])) { // 如果anchor开头是数字,则需要加转义符:\\3 ,否则querySelector会报错 let newAnchor = '\\3' + anchor[0] + ' ' + anchor.slice(1); postBody?.querySelector(`#${newAnchor}`)?.scrollIntoView({behavior: 'smooth'}); } else { postBody?.querySelector(`#${anchor}`)?.scrollIntoView({behavior: 'smooth'}); } }); } }) }
-
- 现在就是最终完全版啦