JavaScript中大量列表的渲染

4
我正在尝试基于虚拟渲染概念呈现列表。我遇到了一些小问题,但它们并不会阻碍行为。这是工作的fiddle http://jsfiddle.net/53N36/9/,以下是我的问题:
  1. 最后几项不可见,我认为我错过了某个索引。(已解决,请参见编辑)
  2. 如果我想添加自定义滚动条,如何计算scrollPosition。
  3. 这是最好的方法还是其他方法?

我已在chrome中测试了700000个项目和70个项目。以下是代码:

(function () {
var list = (function () {
    var temp = [];
    for (var i = 0, l = 70; i < l; i++) {
        temp.push("list-item-" + (i + 1));
    }
    return temp;
}());

function listItem(text, id) {
    var _div = document.createElement('div');
    _div.innerHTML = text;
    _div.className = "listItem";
    _div.id = id;
    return _div;
}
var listHold = document.getElementById('listHolder'),
    ht = listHold.clientHeight,
    wt = listHold.clientWidth,
    ele = listItem(list[0], 'item0'),
    frag = document.createDocumentFragment();
listHold.appendChild(ele);
var ht_ele = ele.clientHeight,
    filled = ht_ele,
    filledIn = [0];
for (var i = 1, l = list.length; i < l; i++) {
    if (filled + ht_ele < ht) {
        filled += ht_ele;
        ele = listItem(list[i], 'item' + i);
        frag.appendChild(ele);
    } else {
        filledIn.push(i);
        break;
    }
}
listHold.appendChild(frag.cloneNode(true));
var elements = document.querySelectorAll('#listHolder .listItem');

function MouseWheelHandler(e) {
    var e = window.event || e;
    var delta = Math.max(-1, Math.min(1, (e.wheelDelta || -e.detail)));
    console.log(delta);
    //if(filledIn[0] != 0 && filledIn[0] != list.length){
    if (delta == -1) {
        var start = filledIn[0] + 1,
            end = filledIn[1] + 1,
            counter = 0;
        if (list[start] && list[end]) {
            for (var i = filledIn[0]; i < filledIn[1]; i++) {
                if (list[i]) {
                    (function (a) {
                        elements[counter].innerHTML = list[a];
                    }(i));
                    counter++;
                }
            }
            filledIn[0] = start;
            filledIn[1] = end;
        }
    } else {
        var start = filledIn[0] - 1,
            end = filledIn[1] - 1,
            counter = 0;
        if (list[start] && list[end]) {
            for (var i = start; i < end; i++) {
                if (list[i]) {
                    (function (a) {
                        elements[counter].innerHTML = list[a];
                    }(i));
                    counter++;
                }
            }
            filledIn[0] = start;
            filledIn[1] = end;
        }
    }
    //}
}
if (listHold.addEventListener) {
    listHold.addEventListener("mousewheel", MouseWheelHandler, false);
    listHold.addEventListener("DOMMouseScroll", MouseWheelHandler, false);
} else listHold.attachEvent("onmousewheel", MouseWheelHandler);
}());

请在这方面给我建议。 编辑: 我已经尝试过了,现在我能够修复索引问题。http://jsfiddle.net/53N36/26/ 但是如何根据当前显示的数组列表计算滚动位置呢?
2个回答

11

这是最好的方法还是其他方法更好呢?
我认为让浏览器自己处理滚动会让这个过程更加容易。在这个例子中,我展示了即使我们使用了虚拟渲染,也可以让浏览器帮助我们处理滚动。

通过使用.scrollTop,我能够检测到浏览器认为用户正在查看的位置,并根据此绘制元素。
你会注意到如果将hidescrollbar设置为false并且用户使用它来滚动,我的方法仍然可以正常运行。

因此,为了计算滚动位置,你只需要使用.scrollTop
至于自定义滚动,请确保影响#listHolder.scrollTop并重新调用refreshWindow()

来自示例代码:

