如何处理大数据量(约50,000个对象)的Vue 2内存使用问题?

35

我正在尝试在Vue 2上实现一个大型半复杂对象的表格视图。基本上,想法是从数据库中收集50,000到100,000行记录到JS缓存中,然后动态分析构建带有实时过滤器(文本搜索)的表格视图。表格中的每一行都可以切换,即点击该行将把该行更改为编辑模式,从而启用特定字段/单元格的类似Excel的编辑。

每个对象大约有100-150个字段/属性,但只有在任何给定时刻表格中显示其中的一定数量(表格列可以实时切换)。对于大型数据集,似乎DB会推送大约10-100 MB的JSON数据,在这种用例中是可以接受的。在渲染方面,性能不是问题--过滤器足够快,并且DOM仅呈现有限数量的结果。

一切都已经可以工作,过滤器,根据过滤器列出大约100行(+“显示100个以上”机制等),但当我将大约8000个对象加载到数组中时,我遇到了内存限制。这似乎保留了2 GB的RAM,之后Chrome停止运行JS代码(尽管奇怪的是我没有收到任何警告/错误)。

我对行的内存使用率进行了基准测试,似乎每1000行保留大约300 MB的内存。这很可能是由Vue响应式观察器保留的。

三个问题:

  1. 是否有一种方法可以为特定的数组列表对象(按索引或类似方式)切换响应性,以便数组内部的对象在没有被特别调用变为可变的情况下未被观察/不可变(即当用户点击行时启用编辑模式时)?
  2. 您将如何实现处理Vue的大型数据集,因为响应性似乎会瓶颈内存使用? 请不要建议“在后端限制结果”,因为我在这里寻求的不是解决方案(尽管我可能需要创建两部分过滤器,一部分用于获取较小的初始数据集,另一部分用于实时过滤)。基本上,我正在尝试通过重新思考Vue的数据结构来推动从8,000->80,000的“内存末日”边界。唯一的问题是将数据集存储在Vue的响应式数据变量中吗?
  • 我有一个想法,就是使用Object.freeze或其他类似的方法将“items”数据集变为不可观察/非响应式的,并且使用表格来呈现两个数据集:一个用于非响应式的数据,另一个用于当前处于编辑模式的数据(当行被点击时会被推送到“editableItems”数据集中)... 这里有什么建议吗?(有没有更简单的方法,这样我就能在一个数组中处理所有内容了?)
  • 我曾在Angular 1上开发过类似的应用程序,并且可以很好地处理5万行数据,所以我相信在Vue 2中也应该可以做到...只需找到一种处理响应性的方式即可。


    最佳实践是提供一种滚动API,以每个数据包发送对象。 - andrea06590
    在Angular 1中,您没有虚拟DOM,因此Vue2为更好的事件计算而消耗更多内存。如果您尝试在视图中过滤这50,000行并重新呈现它们,则需要很长时间。 - Georgi Antonov
    4个回答

    47

    2021年4月20日编辑 - 两年后,更加睿智

    由于这个问题/答案引起了很多关注,在经过所有这些年之后仍然有效,我想提供一些指示。大部分细节仍然有效。不过,我建议在处理过滤结果和复杂对象时使用VueX与Lodash(或现代版本的本地JS函数)。

    为了减轻后端压力,您可以保持简单:获取没有相关模型的纯粹对象。这意味着您的主要结果只有相关对象的ID键。使用Axios或类似的库获取所有相关数据,并使用VueX将它们存储在自己的列表属性中。为每个创建getter,例如:

    projectsById: state => {
        return _.keyBy(state.projects, "id")
    },
    

    这样,当需要时,您可以使用相关模型来获取标签和/或完整对象,而且您的后端不需要多次获取相关数据。状态和getter也将在微组件中可用。
    基本上:处理大型数据集时,避免获取完整的模型树(即使C# EF或PHP Laravel提供了工具)。使用原子方法:获取20个不同的列表("Axios.all([...])"是您的朋友!),每个列表都有自己的控制器端点,并缓存结果到VueX存储...然后尽情享受 ;) 编辑12.03.2019 - 此答案末尾添加了额外的提示 我问这个问题已经有一段时间了,最终我优化了我的项目的这一部分。我想给那些遇到这些性能和/或内存问题的人提供一些指针。
    Vue文档从未真正解释过它,但正如Andrey所指出的,您可以将组件对象用作自定义对象和对象列表的数据存储。毕竟,它只是一个普通的JavaScript对象。
    优化后,我的列表组件设置看起来像这样:
    module.exports = {
        items: [],
        mixins: [sharedUtils],
        data: function() {
            return {
                columns: {
                    all: []
        etc... Lot's of data & methods
    

    items-array被填充了数千个复杂对象(大约80MB的数据,6MB压缩),我将其视为非响应式处理。这证明它比我想象的要少成问题 - 我没有直接使用v-for来遍历items,而是使用结构,在其中当用户点击某些过滤按钮和/或输入字符串过滤(例如名称)时触发对该数组的过滤。基本上,“processFilters”方法遍历非响应式items-array并返回filteredItems,它存储在数据上下文中。因此,它会自动变为响应式,因为它被改变了。
    <tr v-for="item in filteredItems" 
    

    这样,过滤后的所有项目都保持反应性,但在被过滤掉时也失去了反应性,从而节省了大量内存。令人惊讶的是,将1200mb缩减到了400mb,这正是我所期望的。聪明!

    还有一些问题需要解决。由于项目不存在于数据上下文中,因此无法直接在模板中使用它。这意味着,你不能直接写...

    <div v-if="items.length > 0 && everythingElseIsReady">
    

    我必须将items数组的长度存储到分离的数据属性中。这也可以通过计算值来解决,但我喜欢保留这些属性。

    放弃主数据数组的反应性并不是一件坏事 - 最重要的是要理解,直接对基础数组中的项目进行的修改永远不会触发任何UI和/或子组件的更改(当然)。只要您以这样的方式将代码分离开来,即具有“隐藏数据容器”,其中保存来自后端的所有结果,并且您具有该大容器的较小(过滤)表示数组,那么这不应该是一个问题。通过使用良好的REST架构,您应该已经可以使用非响应式数据存储,只要您记得在保存非响应式数据存储中的项目后,也已更新为最新版本。

    此外,我惊讶于微型组件数量与数百行相比性能上的影响很小。显然,呈现会受到影响,但即使我要传递大量的props数千次(因为我有数千个输入单元格实例),它似乎也没有影响内存。这种对象之一是我的全局翻译键/值对对象,具有超过20,000行翻译字符串...但它仍然无关紧要。这是有道理的,因为Javascript使用对象引用,Vue Core似乎已经正确编码,因此只要您将这样的配置对象用作props,您就只是从数千个对象到相同数据集的引用。

    最后,我想说,开始疯狂地使用复杂的CRUD对象,不必担心内存限制!

    非常感谢Andrey Popov给予正确方向的提示!

    提示(2019年3月12日)

    由于一段时间以来,我一直在使用大型和复杂的数据集构建UI,因此我决定提出一些简短的想法和技巧。

    1. 考虑如何管理主记录(即人员或产品)与相关记录(子对象/关系对象)。尝试限制注入到子组件中的数据量,因为您可能正在为不同的主记录表示相同的子对象多次。问题在于这些对象可能实际上并不是引用对象!

    考虑这样一种情况,您有一个包含城市对象的人员对象。许多人住在同一个城市,但当您从后端获取JSON数据时,您确定那些重复的城市对象实际上是同一个城市(共享/引用人员之间的城市对象),还是类似对象的多个表示(数据完全相同,但在幕后每个对象都是单独的实例/唯一对象)。假设您有50,000个人,每个人都包含相同的子对象/属性“城市”:{id:4,name:“ Megatown”},您刚刚获取了50,000个单独的城市实例而不是一个吗? person1.city === person2.city,还是它们只是看起来相同,仍然是两个不同的对象?

    如果您不确定是引用共享城市对象还是使用多个类似子对象的实例,您可以在person-list-component中进行引用。您的person包含city-id,因此使用单独的REST方法(getCities)获取城市列表,并在UI级别上进行配对。这样,您只有一个城市列表,并且可以从该列表中解析城市并将其注入到person中,从而仅引用一个城市。或者,您可以从列表中解析城市并将其作为属性传递给person-component。

    还要确保考虑子对象的目的。您需要让它具有反应性吗,还是静态的?为了节省大量内存,您可以简单地告诉“person.city = city”,这将被注入到每个person-component中,但如果它需要具有反应性,则需要使用Vue.set方法...并记住,如果每个城市都需要自己的实例(以便每个人都拥有类似的城市对象,但需要根据每个人的属性进行编辑),则需要确保您没有使用引用对象!因此,您很可能需要克隆城市对象,这将占用浏览器的内存。

    1. 您的微型组件可能包含分别用于只读状态和编辑器状态的单独视图状态。这是非常常见的。但是,每次都会创建该微型组件的实例,从而初始化该组件数千次。

    想象一下,您拥有类似于Excel电子表格的带有表格和表格行的UI。每个单元格都包含自定义“my-input”组件,该组件从布局中获取“readonly”属性。如果UI处于只读状态,则仅显示该my-input组件中的标签部分,但否则将显示具有某些特殊条件(例如具有不同输入的日期时间、数字、文本、文本区域、select标记等)的输入标记。现在假设您有100行20列,因此实际上正在初始化2000个my-input组件。现在的问题是--如何提高性能?

    好吧,您可以将只读标签与my-input组件分开到list-view中,以便您仅显示只读版本(标签)或显示可编辑的my-input组件。这样,您就有了v-if条件,确保这些2000个微型组件不会被初始化,除非您已经明确要求初始化它们(由于整个布局或行从只读->可编辑状态移动)...当Vue不需要创建2000个组件时,浏览器的内存影响可能很大。

    如果您发现页面加载非常缓慢,可能根本不是VUE的问题。检查呈现到HTML中的HTML标记数量。当您拥有大量标记时,HTML的性能表现相当差。最简单的演示方法之一是重复具有2000个选项的select标记100次,或者拥有一个单独的20000个选项的select标记。同样,您可能通过具有许多不必要的包装div等微型组件来溢出html标记的数量...您拥有的深度和标记越少,浏览器和CPU需要的呈现性能就越少。

    尝试通过示例学习良好的HTML标记结构。例如,您可以研究Trello服务仪表板视图的编程方式。这是一个相当简单而美观的半复杂服务表示,具有最少量的子div。
    有许多改进内存处理的方法,但我认为最重要的是将“隐藏”的对象与可见对象分开,如我的原始答案所述。第二部分是理解实例化对象与引用对象之间的区别。第三个是限制对象之间不必要的数据传递量。
    个人而言,我没有尝试过这一点,但存在一个Vue-virtual-scroller组件,它通过简单地成为似乎无限数量数据的包装器来处理任意数量的数据。查看概念@https://github.com/Akryum/vue-virtual-scroller,并告诉我是否解决了您的问题。
    希望这些指南能够为优化您的组件提供一些思路。永远不要放弃希望,总有改进的空间!

    4
    嗨,Janne,我刚看到你的回答,不知道为什么没有收到通知 :) 我很高兴你解决了问题,而且你描述的一切似乎是一个相当聪明的方法!干得好!只想补充最后一件事,我最近发现的(不确定是何时引入的)-在Vue组件中有$options属性(https://vuejs.org/v2/api/#vm-options),你可以使用像`<div v-if="$options.items.length`这样的语法 - 这是所有不属于Vue系统的属性的getter ;) 祝好运,并向我们汇报 - 你的项目看起来很有趣! - Andrey Popov
    你好,安德烈。使用 $options getter 会将响应性附加到 items.length 吗?也就是说,在你的例子中,如果我删除了所有 items 的成员,那么 <div v-if="$options.items.length" 的可见性是否会发生变化?如果不是,那么表达式何时被评估(在渲染期间,挂载期间)? - mp035

    5

    根据我所阅读的内容,你并不需要为这些数据使用响应式,因为:

    表格中的每一行都可以切换,也就是说,点击行会将其更改为编辑模式,从而使该特定字段/单元格启用类似于Excel的编辑

    这意味着行不能编辑,用户没有交互,则数据不能被改变。

    每个对象大约有100-150个字段/属性,但在任何给定时刻只有一定数量的它们在表格中显示(表格列可以实时切换)。

    您仍然使字段具有响应性,但不显示它们。


    现在回答您的问题

    是否有一种方法可以通过索引或其他方式切换特定数组列表对象的响应性,以便数组内的对象未被观察/不可变,除非专门调用它们变为可变(即当用户点击行时,启用编辑模式)?

    如果一次只能编辑一个项目,那么为什么要使所有内容具有响应性? 您可以轻松地使用单个变量来监听更改。

    如何为Vue处理大型数据集,因为响应性似乎成为内存使用的瓶颈?

    这完全取决于实现 - 您很少会遇到需要使大量项目具有响应性的情况。您拥有的项目越多,就需要发生更多事件才能使用响应性。如果您有50k个项目,并且只有少数事件需要发生变化(例如用户手动修改数据),那么您可以轻松地监听这些事件并手动进行响应性处理,而不是让Vue处理所有数据。您可以查看Vuex,它可以为您带来一些方便 :)

    我想到的一个想法是使用Object.freeze或类似方法将“items”数据集转换为非可观察/非响应式,并使表格呈现两个数据集:一个用于非响应式,另一个用于当前处于编辑模式的数据(当单击行时将其推送到“editableItems”数据集中)

    这有点朝着正确的方向前进,但没有必要支持两个数组。想象一下使用类似于这样的东西:

    data: function() {
        return {
            editingItem: {}
            // when editing is enabled bind the input fields to this item
        }
    },
    created: function() {
        this.items = [] // your items, can be used in markdown in the loop, but won't be reactive!
    },
    watch: {
        editingItem: function(data) {
            // this method will be called whenever user edits the input fields
            // here you can do whatever you want
            // like get item's id, find it in the array and update it's properties
            // something like manual reactivity ;)
        }
    }
    

    我想知道你是否弄错了什么。通过点击行,用户可以切换该行以进行编辑。这将隐藏td中的标签并使输入字段可见,并且这些输入字段具有v-model绑定,因此实际上行是响应式的。此外,多行可以同时进行编辑(每个都有自己的“保存”按钮和表格也有“全部保存”按钮,它会迭代每个未保存的行并将其提交)。不过,我不需要所有数据最初都是响应式的。当我使用axios.get获取初始数据并将其传递到data->items时,数组会自动变为响应式。 - Janne
    有一件事需要澄清:“表格中的每一行都是可切换的,这意味着单击该行会将其更改为编辑模式,从而为该特定字段/单元格启用类似于Excel的编辑”——这段话写得不好... 行/对象本身变得可编辑(对象获得属性“isEditable”设置为true),而不仅仅是该对象内的单个字段。 - Janne
    考虑到这一点,似乎您应该将这些“输入字段”放入子组件中,并重用所有逻辑来标记此行为可编辑。然后,您可以将数据放入每个组件中,而不需要它是响应式的(与上面相同的方法,但只有一个项目)。当行变为可编辑时,非响应式数据可以放置在绑定到输入字段的观察属性的响应式数据中。希望这有意义。 - Andrey Popov
    谢谢指导。我相信这些是正确的方向。只在输入组件内部具有响应性的想法是有意义的,而且由于我已经创建了输入组件(因为需要为不同的字段类型呈现不同的输入),我应该能够立即实现它。当数组被修改时,我可能需要手动 $forceUpdate,因为Vue很可能不会检测到变化,因为项数组变成了非响应式,但这不应该是问题。点赞! - Janne
    哦,是的,也许你需要微调一些东西来更新你的列表并通知所有依赖它的部分,但我希望这不会太难。保持反应性最低限度是关键 :) 当你准备好一些示例说明它如何工作时,请发布另一个答案;) 祝你好运! - Andrey Popov
    显示剩余3条评论

    4
    • 我曾经遇到过这样的问题,需要显示一个巨大的列表,至少有50000个可变高度的项目,但我找不到任何解决方案。
    • 通用的解决方案是构建/使用虚拟滚动。
    • 它只在DOM中保留了几个项目,而其余项目则在DOM中进行编辑。然而,它会根据您向上/向下滚动而不断更改可见内容。
    • 我发现现有的库不处理动态高度,除非您像vue-virtual-scrollervue-virtual-scroll-list一样硬编码高度。
    • vue-collection-cluster允许您动态计算高度,但在50000个项目时表现不佳。
    • 因此,我提出了自己的解决方案,可以在50000多个项目上平稳滚动,甚至测试了100k个项目,效果非常好。
    • 动态行高的实现思路如下:
    • 我们需要在数组中维护每个项目的高度列表 enter image description here

    • 基于滚动位置,我们对要始终向用户显示的少量项目应用垂直的transform translateY偏移

    enter image description here

    • 我已经在解决方案中添加了足够的注释,让您轻松地理解正在发生的事情

    HTML

    <script type="text/x-template" id="virtual-list">
       <div id="root" ref="root">
          <div id="viewport" ref="viewport" :style="viewportStyle">
            <div id="spacer" ref="spacer" :style="spacerStyle">
             <div v-for="i in visibleItems" :key="i.id" class="list-item" :ref="i.id" :data-index="i.index" @click="select(i.index)"  :class="i.index === selectedIndex ? 'selected': ''">
               <div>{{ i.index + ' ' + i.value }}</div>
       </div>
       </div>
       </div>
       </div>
    </script>
    <div id="app">
       <h1 class="title">
          Vue.js Virtual + Infinite Scroll + Dynamic Row Heights + Arrow Key Navigation + No Libraries
       </h1>
       <p class="subtitle">
          No hardcoding of heights necessary for each row. Set emitEnabled to false
          for max performance. Tested with <span id="large_num">50000</span> items...
       </p>
       <div id="list_detail">
          <div id="list">
             <virtual-list></virtual-list>
          </div>
          <div id="detail">
             <table>
                <tbody>
                   <tr>
                      <th class="caption">Root Container Height</th>
                      <td>{{store['root-height']}} px</td>
                   </tr>
                   <tr>
                      <th class="caption">Viewport Height</th>
                      <td>{{store['viewport-height']}} px</td>
                   </tr>
                   <tr>
                      <th class="caption">Smallest Row Height</th>
                      <td>{{store['smallest-height']}} px</td>
                   </tr>
                   <tr>
                      <th class="caption">Largest Row Height</th>
                      <td>{{store['largest-height']}} px</td>
                   </tr>
                   <tr>
                      <th class="caption">Scroll Top</th>
                      <td>{{store['scroll-top']}} px</td>
                   </tr>
                   <tr>
                      <th class="caption">Page Index</th>
                      <td>{{store['page-start-index']}}</td>
                   </tr>
                   <tr>
                      <th class="caption">Start Index</th>
                      <td>{{store['start-index']}}</td>
                   </tr>
                   <tr>
                      <th class="caption">End Index</th>
                      <td>{{store['end-index']}}</td>
                   </tr>
                   <tr>
                      <th class="caption">Translate Y</th>
                      <td>{{store['translate-y']}} px</td>
                   </tr>
                </tbody>
             </table>
             <p><b>Visible Item Indices on DOM</b> {{store['visible-items']}}</p>
             <p><b>Total Height Till Current Page</b> {{store['page-positions']}}</p>
             <p>
                <b>Row's Vertical Displacement From Viewport Top</b>
                {{store['row-positions']}}
             </p>
             <p><b>Heights</b> {{store['heights']}}</p>
          </div>
       </div>
    </div>
    

    CSS

    @import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');
    
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    
    
    /**
    Apply Scroll Bar Styles
    
    https://css-tricks.com/the-current-state-of-styling-scrollbars/
    */
    html {
      --scrollbarBG: #181C25;
      --thumbBG: orange;
    }
    body::-webkit-scrollbar {
      width: 11px;
    }
    body {
      scrollbar-width: thin;
      scrollbar-color: var(--thumbBG) var(--scrollbarBG);
    }
    body::-webkit-scrollbar-track {
      background: var(--scrollbarBG);
    }
    body::-webkit-scrollbar-thumb {
      background-color: var(--thumbBG) ;
      border-radius: 6px;
      border: 3px solid var(--scrollbarBG);
    }
    
    
    html {
      height: 100%;
    }
    
    body {
      min-height: 100%;
      height: 100%;
      padding: 2rem;
      color: #AAA;
      background: #181C25;
      font-family: 'Open Sans', sans-serif;
      font-size: 0.9rem;
      line-height: 1.75;
    }
    
    #app {
      height: 100%;
      display: flex;
      flex-direction: column;
    }
    
    #list_detail {
      display: flex;
      height: 70%;
    }
    
    #list {
      flex: 2;
      height: 100%;
    }
    
    #detail {
      flex: 1;
      padding: 1rem;
      overflow: auto;
      height: 100%;
    }
    
    #root {
      height: 100%;
      overflow: auto;
    }
    
    .list-item {
      padding: 0.75rem 0.25rem;
      border-bottom: 1px solid rgba(255, 255, 0, 0.4);
    }
    
    .title {
      color: white;
      text-align: center;
    }
    
    .subtitle {
      color: orange;
      text-align: center;
    }
    
    table {
      width: 100%;
      table-layout: fixed;
      text-align: center;
    }
    
    th.caption {
      text-align: left;
      color: #00BEF4;
      font-weight: 100;
      padding: 0.5rem 0;
    }
    
    td {
      text-align: left;
    }
    
    b{
      font-weight: 100;
      color: #00BEF4;
    }
    
    #large_num {
      color: red;
    }
    
    .selected {
      background: midnightblue;
    }
    

    Vue.js

    我在SO上的字数受到了限制,因此此处是完整代码(CodePen链接)限制
    • 目前无法与屏幕调整大小兼容,正在处理中
    特点
    • 50000+项无缝滚动
    • 支持箭头导航,就像本地列表一样

    • 如果您有任何问题,请在评论中让我知道


    啊哈!我没能修复屏幕调整大小的问题。原本情况是当屏幕变得太小时,项目高度会增加,而当您让屏幕宽度足够时,我们会回到正常状态。后来我做的是保持固定高度,当屏幕宽度变得太小时,我的内容会溢出容器,并隐藏超出部分。 - PirateApp
    如果有人能提供一个比仅在每个项目中使用固定高度溢出内容更好的处理屏幕调整大小的方法,那将非常有帮助。 - PirateApp

    0
    为了渲染大量组件,我刚刚发现https://github.com/RadKod/v-lazy-component并且非常喜欢它。它只是使用Intersection API来渲染或不渲染组件。它以非常流畅的方式删除不可见的惰性加载组件并加载可见的组件,没有不必要的复杂性。

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