PrismJS源码解读 - 插件篇 - prism-line-number

296 阅读10分钟

!!!警告:本文中的例子依赖于博主自己的博客,如果希望查看细节可移步至 我的博客!!!

引言

总有人拿着火炬在黑夜里探索未知。 感谢@FFFF为我们介绍了markdown-it的源码细节,同时展示了vuepressmarkdown-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-itrender函数(你可以理解为一种插件,实际上插件是基于render来提供功能的),它会操作html元素被挂载前的字符串,因而限制了插件的功能,比如不能实现软换行。

prism的优点

prism同时也拥有自己的一套插件系统,它定义了一些hook,表示将代码渲染到页面上的生命周期。用户可以自己去定义生命周期,这里也存在一些内置的。 然而prism竟然在文档里没有提供内置生命周期的说明!这是我无法忍受的。我隐约听见了犹大的哪句名言:

为什么我要浪费自己陪家人的时间为你节省学英语的时间。

prism官方的

当然,要了解使用哪些钩子,您必须阅读 Prism 的源代码。

不能说很像只能说一模一样吧。

生命周期

Snipaste_2024-05-06_19-37-49.png

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-highlightcomplete中,解析后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-wrappre-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的通信手段之一。

写在最后

接下来我可能会去探索高亮插件的源码,希望持续追更我的文章。 希望我也可以能手握火把,探索黑夜。