头图

这是我关于PrimsJS的第二篇文章,不知道是机缘巧合或什么原因,我发现这个顺序非常合适,因为line-highlight中使用到了prism-line-number插件的相关功能,如果你还没看过的话请看这里

还记得上次的getLine方法吗?

getLine方法甚虽然被定义了,但是在代码中却没有被使用,所以忽略不计

也在这个插件里被使用了。所以,请阅读吧!

写在开头

在开头我想唠唠嗑,我在自己的博客解决了一个写作协同预览的bug,这花了我几个小时,虽然和这篇文章没什么关系,但间接导致我少了好几个小时来写作的时间。害。
闲话少说,我们开始吧。

总体实现思路

上一篇的缺点是,拆的太细了,导致没有一个总体观去带领读者阅读。这点也被吐槽了。所以在此改进。
总的来说,分为几个部分,一个是数据的获取,实现的方式,功能的扩展

数据获取

数据获取是通过类名来实现的,用一张表格说明问题

标签属性作用
predata-line需要高亮的行号
predata-line-offset行号偏移量
prelinkable-line-numbers将元素设置为可链接的行号

具体的作用可以查看官网介绍,在此不做赘述

实现方式

可以把fence(代码块)想象成一张图片,高亮插件就是往图片上添加了一层透明蒙版,实现高亮的效果,在代码层面,通过类名获取需要高亮的数据,蒙版元素(可能不止一个)的display属性为absolute,通过计算控制元素的topheight属性。
使用到的生命周期有before-sanity-checkcomplete
complete的作用和prism-line-number中差不多,调用插件的本身的函数进行高亮。before-sanity-check上一节已经结束是生命周期的入口,用vue的生命周期类比就是create,之所以在这个生命周期注册回调,是因为一些其他插件额外的调用高亮钩子,会导致高亮部分重叠,以及本身可能导致的一些副作用,因此要在这里进行清除。

功能扩展

除了高亮功能以外,插件还可以配合prism-line-number正确高亮软换行后的代码,以及实现可链接的行号,即pre设置一个id,通过点击带有对应for属性的标签,for=my-id.5-6,在这个例子中id为my-id的pre容器下面的第五行到第6行代码会被正确高亮。

缺点

prism虽然实现了完整的高亮功能,甚至提供了高亮行的class,如果我们能够自定义class就可以将其作为钩子使用任意样式。但是prism并没有暴露这个class,需要二次开发进行定义。

思考

对比vuepressmarkdown-it中的实现方式,prism脱离字符串操作的限制,得以扩展更多功能,并且能够适配行号插件。在markdown-it中,只能通过对行号和高亮元素限定想同的行高来实现匹配。

走进源码

同样的,限制于篇幅,完整代码在此不给出,可以去github上自行查看。
先介绍插件每个函数的定义再拆开来介绍功能,最后介绍在生命周期中插件是如何被使用的。
高亮插件由两个函数组成

/**
         * 对给定的pre高亮相应的行.
         *
         * 为了提高性能,函数分为dom测量(信息收集)和修改阶段。
         * 返回的函数在被调用时改变dom(显示效果).
         *
         * @param {HTMLElement} pre pre标签容器
         * @param {string | null} [lines] 包含行号信息的字符串
         * @param {string} [classes=''] 对高亮的行添加类名
         * @returns {() => void}
         */
        highlightLines: function highlightLines(pre, lines, classes)
/**
        网站的hash发送变化时的监听钩子。
        是实现可链接的行号的方法
        */
        function applyHash() 

highlightLines

这是最主要的方法,完整代码有100多行,在此不再给出,请到github上自行阅读

