🚀

超軽量なSpeed-highlight.jsについて

2023/03/17に公開

https://github.com/speed-highlight/core

はじめに

近年ではDiscordなどにもシンタックスハイライトがありますね。
他にもいろいろな人のブログで、シンタックスハイライトをしているのを見かけます。
ですが、そういうのって、もうほとんどすべてがクライアントサイドでのレンダリングなんです。
また、すべて確実にHighlight.jsとかを使っているんですね。
これの何がいけないかと言うと、実はHighlight.jsはminifyしても 897kB 、その後gzipしても 283kB という驚異的な重さを誇っています。(Bundlephobiaより)
これをクライアントサイドで動かすとなると、かなりのパフォーマンス低下が見込めます。
開発者側としてもここまで肥大化されたらハックもしずらいですし、バグも見つかりにくいかもしれません。
そこで、私はそんなシンタックスハイライト業界に革命を起こす銀の弾丸、Speed-highlight.jsをご紹介します。

Speed-highlight.jsとは?

Speed-highlight.jsもHighlight.jsと同じシンタックスハイライトのためのJSライブラリです。なんと言ってもこのライブラリ、本体のみだとgzipped 1.6kB という驚異的な軽さを誇ります。
というのも、後で言語を動的インポートで取得するんですが、この言語別のJavaScriptも物凄く簡単に正規表現でまとめられているため、非常にシンプルに出来上がっています。
とりあえずコードを読んでみてください。

使ってみる

とりあえず以下のようなコードを書いてみました。

html
<link rel="stylesheet" href="https://unpkg.com/@speed-highlight/core/dist/themes/default.css">

<code class="shj-lang-js">
  console.log(hello('kstdx'))
  
  function hello(name = 'world') {
    return `Hello, ${name}!`
  }
</code>
js
import { highlightAll } from 'https://unpkg.com/@speed-highlight/core/dist/index.js'

highlightAll()

こんな感じにちゃんとハイライトできていることがわかります。
ですが、なんか一瞬ラグがありませんでしたか?
そうです。内部的に動的にインポートしているのでどうしても最適化されていないんですね。
Highlight.jsよりは早く動きますが所詮シンタックスハイライターという感じです。
そこで、クライアントサイドでのシンタックスハイライトを無くすというやり方を思いつきました。
実際に自分のブログ

https://kstdx.com

ではすべてSSRしていますが、これがパフォーマンス良いです。

ちなみにSSRの際は

highlightText()

という関数をインポートしましょう。
また、ここで問題が起きます。
自分の運営しているサイトのデプロイ先Deno Deployでは動的インポートは機能しません。
そこでソースを以下のように書き換えました。

import asm from 'highlight_langs/asm.js'
import bash from 'highlight_langs/bash.js'
import bf from 'highlight_langs/bf.js'
import c from 'highlight_langs/c.js'
import css from 'highlight_langs/css.js'
import csv from 'highlight_langs/csv.js'
import diff from 'highlight_langs/diff.js'
import docker from 'highlight_langs/docker.js'
import git from 'highlight_langs/git.js'
import go from 'highlight_langs/go.js'
import html from 'highlight_langs/html.js'
import http from 'highlight_langs/http.js'
import ini from 'highlight_langs/ini.js'
import java from 'highlight_langs/java.js'
import js from 'highlight_langs/js.js'
import js_template_literals from 'highlight_langs/js_template_literals.js'
import jsdoc from 'highlight_langs/jsdoc.js'
import json from 'highlight_langs/json.js'
import leanpub_md from 'highlight_langs/leanpub-md.js'
import log from 'highlight_langs/log.js'
import lua from 'highlight_langs/lua.js'
import make from 'highlight_langs/make.js'
import md from 'highlight_langs/md.js'
import pl from 'highlight_langs/pl.js'
import plain from 'highlight_langs/plain.js'
import py from 'highlight_langs/py.js'
import regex from 'highlight_langs/regex.js'
import rs from 'highlight_langs/rs.js'
import sql from 'highlight_langs/sql.js'
import todo from 'highlight_langs/todo.js'
import toml from 'highlight_langs/toml.js'
import ts from 'highlight_langs/ts.js'
import uri from 'highlight_langs/uri.js'
import xml from 'highlight_langs/xml.js'
import yaml from 'highlight_langs/yaml.js'

