使用 Shiki.js 對網頁上的程式碼進行著色

使用 Shiki.js 對網頁上的程式碼進行著色
程式技術

前陣子突然又心血來潮,將自己部落格的程式碼著色工具從 Highlight.js 換成 Shiki.js

雖然 Highlight.js 使用上並沒有什麼問題,我甚至還使用它提供的 API,幫自己寫了 Blade 與 HCL 的程式碼著色套件。Highlight.js 比較可惜的點是目前幫忙維護的人並不多,而且 Shiki.js 支援的語法、主題、還有功能都比 Highlight.js 多得多,在經過不怎麼激烈的思想鬥爭之後,就決定改為使用 Shiki.js 了。

Shiki.js 的作者是 Vue Core Team 的 Anthony Fu 大大,背後使用的是與 VS Code 相同的程式碼著色引擎 TextMate,所以著色效果非常好,而且還支援明暗主題切換。

簡單介紹 Shiki.js 的用法

Highlight.js 本身提供相當方便的函式,讓你可以直接對頁面上的包含程式碼的 <pre> 元素進行著色:

  • hljs.highlightAll()
  • hljs.highlightElement(element)

但 Shiki.js 並沒有提供類似的 API,只有一個簡單的 codeToHtml。傳一組字串進去,並指定語言和主題,它就會回傳著色後的 HTML 字串。

import { codeToHtml } from 'shiki'

const code = 'const a = 1' // input code
const html = await codeToHtml(code, {
  lang: 'typescript',
   theme: 'one-dark-pro',
})

console.log(html) // highlighted html string

渲染後的 html 字串如下:

<pre class="shiki one-dark-pro shiki-highlighted" style="background-color:#282c34;color:#abb2bf" tabindex="0">
  <code>
    <span class="line">
      <span style="color:#C678DD">const</span>
      <span style="color:#E5C07B"> a</span>
      <span style="color:#56B6C2"> =</span>
      <span style="color:#D19A66"> 1</span>
    </span>
  </code>
</pre>

可以看到 Shiki.js 是使用 Inline CSS 對程式碼進行著色,所以不需要再額外引入主題的 CSS 檔案。

了解了該怎麼使用 Shiki.js 後,就可以開始來寫扣了!

開始動工

使用 NPM 安裝 Shiki.js 套件。

npm install -D shiki

我們需要建立一個 Highlighter 實體。

import { createHighlighter, type Highlighter } from 'shiki';

let highlighter: Highlighter | null = null;

async function getHighlighter(): Promise<Highlighter> {
    if (!highlighter) {
        highlighter = await createHighlighter({
            // 指定會使用到的程式語言
            langs: ['javascript', 'typescript', 'php', ...languages],
            // 引入主題
            themes: ['one-dark-pro'],
        });
    }

    return highlighter;
}

// ...

Shiki.js 的 Best Performance Practices 中有提到,Highlighter 實體的建立是很耗資源的,所以建立後就應該要留著,並在之後的著色操作中反覆使用(單例模式)。

寫一個 highlightElement 函式,用來對包含程式碼的 <pre> 元素進行著色。

// ...

async function highlightElement(
    preElement: HTMLPreElement,
    highlighter: Highlighter,
) {
    // 避免重複著色
    if (preElement.classList.contains('shiki-highlighted')) {
        return;
    }

    // 取得 <code> 元素
    const codeElement = preElement.querySelector('code');

    if (!codeElement) {
        return;
    }

    // 取得 <code> 元素上標注的語言類型
    const langClass = Array.from(codeElement.classList).find((c) =>
        c.startsWith('language-'),
    );
    const lang = langClass ? langClass.replace('language-', '') : 'text';
    // 取得程式碼
    const code = codeElement.innerText;

    try {
        // 使用 highlighter 對程式碼進行著色
        const html = highlighter.codeToHtml(code, {
            lang,
            theme: 'one-dark-pro',
        });

        const template = document.createElement('template');
        template.innerHTML = html.trim();
        const pre = template.content.firstChild as HTMLElement;

        // 將原本的 <pre> 元素替換成著色後的程式碼 <pre> 元素
        preElement.replaceWith(pre);
        // 添加標記,避免重複著色
        pre.classList.add('shiki-highlighted');
    } catch (e) {
        console.warn(`Failed to highlight language: ${lang}`, e);
        // Fallback or ignore
    }
}

利用剛剛寫好的 highlightElement 函式,寫一個 highlightAllInElement 函式,用來對指定元素下的所有包含程式碼的 <pre> 元素進行著色。

async function highlightAllInElement(htmlElement: HTMLElement): Promise<void> {
    const highlighter = await getHighlighter();

    let preElements = htmlElement.querySelectorAll(
        'pre:not(.shiki-highlighted)',
    ) as NodeListOf<HTMLPreElement>;

    for (const preElement of preElements) {
        await highlightElement(preElement, highlighter);
    }
}

寫好了就可以將 highlightAllInElement 函式掛到 window 上,方便之後使用。

declare global {
    interface Window {
        highlightAllInElement: (element: HTMLElement) => Promise<void>;
    }
}

window.highlightAllInElement = highlightAllInElement;
<script>
    window.highlightAllInElement(document.body);
</script>

根據網頁的明亮/暗黑模式來切換 Shiki.js 的主題