准备工作

    /* 必要的变量 */
    // 高亮的行号的行数信息,如果未给出将通过pre标签的data-line属性获取
    lines = typeof lines === 'string' ? lines : (pre.getAttribute('data-line') || '');
    // lines转化为数组,每一项代表一个高亮元素的行号信息
    var ranges = lines.replace(/\s+/g, '').split(',').filter(Boolean);
    // 行号的偏移量
    var offset = +pre.getAttribute('data-line-offset') || 0;
    // isLineHeightRounded是一个工具函数,判断浏览器计算行高的方式,以便正确计算行高。
    var parseMethod = isLineHeightRounded() ? parseInt : parseFloat;
    // 行高信息
    var lineHeight = parseMethod(getComputedStyle(pre).lineHeight);
    // 是否应用了linenumber插件
    var hasLineNumbers = Prism.util.isActive(pre, LINE_NUMBERS_CLASS);
    var codeElement = pre.querySelector('code');
    var parentElement = hasLineNumbers ? pre : codeElement || pre;
    // 由于这个函数不对dom进行操作,操作函数会被存放在这个数组里,返回的函数被调用时会依次执行数组里的函数
    var mutateActions = /** @type {(() => void)[]} */ ([]);
    // 两行中间的分隔符数量,用来计算行数
    var lineBreakMatch = codeElement.textContent.match(NEW_LINE_EXP);
    // 行数总量
    var numberOfLines = lineBreakMatch ? lineBreakMatch.length + 1 : 1;
    // pre和code标签之间的头部偏移距离,用来计算高亮的元素的top属性
    var codePreOffset = !codeElement || parentElement == codeElement ? 0 : getContentBoxTopOffset(pre, codeElement);
  • codePreOffset这个变量在源码里花了很大一段来介绍,总的来说就是某些插件或使用者会通过css等方法改变容器的样式,头部区域可能存在padding, 如果不额外计算,可能导致高亮元素的位置计算错误。

    ranges.forEach(function (currentRange) {
      var range = currentRange.split('-');
    
      var start = +range[0];
      var end = +range[1] || start;
      end = Math.min(numberOfLines + offset, end);
    
      if (end < start) {
          return;
      }
    
      /** @type {HTMLElement} */
      var line = pre.querySelector('.line-highlight[data-range="' + currentRange + '"]') || document.createElement('div');
    
      mutateActions.push(function () {
          line.setAttribute('aria-hidden', 'true');
          line.setAttribute('data-range', currentRange);
          line.className = (classes || '') + ' line-highlight';
      });
    
      // if the line-numbers plugin is enabled, then there is no reason for this plugin to display the line numbers
      if (hasLineNumbers && Prism.plugins.lineNumbers) {
          var startNode = Prism.plugins.lineNumbers.getLine(pre, start);
          var endNode = Prism.plugins.lineNumbers.getLine(pre, end);
    
          if (startNode) {
              var top = startNode.offsetTop + codePreOffset + 'px';
              mutateActions.push(function () {
                  line.style.top = top;
              });
          }
    
          if (endNode) {
              var height = (endNode.offsetTop - startNode.offsetTop) + endNode.offsetHeight + 'px';
              mutateActions.push(function () {
                  line.style.height = height;
              });
          }
      } else {
          mutateActions.push(function () {
              line.setAttribute('data-start', String(start));
    
              if (end > start) {
                  line.setAttribute('data-end', String(end));
              }
    
              line.style.top = (start - offset - 1) * lineHeight + codePreOffset + 'px';
    
              line.textContent = new Array(end - start + 2).join(' \n');
          });
      }
    
      mutateActions.push(function () {
          line.style.width = pre.scrollWidth + 'px';
      });
    
      mutateActions.push(function () {
          // allow this to play nicely with the line-numbers plugin
          // need to attack to pre as when line-numbers is enabled, the code tag is relatively which screws up the positioning
          parentElement.appendChild(line);
      });
    });

    这段代码对行号进行遍历,因为行号的表达有以下几种类型

    表达说明
    5第五行
    1-5第一行到第五行
    1,4第一行和第四行

以及考虑到可能的行号便宜,所以第二行到第十行代码是计算相关的行号获得开始行号(start)结束行号(end),因为行号元素是连续的。
接着添加了类名为.line-highlight[data-range="' + currentRange + '"]的元素来作为当前行号的容器。
注意到这里没有将容器元素添加进dom中去,正如我们之前所说的,所有对dom的操作都被push进mutateActions这个数组中,在返回函数中调用。
接下来为这个容器添加属性。

  • aria-hidden,辅助阅读不可见的,因为行号对盲人读者是不重要的信息。
  • data-range行号的标记。
  • 添加自定义的类名

计算位置

if (hasLineNumbers && Prism.plugins.lineNumbers) {
    var startNode = Prism.plugins.lineNumbers.getLine(pre, start);
    var endNode = Prism.plugins.lineNumbers.getLine(pre, end);

    if (startNode) {
        var top = startNode.offsetTop + codePreOffset + 'px';
        mutateActions.push(function () {
            line.style.top = top;
        });
    }

    if (endNode) {
        var height = (endNode.offsetTop - startNode.offsetTop) + endNode.offsetHeight + 'px';
        mutateActions.push(function () {
            line.style.height = height;
        });
    }
} else {
    mutateActions.push(function () {
        line.setAttribute('data-start', String(start));

        if (end > start) {
            line.setAttribute('data-end', String(end));
        }

        line.style.top = (start - offset - 1) * lineHeight + codePreOffset + 'px';

        line.textContent = new Array(end - start + 2).join(' \n');
    });
}

这里有一个if判断,之所以存在是因为假如行号插件是启用的,我们可以通过行号的位置来计算高亮元素的位置。正如上一节说的一样,行号插件为我们提供了一个方法getLine,通过提供下标来获取对应的行号元素。
这里获取到了第一个元素和第最后一个元素的位置,据此通过第一个元素计算top属性的值,通过第一个元素和第二个元素计算height属性的值。
在行号未应用的情况下,通过模拟文本(换行符),设置对应的行高来实现。
这两种方法实现的效果应该是相同的,因为我们上一节提到,行号元素的高度是模拟display元素的高度获得的,而display元素的高度在大多数情况下即为line-height大小,这一点mdn的信息可以证明。

line-height CSS 属性用于设置多行元素的空间量,如多行文本的间距。对于块级元素,它指定元素行盒(line boxes)的最小高度。对于非替代的 inline 元素,它用于计算行盒(line box)的高度


