首先介绍原生的js渲染大量DOM会出现的问题和解决方法

一、渲染大量DOM会出现的问题
  1. 会出现较长的白屏事件。
<html>
	<head>
		<title>渲染会卡的代码示例</title>
	</head>
	<body>
		<ul>列表</ul>
		<script>
			// 插入十万条数据,渲染十万个DOM
			const total = 100000;
			let ul = document.querySelector("ul");
			// 性能优化不会每次插入一个DOM就回流一次,在这里没有效果,因为页面结构简单
			const fragment = document.createDocumentFragment();
			for(let i = 0; i < total; i++){
				const li = document.createElement("li");
				li.innerText = Math.floor(Math.random() * total);
				fragment.appendChild(li);
			}
			ul.appendChild(fragment);
		</script>
	</body>
</html>
  1. 阻塞用户操作。以下代码提供一个按钮用于模拟用户交互,并且增加一个定时器,让十万个DOM的创建渲染延后执行,不这样做的化,按钮都显示不出来。我们运行发现,在3秒定时器执行之前用户操作的点击事件是正常执行的,但是3秒之后点击操作就无效了。实际上如果有滚动条,此时用户的滚动行为也是无效的。用户体验很差。
<!-- 未优化的渲染,复制以下代码运行可以看出click事件被阻塞了 -->
<html>
	<head>
		<title>渲染会卡的代码示例</title>
	</head>
	<body>
		<button onclick="alert(123)">按钮</button>
		<ul>列表</ul>
		<script>
			setTimeout(()=>{
				// 插入十万条数据,渲染十万个DOM
				const total = 100000;
				let ul = document.querySelector("ul");
				// 性能优化不会每次插入一个DOM就回流一次,在这里没有效果,因为页面结构简单
				const fragment = document.createDocumentFragment();
				for(let i = 0; i < total; i++){
					const li = document.createElement("li");
					li.innerText = Math.floor(Math.random() * total);
					fragment.appendChild(li);
				}
				ul.appendChild(fragment);
			},3000)
		</script>
	</body>
</html>
二、出现上述问题的原因
  1. js执行时间过长,导致白屏时间过长。第一段贴的代码一次性创建加载的DOM数量太多,js执行时间很长,导致白屏。第二段代码将渲染大量DOM的js代码放到setTimeout里延后执行,所以没有出现白屏。
  2. 浏览器是单线程的,所以用户交互的操作被阻塞了。用户点击、滚动的回调函数会添加到js任务队列尾部,然后等待执行。这些回调都因为等待js引擎创建渲染DOM而被阻塞了。所以点击的操作不会马上执行,而是在DOM加载完成之后又得到了执行,给出弹窗。造成卡的情况发生。
三、解决办法
  1. 解决思路:

    1. 既然一次性创建渲染大量的DOM会产生上述问题,那改为每次创建渲染一部分DOM,这样就可以解决js代码执行过长的问题。
    2. 剩下的DOM怎么办?不断的创建渲染一部分DOM,直到所有DOM创建渲染完成。只需要将部分DOM的创建渲染代码插入到执行队列的尾部等待执行即可延后渲染。这样既不会阻塞GUI渲染引擎,JS执行队列的其他任务比如用户交互产生的任务、事件的触发等都能得到执行。

    有人可能会觉得剩下的DOM延后创建渲染,那不是一样会产生卡的效果吗?其实不会的,CPU的执行速度很快,只是让浏览器引擎的其他任务提前执行了。从CPU的角度来看,线程也是顺序执行的,只是因为CPU执行速度太快,多线程让所有进程看起来是同时运行的,CPU的每一时刻最多只有一个线程得到执行。这里的道理也一样。

    1. 那么怎么插入到执行队列的尾部呢,把代码放到requestAnimationFrame、setTimeout、setInterval这些定时器里面执行即可。关于为什么这样做就可以插入到执行队列的尾部,大家可以查看javsScript的Event Loop机制了解宏任务和微任务的知识。

这里每次渲染部分DOM,而不是一次渲染所有DOM的思想,与CPU用时间片来控制线程的执行思想差不多。时间片机制让每个线程都能平等的得到CPU的执行,而不会互相阻塞。
如果浏览器对应CPU,那么js引擎的消息循环机制的每一个微任务和宏任务我们可以认为它跟线程是差不多的东西。javaScript的宏任务和微任务的缺点就是缺少CPU的时间片机制,导致当一个任务执行时间比较长,其他任务得不到执行。

  1. 接下来依照思路,将代码写出来即可。
    1. 先设置每次插入DOM的数量,这个可以根据DOM的复杂度,灵活设置,这里渲染一个li标签和简单内容就属于比较简单的情况,值就可以设大一点。如果是其他复杂的DOM,比如vue框架v-for循环渲染的DOM可能就比较复杂,这时候就可以设置小一点。
    2. 然后每次只创建渲染指定数目的DOM,指定countOfRender记录当前创建渲染的DOM数目,如果当前DOM数目已经达到了总数就停止继续创建DOM。
    3. 最后把渲染指定数目的JS代码放到requestAnimaitionFrame里即可,为什么要放到requestAnimaitionFrame里面,因为requestAnimaitionFrame用于告诉浏览器你希望执行一个动画,它的回调会在浏览器重绘前被调用,这样可以避免一些不必要的重绘,用于优化动画效果,看起来更流畅。回调函数执行次数通常是每秒60次,一秒60帧的动画,看起来就是流畅的动画。

当然也可以放在setTimeout里面,并且设置较长的延迟时长,比如300毫秒,这样在某些特殊情况可以模仿出懒加载的效果。在加载完一部分DOM之后,剩下的DOM还未加载,此时用户刚好滚动滚轮到最下面,下一部分DOM刚好加载回来,就能模仿出懒加载的效果。