Shiki.js 支援明亮與暗黑主題的切換,你可以根據據網頁當前的模式來使用不同的著色主題。

createHighlighter 中,我們可以一次引入兩種主題。

highlighter = await createHighlighter({
    langs: ['javascript', 'typescript', 'php', ...languages],
    // 引入多種主題
    themes: ['one-light', 'one-dark-pro'],
});

在著色前設定明亮與暗黑模式下分別要使用的主題。

const html = highlighter.codeToHtml(code, {
    lang,
    // 設定在明亮與暗黑模式下要使用的主題
    themes: {
        light: 'one-light',
        dark: 'one-dark-pro',
    },
});

如果你跟我一樣,是依據 <html>data-theme 屬性來切換網頁模式 ,接下來只要在網頁上加入下面這段 CSS 就可以了。

/* 根據 <html> 元素上的 class name 是否包含 dark 來切換主題 */
html[data-theme='dark'] .shiki,
html[data-theme='dark'] .shiki span {
    color: var(--shiki-dark) !important;
    background-color: var(--shiki-dark-bg) !important;
}

pre.shiki {
    padding: 1rem !important;
    border-radius: 0.75rem !important;
}

pre.shiki code {
    display: block !important;
    font-family: var(--font-jetbrains-mono), sans-serif !important;
    font-size: var(--text-lg) !important;
    line-height: var(--text-lg--line-height) !important;
    font-weight: 600 !important;
    overflow-x: auto !important;
}

加上行數編號

Shiki.js 本身並沒有行數編號的功能,但是在 Shiki 的某串 Issue 討論串中,有一位大大提供了一段神秘的 CSS,讓你可以在完全不需要 JavaScript 的情況下,幫程式碼加上行數編號。

pre.shiki code {
  counter-reset: step;          /* 建立一個名為 "step" 的計數器,預設歸零 */
  counter-increment: step 0;    /* 在這個容器層級不增加數值 (加 0) */
}

code .line::before {
  content: counter(step);       /* 讀取並顯示計數器目前的數字 */
  counter-increment: step;      /* 讓計數器 +1 */
  width: 1rem;                  /* 設定行號欄位的固定寬度 */
  margin-right: 1.5rem;         /* 設定行號與程式碼之間的距離 */
  display: inline-block;        /* 讓行號變成區塊,才能設定寬度 */
  text-align: right;            /* 讓數字靠右對齊 */
  color: rgba(115,138,148,.4);  /* 設定行號顏色 (通常較淡) */
}

原來 CSS 還有計數器這種功能?真是太神奇了。

使用 Transformers 突顯特定行數

Shiki.js 支援 Transformers 功能,讓你能更彈性的調整產生的 HTML 內容,例如在程式碼區塊中突顯特定行數

官方已提供多款內建的 Transformers 供開發者直接呼叫,我們可以直接使用。

npm i -D @shikijs/transformers

接下來簡單示範一下如何使用 Transforms 來突顯特定行數。從 Transforms 套件中引入 transformerNotationHighlight 後,就可以在 codeToHtml 中使用這個 Transforms。

import { transformerNotationHighlight } from '@shikijs/transformers';

// ...

const html = highlighter.codeToHtml(code, {
    lang,
    themes: {
        // ...
    },
    colorReplacements: {
        // ...
    },
    transformers: [
        transformerNotationHighlight() // [!code highlight]
    ]
});

突顯行數的方式很簡單,只要在註解中標註 [!code highlight] 即可,有兩種標註方式。

// 第一種方式,把註解加在該行後面

console.log('hello'); // [\!code highlight]

// 第二種方式,把註解加在該行上面一行,後面可以使用數字指示要突顯幾行

// [\!code highlight:2]
console.log('這一行會被突顯');
console.log('這一行也會突顯');
console.log('這一行不會被突顯');

需要注意的是,Transformers 預設不會有任何樣式,它只會在 HTML 中幫你加上 highlighted 的 Class Name。

<span class="line highlighted">
    <!-- ... -->
</span>

因此我們需要自己加上 CSS 樣式。下面是我部落格的版本。

pre.shiki {
    padding: 0 !important;
    border-radius: 0.75rem !important;
}

pre.shiki code {
    display: block !important;
    width: fit-content !important;
    min-width: 100% !important;
    padding: 1rem !important;

    /* ... */
}

pre.shiki code span.line.highlighted {
    display: inline-block !important;
    width: calc(100% + 2rem) !important;
    margin: 0 -1rem !important;
    padding: 0 1rem !important;
    background-color: oklch(70.7% 0.022 261.325 / .2) !important;
}

pre.shiki code span.line.highlighted span {
    background-color: transparent !important;
}

有了樣式之後的效果如下:

// 第一種方式,把註解加在該行後面

console.log('hello'); // [!code highlight]

// 第二種方式,把註解加在該行上面一行,後面可以使用數字指示要突顯幾行

// [!code highlight:2]
console.log('這一行會被突顯');
console.log('這一行也會突顯');
console.log('這一行不會被突顯');

除了突顯行數,官方的 Transforms 還提供突顯 Diff 刪減行數等多種功能,更多詳細內容可以參考文件

參考資料

Allen
written by
Allen

持續努力中的後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

則留言
顯示更多留言
新增留言
訪客 2026 年 02 月 07 日