最后将所有的元素添加进容器。
这里有一段描述
// allow this to play nicely with the line-numbers plugin

                // need to attack to pre as when line-numbers is enabled, the code tag is relatively which screws up the positioning
// allow this to play nicely with the line-numbers plugin// need to attack to pre as when line-numbers is enabled, the code tag is relatively which screws up the positioningl
//允许它与行号插件很好地发挥作用需要挂载到pre,因为当行号被启用时,代码标签是相对的,它会打乱位置

其实不难理解,因为我们的top的计算里包含了pre到code标签的高度,假如挂载到code标签下,而行号插件开启时code标签是relative的,我们的高亮插件的top就会相对code布局了,故应当添加在pre标签下。

反思

在后面生命周期的代码里,函数被使用了不止一次,如果函数不返回mutateActions而是直接在函数里调用对dom的修改的话,每次调用函数都要计算以便高亮元素的信息。返回一个函数能避免重复信息的计算。因此这个函数主要是计算信息而非调用修改dom。

applyHash

这是一个扩展功能,以实现可链接的行号功能。启用方式也很简单,就是在pre标签上面添加上相应的类名linkable-line-numbers

function applyHash() {
    var hash = location.hash.slice(1);

    // Remove pre-existing temporary lines
    $$('.temporary.line-highlight').forEach(function (line) {
        line.parentNode.removeChild(line);
    });

    var range = (hash.match(/\.([\d,-]+)$/) || [, ''])[1];

    if (!range || document.getElementById(hash)) {
        return;
    }

    var id = hash.slice(0, hash.lastIndexOf('.'));
    var pre = document.getElementById(id);

    if (!pre) {
        return;
    }

    if (!pre.hasAttribute('data-line')) {
        pre.setAttribute('data-line', '');
    }

    var mutateDom = Prism.plugins.lineHighlight.highlightLines(pre, range, 'temporary ');
    mutateDom();

    if (scrollIntoView) {
        document.querySelector('.temporary.line-highlight').scrollIntoView();
    }
}

先获取hash(去掉#),hash内容就是我们pre的id值跟上行号信息,$$是一个工具函数,通过querySelectorAll找到对应的元素,这里的.temporary.line-highlight是可链接的行号元素的类名,.temporary特别标记可链接的行号元素。
获取行号信息。hash的形式例如#play.1-5,意思是找到id为play的pre标签,高亮下面的第一行到第五行代码
Prism.plugins.lineHighlight.highlightLines(pre, range, 'temporary ');对对应的行号进行高亮,这里的temporary即验证了.temporary特别标记可链接的行号元素。
scrollIntoView是用来指定当链接行号被激活时,是否允许页面移动到对应位置。不幸的是,这个变量也是一个死数据,即用户不能自定义,如果想改变行为,要在源码里改动。

生命周期里调用

正如总体实现思路里说的那样,这里使用到了before-sanity-checkcomplete两个生命周期。

Prism.hooks.add('before-sanity-check', function (env) {
    var pre = env.element.parentElement;
    if (!isActiveFor(pre)) {
        return;
    }

    /*
     * Cleanup for other plugins (e.g. autoloader).
     *
     * Sometimes <code> blocks are highlighted multiple times. It is necessary
     * to cleanup any left-over tags, because the whitespace inside of the <div>
     * tags change the content of the <code> tag.
     */
    var num = 0;
    $$('.line-highlight', pre).forEach(function (line) {
        num += line.textContent.length;
        line.parentNode.removeChild(line);
    });
    // Remove extra whitespace
    if (num && /^(?: \n)+$/.test(env.code.slice(-num))) {
        env.code = env.code.slice(0, -num);
    }
});

Prism.hooks.add('complete', function completeHook(env) {
    var pre = env.element.parentElement;
    if (!isActiveFor(pre)) {
        return;
    }

    clearTimeout(fakeTimer);

    var hasLineNumbers = Prism.plugins.lineNumbers;
    var isLineNumbersLoaded = env.plugins && env.plugins.lineNumbers;

    if (hasClass(pre, LINE_NUMBERS_CLASS) && hasLineNumbers && !isLineNumbersLoaded) {
        Prism.hooks.add('line-numbers', completeHook);
    } else {
        var mutateDom = Prism.plugins.lineHighlight.highlightLines(pre);
        mutateDom();
        fakeTimer = setTimeout(applyHash, 1);
    }
});

之所以在before-sanity-check这个生命周期注册回调,是因为一些其他插件额外的调用高亮钩子,会导致高亮部分重叠,以及本身可能导致的一些副作用,因此要在这里进行清除。
complete里面fakeTimer的应用主要是防止页面自动跳转到链接元素对应位置,这个行为由scrollIntoView进行控制。
将元素的的调用在line-number这个hook里又重新注册了一遍的目的是让元素的调用在line-number插件之后执行line-numberhook是由行号插件定义的。

写在最后

我打算开一个新坑,关于codemirror的,这是一个编辑器,不过我不算很精通。我发现我写博文能激励我去深挖其知识,开新坑的目的也是如此。
等我消息吧!我还会继续结束prismjs,请持续关注我!


张小仙人
12 声望0 粉丝

我是大睡个