杨建萍 發表於 2026-2-5 19:39:00

利用自定义html元素实现支持实时修改的高亮代码块

<h1 id="利用自定义html元素实现支持实时修改的高亮代码块">利用自定义html元素实现支持实时修改的高亮代码块</h1>
<p>代码块高亮是前端开发中常见的需求,尤其是在展示代码片段的博客、文档等场景中。市面上有很多成熟的代码高亮库,比如<code>Highlight.js</code>、<code>Prism.js</code>等,它们都能很好地实现代码高亮功能。</p>
<p>通常的高亮代码块是“静态”的,修改代码内容后需要对DOM元素重新应用高亮样式。由于涉及DOM操作,在Vue等前端框架中使用必须谨慎处理,否则会出现DOM树和虚拟DOM不一致的问题,造成很多麻烦。</p>
<p>那么有没有办法让代码高亮不改变DOM结构呢?答案是有的,我们可以利用自定义HTML元素和Shadow DOM来实现这一点。</p>
<h3 id="shadow-dom和自定义html元素">Shadow DOM和自定义HTML元素</h3>
<p>Shadow DOM允许我们创建封闭的DOM树,Shadow DOM内可以使用自己的样式,并封装复杂的逻辑,而不会影响到外部的DOM结构。现代浏览器的<code>&lt;input&gt;</code>(特别是<code>&lt;input type="range"&gt;</code>、<code>&lt;input type="date"&gt;</code>等复杂控件)元素就是利用Shadow DOM实现的。</p>
<p>要想使用Shadow DOM,我们需要创建一个自定义HTML元素,并在其中通过<code>attachShadow</code>方法创建Shadow DOM。</p>
<pre><code class="language-js">class MyElement extends HTMLElement {
    constructor() {
      super()
      const shadow = this.attachShadow({ mode: 'open' })
      shadow.innerHTML = `&lt;p&gt;Hello, Shadow DOM!&lt;/p&gt;`
    }
}
customElements.define('my-element', MyElement)
</code></pre>
<p>之后,我们就可以在HTML中使用<code>&lt;my-element&gt;&lt;/my-element&gt;</code>来插入这个自定义元素。</p>
<pre><code class="language-html">&lt;my-element&gt;&lt;/my-element&gt;
</code></pre>
<p>在DevTools中,我们可以看到<code>&lt;my-element&gt;</code>的渲染结果,其中包括元素内部的Shadow DOM:</p>
<pre><code class="language-html">&lt;my-element&gt;
#shadow-root (open)
    &lt;p&gt;Hello, Shadow DOM!&lt;/p&gt;
