在 Markdown 中使用 Shadow DOM 构建模式库
已发表: 2022-03-10我使用桌面文字处理器的典型工作流程是这样的:
- 选择一些我想复制到文档另一部分的文本。
- 请注意,应用程序的选择比我告诉它的要多或少。
- 再试一次。
- 放弃并决定稍后添加我预期选择的缺失部分(或删除额外部分)。
- 复制并粘贴选择。
- 请注意,粘贴文本的格式与原始文本有些不同。
- 尝试找到与原始文本匹配的样式预设。
- 尝试应用预设。
- 放弃并手动应用字体系列和大小。
- 注意粘贴文本上方的空白太多,按“Backspace”关闭空白。
- 请注意,有问题的文本一次提升了几行,加入了上面的标题文本并采用了它的样式。
- 思考我的死亡。
在编写技术网络文档(阅读:模式库)时,文字处理器不仅不听话,而且不合适。 理想情况下,我想要一种允许我包含内联文档的组件的编写模式,除非文档本身由 HTML、CSS 和 JavaScript 组成,否则这是不可能的。 在本文中,我将分享一种在短代码和影子 DOM 封装的帮助下,在 Markdown 中轻松包含代码演示的方法。

CSS 和 Markdown
说出你对 CSS 的看法,但它肯定是比市场上任何 WYSIWYG 编辑器或文字处理器更一致和可靠的排版工具。 为什么? 因为没有高级黑盒算法试图猜测你真正打算去哪里的风格。 相反,它非常明确:您定义哪些元素在哪些情况下采用哪些样式,并且它遵守这些规则。
CSS 唯一的问题是它需要你编写它的对应物 HTML。 即使是 HTML 的伟大爱好者也可能会承认,当您只想制作散文内容时,手动编写它是一项艰巨的任务。 这就是 Markdown 的用武之地。凭借其简洁的语法和减少的功能集,它提供了一种易于学习的编写模式,但一旦以编程方式转换为 HTML,仍然可以利用 CSS 强大且可预测的排版功能。 它成为静态网站生成器和现代博客平台(如 Ghost)的实际格式是有原因的。
如果需要更复杂的定制标记,大多数 Markdown 解析器将接受输入中的原始 HTML。 然而,一个人越依赖于复杂的标记,一个人的创作系统就越难被那些技术性较差的人或那些缺乏时间和耐心的人所接受。 这就是短代码的用武之地。
Hugo 中的简码
Hugo 是一个用 Go 编写的静态站点生成器,Go 是一种由 Google 开发的多用途编译语言。 由于并发性(毫无疑问,还有我不完全理解的其他低级语言特性),Go 使 Hugo 成为了静态 Web 内容的快速生成器。 这是雨果被选为新版 Smashing Magazine 的众多原因之一。
除了性能之外,它的工作方式与您可能已经熟悉的基于 Ruby 和 Node.js 的生成器类似:通过模板处理的 Markdown 和元数据(YAML 或 TOML)。 Sara Soueidan 写了一篇关于 Hugo 核心功能的优秀入门书。
对我来说,Hugo 的杀手级功能是它的简码实现。 那些来自 WordPress 的人可能已经熟悉这个概念:一种缩短的语法,主要用于包含第三方服务的复杂嵌入代码。 例如,WordPress 包含一个 Vimeo 短代码,它只获取相关 Vimeo 视频的 ID。
[vimeo 44633289]
方括号表示其内容应作为短代码处理,并在解析内容时扩展为完整的 HTML 嵌入标记。
利用 Go 模板函数,Hugo 提供了一个非常简单的 API 来创建自定义简码。 例如,我创建了一个简单的 Codepen 短代码以包含在我的 Markdown 内容中:
Some Markdown content before the shortcode. Aliquam sodales rhoncus dui, sed congue velit semper ut. Class aptent taciti sociosqu ad litora torquent. {{<codePen VpVNKW>}} Some Markdown content after the shortcode. Nulla vel magna sit amet dui lobortis commodo vitae vel nulla sit amet ante hendrerit tempus.
Hugo 会在编译期间自动在shortcodes
子文件夹中查找名为codePen.html
的模板来解析 shortcode。 我的实现如下所示:
{{ if .Site.Params.codePenUser }} <iframe height='300' scrolling='no' title="code demonstration with codePen" src='//codepen.io/{{ .Site.Params.codepenUser | lower }}/embed/{{ .Get 0 }}/?height=265&theme-id=dark&default-tab=result,result&embed-version=2' frameborder='no' allowtransparency='true' allowfullscreen='true'> <div> <a href="//codepen.io/{{ .Site.Params.codePenUser | lower }}/pen/{{ .Get 0 }}">See the demo on codePen</a> </div> </iframe> {{ else }} <p class="site-error"><strong>Site error:</strong> The <code>codePenUser</code> param has not been set in <code>config.toml</code></p> {{ end }}
为了更好地了解 Go 模板包的工作原理,您需要参考 Hugo 的“Go Template Primer”。 同时,请注意以下事项:
- 它非常丑陋但功能强大。
-
{{ .Get 0 }}
部分用于检索提供的第一个(在本例中是唯一的)参数——Codepen ID。 Hugo 还支持命名参数,它们像 HTML 属性一样提供。 -
.
语法是指当前上下文。 因此,.Get 0
表示“获取为当前简码提供的第一个参数”。
无论如何,我认为短代码是自脆饼以来最好的东西,而 Hugo 编写自定义短代码的实现令人印象深刻。 我应该从我的研究中注意到,可以使用 Jekyll 包含来达到类似的效果,但我发现它们不太灵活和强大。
没有第三方的代码演示
我有很多时间使用 Codepen(以及其他可用的代码游乐场),但是在模式库中包含此类内容存在固有问题:
- 它使用 API,因此无法轻松或有效地离线工作。
- 它不仅仅代表模式或组件; 它是自己的复杂界面,包裹在自己的品牌中。 当焦点应该放在组件上时,这会产生不必要的噪音和干扰。
有一段时间,我尝试使用自己的 iframe 嵌入组件演示。 我会将 iframe 指向包含演示的本地文件作为其自己的网页。 通过使用 iframe,我能够在不依赖第三方的情况下封装样式和行为。
不幸的是,iframe 相当笨重且难以动态调整大小。 就创作复杂性而言,它还需要维护单独的文件并必须链接到它们。 我更愿意将我的组件编写到位,包括使它们工作所需的代码。 我希望能够在编写文档时编写演示。
demo
简码
幸运的是,Hugo 允许您创建包含开始和结束简码标签之间内容的简码。 使用{{ .Inner }}
可以在短代码文件中找到内容。 所以,假设我要使用这样的demo
短代码:
{{<demo>}} This is the content! {{</demo>}}
“这就是内容!” 将在解析它的demo.html
模板中以{{ .Inner }}
的形式提供。 这是支持内联代码演示的一个很好的起点,但我需要解决封装问题。
样式封装
在封装样式方面,需要担心三件事:
- 组件从父页面继承的样式,
- 从组件继承样式的父页面,
- 样式在组件之间无意共享。
一种解决方案是仔细管理 CSS 选择器,以便组件之间以及组件与页面之间没有重叠。 这意味着每个组件都使用深奥的选择器,而当我可以编写简洁易读的代码时,我不会有兴趣考虑。 iframe 的优点之一是默认情况下会封装样式,因此我可以编写button { background: blue }
并确信它只会应用在 iframe 内。
防止组件从页面继承样式的一种不太密集的方法是在选定的父元素上使用带有initial
值的all
属性。 我可以在demo.html
文件中设置这个元素:
<div class="demo"> {{ .Inner }} </div>
然后,我需要将all: initial
应用于此元素的实例,该实例会传播到每个实例的子代。
.demo { all: initial }
initial
的行为是相当……特殊的。 在实践中,所有受影响的元素都会重新采用它们的用户代理样式(例如display: block
for <h2>
元素)。 但是,应用它的元素class=“demo”
——需要明确恢复某些用户代理样式。 在我们的例子中,这只是display: block
,因为class=“demo”
是一个<div>
。
.demo { all: initial; display: block; }
注意:到目前为止,Microsoft Edge 不支持all
,但正在考虑中。 否则,支持范围非常广泛。 出于我们的目的, revert
值会更加健壮和可靠,但它尚未在任何地方得到支持。
Shadow DOM'ing 简码
使用all: initial
不会使我们的内联组件完全不受外部影响(特异性仍然适用),但我们可以确信样式未设置,因为我们正在处理保留的demo
类名称。 大多数只是从低特异性选择器(如html
和body
)继承的样式将被消除。
尽管如此,这只处理从父级到组件的样式。 为了防止为组件编写的样式影响页面的其他部分,我们需要使用 shadow DOM 来创建封装的子树。
想象一下,我想记录一个样式化的<button>
元素。 我希望能够简单地编写如下内容,而不必担心button
元素选择器将应用于模式库本身或同一库页面中的其他组件中的<button>
元素。

