!!!警告:本文中的例子依赖于博主自己的博客,如果希望查看细节可移步至 我的博客!!!
引言
总有人拿着火炬在黑夜里探索未知。
感谢@FFFF
为我们介绍了markdown-it
的源码细节,同时展示了vuepress
中markdown-it
的应用。这深深刺激和鼓舞了我。
——谨以此篇献给像@FFFF
一样的先驱者。
介绍
写作本篇文章的契机是在制作博客的fence(代码块)
换行插件的时候,发现了一些问题,由于bug已经改进,无法重现。
if (parent && parent.nodeName.toLowerCase() === 'pre' && !parent.hasAttribute('tabindex')) {
parent.setAttribute('tabindex', '0');
}
请先确认右上角换行按钮处于开启的状态(深色),接着你应该很容易注意到每个行号不仅仅只占据自己的一行,它包括了换行在内的所有行。在prism
中称作软换行。但是vuepress
的原生插件并没有实现类似的功能。偶然的契机让我发现了prism
的这个特性,毫不犹豫的选择替换掉vuepress
。(I’m sorry)
vuepress的局限
上面已经介绍了vuepress
插件不能实现软换行的功能,当强制换行时,总行数会大于有限的行号。这实际上是vuepress
插件的实现方式决定的。
vuepress
是基于markdown-it
的render
函数(你可以理解为一种插件,实际上插件是基于render
来提供功能的),它会操作html元素被挂载前的字符串,因而限制了插件的功能,比如不能实现软换行。
prism的优点
prism
同时也拥有自己的一套插件系统,它定义了一些hook
,表示将代码渲染到页面上的生命周期。用户可以自己去定义生命周期,这里也存在一些内置的。
然而prism
竟然在文档里没有提供内置生命周期的说明!这是我无法忍受的。我隐约听见了犹大的哪句名言:
为什么我要浪费自己陪家人的时间为你节省学英语的时间。
prism官方的
当然,要了解使用哪些钩子,您必须阅读 Prism 的源代码。
不能说很像只能说一模一样吧。
生命周期
prism
的内置生命周期大致有
- before-sanity-check
- before-highlight
- before-insert
- after-highlight
- complete
具体的流程关系在上图已经给出,在prism
的hook的回调函数中,我们会获得一个env对象,在内置的函数中,它记录了一下信息来辅助我们开发
var env = {
element: element, // 包裹code的element元素,有事它是code本身。
language: language, // 语言字符串,比如在当前fence里面,它表示js
grammar: grammar, // 暂时不会,hh
code: code, // code的原来的contentText
highlightedCode: highlightedCode; // 解析后的html字符串
};
值得注意的是highlightedCode
属性只有在after-highlight
生命周期后(包括)才会拥有。
具体的源码在prism-code.js
的562行可以查看。
总之我们今天的主角就是通过了complete这个hook来实现相关功能,after-highlight
和complete
中,解析后html已经挂载到元素上,我们可以进行更多的操控权,超越markdown-it
。(无限鞭尸ing)
你可能会疑惑prism的hook是如何工作的,官方文档已经给出很详细的描述,在此不做赘述。
进入源码
粗看功能
为了节约篇幅,完整代码在此不再放出,有兴趣可以去github上查看完整代码
Prism.plugins.lineNumbers
包含一系列方法,这些方法一起构成了其功能。
/**
* 通过指定下标的span元素(代表某一行)
*
* @param {Element} element pre元素(包裹容器)
* @param {number} number 行号
* @returns {Element|undefined} 返回span元素或undefined
*/
getLine: function (element, number)
/**
* 调整给定元素(行号)的高度,通常在窗体变化时调用
*
* @param {HTMLElement[]} elements 由pre元素组成的数组
*/
function resizeElements(elements)
/**
* 为指定的pre元素调整其行号元素的高度
*
* 这个方法不会添加行号,它只会调整给定的唯一的pre元素的行号大小
*
* @param {HTMLElement} element 一个pre元素(容器)
* @returns {void}
*/
resize: function (element) {
resizeElements([element]);
},
还存在着许多的工具方法,在此不一一赘述,getLine方法甚虽然被定义了,但是在代码中却没有被使用,所以忽略不计,resize
的本质实际上是调用了resizeElements
函数,只是将给定的element包装成数组给到resizeElements
。可以看到resizeElements是插件的核心,本文主要介绍其原理。
/* 源码中resize的定义 */
resize: function (element) {
resizeElements([element]);
},
关键函数 resizeElements
/** resizeElements的定义
* Resizes the given elements.
*
* @param {HTMLElement[]} elements
*/
function resizeElements(elements) {
elements = elements.filter(function (e) {
var codeStyles = getStyles(e);
var whiteSpace = codeStyles['white-space'];
return whiteSpace === 'pre-wrap' || whiteSpace === 'pre-line';
});
if (elements.length == 0) {
return;
}
var infos = elements.map(function (element) {
var codeElement = element.querySelector('code');
var lineNumbersWrapper = element.querySelector('.line-numbers-rows');
if (!codeElement || !lineNumbersWrapper) {
return undefined;
}
/** @type {HTMLElement} */
var lineNumberSizer = element.querySelector('.line-numbers-sizer');
var codeLines = codeElement.textContent.split(NEW_LINE_EXP);
if (!lineNumberSizer) {
lineNumberSizer = document.createElement('span');
lineNumberSizer.className = 'line-numbers-sizer';
codeElement.appendChild(lineNumberSizer);
}
lineNumberSizer.innerHTML = '0';
lineNumberSizer.style.display = 'block';
var oneLinerHeight = lineNumberSizer.getBoundingClientRect().height;
lineNumberSizer.innerHTML = '';
return {
element: element,
lines: codeLines,
lineHeights: [],
oneLinerHeight: oneLinerHeight,
sizer: lineNumberSizer,
};
}).filter(Boolean);
infos.forEach(function (info) {
var lineNumberSizer = info.sizer;
var lines = info.lines;
var lineHeights = info.lineHeights;
var oneLinerHeight = info.oneLinerHeight;
lineHeights[lines.length - 1] = undefined;
lines.forEach(function (line, index) {
if (line && line.length > 1) {
var e = lineNumberSizer.appendChild(document.createElement('span'));
e.style.display = 'block';
e.textContent = line;
} else {
lineHeights[index] = oneLinerHeight;
}
});
});
infos.forEach(function (info) {
var lineNumberSizer = info.sizer;
var lineHeights = info.lineHeights;
var childIndex = 0;
for (var i = 0; i < lineHeights.length; i++) {
if (lineHeights[i] === undefined) {
lineHeights[i] = lineNumberSizer.children[childIndex++].getBoundingClientRect().height;
}
}
});
infos.forEach(function (info) {
var lineNumberSizer = info.sizer;
var wrapper = info.element.querySelector('.line-numbers-rows');
lineNumberSizer.style.display = 'none';
lineNumberSizer.innerHTML = '';
info.lineHeights.forEach(function (height, lineNumber) {
wrapper.children[lineNumber].style.height = height + 'px';
});
});
}
可以看到resizeElements
由一个个循环组成,且这些方法的循环是线性的,下一个代码会继承上一个代码的副作用。
codeStyles
是个工具函数,获取pre元素的white-space
属性,筛出
white-space
属性满足等于pre-wrap
或pre-line
的pre容器。
附一段mdn对white-space的描述:
pre-wrap
连续的空白符会被保留。在遇到换行符或
元素时,或者根据填充行框盒子的需要换行。
pre-line
连续的空白符会被合并。在遇到换行符或
元素时,或者根据填充行框盒子的需要换行。
可见这两个属性会导致软换行
这里的逻辑和prism在介绍这个插件时也一致
要使用软换行支持多行行号,请应用 CSS 或所需的 .white-space: pre-line;white-space: pre-wrap;
信息收集
var infos = elements.map(function (element) {
var codeElement = element.querySelector('code');
var lineNumbersWrapper = element.querySelector('.line-numbers-rows');
if (!codeElement || !lineNumbersWrapper) {
return undefined;
}
/** @type {HTMLElement} */
var lineNumberSizer = element.querySelector('.line-numbers-sizer');
var codeLines = codeElement.textContent.split(NEW_LINE_EXP);
if (!lineNumberSizer) {
lineNumberSizer = document.createElement('span');
lineNumberSizer.className = 'line-numbers-sizer';
codeElement.appendChild(lineNumberSizer);
}
lineNumberSizer.innerHTML = '0';
lineNumberSizer.style.display = 'block';
var oneLinerHeight = lineNumberSizer.getBoundingClientRect().height;
lineNumberSizer.innerHTML = '';
return {
element: element,
lines: codeLines,
lineHeights: [],
oneLinerHeight: oneLinerHeight,
sizer: lineNumberSizer,
};
}).filter(Boolean);
现在介绍第一个循环的作用,先看整体
elements.map(func).filter(Boolean)
这里filter的作用是对每个元素执行Boolean函数,如果前一个map后返回undefined则结果为假,就会被筛掉,否则保留。
再看代码内部,函数最终返回了一个对象,里面记录了每个pre的一系列信息。
{
element: element,
lines: codeLines,
lineHeights: [],
oneLinerHeight: oneLinerHeight,
sizer: lineNumberSizer,
};
element不难理解,就是我们的pre元素,lineHeights暂时为空,后续会存储每个行号的高度信息lines是通过codeElement.textContent.split(NEW_LINE_EXP);
获得的,codeElement就是code元素,NEW_LINE_EXP是正则
var NEW_LINE_EXP = /\n(?!$)/g;
表示匹配除了最后一个换行符的所有换行符,lines就是每一行所包含的代码文本。
sizer是一个被添加进code元素里的span.line-numbers-sizer
标签,它的display为block,contentText为0,是为了模拟单行span。
oneLinerHeight是sizer的高度,是模拟单行高度。
这里有一个非常巧妙的点。首先我们知道,经过prism解析的html标签并非会给每个token(文本元素)用span包裹起来,因此我们设置每一行的高度通常是通过设置文本的line-height来实现的,从而确保每一行的元素一样高。
然后此时span的display为inline,故不包含当前line-height的高度。但是了解每一行的高度对于计算我们的行号高度是很重要的,如何实现呢?下面展示一段mdn关于line-height的描述
line-height CSS 属性用于设置多行元素的空间量,如多行文本的间距。对于块级元素,它指定元素行盒(line boxes)的最小高度。对于非替代的 inline 元素,它用于计算行盒(line box)的高度
简单来说,如果我们想要获得line-height效果下的高度,就需要通过一个块级元素来获取,prism就是这样做的,它将sizer
设置为块级元素从而获取一行中line-height效果下的高度。
有了每个pre容器的信息就可以进入下一步了。
计算行高
infos.forEach(function (info) {
var lineNumberSizer = info.sizer;
var lines = info.lines;
var lineHeights = info.lineHeights;
var oneLinerHeight = info.oneLinerHeight;
lineHeights[lines.length - 1] = undefined;
lines.forEach(function (line, index) {
if (line && line.length > 1) {
var e = lineNumberSizer.appendChild(document.createElement('span'));
e.style.display = 'block';
e.textContent = line;
} else {
lineHeights[index] = oneLinerHeight;
}
});
});
lineHeights[lines.length - 1] = undefined;
定义了行数一样多的数组。
接着遍历lines(每行的文本信息),如果存在且长度大于1,则添加进lineNumberSizer这个span中。
其他情况则记录lineHeights对应下标的值为oneLinerHeight
,即一行的高度,这是因为只有单个字符或空字符时认为绝对不可能存在换行的情况,故很容易的记录了当前行号的高度。
这一步连同下一步其实就是为了获取每行的高度,接下来还要对无法判定是否换行的元素进行行高计算。
infos.forEach(function (info) {
var lineNumberSizer = info.sizer;
var lineHeights = info.lineHeights;
var childIndex = 0;
for (var i = 0; i < lineHeights.length; i++) {
if (lineHeights[i] === undefined) {
lineHeights[i] = lineNumberSizer.children[childIndex++].getBoundingClientRect().height;
}
}
});
实际上这里就很简单了,将无法计算行高的元素插入,然后读取其高度,注意上面的代码里面有两句代码很精辟
e.style.display = 'block';
e.textContent = line;
第一个已经解释过了,是为了获取line-height影响下的高度
第二个是将当前行内容给textContent。
其实就是去模拟当前行,但是因为是块级元素,就可以获得当前行的高度了。
赋予高度
infos.forEach(function (info) {
var lineNumberSizer = info.sizer;
var wrapper = info.element.querySelector('.line-numbers-rows');
lineNumberSizer.style.display = 'none';
lineNumberSizer.innerHTML = '';
info.lineHeights.forEach(function (height, lineNumber) {
wrapper.children[lineNumber].style.height = height + 'px';
});
});
这段代码做的是一个清理的工作,lineNumberSizer下面的元素只是模拟的,为了获取每行高度并记录在lineHeights数组中,真正的行号元素是在.line-numbers-rows
这个元素下面的。
调用
Prism.hooks.add('complete', function (env) {
if (!env.code) {
return;
}
var code = /** @type {Element} */ (env.element);
var pre = /** @type {HTMLElement} */ (code.parentNode);
// works only for <code> wrapped inside <pre> (not inline)
if (!pre || !/pre/i.test(pre.nodeName)) {
return;
}
// Abort if line numbers already exists
if (code.querySelector('.line-numbers-rows')) {
return;
}
// only add line numbers if <code> or one of its ancestors has the `line-numbers` class
if (!Prism.util.isActive(code, PLUGIN_NAME)) {
return;
}
// Remove the class 'line-numbers' from the <code>
code.classList.remove(PLUGIN_NAME);
// Add the class 'line-numbers' to the <pre>
pre.classList.add(PLUGIN_NAME);
var match = env.code.match(NEW_LINE_EXP);
var linesNum = match ? match.length + 1 : 1;
var lineNumbersWrapper;
var lines = new Array(linesNum + 1).join('<span></span>');
lineNumbersWrapper = document.createElement('span');
lineNumbersWrapper.setAttribute('aria-hidden', 'true');
lineNumbersWrapper.className = 'line-numbers-rows';
lineNumbersWrapper.innerHTML = lines;
if (pre.hasAttribute('data-start')) {
pre.style.counterReset = 'linenumber ' + (parseInt(pre.getAttribute('data-start'), 10) - 1);
}
env.element.appendChild(lineNumbersWrapper);
resizeElements([pre]);
Prism.hooks.run('line-numbers', env);
});
Prism.hooks.add('line-numbers', function (env) {
env.plugins = env.plugins || {};
env.plugins.lineNumbers = true;
});
介绍完了插件的所有功能,到了调用时刻了。
前面介绍了lineNumberSizer下面的元素只是模拟的,故这里会插入真正的行号元素,linesNum
记录了行号总数,lineNumbersWrapper
即行号的容器,env.element.appendChild(lineNumbersWrapper);
再将容器添加进code元素(前面已经给出env的属性),最后调用resizeElements对行号的高度进行计算。
这里的'line-numbers'
作为hook的作用是标记env的plugins.lineNumbers
为true,表示lineNumber这个插件被成功的调用了,是env的通信手段之一。
写在最后
接下来我可能会去探索高亮插件的源码,希望持续追更我的文章。
希望我也可以能手握火把,探索黑夜。