(function () {
    //CHANGE THESE IF YOU WANT
    var hidescrollbar = false;
    var numberofitems = 700000;
    //

    var holder = document.getElementById('listHolder');
    var view = null;

    //get the height of a single item
    var itemHeight = (function() {
        //generate a fake item
        var div = document.createElement('div');
        div.className = 'listItem';
        div.innerHTML = 'testing height';
        holder.appendChild(div);

        //get its height and remove it
        var output = div.offsetHeight;
        holder.removeChild(div);
        return output;
    })();

    //faster to instantiate empty-celled array
    var items = Array(numberofitems);
    //fill it in with data
    for (var index = 0; index < items.length; ++index)
        items[index] = 'item-' + index;

    //displays a suitable number of items
    function refreshWindow() {
        //remove old view
        if (view != null)
            holder.removeChild(view);
        //create new view
        view = holder.appendChild(document.createElement('div'));

        var firstItem = Math.floor(holder.scrollTop / itemHeight);
        var lastItem = firstItem + Math.ceil(holder.offsetHeight / itemHeight) + 1;
        if (lastItem + 1 >= items.length)
            lastItem = items.length - 1;

        //position view in users face
        view.id = 'view';
        view.style.top = (firstItem * itemHeight) + 'px';

        var div;
        //add the items
        for (var index = firstItem; index <= lastItem; ++index) {
            div = document.createElement('div');
            div.innerHTML = items[index];
            div.className = "listItem";
            view.appendChild(div);
        }
        console.log('viewing items ' + firstItem + ' to ' + lastItem);
    }

    refreshWindow();

    document.getElementById('heightForcer').style.height = (items.length * itemHeight) + 'px';
    if (hidescrollbar) {
        //work around for non-chrome browsers, hides the scrollbar
        holder.style.width = (holder.offsetWidth * 2 - view.offsetWidth) + 'px';
    }

    function delayingHandler() {
        //wait for the scroll to finish
        setTimeout(refreshWindow, 10);
    }
    if (holder.addEventListener)
        holder.addEventListener("scroll", delayingHandler, false);
    else
        holder.attachEvent("onscroll", delayingHandler);
}());
<div id="listHolder">
    <div id="heightForcer"></div>
</div>
html, body {
    width:100%;
    height:100%;
    padding:0;
    margin:0
}
body{
    overflow:hidden;
}
.listItem {
    border:1px solid gray;
    padding:0 5px;
    width: margin : 1px 0px;
}
#listHolder {
    position:relative;
    height:100%;
    width:100%;
    background-color:#CCC;
    box-sizing:border-box;
    overflow:auto;
}
/*chrome only
#listHolder::-webkit-scrollbar{
    display:none;
}*/
#view{
    position:absolute;
    width:100%;
}

你能否看一下这个问题:https://dev59.com/-WMl5IYBdhLWcg3wknwT。我正在使用相同的代码,但是针对水平列表。你能否回答一下? - Exception
这非常整洁。我的担忧是它需要元素的一致高度...但也许这就是虚拟渲染必须工作的方式。 - David Eads
好的,你可以有不同的高度,但是根据你如何计算元素的高度,评估一组元素的总高度可能会非常慢。在这里,它是一个简单的常数乘法,但如果它们是可变的,你就必须迭代求和。 - Hashbrown
虽然我欣赏在我的两小时黑客活动中传播一个专门支持的库,但请比“或者只是使用”更有礼貌,并认识到当问题得到回答时,这个库根本不存在。也许一个不那么轻蔑的“现在有一个很酷的库可以做到这一点”更符合SO的价值观。此外,为了消除关于广告的任何担忧,最好披露您是clusterize的创建者@Denis。 - Hashbrown
哇,我不是有意冒犯你的,@Hashbrown。对此我很抱歉。没错,我是这个插件的创作者。 - Denis
没关系,可能是我误解了它的意思,因为它只是一条评论。说真的,你应该再写一个答案并把它放进去,这样人们更有可能看到它。我甚至会给它点个赞,因为它看起来很不错。 - Hashbrown

0

感谢Hashbrown提供的代码,它确实帮了我很多忙。基于你的代码回馈社区,我尝试创建了一个完整的日志查看器,具备渲染、高亮、过滤、搜索和更新能力。你可以在这个fiddle上找到它。关于如何在fiddle上使用该类的示例已经提供。

