在hash模式下markdown锚点跳转

585 阅读9分钟

「什么是锚点」

  • 锚点是一种超级链接,能快速将访问者带到指定位置。
  • 在 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-routerreact-router等等。
  • 而有时候为了兼容一些情况, 例如: 旧浏览器(IE 8/9)markdown跳转等. 我们会使用 Hash 的路由模式来实现前端路由.
  • 这种时候, 因为 hash 被路由占据了, 锚点功能就会和路由冲突.
  • 比如在一个组件库文档中用了hashRouter,这个时候要使用markdown中的锚点跳转改变了hash,就会跳转到404页面 正常url.png 错误url.png

「解决思路」

  • 锚点跳转的实现原理是 在点击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:渲染后.png
    • 我们可以看到,标题是被渲染为以内容为id的标签
    • 而下面的引用语法则是被渲染为href值为括号内 内容的a标签
      • 其中a标签的href会 将某些字符的每个实例替换为表示字符的UTF-8编码的一个、两个、三个或四个转义序列
  • 我们再举一个例子:
    •  # Button按钮
      
       [跳转到Button按钮](#Button按钮)
      
    • 我们会发现无法跳转了,让我们再来看看渲染后的html:渲染后.png
    • 观察上图,我们可以发现,标题在将内容转化为id的时候,把大写字母变小写了。所以,标题内容转id并不是完全把内容复制过去的

「markdown标题内容 转ID 的规则」

  1. 大写字母转小写
    • 比如:# BUTTON
    • 会转化成:<h1 id="button">BUTTON</h1>
  2. 前后空格去除
    • 比如:# BUTTON
    • 会转化成:<h1 id="button">BUTTON</h1>
  3. 内容中间的空格会转化成 -
    • 比如:# BUTTON 按钮
    • 会转化成:<h1 id="button--按钮">BUTTON 按钮</h1>
  4. 特殊字符去除
    • 以下特殊字符会被去除:

      • !$^()+={}|[]:"';<>?,./@#%……&*——+=“”‘’~`
        
    • 比如:# BUTTON 按钮;<>?,

    • 会被转化成:<h1 id="button--按钮">BUTTON 按钮;&lt;&gt;?,</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.querySelectordocument.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'});
                    }
                });
              }
          })
        }
      
  • 现在就是最终完全版啦