之前在文章中插入示例DEMO是通过异步加载codepen来实现的,但是受制于网络条件,有时候加载缓慢,甚至无法加载。就想着将一些简单的示例直接在Markdown中写为HTML代码输出到前端页面运行,可是这样一来,不仅让Markdown失去了严谨性和优美性,而且如果不小心写了一个可能污染全局的样式或者脚本,那就翻船咯~
但是Markdown最终是要转换成HTML的,因此只要在Markdown的转换过程中做点手脚,再配合上Shadow DOM这个魔法盒子将示例代码运行在一个沙箱中,问题就能得到解决。

我要做什么

一图表达,就是将如图左侧从后台录入的Markdown中带有特殊标记demo的代码块部分转换成图中右侧的可执行示例。

Markdown代码块转可执行代码
Markdown代码块转可执行代码

为什么不能直接渲染

Markdown中是可以直接写HTML的,但考虑到我的使用场景是要在页面中插入一些可运行的代码(不是高亮的那种代码块)作为某个场景的示例DEMO,考虑如下Markdown片段:

<style>
  body {
    background: red;
  }
</style>
<script>
  document.body.innerHTML = ''
</script>  

试想一下,这样一段Markdown直接转换为HTML在前端页面运行😱。好吧,我的页面已经变成红色并且所有内容被清空了~

实现思路

在Markdown编译过程中,提取某些有特定标识(如 demo)的代码块,如下图(这里只能用图了,不然就直接运行了):

带有demo标识的代码块
带有demo标识的代码块

默认情况下,它被包裹在pre标签内,将以代码块的形式在页面上展示,而不会直接运行:
带有demo标识的代码块默认展示
带有demo标识的代码块默认展示

因此,在这段代码被Markdown编译进pre标签之前,需要拦截并将其提取。HTML中,template<script type="text/template">这样的标签是不能运行并且是不可见的,这里使用template标签将提取出来的待执行代码包裹起来。

最后再使用Web Components创建的自定义标签mo-demo-sandbox引用template的内容来填充影子DOM使其执行。

Shadow DOM

Web components 的一个重要属性是封装——可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起,可使代码更加干净、整洁。其中,Shadow DOM 接口是关键所在,它可以将一个隐藏的、独立的 DOM 附加到一个元素上。-- 引自MDN

我们经常使用的video/audio等标签内部就是Shadow DOM实现的。

自定义Markdown的编译

基于上述实现思路,第一步就是要拦截Markdown对带有特定标识的代码块的编译,我使用的是marked这个库来解析编译Markdown文件的,之前对代码块仅做了代码高亮的处理:

// 原有配置
marked.setOptions({
  // ...省略部分配置
  langPrefix: 'hljs ',
  highlight: (code, lang) => hljs.highlightAuto(code).value
})

highlight配置是无法完全拦截编译的,这里我通过重写render来实现:

// 去掉highlight相关配置
marked.setOptions({
  // ...省略部分配置
  // langPrefix: 'hljs ',
  // highlight: (code, lang) => hljs.highlightAuto(code).value
})

// 增加对`code`语法的render
const renderer = new marked.Renderer()

let DEMO_UID = 0

renderer.code = function (code, language) {
  // 提取language标识为 demo 的代码块重写
  if (language === 'demo') {
    // 页面中可能会有很多的示例,这里增加ID标识
    const id = 'demo-sandbox-template-' + (++DEMO_UID)
    // 将代码内容保存在template标签中
    const template = `<template type="text/demo" id="${id}">${code}</template>`
    // 将template和自定义标签通过ID关联
    const sandbox = `<mo-demo-sandbox template="${id}"></mo-demo-sandbox>`
    // 返回新的HTML
    return template + sandbox
  }

  // 其他标识的代码块依然使用代码高亮显示
  return `<pre rel="${language}"><code class="hljs ${language}">${hljs.highlightAuto(code).value}</code></pre>`
}

编译后的HTML如下图:

自定义render后的源码
自定义render后的源码

此时,页面中既不会展示Demo代码块,也不会展示mo-demo-sandbox这个自定义标签,因为它还没有实现。

使用自定义标签来实现代码的运行沙箱

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。-- 引自MDN

// 标签名称
const TAG_NAME = 'mo-demo-sandbox'
class DemoSandbox extends HTMLElement {
  constructor() {
    super()
    // 使用影子DOM
    this.shadow = this.attachShadow({
      mode: 'open'
    })
    // 获取关联的代码块模板的ID
    const templateId = this.getAttribute('template')
    const $template = document.getElementById(templateId)
    if (!templateId) {
      return
    }
    // 获取代码块内容
    const template = $template.innerHTML
    // 用获取到的代码块来填充影子DOM的HTML
    this.shadow.innerHTML = template
    // 移除掉关联的template节点
    $template.parentNode.removeChild($template)
    // todo 处理 script
  }
}

customElements.define(TAG_NAME, DemoSandbox)

此时,红色的"这是一个测试demo"文字已经渲染出来,但此时内置的脚本还未执行。

  <style>
    .demo1{
      color: red
   }
  </style>
  <span class="demo1">这是一个测试demo</span>
  <script>
     console.log('测试代码1', document.querySelector('.demo1'))
  </script>

解决Shadow DOM中脚本的运行问题

console脚本未执行的原因是通过innerHTML属性设置的script标签是不会执行的,这里需要对代码块中的script进行特殊处理。