class DisplayLogs {
    constructor(id, logs, hidescrollbar = false, emphasis = "", filter = "", render = "", openatend = false) {
        const temp = document.getElementById(id);

        this.view = null;
        this.logs = (typeof (logs) === "string" ? [logs] : logs);
        this.emphasis = emphasis;
        this.filter = filter;
        this.render = render;
        this.tab = 0;
        this.lastScrollTop = 0;
        this.hidescrollbar = hidescrollbar;
        this.highlight = { tab: -1, line: -1 };
        this.nitems = this.numberOfItems();
        this.openatend = openatend;

        temp.textContent = ""; // Clear existing content
        if (this.logs.length === 1) {
            let el = temp.appendChild(document.createElement("div"));
            el.id = "logpane";
            el.setAttribute("style", "height: 500px; position: relative; overflow-x: auto; overflow-y: auto;");
            el.appendChild(document.createElement("div")).id = "heightForcer";
            el = el.appendChild(document.createElement("div"));
            el.id = "log0";
            el.className = "container tab-pane active";
        } else {
            let el = temp.appendChild(document.createElement("ul"));
            el.className = "nav nav-tabs";
            el.id = "logs";
            el.setAttribute("role", "tablist");
            for (let i = 0; i < this.logs.length; i += 1) {
                const li = el.appendChild(document.createElement("li"));
                li.className = "nav-item";
                const link = li.appendChild(document.createElement("a"));
                link.className = "nav-link" + (i === 0 ? " active" : "");
                link.id = "navlog" + i;
                link.setAttribute("data-bs-toggle", "tab");
                link.href = "#log" + i;
                link.textContent = i;
            }
            el = temp.appendChild(document.createElement("div"));
            el.id = "logpane";
            el.setAttribute("style", "height: 500px; position: relative; overflow-x: auto; overflow-y: auto;");
            el.appendChild(document.createElement("div")).id = "heightForcer";
            for (let i = 0; i < this.logs.length; i += 1) {
                const div = el.appendChild(document.createElement("div"));
                div.id = "log" + i;
                div.className = "container tab-pane " + (i === 0 ? "active" : "fade");
            }
        }
        this.initHolder();
    }

    initHolder() {
        let i;
        this.holder = document.getElementById("logpane");
        this.height = this.itemHeight();
        if (this.holder && this.height !== 0) {
            if (this.openatend) {
                // Everything needs to be rendered with possible x-axis
                // scroll before really moving to the end
                setTimeout(this.scrollToEnd.bind(this), 75);
            }
            this.refreshWindow();
            if (this.holder.addEventListener) {
                this.holder.addEventListener("scroll", this.delayingHandler.bind(this), false);
                if (this.logs.length > 1) {
                    for (i = 0; i < this.logs.length; i += 1) { document.getElementById("navlog" + i).addEventListener("click", this.changeTab.bind(this), false); }
                }
            } else {
                this.holder.attachEvent("onscroll", this.delayingHandler.bind(this));
                if (this.logs.length > 1) {
                    for (i = 0; i < this.logs.length; i += 1) { document.getElementById("navlog" + i).attachEvent("click", this.changeTab.bind(this)); }
                }
            }
        } else { window.requestAnimationFrame(this.initHolder.bind(this)); }
    }

    scrollToEnd() {
        if (this.holder.scrollTop < this.holder.scrollHeight - this.holder.clientHeight) {
            // Don't need to explictly refresh as the event listener will deal with the scroll
            this.holder.scrollTop = this.holder.scrollHeight - this.holder.clientHeight;
        }
    }

    delayingHandler() {
        if (this.holder) {
            // Don't force refresh if scrolling in the X
            if (this.holder.scrollTop === this.lastScrollTop) { return; }
            this.lastScrollTop = this.holder.scrollTop;
        }
        setTimeout(this.refreshWindow.bind(this), 10);
    }

    changeTab(e) {
        let tab = parseInt(e.target.id.substr(6), 10);
        if (tab > this.logs.length - 1) { tab = this.logs.length - 1; }
        if (tab < 0) { tab = 0; }
        this.tab = tab;
        this.nitems = this.numberOfItems();
        this.refreshWindow();
    }

    itemHeight() {
        const pre = document.createElement("pre");
        pre.textContent = "testing height";
        this.holder.appendChild(pre);

        const output = pre.offsetHeight;
        this.holder.removeChild(pre);
        return output;
    }

