# 将Markdown代码块转换成可运行实例
> 栏目:前端分享,发布于:Tue Feb 25 2020 22:32:10 GMT+0800 (China Standard Time),最后更新于:Tue Aug 29 2023 16:16:06 GMT+0800 (China Standard Time)
> 本文标签:markdown,Web Components,custom elements,Shadow DOM,marked,highlight
> 本文地址:https://smohan.net/blog/xbbqk9
之前在文章中插入示例DEMO是通过异步加载[codepen](https://codepen.io/)来实现的,但是受制于网络条件,有时候加载缓慢,甚至无法加载。就想着将一些简单的示例直接在Markdown中写为HTML代码输出到前端页面运行,可是这样一来,不仅让Markdown失去了严谨性和优美性,而且如果不小心写了一个可能污染全局的样式或者脚本,那就翻船咯~
但是Markdown最终是要转换成HTML的,因此只要在Markdown的转换过程中做点手脚,再配合上`Shadow DOM`这个魔法盒子将示例代码运行在一个沙箱中,问题就能得到解决。
### 我要做什么
一图表达,就是将如图左侧从后台录入的Markdown中带有特殊标记`demo`的代码块部分转换成图中右侧的可执行示例。

### 为什么不能直接渲染
Markdown中是可以直接写HTML的,但考虑到我的使用场景是要在页面中插入一些**可运行**的代码(不是高亮的那种代码块)作为某个场景的示例DEMO,考虑如下Markdown片段:
```markdown
```
试想一下,这样一段Markdown直接转换为HTML在前端页面运行😱。好吧,我的页面已经变成红色并且所有内容被清空了~
### 实现思路
在Markdown编译过程中,提取某些有特定标识(如 `demo`)的代码块,如下图(这里只能用图了,不然就直接运行了):

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

因此,在这段代码被Markdown编译进`pre`标签之前,需要拦截并将其提取。HTML中,`template`和`
```
#### 解决Shadow DOM中脚本的运行问题
console脚本未执行的原因是通过`innerHTML`属性设置的`script`标签是不会执行的,这里需要对代码块中的script进行特殊处理。
```javascript
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`这样一个属性:
```javascript
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的部分:
```javascript
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
```demo
```
##### demo2
```demo
```

可以看到,虽然demo2的结构、ID和demo1完全相同,但是,无论样式和脚本(利用闭包)两者之间都是独立,互不影响。
### 参考文档
- [marked renderer](https://marked.js.org/#/USING_PRO.md#renderer)
- [Using_shadow_DOM](https://developer.mozilla.org/zh-CN/docs/Web/Web_Components/Using_shadow_DOM)