这是我关于PrimsJS的第二篇文章,不知道是机缘巧合或什么原因,我发现这个顺序非常合适,因为line-highlight中使用到了prism-line-number
插件的相关功能,如果你还没看过的话请看这里
还记得上次的getLine方法吗?
getLine方法甚虽然被定义了,但是在代码中却没有被使用,所以忽略不计
也在这个插件里被使用了。所以,请阅读吧!
写在开头
在开头我想唠唠嗑,我在自己的博客解决了一个写作协同预览的bug,这花了我几个小时,虽然和这篇文章没什么关系,但间接导致我少了好几个小时来写作的时间。害。
闲话少说,我们开始吧。
总体实现思路
上一篇的缺点是,拆的太细了,导致没有一个总体观去带领读者阅读。这点也被吐槽了。所以在此改进。
总的来说,分为几个部分,一个是数据的获取,实现的方式,功能的扩展
数据获取
数据获取是通过类名来实现的,用一张表格说明问题
标签 | 属性 | 作用 |
---|---|---|
pre | data-line | 需要高亮的行号 |
pre | data-line-offset | 行号偏移量 |
pre | linkable-line-numbers | 将元素设置为可链接的行号 |
具体的作用可以查看官网介绍,在此不做赘述
实现方式
可以把fence(代码块)想象成一张图片,高亮插件就是往图片上添加了一层透明蒙版,实现高亮的效果,在代码层面,通过类名获取需要高亮的数据,蒙版元素(可能不止一个)的display属性为absolute
,通过计算控制元素的top和height属性。
使用到的生命周期有before-sanity-check
和complete
。
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,需要二次开发进行定义。
思考
对比vuepress
在markdown-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-check
和complete
两个生命周期。
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-number
hook是由行号插件定义的。
写在最后
我打算开一个新坑,关于codemirror的,这是一个编辑器,不过我不算很精通。我发现我写博文能激励我去深挖其知识,开新坑的目的也是如此。
等我消息吧!我还会继续结束prismjs,请持续关注我!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。