    numberOfItems() {
        let output = 0;
        if (this.logs.length === 0) {
            output = 0;
        } else {
            const lines = this.logs[this.tab].split("\n");
            if (this.filter) {
                this.logs[this.tab].split("\n").forEach((line) => {
                    if (line.trim() && this.filter(line)) { output += 1; }
                });
            } else if (lines[lines.length - 1].trim()) {
                output = lines.length;
            } else {
                output = lines.length - 1;
            }
        }
        return output;
    }

    appendlog(logs, tab = 0) {
        if (logs) {
            this.logs[tab] += logs;
            if (this.tab === tab && (this.curItem
                    + Math.ceil(this.holder.offsetHeight / this.height) >= this.nitems)) {
                this.nitems = this.numberOfItems();
                this.refreshWindow();
                this.holder.scrollTop = this.holder.scrollHeight - this.holder.clientHeight;
            } else if (this.tab === tab) { this.nitems = this.numberOfItems(); }
        }
    }

    search(str = "") {
        if (str !== "") {
            const curIndex = (this.highlight.line < 0 ? this.curItem : this.highlight.line);
            const curTab = (this.highlight.tab < 0 ? this.tab : this.highlight.tab);
            const matches = [];
            let found = 0;
            for (let i = 0; i < this.logs.length; i += 1) {
                let j = 0;
                this.logs[i].split("\n").forEach((line) => {
                    if (!this.filter || this.filter(line)) {
                        if (line.includes(str)) {
                            matches.push({ tab: i, line: j });
                        }
                        j += 1;
                    }
                });
            }
            for (let j = 0; j < matches.length; j += 1) {
                if ((matches[j].tab > curTab) || ((matches[j].tab === curTab)
                    && (matches[j].line > curIndex))) {
                    found = j;
                    break;
                }
            }

            if (matches.length > 0) {
                this.tab = matches[found].tab;
                this.highlight = { tab: this.tab, line: matches[found].line };
                if (this.tab !== curTab) {
                    // If possible return focus to the element after changing tabs
                    // Allows repeated searches after changing tabs
                    if (document.activeElement) {
                        const act = document.activeElement;
                        document.querySelector("#navlog" + this.tab).click();
                        act.focus();
                    } else {
                        document.querySelector("#navlog" + this.tab).click();
                    }
                    this.nitems = this.numberOfItems();
                }
                this.refreshWindow();
                this.holder.scrollTop = Math.floor(matches[found].line * this.height);
            }
        }
    }

    updatefilter(filter = "") {
        this.filter = filter;
        this.nitems = this.numberOfItems();
        // FIXME : Rather than reset, try to keep same line
        this.highlight = { tab: this.highlight.tab, line: -1 };
        this.refreshWindow();
    }

    color(line) {
        const colors = ["text-muted", "text-dark", "text-info", "text-primary",
            "text-success", "text-warning", "text-danger"];
        let index = (this.emphasis ? this.emphasis(line) : 0);
        index = (index < 0 ? 0 : index);
        index = (index >= colors.length ? colors.length - 1 : index);
        return colors[index];
    }

