這些年在 JavaScript 處理 XML 的機會愈來愈少,但從事古蹟翻新與維護難免遇到。最近想為老系統增加一個編輯及檢視 XML 的小工具,有兩個小需求:

  1. 想讓 <textarea> 輸入的 XML 內容依標準縮排規則排版
  2. 提供可以展開、縮合 XML 節點的互動式 XML 檢視器

兩個都算常見的需求,理應有現成可撿,不需要自己造輪子,重點在找到符合自己磁場的版本。內部使用,故瀏覽器僅需支援 Chrome/Edge,不用費心跨瀏覽器問題,感覺輕鬆好多,算是簡單任務。

我喜歡輕巧、簡潔的程式碼,最好只用一小段 JavaScript 函式搞定,依這個標準在 Internet 搜尋一番,整理出一套極簡風格做法。

原以為 JavaScript 應已內建 XML 排版 API 卻撲了空,最後找到一段 Gist 範例,用 RegExp 加 .split().map().join() 不到 20 行搞定排版,深得我心。我有多加 DOMParser + XMLSerializer 前處理,清除標籤自身的換行。程式碼與實測結果如下:

<!DOCTYPE html>
<html>
<body>
    <div>
        <button onclick="test()">Format XML</button>
    </div>
    <textarea id="result" rows="8" cols="32"></textarea>
    <script>
        var xml = `<rows>
<row id="1">ITEM1</row>
<row id="2"
    remark="reserved"
    >ITEM2</row>
<row id="3" /><row id="4">ITEM4</row>
</rows>`;
        document.getElementById('result').value = xml;
        function test() {
            let res = document.getElementById('result');
            res.value = formatXml(res.value);
        }
        // 
        function formatXml(xml) {
            xml = new XMLSerializer().serializeToString(
                new DOMParser().parseFromString(xml, 'text/xml'))
                .replace(/>\s{0,}</g, '><');
            const PADDING = ' '.repeat(2); // set desired indent size here
            const reg = /(>)(<)(\/*)/g;
            let pad = 0;
            xml = xml.replace(reg, '$1\r\n$2$3');
            return xml.split('\r\n').map((node, index) => {
                let indent = 0;
                if (node.match(/.+<\/\w[^>]*>$/)) {
                    indent = 0;
                } else if (node.match(/^<\/\w/) && pad > 0) {
                    pad -= 1;
                } else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
                    indent = 1;
                } else {
                    indent = 0;
                }
                pad += indent;
                return PADDING.repeat(pad - indent) + node;
            }).join('\r\n');
        }
    </script>
</body>

</html>

線上展示

至於 XML 節點展開收合檢視功能,有些人會選擇用 XSLT 實現,我則找到一個開源 jQuery 小套件 - SimpleXML

程式含註解約 200 行,嫌要載入 css 及使用 png 不夠輕巧,我將 css 內容放進 js 改為動態注入,換用較小的展開收合圖示並改為 DataUri 不再需要 png 檔,如此只需一個 js 搞定,更符合 KISS 精神,並將修改過的版本命名為 SimplerXML.js:(比簡單更簡單 XD)

(function ($) {

    // Additional features that are needed
    //
    // allow specification of how attributes are shown

    // Data Model:
    //
    //   One of the following must be supplied
    //      xml                 - the XML object to be shown
    //      xmlString           - the XML string to be shown
    //
    //   Options:
    //      collapsedText       - the text shown when the node is collapsed. Defaults to '...'
    $.fn.simpleXML = function (options) {

        // This is the easiest way to have default options.
        var settings = $.extend({
            // These are the defaults.
            collapsedText: "...",
        }, options);

        if (settings.xml == undefined && settings.xmlString == undefined)
            throw "No XML to be displayed was supplied";

        if (settings.xml != undefined && settings.xmlString != undefined)
            throw "Only one of xml and xmlString may be supplied";

        var xml = settings.xml;
        if (xml == undefined)
            xml = $.parseXML(settings.xmlString);

        return this.each(function () {
            var wrapperNode = document.createElement("span");
            $(wrapperNode).addClass("simpleXML");

            showNode(wrapperNode, xml, settings);

            this.appendChild(wrapperNode);

            $(wrapperNode).find(".simpleXML-expanderHeader").click(function () {

                var expanderHeader = $(this).closest(".simpleXML-expanderHeader");

                var expander = expanderHeader.find(".simpleXML-expander");

                var content = expanderHeader.parent().find(".simpleXML-content").first();
                var collapsedText = expanderHeader.parent().children(".simpleXML-collapsedText").first();
                var closeExpander = expanderHeader.parent().children(".simpleXML-expanderClose").first();

                if (expander.hasClass("simpleXML-expander-expanded")) {
                    // Already Expanded, therefore collapse time...
                    expander.removeClass("simpleXML-expander-expanded").addClass("simpleXML-expander-collapsed");

                    collapsedText.attr("style", "display: inline;");
                    content.attr("style", "display: none;");
                    closeExpander.attr("style", "display: none");
                }
                else {
                    // Time to expand..
                    expander.addClass("simpleXML-expander-expanded").removeClass("simpleXML-expander-collapsed");
                    collapsedText.attr("style", "display: none;");
                    content.attr("style", "");
                    closeExpander.attr("style", "");
                }
            });
        });

    };

    let css = `.simpleXML ul
    { list-style: none; margin: 0px; padding-left: 1.2em; }
    .simpleXML { font-family: 'Courier New'; }
    .simpleXML-comment { color: green; }
    .simpleXML-tagHeader { color: blue; }
    .simpleXML-cdata { color: gray; }
    .simpleXML-tagValue { color: darkred; }
    .simpleXML-collapsedText { color: lightgray; }
    .simpleXML-attrName { color: red; }
    .simpleXML-attrValue { color: blue; }
    .simpleXML span.simpleXML-expander, .simpleXML span.simpleXML-expanderClose
    { height: 1em; width: 1em; display: inline-block; }
    .simpleXML span.simpleXML-expanderHeader { cursor: pointer; }
    .simpleXML span.simpleXML-expanderHeader span.simpleXML-expander { 
        background-repeat: no-repeat; background-position: center; 
        position: relative; top: 0.2em;
        border: 1px solid lightgray; margin-right: 2px;
    }
    .simpleXML span.simpleXML-expander-collapsed
    { 
        background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAGCAYAAAAG5SQMAAAAOUlEQVR42jXKwQkAMAgDwKwqKD4EwQ26sSOkVWjgIIHAzPiCgaqiqnJHZnKICBERHN194O5b9vbLuAVRL+l0YWnZAAAAAElFTkSuQmCCXA=="); 
    }
    .simpleXML span.simpleXML-expander-expanded
    {
        background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAANElEQVR42mWKsQ0AMAzC8ixLlrzQjzmBiEjp0A6WwBCSPgKAXoLkqSot7nN3yMwR7pZ32NzpKkVoDBUxKAAAAABJRU5ErkJggg==");
    }`;
    if ($('#simpleXmlCss').length == 0) {
        $('head').append('<style id="simpleXmlCss">' + css + '</style>');
    }

    function showNode(parent, xml, settings) {
        if (xml.nodeType == 9) {
            for (var i = 0; i < xml.childNodes.length; i++)
                showNode(parent, xml.childNodes[i], settings);
            return;
        }

        switch (xml.nodeType) {
            case 1: // Simple element
                var hasChildNodes = xml.childNodes.length > 0;
                var expandingNode = hasChildNodes && (xml.childNodes.length > 1 || xml.childNodes[0].nodeType != 3);

                var expanderHeader = expandingNode ? makeSpan("", "simpleXML-expanderHeader") : parent;

                var expanderSpan = makeSpan("", "simpleXML-expander");
                if (expandingNode)
                    $(expanderSpan).addClass("simpleXML-expander-expanded");
                expanderHeader.appendChild(expanderSpan);

                expanderHeader.appendChild(makeSpan("<", "simpleXML-tagHeader"));
                expanderHeader.appendChild(makeSpan(xml.nodeName, "simpleXML-tagValue"));

                if (expandingNode)
                    parent.appendChild(expanderHeader);

                // Handle attributes
                var attributes = xml.attributes;
                for (var attrIdx = 0; attrIdx < attributes.length; attrIdx++) {
                    expanderHeader.appendChild(makeSpan(" "));
                    expanderHeader.appendChild(makeSpan(attributes[attrIdx].name, "simpleXML-attrName"));
                    expanderHeader.appendChild(makeSpan('="'));
                    expanderHeader.appendChild(makeSpan(attributes[attrIdx].value, "simpleXML-attrValue"));
                    expanderHeader.appendChild(makeSpan('"'));
                }

                // Handle child nodes
                if (hasChildNodes) {

                    parent.appendChild(makeSpan(">", "simpleXML-tagHeader"));

                    if (expandingNode) {
                        var ulElement = document.createElement("ul");
                        for (var i = 0; i < xml.childNodes.length; i++) {
                            var liElement = document.createElement("li");
                            showNode(liElement, xml.childNodes[i], settings);
                            ulElement.appendChild(liElement);
                        }

                        var collapsedTextSpan = makeSpan(settings.collapsedText, "simpleXML-collapsedText");
                        collapsedTextSpan.setAttribute("style", "display: none;");
                        ulElement.setAttribute("class", "simpleXML-content");
                        parent.appendChild(collapsedTextSpan);
                        parent.appendChild(ulElement);

                        parent.appendChild(makeSpan("", "simpleXML-expanderClose"));
                    }
                    else {
                        parent.appendChild(makeSpan(xml.childNodes[0].nodeValue));
                    }

                    // Closing tag
                    parent.appendChild(makeSpan("</", "simpleXML-tagHeader"));
                    parent.appendChild(makeSpan(xml.nodeName, "simpleXML-tagValue"));
                    parent.appendChild(makeSpan(">", "simpleXML-tagHeader"));
                } else {
                    var closingSpan = document.createElement("span");
                    closingSpan.innerText = "/>";
                    parent.appendChild(closingSpan);
                }
                break;

            case 3: // text
                if (xml.nodeValue.trim() !== "") {
                    parent.appendChild(makeSpan("", "simpleXML-expander"));
                    parent.appendChild(makeSpan(xml.nodeValue));
                }
                break;

            case 4: // cdata
                parent.appendChild(makeSpan("", "simpleXML-expander"));
                parent.appendChild(makeSpan("<![CDATA[", "simpleXML-tagHeader"));
                parent.appendChild(makeSpan(xml.nodeValue, "simpleXML-cdata"));
                parent.appendChild(makeSpan("]]>", "simpleXML-tagHeader"));
                break;

            case 8: // comment
                parent.appendChild(makeSpan("", "simpleXML-expander"));
                parent.appendChild(makeSpan("<!--" + xml.nodeValue + "-->", "simpleXML-comment"));
                break;

            default:
                var item = document.createElement("span");
                item.innerText = "" + xml.nodeType + " - " + xml.name;
                parent.appendChild(item);
                break;
        }

        function makeSpan(innerText) {
            return makeSpan(innerText, undefined);
        }

        function makeSpan(innerText, classes) {
            var span = document.createElement("span");
            span.innerText = innerText;
            if (classes != undefined)
                span.setAttribute("class", classes);
            return span;
        }
    }
}(jQuery));

測試網頁:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
        <script src="simplerXML.js"></script>
    </head>
    <body>
        <button>12pt</button>
        <button>10pt</button>
        <button>9pt</button>
        <div id="demo">

        </div>
        <script>
            $.get('demo.xml', function (data) {
                $('#demo').empty().simpleXML({ xml: data });
            });
            $('button').click(function () {
                $("#demo").css('font-size', $(this).text());
            });
        </script>
    </body>
</html>

線上展示

Examples XML formatting and interactive node expanding/collapsing with JavaScript.


Comments

Be the first to post a comment

Post a comment