&lt;/my-element&gt;
</code></pre>
<h3 id="在自定义元素中获取内容">在自定义元素中获取内容</h3>
<p>我们希望在自定义元素中获取标签之间的内容。这可以通过插槽(slot)机制实现。插槽机制允许我们在自定义元素中定义占位符,外部传入的内容会被插入到这些占位符中。</p>
<p>为了使用插槽,我们需要在Shadow DOM中添加一个<code>&lt;slot&gt;</code>元素:</p>
<pre><code class="language-js">class MyElement extends HTMLElement {
    constructor() {
      super()
      const shadow = this.attachShadow({ mode: 'open' })
      shadow.innerHTML = `&lt;slot&gt;&lt;/slot&gt;`
      const slot = shadow.querySelector('slot')
      slot.addEventListener('slotchange', this.handleSlotChange.bind(this))
    }
    handleSlotChange(event) {
      const slot = event.target
      console.log('Slot content changed:', slot.assignedNodes({ flatten: true }))
    }
}
customElements.define('my-element', MyElement)
</code></pre>
<p>对于HTML片段</p>
<pre><code class="language-html">&lt;my-element id="my-el"&gt;&lt;p&gt;This is slotted content.&lt;/p&gt;&lt;/my-element&gt;
</code></pre>
<p>当页面第一次加载时,控制台会显示</p>
<pre><code>Slot content changed:&nbsp;
</code></pre>
<p>其中<code>p</code>就是元素内部的<code>&lt;p&gt;</code>节点。</p>
<p>如果我们动态修改<code>&lt;my-element&gt;</code>内的内容,比如通过JavaScript:</p>
<pre><code class="language-js">document.getElementById('my-el').innerHTML = '&lt;pre&gt;New slotted content1.&lt;/pre&gt;&lt;pre&gt;New slotted content2.&lt;/pre&gt;'
</code></pre>
<p>控制台会显示</p>
<pre><code>Slot content changed: (2)&nbsp;
</code></pre>
<p>两个<code>pre</code>节点就是我们新修改的内容。</p>
<p>通过这种方法,我们可以在自定义元素中实时获取内容的变化。</p>
<h3 id="利用自定义元素实现高亮代码块">利用自定义元素实现高亮代码块</h3>
<p>结合前面的内容,我们可以创建一个自定义元素<code>&lt;pre-highlight&gt;</code>,用于实现高亮代码块的功能。只需要监听插槽内容的变化,将内容传递给高亮库进行处理,然后将处理后的结果显示出来即可。</p>
<pre><code class="language-js">class PreHighlightElement extends HTMLElement {
    constructor() {
      super()
      const shadow = this.attachShadow({ mode: 'open' })
      shadow.innerHTML = `
&lt;link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css"&gt;
&lt;pre id="code"&gt;&lt;/pre&gt;
&lt;pre hidden&gt;&lt;slot&gt;&lt;/slot&gt;&lt;/pre&gt;
`
      this.__code = this.shadowRoot.querySelector('#code')
      this.__slot = this.shadowRoot.querySelector('slot')
      this.__slot.addEventListener('slotchange', this.highlightContent.bind(this))
    }

    highlightContent() {
      if (typeof hljs === 'undefined') return

      let text = this.__slot.assignedNodes({ flatten: true }).map(n =&gt; n.textContent).join("")
      const code = document.createElement('code')

      const result = hljs.highlightAuto(text)
      code.innerHTML = result.value
      if (result.language) code.classList.add(`language-${result.language}`)
      this.__code.replaceChildren(code)
    }
}

customElements.define('pre-highlight', PreHighlightElement)
</code></pre>
<p>使用方法:</p>
<pre><code class="language-html">&lt;pre-highlight id="my-el"&gt;
function helloWorld() {
    console.log("Hello, world!")
}
&lt;/pre-highlight&gt;
</code></pre>
<p>渲染结果为</p>
<p><img src="https://img2024.cnblogs.com/blog/3748561/202603/3748561-20260331111253659-364367536.png"></p>
<p>修改<code>&lt;pre-highlight&gt;</code>内的内容后,高亮效果会自动更新。</p>
<pre><code class="language-js">document.getElementById('my-el').textContent = `void helloWorld(void) {
    printf("Hello, World!");
}`
</code></pre>
<p>渲染结果为</p>
<p><img src="https://img2024.cnblogs.com/blog/3748561/202603/3748561-20260331111323752-939086380.png"></p>
<h3 id="一些改进">一些改进</h3>
<p>为了避免高亮库加载和高亮处理过程中的闪烁,我们可以在Shadow DOM中使用两个<code>&lt;pre&gt;</code>元素:一个用于显示原始内容,另一个用于显示高亮后的内容。初始时只显示原始内容,高亮处理完成后再切换显示。</p>
<p>此外,我们还可以添加一个<code>lang</code>属性,允许用户指定代码语言,以提高高亮的准确性。</p>
<p>最终结果如下:</p>
<pre><code class="language-js">class PreHighlightElement extends HTMLElement {
    constructor() {
      super()
      const shadow = this.attachShadow({ mode: 'open' })
      shadow.innerHTML = `
&lt;link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css"&gt;
&lt;pre id="raw"&gt;&lt;slot&gt;&lt;/slot&gt;&lt;/pre&gt;
&lt;pre id="cooked" hidden&gt;&lt;/pre&gt;
`
      this.__raw = this.shadowRoot.querySelector('#raw')
      this.__cooked = this.shadowRoot.querySelector('#cooked')
      this.__slot = this.shadowRoot.querySelector('slot')
      this.__slot.addEventListener('slotchange', this.highlightContent.bind(this))
    }

    highlightContent() {
      this.__raw.hidden = false
      this.__cooked.hidden = true
      if (typeof hljs === 'undefined') return

      let text = this.__slot.assignedNodes({ flatten: true }).map(n =&gt; n.textContent).join("")
      const lang = this.getAttribute('lang')
      const code = document.createElement('code')

      if (lang) {
            const result = hljs.highlight(text, { language: lang, ignoreIllegals: true })
            code.innerHTML = result.value
            code.classList.add(`language-${lang}`)
      } else {
            const result = hljs.highlightAuto(text)
            code.innerHTML = result.value
            if (result.language) code.classList.add(`language-${result.language}`)
      }
      this.__cooked.replaceChildren(code)
      this.__raw.hidden = true
      this.__cooked.hidden = false
    }
}

