用于大型 html 的 DOMParser

Posted

技术标签:

【中文标题】用于大型 html 的 DOMParser【英文标题】:DOMParser for large html 【发布时间】:2021-06-13 03:18:56 【问题描述】:

我有大量来自 Excel 的 html 剪贴板数据,大约 250MB(虽然它包含很多格式,所以当实际粘贴时,数据要小得多)。

目前我正在使用以下DOMParser,这只是一行代码,一切都发生在幕后:

const doc3 = parser.parseFromString(htmlString, "text/html");

但是,解析这个需要大约 18 秒,并且在此期间页面完全阻塞直到它完成 - 或者,如果卸载到网络工作者,一个没有进展并且只是“等待”的操作18 秒直到某事最终发生——我认为这几乎与冻结相同,即使是的,用户可以与页面进行真正的交互

是否有其他方法来解析大型 html/xml 文件?也许使用不会一次加载所有内容的东西,因此可以响应,或者什么可能是一个好的解决方案?我想以下可能与它内联?但不太确定:https://github.com/isaacs/sax-js。


更新:这是一个示例 Excel 文件:https://drive.google.com/file/d/1GIK7q_aU5tLuDNBVtlsDput8Oo1Ocz01/view?usp=sharing。您可以下载文件,在 Excel 中打开它,按 Cmd-A(全选)和 Cmd-C(复制),它会将数据粘贴到剪贴板中。对我来说,复制剪贴板中的 text/html 格式需要 249MB。

是的,它也可用于 teext/plain(我们用作备份),但从 text/html 中获取它的目的是捕获格式(两种数据格式,例如 numberType=Percent,3 位小数和风格,例如,背景颜色=红色)。请使用它作为任何示例代码的测试。这是剪贴板中的实际 test/html 内容(以 asci 格式):https://drive.google.com/file/d/1ZUL2A4Rlk3KPqO4vSSEEGBWuGXj7j5Vh/view?usp=sharing

【问题讨论】:

是的,流 xml 解析器可能会有所帮助。见my comment here。但是,您声明要解析 html,但 xlsx 由 xml 文件组成,并且 html 比 xml 更难解析。那么你到底想做什么呢? (另外,Worker 无论如何都无法访问 DOMParser API) @Kaiido 这是从 Excel 中的复制粘贴生成的 html。这是一个示例:gyazo.com/e3b061f3de6eeff0117867c8d7ac9102 它来自应用程序“数字”吗?如果是这样,这些数据也可以作为剪贴板中的 tsv 访问(“text/plain”),可能更容易解析,而且内存也更小。如果是 Excel 或其他应用程序,我不知道它们是如何填充剪贴板的,但也可能值得检查一下替代方案。 @Kaiido 它来自 Excel,但是是的,Google 表格或任何其他应用程序可能应该具有类似的“输出为文本/html”格式。是的,解析文本/纯文本要简单得多,并且是我们的后备方案,但回到手头的问题……任何方法可以更快地解析它,或者至少让它响应:)? 拥有生成的 html 标记可能会更有用,所有软件在所有平台上都不会以相同的方式填充剪贴板。此外,在您的屏幕截图中,我们可以看到您的设置创建了一个 <style> 标记,其规则必须与以下元素匹配=>您不仅需要 HTML 解析器,而不仅仅是简单的 XML 解析器,还需要一个CSS 解析器和 CSSOM 实现。如果我处于你的位置,我会与客户再次确认他们是否可以在粘贴大数据时省略样式,或者强制直接发送 XML 文件。 【参考方案1】:

我至少会尝试使用XMLHttpRequest 作为解析器。与DOMParser 不同,它是异步的(因此可以在加载过程中与网页进行交互),它能够报告进度并从您从Clipboard.read 获得的Blob 对象中读取,因此传递大字符串的开销是也最小化了。

不过,我上次检查过,这项技术并非总是适用于所有浏览器,所以暂时不要丢弃 DOMParser,如果只是将其作为备用。

除了DOMParserXMLHttpRequest之外,唯一提供DOM解析功能的原生Web API是DOM Level 3 Load & Save,据我所知,目前还没有主流浏览器实现过。这意味着XMLHttpRequest 基本上是您唯一的选择。

这是一个使用 XMLHttpRequest 作为解析器的简单粗暴的示例:

const parseHTML = (html, progress) => 
    let cleanup = null;
    let url;

    if (typeof Blob !== 'undefined') 
        if (typeof html === 'string') 
            url = URL.createObjectURL(new Blob([html],  'type': 'text/html' ));
         else if (html instanceof Blob) 
            url = URL.createObjectURL(html);
         else 
            throw new TypeError('html is neither a string nor a Blob');
        
        cleanup = () =>  URL.revokeObjectURL(url); 
     else if (typeof html === 'string') 
        /* fallback to using data: URIs */
        url = 'data:text/html,' + encodeURIComponent(html);
     else 
        throw new TypeError('html is neither a string nor a Blob');     
    
    
    return new Promise((accept, reject) => 
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.overrideMimeType('text/html');
        xhr.responseType = 'document';
    
        xhr.onload = () => 
            accept(xhr.response || xhr.responseXML);
        ;
        
        if (progress) 
            xhr.onprogress = (ev) => 
                /* percentage = ev.loaded / ev.total * 100;
                 * (beware of ev.total === 0)
                 */
                progress(ev);
            ;
        
        
        /* XXX: if the promise is awaited, this makes it
         * throw a ProgressEvent on failure, which is…
         * unusual, though workable */
        xhr.onabort = xhr.onerror = (ev) => 
            reject(ev);
        ;
        
        xhr.onloadend = cleanup;
        
        xhr.send(null);
    );
;

当我自己对此进行测试时,虽然可以忍受,但性能并不出色(加载文件后,解析本身大约需要半分钟,在此期间浏览器相当无响应)。我还注意到这偶尔会为空字符串返回null,所以也要小心。

【讨论】:

你真的试过了吗?这里有一个 60MB 的 xml,它只是在我的 Chrome 上失败,响应设置为空字符串,responseXML 设置为 null。 我在 HTML 上试过了。在 Firefox 上,物有所值。 不管是 HTML 还是 XML,重要的是大小和浏览器。 Ps:现在在 FF 上尝试,它会像 DOMParser 一样冻结浏览器。 (这是有道理的,因为它不应该使用其他线程,即使它是一个异步操作) 它是否使用单独的线程进行解析是站点不可见的实现细节,因此将来可能会透明地实现。还没有——运气不好。但有了DOMParser,就根本不可能发生这种情况。【参考方案2】:

这里的问题不是html 文件大小而是它包含的大量DOM 节点。对于html 文件中的 900000 行和 8 列,我们有这些数字:

900000TR元素)*(8TD元素)+8 strong> (Text 节点)) = ~1400 万 个 DOM 节点!

我没能用DOMParser 加载它,浏览器选项卡在一段时间后崩溃(FF、Chrome、16GB RAM),不过看看成功加载时的浏览器行为会很有趣。 无论如何,我遇到了类似的挑战,要在浏览器中处理数百万条记录,我想出的解决方案是一次只为一个屏幕构建表格行。

考虑到您的text/html 文件的结构,下一个方法可能是:

    使用FileReader将html文件加载为原始文本 抓取行,将它们保存为文本数组,从输出中删除它们 解析结果输出,将表格和样式插入 DOM 使用视图/分页,在分页/滚动或搜索时呈现当前批次的行 为鼠标/键盘控制附加事件

下面是一个简单的实现,它提供了基本的控件,如调整视图大小、分页/滚动、使用正则表达式过滤行。请注意,过滤是在行 html 上完成的,对于 text 仅搜索您可以取消注释行“//text: text.match...”,尽管在这种情况下文件解析时间会增加一点。

let tbody, style;
let rows = [], view = [], viewSize = 20, page = 0, time = 0;

const load = fRead => 
    console.timeEnd('FILE LOAD');
    console.time('GRAB ROWS');
    let thead, trows = '', table = fRead.result
        .replace(/<tr[^]+<\/tr>/i, text => (trows += text) && '');
    console.timeEnd('GRAB ROWS');
    console.time('PARSE/INSERT TABLE & STYLE');
    const html = document.createElement('div');
    html.innerHTML = table;
    table = html.querySelector('table');
    if (!table || !trows) 
        setInfo('NO DATA FOUND');
        return;
    
    if (style = html.querySelector('style'))
        document.head.appendChild(style);
    table.textContent = '';
    el('viewport').appendChild(table);
    console.timeEnd('PARSE/INSERT TABLE & STYLE');
    console.time('PREPARE ROWS ARRAY');
    rows = trows.split('<tr').slice(1).map(text => (
        html: '<tr' + text, text,
        //text: text.match(/>.*<\/td>/gi).map(s => s.slice(1, -5)).join(' '),
    ));
    console.timeEnd('PREPARE ROWS ARRAY');
    console.time('RENDER TABLE');
    table.appendChild(thead = document.createElement('thead'));
    table.appendChild(tbody = document.createElement('tbody'));
    thead.innerHTML = rows[0].html;
    view = rows = rows.slice(1);
    renew();
    console.timeEnd('RENDER TABLE');
    console.timeEnd('INIT');
;

const reset = info => 
    el('info').textContent = info ?? '';
    el('viewport').textContent = '';
    style?.remove();
    style = null;
    tbody = null;
    view = rows = [];
;

const pages = () => Math.ceil(view.length / viewSize) - 1;