const expandData = {
    num: {
        type: 'num',
        match: /(\.e?|\b)\d(e-|[\d.oxa-fA-F_])*(\.|\b)/g
    },
    str: {
        type: 'str',
        match: /(["'])(\\[^]|(?!\1)[^\r\n\\])*\1?/g
    },
    strDouble: {
        type: 'str',
        match: /"((?!")[^\r\n\\]|\\[^])*"?/g
    }
}

const langs = {
        asm,
        bash,
        bf,
        c,
        css,
        csv,
        diff,
        docker,
        git,
        go,
        html,
        http,
        ini,
        java,
        js,
        js_template_literals,
        jsdoc,
        json,
        leanpub_md,
        log,
        lua,
        make,
        md,
        pl,
        plain,
        py,
        regex,
        rs,
        sql,
        todo,
        toml,
        ts,
        uri,
        xml,
        yaml
    },
    sanitize = (str = '') =>
        str
            .replaceAll('&', '&#38;')
            .replaceAll?.('<', '&lt;')
            .replaceAll?.('>', '&gt;'),
    toSpan = (str, token) =>
        token ? `<span class="shj-syn-${token}">${str}</span>` : str

export function tokenize(src, lang, token) {
    try {
        let m,
            part,
            first = {},
            match,
            cache = [],
            i = 0,
            data = typeof lang === 'string' ? langs[lang] : lang,
            // make a fast shallow copy to bee able to splice lang without change the original one
            arr = [...(typeof lang === 'string' ? data : lang.sub)]

        while (i < src.length) {
            first.index = null
            for (m = arr.length; m-- > 0; ) {
                part = arr[m].expand ? expandData[arr[m].expand] : arr[m]
                // do not call again exec if the previous result is sufficient
                if (cache[m] === undefined || cache[m].match.index < i) {
                    part.match.lastIndex = i
                    match = part.match.exec(src)
                    if (match === null) {
                        // no more match with this regex can be disposed
                        arr.splice(m, 1)
                        cache.splice(m, 1)
                        continue
                    }
                    // save match for later use to decrease performance cost
                    cache[m] = { match, lastIndex: part.match.lastIndex }
                }
                // check if it the first match in the string
                if (
                    cache[m].match[0] &&
                    (cache[m].match.index <= first.index ||
                        first.index === null)
                )
                    first = {
                        part: part,
                        index: cache[m].match.index,
                        match: cache[m].match[0],
                        end: cache[m].lastIndex
                    }
            }
            if (first.index === null) break
            token(src.slice(i, first.index), data.type)
            i = first.end
            if (first.part.sub)
                tokenize(
                    first.match,
                    typeof first.part.sub === 'string'
                        ? first.part.sub
                        : typeof first.part.sub === 'function'
                        ? first.part.sub(first.match)
                        : first.part,
                    token
                )
            else token(first.match, first.part.type)
        }
        token(src.slice(i, src.length), data.type)
    } catch {
        token(src)
    }
}

export function highlightText(src, lang, multiline = true, opt = {}) {
    let tmp = ''
    tokenize(src, lang, (str, type) => (tmp += toSpan(sanitize(str), type)))

    return multiline
        ? `<div><div class="shj-numbers">${'<div></div>'.repeat(
              !opt.hideLineNumbers && src.split('\n').length
          )}</div><div>${tmp}</div></div>`
        : tmp
}

export let loadLanguage = (languageName, language) => {
    langs[languageName] = language
}

これで実装が可能になりました。
ついでにSSRでは使わない関数(highlightAll()highlightElement()など)は排除しました。

コードとしては汚くなりましたが、これもDeno Deployへの実装なので仕方ありません。
またこう見ると対応言語も多いですね。
後はこれのhighlightText()を実際に利用するだけですね。
テーマのCSSも豊富なのでぜひ使ってみてください👉

Discussion