customElements.define('pre-highlight', PreHighlightElement)
</code></pre>
<p>用例:</p>
<pre><code class="language-html">&lt;pre-highlight id="code" lang="html"&gt;&lt;/pre-highlight&gt;
&lt;input type="range" id="input" value="10" /&gt;
&lt;script&gt;
    const input = document.getElementById('input')
    const preHighlight = document.getElementById('code')

    input.oninput = function(e) {
      preHighlight.textContent = `&lt;textarea rows="${this.value}" cols="50"&gt;
    Hello, world!
&lt;/textarea&gt;`
    }
    input.oninput()
&lt;/script&gt;
</code></pre>
<p>在这个例子中,我们创建了一个滑动条,可以动态修改<code>&lt;pre-highlight&gt;</code>内的代码内容,内容修改后会实时显示高亮效果。</p>
<p><img src="https://img2024.cnblogs.com/blog/3748561/202603/3748561-20260331112326139-1584255507.png"></p>
<h3 id="在vue中使用pre-highlight">在Vue中使用<code>&lt;pre-highlight&gt;</code></h3>
<p>通过自定义元素的方法,我们可以轻松地在Vue项目中使用高亮代码块,而无需担心DOM和虚拟DOM的不一致问题。</p>
<p>为了避免自定义元素和Vue组件名冲突,我们需要在配置中制定<code>isCustomElement</code>选项:</p>
<pre><code class="language-js">// vite.config.js
export default defineConfig({
plugins: [
    vue({
      template: {
      compilerOptions: {
          // 将所有含"-"的标签视为自定义元素
          // Vue3中通常使用帕斯卡命名法(单词首字母大写)作为组件标签
          isCustomElement: (tag) =&gt; tag.includes('-')
      }
      }
    })
]
})
</code></pre>
<p>之后就可以在组件或页面中直接使用<code>&lt;pre-highlight&gt;</code>元素,内部可以使用Vue的数据绑定而不用担心虚拟DOM冲突的问题:</p>
<pre><code class="language-html">&lt;template&gt;
    &lt;pre-highlight lang="javascript"&gt;
function greet({{arg}}) {
    console.log("Hello, " + {{arg}} + "!")
}
    &lt;/pre-highlight&gt;
&lt;/template&gt;
</code></pre>
<h3 id="附完整的单页html演示代码">附:完整的单页html演示代码</h3>
<details>
<summary>原生html</summary>
<pre><code class="language-html">&lt;html&gt;

&lt;head&gt;
    &lt;script src="https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js"&gt;&lt;/script&gt;
    &lt;script&gt;
      class PreHighlightElement extends HTMLElement {
            constructor() {
                super()
                const shadow = this.attachShadow({ mode: 'open' })
                shadow.innerHTML = `
&lt;link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css"&gt;
&lt;pre id="raw"&gt;&lt;slot&gt;&lt;/slot&gt;&lt;/pre&gt;
&lt;pre id="cooked" hidden&gt;&lt;/pre&gt;
`
                this.__raw = this.shadowRoot.querySelector('#raw')
                this.__cooked = this.shadowRoot.querySelector('#cooked')
                this.__slot = this.shadowRoot.querySelector('slot')
                this.__slot.addEventListener('slotchange', this.highlightContent.bind(this))
            }

            highlightContent() {
                this.__raw.hidden = false
                this.__cooked.hidden = true
                if (typeof hljs === 'undefined') return

                let text = this.__slot.assignedNodes({ flatten: true }).map(n =&gt; n.textContent).join("")
                const lang = this.getAttribute('lang')
                const code = document.createElement('code')

                if (lang) {
                  const result = hljs.highlight(text, { language: lang, ignoreIllegals: true })
                  code.innerHTML = result.value
                  code.classList.add(`language-${lang}`)
                } else {
                  const result = hljs.highlightAuto(text)
                  code.innerHTML = result.value
                  if (result.language) code.classList.add(`language-${result.language}`)
                }
                this.__cooked.replaceChildren(code)
                this.__raw.hidden = true
                this.__cooked.hidden = false
            }
      }

      customElements.define('pre-highlight', PreHighlightElement)
    &lt;/script&gt;