<!-- 做了优化,复制以下代码运行发现以上问题都解决了,注意这里没有加定时器,js代码是马上执行的 -->
<html>
	<head>
		<title>渲染不会卡的代码示例</title>
	</head>
	<body>
		<button onclick="alert(123)">按钮</button>
		<ul>列表</ul>
		<script>
			// 插入十万条数据,渲染十万个DOM
			const total = 100000;
			// 设置每次插入显示的DOM数量,根据情况自己设置
			const once = 100;
			// 当前已经渲染DOM的总数
			let countOfRender = 0;
			let ul = document.querySelector("ul");
			function loop(){
				// 游览器单线程,一次性渲染大量的DOM,会阻塞用户操作,阻塞CSS渲染,有较长白屏事件等问题
				// 所以我们只需要每次渲染少量的DOM不会阻塞用户操作即可解决这些问题
				requestAnimationFrame(()=>{
					const fragment = document.createDocumentFragment();
					// 每次只渲染once条数据
					for(let i = 0; i < once; i++){
						// 当DOM渲染完就退出
						if(countOfRender >= total) return;
						const li = document.createElement("li");
						li.innerText = Math.floor(Math.random() * total);
						fragment.appendChild(li);
						countOfRender += 1;
					}
					ul.appendChild(fragment);
					loop();
				})
			}
			loop();
		</script>
	</body>
</html>
四、缺点
  1. 说了这么多,好像通过这种方式渲染大量的DOM一点缺点都没有。其实还是有的,那就是如果用户希望直接拉到最下面查看最后一条数据,对不起,只能慢慢等它加载完才能查看到最后一条数据。这种优化方式创建渲染完所有DOM是比直接一次性创建渲染慢很多的。不过如果这种情况是比较少的。

在流行的前端框架上如何使用

  1. 上面的版本是介绍原生js创建渲染大量的DOM如何进行性能优化。但是现在哪个前端攻城狮参与的项目不是vue、react、Angular。今天我们讲讲将它运用到框架上,以Vue为例子举例。
  2. 通过接口拿到的数据列表要展示到页面上,一般都会使用for循环去渲染重复的DOM。但是一般开发的时候数据列表的长度都比较小,最多的可能就是表格的时候,大概是五六十条的数据长度。这时候加载起来还不会卡,偶尔少数的情况,也会返回成千上万条数据(比如聊天记录),这时候如此数量的DOM创建渲染起来肯定是很慢的。
  3. 以下代码演示了如何在框架上使用这种方法。尽量模拟了平时开发请求接口拿到数据最后渲染的处理方式。这里使用了setTimeout并且延迟时间设置的比较大是为了模拟懒加载,如果想尽快渲染完全部的DOM并提高动画流畅度,建议使用requestAnimationFrame。
<html>
  <head>
    <title>框架使用示例</title>
    <style>
      #app {
        height: 300px;
        width: 200px;
        overflow: hidden scroll;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
  </head>
  <body>
    <div id="app">
      <button onclick="alert(123)">按钮</button>
      <ul>
        列表
        <li v-for="(item,key) in list" :key="key">{{item}}</li>
      </ul>
    </div>
    <script>
      const app = new Vue({
        el: '#app',
        data: {
          // v-for循环的数据数组
          list: [],
          // 接口请求回来的完整数据
          data: [],
          // 渲染总数
          total: 0,
          // 设置每次插入显示的DOM数量,根据情况自己设置
          once: 20,
          // 当前已经渲染DOM的总数
          countOfRender: 0
        },
        created() {
          this.fetchData();
        },
        methods: {
          fetchData() {
            // 模拟接口请求
            new Promise((resolve, reject) => {
              setTimeout(() => {
                let res = { total: 100000 };
                res.data = [];
                for (let i = 0; i < res.total; i++) {
                  res.data.push(i);
                }
                resolve(res);
              }, 100);
            }).then((res) => {
              this.data = res.data;
              this.total = res.total;
              this.loop();
            });
          },
          loop() {
            // 用setTimeout模拟懒加载,用requestAnimationFrame提高渲染效率和动画流畅度
            setTimeout(() => {
              // 每次只渲染once条数据
              const temp = [];
              for (let i = 0; i < this.once; i++) {
                // 当DOM渲染完就退出
                if (this.countOfRender >= this.total) return;
                temp.push(this.data[this.countOfRender]);
                this.countOfRender += 1;
              }
              this.list = this.list.concat(temp);
              this.loop();
            }, 700);
          }
        }
      });
    </script>
  </body>
</html>
  1. 未优化版本:可以看出未优化的版本渲染起来很慢,这还是在for循环的DOM结构简单的情况下,并且按钮点击的回调也被推迟到了DOM加载完成之后才会执行,也就是用户交互依然会被阻塞。
<html>
  <head>
    <title>框架使用示例</title>
    <style>
      #app {
        height: 300px;
        width: 200px;
        overflow: hidden scroll;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
  </head>
  <body>
    <div id="app">
      <button onclick="alert(123)">按钮</button>
      <ul>
        列表
        <li v-for="(item,key) in data" :key="key">{{item}}</li>
      </ul>
    </div>
    <script>
      const app = new Vue({
        el: '#app',
        data: {
          data: []
        },
        created() {
          this.fetchData();
        },
        methods: {
          fetchData() {
            // 模拟接口请求
            new Promise((resolve, reject) => {
              setTimeout(() => {
                let res = { total: 100000 };
                res.data = [];
                for (let i = 0; i < res.total; i++) {
                  res.data.push(i);
                }
                resolve(res);
              }, 100);
            }).then((res) => {
              this.data = res.data;
            });
          }
        }
      });
    </script>
  </body>
</html>
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