{{<demo>}} <button>My button</button> <style> button { background: blue; padding: 0.5rem 1rem; text-transform: uppercase; } </style> {{</demo>}}
诀窍是获取短代码模板的{{ .Inner }}
部分并将其作为新ShadowRoot
的innerHTML
包含在内。 我可能会这样实现:
{{ $uniq := .Inner | htmlEscape | base64Encode | truncate 15 "" }} <div class="demo"></div> <script> (function() { var root = document.getElementById('demo-{{ $uniq }}'); root.attachShadow({mode: 'open'}); root.innerHTML = '{{ .Inner }}'; })(); </script>
-
$uniq
被设置为一个变量来标识组件容器。 它在一些 Go 模板函数中通过管道创建一个唯一的字符串……希望(!)——这不是一个防弹的方法; 这只是为了说明。 -
root.attachShadow
使组件容器成为影子 DOM 宿主。 - 我使用
{{ .Inner }}
填充ShadowRoot
的innerHTML
,其中包括现在封装的 CSS。
允许 JavaScript 行为
我还想在我的组件中包含 JavaScript 行为。 起初,我认为这很容易; 不幸的是,通过innerHTML
插入的 JavaScript 不会被解析或执行。 这可以通过从<template>
元素的内容导入来解决。 我相应地修改了我的实现。
{{ $uniq := .Inner | htmlEscape | base64Encode | truncate 15 "" }} <div class="demo"></div> <template> {{ .Inner }} </template> <script> (function() { var root = document.getElementById('demo-{{ $uniq }}'); root.attachShadow({mode: 'open'}); var template = document.getElementById('template-{{ $uniq }}'); root.shadowRoot.appendChild(document.importNode(template.content, true)); })(); </script>
现在,我可以包含一个内联演示,比如一个工作切换按钮:
{{<demo>}} <button>My button</button> <style> button { background: blue; padding: 0.5rem 1rem; text-transform: uppercase; } [aria-pressed="true"] { box-shadow: inset 0 0 5px #000; } </style> <script> var toggle = document.querySelector('[aria-pressed]'); toggle.addEventListener('click', (e) => { let pressed = e.target.getAttribute('aria-pressed') === 'true'; e.target.setAttribute('aria-pressed', !pressed); }); </script> {{</demo>}}
注意:我已经深入地写过关于包容性组件的切换按钮和可访问性。
JavaScript 封装
令我惊讶的是,JavaScript 并没有像 CSS 在 shadow DOM 中那样自动封装。 也就是说,如果在此组件的示例之前的父页面中有另一个[aria-pressed]
按钮,则document.querySelector
将改为定位该按钮。
我需要的只是演示子树的document
。 这是可以定义的,尽管非常冗长:
document.getElementById('demo-{{ $uniq }}').shadowRoot;
每当我必须针对演示容器中的元素时,我都不想编写这个表达式。 因此,我想出了一个技巧,我将表达式分配给本地demo
变量,并通过带有此分配的短代码提供的前缀脚本:
if (script) { script.textContent = `(function() { var demo = document.getElementById(\'demo-{{ $uniq }}\').shadowRoot; ${script.textContent} })()` } root.shadowRoot.appendChild(document.importNode(template.content, true));
有了这个, demo
就相当于任何组件子树的document
,我可以使用demo.querySelector
轻松定位我的切换按钮。
var toggle = demo.querySelector('[aria-pressed]');
请注意,我已将演示的脚本内容包含在立即调用的函数表达式 (IIFE) 中,因此demo
变量——以及用于组件的所有执行变量——不在全局范围内。 这样, demo
可以在任何简码的脚本中使用,但只会引用手头的简码。
在 ECMAScript6 可用的地方,可以使用“块作用域”实现本地化,只需用大括号括住let
或const
语句。 但是,块中的所有其他定义也必须使用let
或const
(避开var
)。
{ let demo = document.getElementById(\'demo-{{ $uniq }}\').shadowRoot; // Author script injected here }
影子 DOM 支持
当然,只有在支持 shadow DOM 版本 1 的情况下才能实现上述所有操作。 Chrome、Safari、Opera 和 Android 看起来都不错,但 Firefox 和微软浏览器有问题。 在attachShadow
不可用的情况下,可以检测功能并提供错误消息:
if (document.head.attachShadow) { // Do shadow DOM stuff here } else { root.innerHTML = 'Shadow DOM is needed to display encapsulated demos. The browser does not have an issue with the demo code itself'; }
或者,您可以包含 Shady DOM 和 Shady CSS 扩展,这意味着一个较大的依赖项 (60 KB+) 和不同的 API。 Rob Dodson 非常友好地为我提供了一个基本演示,我很乐意分享它以帮助您入门。
组件的标题
有了基本的内联演示功能,快速编写与其文档内联的工作演示非常简单。 这使我们能够提出诸如“如果我想提供标题来标记演示怎么办?”之类的问题。 这是完全可能的,因为——如前所述——Markdown 支持原始 HTML。
<figure role="group" aria-labelledby="caption-button"> {{<demo>}} <button>My button</button> <style> button { background: blue; padding: 0.5rem 1rem; text-transform: uppercase; } </style> {{</demo>}} <figcaption>A standard button</figcaption> </figure>
然而,这个修改后的结构中唯一的新部分是标题本身的措辞。 最好提供一个简单的接口来将其提供给输出,从而节省我未来的自己——以及其他使用简码的人——的时间和精力,并降低编码错误的风险。 这可以通过为短代码提供一个命名参数来实现——在这种情况下,简单地命名为caption
:
{{<demo caption="A standard button">}} ... demo contents here... {{</demo>}}
命名参数可以在{{ .Get "caption" }}
之类的模板中访问,这很简单。 我希望标题以及周围的<figure>
和<figcaption>
是可选的。 使用if
子句,我只能在短代码提供标题参数的情况下提供相关内容:
{{ if .Get "caption" }} <figcaption>{{ .Get "caption" }}</figcaption> {{ end }}
下面是完整的demo.html
模板现在的样子(诚然,它有点乱,但它可以解决问题):
{{ $uniq := .Inner | htmlEscape | base64Encode | truncate 15 "" }} {{ if .Get "caption" }} <figure role="group" aria-labelledby="caption-{{ $uniq }}"> {{ end }} <div class="demo"></div> {{ if .Get "caption" }} <figcaption>{{ .Get "caption" }}</figcaption> {{ end }} {{ if .Get "caption" }} </figure> {{ end }} <template> {{ .Inner }} </template> <script> (function() { var root = document.getElementById('demo-{{ $uniq }}'); root.attachShadow({mode: 'open'}); var template = document.getElementById('template-{{ $uniq }}'); var script = template.content.querySelector('script'); if (script) { script.textContent = `(function() { var demo = document.getElementById(\'demo-{{ $uniq }}\').shadowRoot; ${script.textContent} })()` } root.shadowRoot.appendChild(document.importNode(template.content, true)); })(); </script>
最后一点:如果我想在标题值中支持 markdown 语法,我可以通过 Hugo 的markdownify
函数来管道它。 通过这种方式,作者能够提供降价(和 HTML),但也不会被迫这样做。
{{ .Get "caption" | markdownify }}
结论
就其性能和许多出色的功能而言,Hugo 目前在静态站点生成方面非常适合我。 但我认为最引人注目的是包含短代码。 在这种情况下,我能够为我一直试图解决一段时间的文档问题创建一个简单的界面。
与 Web 组件一样,许多标记复杂性(有时会因调整可访问性而加剧)可以隐藏在短代码后面。 在这种情况下,我指的是我包含了role="group"
和aria-labelledby
关系,它为<figure>
提供了更好的支持“组标签”——不是任何人都喜欢编码不止一次的东西,尤其是在每个实例中都需要考虑唯一的属性值。
我相信短代码之于 Markdown 和内容就像 Web 组件之于 HTML 和功能:一种使作者身份更容易、更可靠和更一致的方法。 我期待在这个奇怪的网络小领域进一步发展。
资源
- 雨果文档
- “包模板”,Go 编程语言
- “简码”,雨果
- “all”(CSS 简写属性),Mozilla 开发者网络
- “初始(CSS 关键字),Mozilla 开发者网络
- “Shadow DOM v1:自包含的 Web 组件”,Eric Bidelman,Web Fundamentals,Google Developers
- “模板元素简介”,Eiji Kitamura,WebComponents.org
- “包括,”杰基尔