&lt;/head&gt;

&lt;body&gt;
    &lt;pre-highlight id="code" lang="javascript"&gt;&lt;/pre-highlight&gt;
    &lt;input type="range" id="input" value="10" /&gt;
    &lt;script&gt;
      const input = document.getElementById('input')
      const preHighlight = document.getElementById('code')

      input.oninput = function (e) {
            preHighlight.textContent = `let a = ${this.value}`
      }
      input.oninput()
    &lt;/script&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre>
</details>
<details>
<summary>使用Vue</summary>
<pre><code class="language-html">&lt;html&gt;

&lt;head&gt;
    &lt;script src="https://unpkg.com/vue@3/dist/vue.global.js"&gt;&lt;/script&gt;
    &lt;script src="https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js"&gt;&lt;/script&gt;
    &lt;script&gt;
      class PreHighlightElement extends HTMLElement {
            constructor() {
                super()
                const shadow = this.attachShadow({ mode: 'open' })
                shadow.innerHTML = `
&lt;link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css"&gt;
&lt;pre id="raw"&gt;&lt;slot&gt;&lt;/slot&gt;&lt;/pre&gt;
&lt;pre id="cooked" hidden&gt;&lt;/pre&gt;
`
                this.__raw = this.shadowRoot.querySelector('#raw')
                this.__cooked = this.shadowRoot.querySelector('#cooked')
                this.__slot = this.shadowRoot.querySelector('slot')
                this.__slot.addEventListener('slotchange', this.highlightContent.bind(this))
            }

            highlightContent() {
                this.__raw.hidden = false
                this.__cooked.hidden = true
                if (typeof hljs === 'undefined') return

                let text = this.__slot.assignedNodes({ flatten: true }).map(n =&gt; n.textContent).join("")
                const lang = this.getAttribute('lang')
                const code = document.createElement('code')

                if (lang) {
                  const result = hljs.highlight(text, { language: lang, ignoreIllegals: true })
                  code.innerHTML = result.value
                  code.classList.add(`language-${lang}`)
                } else {
                  const result = hljs.highlightAuto(text)
                  code.innerHTML = result.value
                  if (result.language) code.classList.add(`language-${result.language}`)
                }
                this.__cooked.replaceChildren(code)
                this.__raw.hidden = true
                this.__cooked.hidden = false
            }
      }

      customElements.define('pre-highlight', PreHighlightElement)
    &lt;/script&gt;
&lt;/head&gt;

&lt;body&gt;
    &lt;div id="app"&gt;&lt;/div&gt;
    &lt;script&gt;
      const { createApp, ref } = Vue
      let app = createApp({
            data() {
                return { a: ref(10) }
            },
            template: `&lt;pre-highlight id="code" lang="javascript"&gt;let a = \{\{a\}\}&lt;/pre-highlight&gt;
            &lt;input type="range" v-model="a" /&gt;`
      })
      app.config.compilerOptions.isCustomElement = (tag) =&gt; tag.includes('-')
      app.mount('#app')
    &lt;/script&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre>
</details>
<p>渲染效果:</p>
<p><img src="https://img2024.cnblogs.com/blog/3748561/202602/3748561-20260205193714336-1370494181.gif"></p><br><br>
来源:https://www.cnblogs.com/Fan-iX/p/-/shadow-dom-code-highlight
頁: [1]
查看完整版本: 利用自定义html元素实现支持实时修改的高亮代码块