    refreshWindow() {
        if (this.view != null) { this.view.remove(); }
        if (this.logs.length > 1) {
            this.view = document.getElementById("log" + this.tab).appendChild(document.createElement("div"));
        } else {
            this.view = this.holder.appendChild(document.createElement("div"));
        }

        if (this.logs.length > 0) {
            let pre;
            if (this.logs[this.tab].length > 0) {
                let lines;
                let index;
                let firstItem;
                if (this.openatend) {
                    if (this.nitems <= Math.ceil(this.holder.offsetHeight / this.height)) {
                        firstItem = 0;
                    } else {
                        firstItem = this.nitems - Math.ceil(this.holder.offsetHeight / this.height);
                    }
                } else {
                    firstItem = Math.floor(this.holder.scrollTop / this.height);
                }
                let lastItem = firstItem + Math.ceil(this.holder.offsetHeight / this.height);
                if (lastItem > this.nitems - 1) { lastItem = this.nitems - 1; }
                this.view.id = "view";
                this.view.style.top = (firstItem * this.height) + "px";
                this.view.style.position = "absolute";
                this.curItem = firstItem;

                if (this.filter) {
                    let line = 0;
                    lines = this.logs[this.tab].split("\n");
                    for (index = 0; index < lines.length; index += 1) {
                        if (this.filter(lines[index])) {
                            if (line >= firstItem) {
                                pre = document.createElement("pre");
                                if ((this.tab === this.highlight.tab)
                                        && (line === this.highlight.line)) {
                                    pre.className = "my-0 bg-info overflow-auto";
                                } else {
                                    pre.className = "my-0 " + this.color(lines[index]) + " overflow-auto";
                                }
                                pre.textContent = (this.render
                                    ? this.render(lines[index])
                                    : lines[index]);
                                this.view.appendChild(pre);
                            }
                            line += 1;
                            if (line > lastItem) { break; }
                        }
                    }
                } else {
                    lines = this.logs[this.tab].split("\n");
                    for (index = firstItem; index <= lastItem; index += 1) {
                        pre = document.createElement("pre");
                        if ((this.tab === this.highlight.tab) && (index === this.highlight.line)) {
                            pre.className = "my-0 bg-info overflow-auto";
                        } else {
                            pre.className = "my-0 " + this.color(lines[index]) + " overflow-auto";
                        }
                        pre.textContent = (this.render ? this.render(lines[index]) : lines[index]);
                        this.view.appendChild(pre);
                    }
                }
            } else {
                pre = document.createElement("pre");
                pre.className = "my-0 text-muted";
                pre.textContent = "<Empty>"; // Can't translate this easily
                this.view.appendChild(pre);
            }
        }
        // Be careful of presence or absence of x-axis scroll bar, by checking
        // against scrollHeight.
        let hf = ((this.nitems === 0 ? 1 : this.nitems) * this.height);
        if (hf < this.holder.scrollHeight) hf += this.height;
        document.getElementById("heightForcer").style.height = hf + "px";
        if (this.hidescrollbar) {
            // work around for non chrome browsers, hides the scrollbar
            this.holder.style.width = (this.holder.offsetWidth * 2 - this.view.offsetWidth) + "px";
        }
        if (this.openatend) {
            // This won't force a rerendering as the scroll
            // event listener isn't in place yet.
            this.openatend = false;
            this.holder.scrollTop = this.scrollHeight - this.clientHeight;
        }
    }
}

// The logs can be filtered to only display relevant log lines
function logFilter(line) {
    return (parseInt(line.split(' ')[0], 10) % 2 === 0);
}

// The logs can be highlighted with 7 different colours
function logHighlight(line) {
    // return 0;
    return (parseInt(line.split(' ')[0], 10) % 7);
}

// The log filter can be updated
function dsasToggleLogs() {
    const btn = document.getElementById("loghide");
    if (btn.value === "All logs") {
        btn.value = "Even logs only";
        logs.updatefilter(logFilter);
    } else {
        btn.value = "All logs";
        logs.updatefilter("");
    }
}

// Logs can be appended and the log view will stay at the
// end of the log to follow the new log entries
function appendlog(n) {
  let str = "";
  for (let j = 0; j < 10; j+=1)
    str += (n + j).toString().padEnd(10) + "   Log line\n";
  logs.appendlog(str);
  setTimeout(appendlog, 5000, n + 10);
}

const numlines = 10000;
let logfiles = [];
for  (let i = 0; i < 4; i+=1) {
  str=""
  for (let j = 0; j < numlines; j+=1)
    str += j.toString().padEnd(10) + "   Log line\n";
  logfiles.push(str);
}

document.getElementById("loghide").addEventListener("click", dsasToggleLogs);
document.getElementById("logsearch").addEventListener("keypress", (event) => {
    if (event.key === "Enter") logs.search(document.getElementById("logsearch").value);
});
logs = new DisplayLogs("logwind", logfiles, false, logHighlight, "", "", true);
setTimeout(appendlog, 5000, numlines);
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>


<div class="container p-3 border">
  <div class="row">
    <div class="col-md-4">
      <h5>Logs :</h5>
    </div>
    <div class="col-md-8 text-end">
      <input type="button" class="btn btn-primary btn-sm" id="loghide" value="All logs">
      <input type="search" class="input-lg rounded"  id="logsearch" placeholder="Search">
    </div>
  </div>
  <span id="logwind"></span>
</div>


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接