class DemoSandbox extends HTMLElement {
  constructor() {
    // ... 省略上述代码
    // 用获取到的代码块来填充影子DOM的HTML
    this.shadow.innerHTML = template
    // 移除掉关联的template节点
    $template.parentNode.removeChild($template)
    // 处理 script
    // 1. 查找影子DOM中刚才填充的script节点
    const scripts = Array.from(this.shadow.querySelectorAll('script'))

    // 遍历节点
    scripts.forEach(script => {
      // 创建新的script节点
      const $script = document.createElement('script')
      // 设置内容为旧节点的内容
      $script.textContent = script.textContent
      // 移除旧节点
      this.shadow.removeChild(script)
      // 插入新节点
      this.shadow.appendChild($script)
    })
  }
}
// ...

这样,代码块中的Script标签就会执行了,此时会打印出测试代码1和null
页面上明明已经展示了红色的"这是一个测试demo",但为什么document.querySelector('.demo1')没有查找到demo1这个元素呢。这就是影子DOM这个魔法盒子的厉害之处,就像一个沙盒一样,它将其内部的DOM结构完全的封闭起来了,因此document是无法获取到这个节点的。

再次考虑我的使用场景,用来展示一些示例DEMO。而这些示例demo可能会涉及到对其DOM(该影子DOM内的节点)的操作。我们在创建this.shadow时传入了mode:open这样一个属性:

this.shadow = this.attachShadow({
  mode: 'open'
})

mode这个配置属性是用来是否支持在外部获取这个Shadow DOM的,设置为open将允许,设置为closed将返回null。因此只要先获取到这个Shadow DOM,就可以对其内部节点进行操作了。

  • 通过document.querySelector('mo-demo-sandbox[template="id"]') 来查找到自定义标签$component
  • 通过$component.shadowRoot来获取影子DOM的根节点shadow root
  • 最后通过shadow root来处理内部DOM操作

再次修改处理Script的部分:

const TAG_NAME = 'mo-demo-sandbox'
class DemoSandbox extends HTMLElement {
  constructor() {
    // ... 省略部分代码
    // 用获取到的代码块来填充影子DOM的HTML
    this.shadow.innerHTML = template
    // 移除掉关联的template节点
    $template.parentNode.removeChild($template)
    // 处理 script
    // 1. 查找影子DOM中刚才填充的script节点
    const scripts = Array.from(this.shadow.querySelectorAll('script'))

    // 2. 创建一个用来保存影子DOM根节点的Script
    const $globalDefines = document.createElement('script')
    // 3. 创建一个自执行函数,将代码包裹起来
    $globalDefines.innerHTML = `(function(){
      const $component = document.querySelector('${TAG_NAME}[template="${templateId}"]');
      const $shadowDocument = $component.shadowRoot;`
    // 4. 拼合所有Script
    scripts.forEach(script => {
      // 全局替换document为新的$shadowDocument
      $globalDefines.innerHTML += `{
         ${script.textContent.replace(/(document)\.(getElementById|querySelector|querySelectorAll|getElementsByClassName|getElementsByName|getElementsByTagName)/gm, '$shadowDocument.$2').replace(/\r\n?/gm, '')}
      }`
      // 移除旧节点
      this.shadow.removeChild(script)
    })
    $globalDefines.innerHTML += `})();`
    this.shadow.appendChild($globalDefines)
  }
}

至此,一个通过拦截并重写Markdown代码块渲染逻辑和利用自定义标签来让代码块转换为实例的功能已经完全实现,并且实例将安全的运行在沙箱中,并不会对全局造成污染。

真实DEMO体验

您可以通过审查元素的方式比较下面两个Demo的影子DOM结构

demo 1
<style>
  .demo-box {
    display: inline-flex;
    align-items: center;
  }
  #demo {
    display: inline-block;
    width: 100px;
    height: 100px;
    background-color: cornflowerblue;
    margin-right: 20px;
  }
  #demo.gradient {
    background-image: linear-gradient(90deg, #4ebbaa, #6bc30d);
  }
</style>
<div class="demo-box">
  <div id="demo"></div>
  <button id="toggle">点击切换背景</button>
</div>
<script>
  const $demo = document.getElementById('demo')
  document.getElementById('toggle').addEventListener('click', function () {
    $demo.classList.toggle('gradient')
  })
</script>
demo2
<style>
  .demo-box {
    display: inline-flex;
    align-items: center;
  }
  #demo {
    display: inline-block;
    width: 100px;
    height: 100px;
    background-color: pink;
    margin-right: 20px;
  }
  #demo.gradient {
    background: linear-gradient(to right, #f64f59, #c471ed, #12c2e9); 
  }
</style>
<div class="demo-box">
  <div id="demo"></div>
  <button id="toggle">点击切换背景</button>
</div>
<script>
  const $demo = document.getElementById('demo')
  document.getElementById('toggle').addEventListener('click', function () {
    $demo.classList.toggle('gradient')
  })
</script>

demo1和demo2源码比较
demo1和demo2源码比较

可以看到,虽然demo2的结构、ID和demo1完全相同,但是,无论样式和脚本(利用闭包)两者之间都是独立,互不影响。

参考文档

-- EOF --

本文标题:将Markdown代码块转换成可运行实例

本文链接:https://smohan.net/blog/xbbqk9

本站使用「 署名-非商业性使用 4.0 国际 (CC BY-NC 4.0) 」创作共享协议,转载或使用请署名并注明出处。 相关说明 »

评论 「 ... 」