const renew = () => 
    if (!tbody)
        return;
    console.time('RENDER VIEW');
    const i = page * viewSize;
    tbody.innerHTML = view.slice(i, i + viewSize)
        .map(row => row.html).join('');
    console.timeEnd('RENDER VIEW');
    setInfo(`
        rows total: $rows.length,
        rows match: $view.length,
        pages: $pages(), page: $page
    `);
;

const gotoPage = num => 
    el('page').value = page = Math.max(0, Math.min(pages(), num));
    renew();
;

const fileInput = () => 
    reset('LOADING...');
    const fRead = new FileReader();
    fRead.onload = load.bind(null, fRead);
    console.time('INIT');
    console.time('FILE LOAD');
    fRead.readAsText(el('file').files[0]);
;

const fileReset = () => 
    reset();
    el('file').files = new DataTransfer().files;
;

const setInfo = text => el('info').innerHTML = text;

const setView = e => 
    let value = +e.target.value;
    value = Number.isNaN(value * 0) ? 20 : value;
    e.target.value = viewSize = Math.max(1, Math.min(value, 100));
    renew();
;

const setPage = e => 
    const page = +e.target.value;
    gotoPage(Number.isNaN(page * 0) ? 0 : page);
;

const setFilter = e => 
    const filter = e.target.value;
    let match;
    try 
        match = new RegExp(filter);
     catch (e) 
        setInfo(e);
        return;
    
    view = rows.filter(row => match.test(row.text));
    page = 0;
    renew();
;

const keys = 'PageUp': -1, 'PageDown': 1;

const scroll = e => 
    const dir = e.key ? keys[e.key] ?? 0 : Math.sign(-e.deltaY);
    if (!dir)
        return;
    e.preventDefault();
    gotoPage(page += dir);
;

const el = id => document.getElementById(id);

el('file').addEventListener('input', fileInput);
el('reset').addEventListener('click', fileReset);
el('view').addEventListener('input', setView);
el('page').addEventListener('input', setPage);
el('filter').addEventListener('input', setFilter);
el('viewport').addEventListener('keydown', scroll);
el('viewport').addEventListener('wheel', scroll);
div 
    display: flex;
    flex: 1;
    align-items: center;
    white-space: nowrap;

thead td,
tbody tr td:first-child 
    background: grey;
    color: white;

td  padding: 0 .5em; 
#menu > *  margin: 0 .25em; 
#file  min-width: 16em; 
#view, #page  width: 8em; 
#filter  flex: 1; 
#info  padding: .5em; color: red; 
<div id="menu">
    <span>FILE:</span>
        <input id="file" type="file" accept="text/html">
        <button id="reset">RESET</button>
    <span>VIEW:</span><input id="view" type="number" value="20">
    <span>PAGE:</span><input id="page" type="number" value="0">
    <span>FILTER:</span><input id="filter">
</div>
<div id="info"></div>
<div id="viewport" tabindex="0"></div>

因此,对于 262 MB html 文件(900000 表行),我们在 Chromium 中有下一个计时:

文件加载:352.57421875 毫秒

抓取行:700.1943359375 毫秒

解析/插入表格和样式:0.78125 毫秒

准备行数组:755.763916015625 毫秒

渲染视图:0.926025390625 毫秒

渲染表:4.317138671875 毫秒

初始化:1814.19287109375 毫秒

渲染视图:5.275146484375 毫秒

渲染视图:4.6318359375 毫秒

因此,直到渲染第一批行的时间(屏幕时间)为~1.8 s,即比 OP 指定的DOMParser 花费的时间低一个数量级,后续行渲染几乎是即时的: ~5 ms

【讨论】:

感谢您。一个问题:900000 (tr) * 8 (td) * 8 (text) 。什么是“文字”? "text" 是 TextNode,即一个单元格中的实际文本,&lt;tr[^]+&lt;tr[^&gt;]* 将给出相同的输出,第一个变体也匹配 &gt; 我明白了,但是为什么&lt;td&gt;text&lt;/td&gt; 会产生 8 个 dom 节点而不是一个? 这是解析器的工作,我不能确定它会插入多少个节点,但任何文本字段至少会有一个 TextNode 可能是 OP 的最佳策略(至少比其他答案好很多)。但是应该有一个重要的注意事项,这仅适用于 OP 解析 HTML 表的特殊情况,不能像那样解析任意 HTML 文件。另请注意,它可能无法正确处理合并的单元格。例如,您可以有一个 rowspan 为 3 的 并将其拆分为两个不同的页面。在第二页中,“should-have-been-merged”单元格后面的所有单元格都将位于错误的列中:jsfiddle.net/p712k0de

以上是关于用于大型 html 的 DOMParser的主要内容,如果未能解决你的问题,请参考以下文章

clojure 中的分析(用于大型代码)

用于导航大型2D图形的简单javascript画布框架?

CSS实现的大型导航下拉菜单

CSS实现的大型导航下拉菜单

使用视频标签从 HTML 中的特定时间戳开始视频(对于大型视频)

计算大型数据集的python树高度