Vue、React在不同阶段性能表现区别

757 阅读10分钟

前言

5e1b7a14abe35bdb541115c37b6d1d79.jpg

VueReact可以说在现如今前端领域中各自占据了半片江山,在社区中存在着各种各样的文章对两者进行比较,本文主要讲本人在学习这些框架底层源码的阶段期间,针对两套框架的设计理念的区别,作出了某些猜想,并针对这些猜想作出印证的过程。

抛出结果

  • 首屏渲染阶段性能 React > Vue
  • 更新阶段性能 Vue > React

Vue在综合使用场景下无疑在性能方面是优于React的,但并非是绝对在任何场景下都是如此,在设计上Vue相对于React有种空间换时间的感觉,下面我会逐渐讲述印证的过程。

响应式数据 VS 数据不可变

接触过对应框架的同学,一定对这两个词有一定的熟悉感。

可能或多或少都会听到的一些描述

  • Vue2响应式数据使用的是Object.defineProperty,而在Vue3使用了Proxy来替代,性能得到了提升,在值改变的时候就会触发方法做对应的更新操作,这就是响应式数据。
  • React中改一个直接对象属性,不能直接改变state的值,而是应该通过setState方法传入新的值,这就是数据不可变。

diff的前提条件

但乍一看似乎没什么多大联系,那么这两个究竟有何联系呢?
要弄清楚这点我们需要认识diff算法,而这两个点共同的地方则是在对应的框架中触发diff的前提条件。

什么是diff以及虚拟dom

虚拟dom: 指的是用对象的形式描述一个真实的dom的各种状态,该对象称指为虚拟dom
diff:就是对于虚拟dom的各种属性状态的比较

diff何时触发

理解什么是diff,我们不妨看一下VueReact的渲染过程中,简单的流程结构。

Vue中逻辑

graph TD
render方法执行 --> 
从当前真实dom根节点取出上一次的vnode以及当前render返回的最新vnode --> 
调用调用patch方法执 -->
diff --> 
根据当前状态创建或修改对应的真实dom --> 
将当前的vnode保存到真实dom的根节点中保存为_vnode -->
render方法执行

React中逻辑

graph TD
render方法执行 --> 
进入render阶段的beginWork --> 
从双缓存数据fiber中取出上一次的虚拟dom,调用reconileChildren --> 
diff --> 
添加对应的标记flag --> 
completeWork将flag标记冒泡出去 --> 
进入commit阶段,处理标记节点修改真实dom -->  
双缓存数据完成翻转 -->  
render方法执行

因此diff算法的执行归根到底需要render方法的调用

render何时触发

到这里才是这一点的核心,也就是前面提到的响应式数据数据不可变

Vue-响应式数据

在这里不得不提到两个很重要的点依赖收集依赖触发

无论是Object.defineProperty,还是Proxy,共同点就是,访问到数据的时候触发get方法,修改数据的时候set方法

Vue内部存在一个对象dep是一个Set,在这里为了方便理解我们可以将他理解为对象Object,他跟普通对象相比多了一个能力,可以用Object做为key值。

假设有一个Vue组件ComponentA

// 组件结构
export default {
    name: 'ComponentA',
    render () {
        return <div>
            <span>{{ obj1.value1 }}</span>
            <span>{{ obj1.obj2.value2 }}</span>
        </div>
    },
    data () {
        return {
            obj1: {
                value1: '1',
                obj2: {
                    value2: '2'
                }
            }
        }
    }
}

因为组件使用到了data中的响应式数据,就会触发get方法,从而触发依赖收集形成dep

// dep数据结构
{
    obj1: {
       value1: [ComponentA.render, 或其他有使用到改变了的方法],
       obj2: {
           value2: [ComponentA.render, 或其他有使用到改变了的方法]
       }
    }
}

那么当值改变的时候就会触发set方法,从而能在dep中很轻易找到应用了该变量的render数组,然后调用执行。这就是依赖触发

React-数据不可变

React会从根部开始遍历fiber树,也就是说默认当组件的render调用默认所有子组件render都会调用,这实际上会对项目的性能造成很大的影响,因此开发中会使用到PureComponentReact.memo,来达到性能优化的目的。

这两个简单点说可以这么理解,若数据相同,则不会触发render,而对象是浅层比较也就是比较地址值。

假设有一个React组件ComponentA

import React, { useState } from 'react'

function ComponentA () {
    const [obj, setObj] = useState({ value: '1', name: 'ljh' })
    
    const changeValue = () => {
        // 错误做法-obj地址值没变,value不会改变-不会触发render
        // obj.value = '2'
        // setObj(obj)

        // 正确做法-传入一个新的对象-会触发render
        setObj({ ...obj, value: '2' })
    }
    
    return <div onClick={changeValue}>{ obj.value }</div>
}

diff的前提条件-总结

diff是由于render执行之后内部的部分逻辑,而Vue是依赖响应式数据变动触发render,React则是从根节点开始对比数据从而触发render

如何进行性能比较

这是一个非常重要的事,我们需要实现一个消耗性能的案例,来对比不同框架的差异,那么对比不同项目之间改如何对比才是相对公平的呢?

一个有意思的问题

以下代码中先后打印的时间差会是多少?

window.startTime = new Date().getTime()
  for (let i = 0; i < 20000; i++) {
    const dom = document.createElement('div')
    document.body.appendChild(dom)
  }
  console.log(new Date().getTime() - window.startTime)
  setTimeout(() => {
    console.log(new Date().getTime() - window.startTime)
  }, 0)

控制台打印结果

image.png 试问中间这相差175毫秒的时间都干了啥呢?我们需要借助Performance来查看

image.png

在这里不过多讲述什么是 Recalculate Style Layout,可以简单理解为浏览器浏览器界面需要计算界面排版布局所花费的时间,但这些任务需要在本次的js执行完毕之后才执行,而setTimeout是一个宏任务,他的执行需要等待以上界面渲染完毕之后才会执行。

关于框架的挂载生命周期

Vuemounted nextTick
React类组件: componentDidMount

以上都可以认为等同于上面的第一次打印,唯独React函数组件useEffect 是依赖scheduler事实上是一个宏任务。我们在以上的生命周期中,可以使用一致的apisetTimeout来打印各自的时间。

通过代码案例来验证

const startTime = new Date().getTime()

function App () {
  const [listData] = useState(JSON.parse(JSON.stringify(new Array(10000).fill(''))))

  setTimeout(() => {
    console.log('setTimeout', new Date().getTime() - startTime)
  }, 0)

  useEffect(() => {
    console.log('useEffect', new Date().getTime() - startTime)
  })

  return <div>
    {
      listData.map((item, index) => {
        return <div key={index}>1</div>
      })
    }
  </div>
}

image.png

const startTime = new Date().getTime()

class App extends React.Component {
  constructor (props) {
    super(props)

    this.state = {
      listData: JSON.parse(JSON.stringify(new Array(10000).fill('')))
    }
  }

  componentDidMount () {
    console.log('componentDidMount', new Date().getTime() - startTime)
  }

  render () {
    setTimeout(() => {
      console.log('setTimeout', new Date().getTime() - startTime)
    }, 0)
    return <div>
      {
        this.state.listData.map((item, index) => {
          return <div key={index}>1</div>
        })
      }
    </div>
  }
}

image.png

可以看到在在类组件中的componentDidMountuseEffect不是同一个概念,useEffect是一个宏任务,至于Vue有兴趣则自行去验证。

性能比较猜想以及验证

从以上的设计理念可以得出以下结论

  • React是无法像Vue一样做到精准定位render组件,因此在更新阶段Vue > React
  • Vue相对于React需要建立dep,当数据相对比较大的时候,在首屏渲染的时候需要一定的耗时,同时需要一定的内存空间因为多了很多响应式数据对象

如何验证当前的猜想

我们可以分别创建出3个项目,本文使用的是脚手架生成的项目,我们可以在实例化之前先打上一个时间节点做标记,在组件挂载完毕的时候计算出时间差,接着我们要实现一个相对耗性能的列表,如下图加载一个5万行的列表,我们需要再各个加载后使用setTimeOut来计算最终完成界面渲染所需要的时间。

5e1b7a14abe35bdb541115c37b6d1d79.jpg

Vue使用的是render因为写template不好打印render的时间点
为了测试相对精准,准备了一个5万行数据的JSON文件,在下面的代码中我们用jsonData来表示,数据结构为 [{ count: '1', id: 唯一id }, ....5万条]

Vue-案例

// 在main.js中添加
window.startDateTime = new Date().getTime()

// 在App.vue中
import jsonData from './jsonData.json'

export default {
  name: 'App',
  data () {
    return {
      listData: jsonData
    }
  },
  render () {
    console.log('Vue2 render时期', new Date().getTime() - window.startDateTime)

    setTimeout(() => {
      console.log('Vue2 首次渲染完毕', new Date().getTime() - window.startDateTime)
    }, 0);
    return (
      <div class="app">
        <div class="list-wrap">
          {
            this.listData.map((item, index) => {
              return (
                <div
                  key={item.id}
                  class="list-row">
                  { item.count }
                </div>
              )
            })
          }
        </div>
      </div>
    )
  }
}

React-案例

// 在index.jsx中添加
window.startDateTime = new Date().getTime()

// 在App.jsx中
import jsonData from './jsonData.json'

function App () {
  console.log('React render时期', new Date().getTime() - window.startDateTime)
  const [listData] = useState(jsonData)

  setTimeout(() => {
    console.log('React首次渲染完毕', new Date().getTime() - window.startDateTime)
  }, 0)

  return (
    <div className="App">
      <div className="list-wrap">
        {
          listData.map((item) => {
            return (
              <div
                key={item.id}
                className="list-row">
                { item.count }
              </div>
            )
          })
        }
      </div>
    </div>
  );
}

这样针对于首屏渲染运行的结果取了一个中间值如下:

image.png

image.png

image.png

是的这出现的情况与我预料中的不符合,这个问题一开始碰到的时候也着实想不透,最终在细心想了一下发现了问题所在,这是由于当前是在开发环境中打印的结果。

React jsxVue jsx的区别

在Vue官网中有这么一段话

image.png

语法上或许有些区别,但这并非是重点,Vue是借助于@vue/babel-plugin-jsx,而React则是借助于@babel

这两者最大的区别是@vue/babel-plugin-jsx是编译时,@babel 是运行时代码,也就是说Vue在运行时期间代码实际上已经被编译好了,而React是在运行过程中再去解析jsx,因此,可以将3份代码打包放到node环境中访问,再一次查看结果。

编译后生产环境运行的结果

image.png image.png image.png

该结果只是取出一个平均值,可以看到实际上Vue几乎毫无差距,但React的效率却与之前大不一样,平均值也远远低于Vue项目。

从而可以证明React在首屏渲染上是高于Vue的。

更新阶段

假设一个组件UserMain是一个相对耗费性能的组件,这里用了一个大量的for循环来模拟,组件需要展示用户的名称,代码案例如下。

Vue更新案例

const UserMain = {
  props: ['userInfo'],
  render () {
    for (let i = 0; i < 1000000000; i++) {}
    return <span>{this.userInfo.name}</span>
  }
}

export default {
  name: 'App',
  data () {
    return {
      userInfo: {
        name: 'ljh',
        age: 18
      }
    }
  },
  render () {
    return (
      <div class="app">
        <div class="list-wrap">
          <UserMain userInfo={this.userInfo}></UserMain>
          <button onClick={this.changeUserAge}>修改</button>
        </div>
      </div>
    )
  },
  methods: {
    changeUserAge () {
      const updateDateTime = new Date().getTime()
      this.userInfo.age = 20
      setTimeout(() => {
        console.log('Vue2更新渲染时间', new Date().getTime() - updateDateTime)
      }, 0)
    }
  }
}

React更新案例

const UserMain = memo((props) => {
  for (let i = 0; i < 1000000000; i++) {
  }
  return <span>{props.userInfo.name}</span>
})

function App () {
  const [userInfo, setUserInfo] = useState({
    name: 'ljh',
    age: 18
  })

  const changeUserAge = (index) => {
    const updateDateTime = new Date().getTime()
    setUserInfo({
      ...userInfo,
      age: 20
    })
    setTimeout(() => {
      console.log('React更新渲染时间', new Date().getTime() - updateDateTime)
    }, 0)
  }

  return (
    <div className="App">
      <div className="list-wrap">
        <UserMain userInfo={userInfo}></UserMain>
        <button onClick={changeUserAge}>修改</button>
      </div>
    </div>
  );
}

image.png image.png image.png

显然React在这里重新render消耗的相当大的性能,可以看到在案例中我们尝试去改变用户的年龄,但是UserMain并未使用到age,按理说不应该重新render

事实上这是因为userInfo发生了改变,即便是组件加上了memo也只能是判断userInfo的地址值是否发生了变化,并没有办法判断到当前组件所使用的变量,这也是Vue优秀之处,在dep中可以精确定位到render内部所依赖的变量。

在明确了这一点之后,或许我们只需要给UserMain组件传过去一个name值等方式就可以解决,或许以上案例有些夸张,但在一个大一点的项目中,我们往往是无法保证任何一个地方都能够将这类问题考虑得非常清楚,虽然React给出了useMemo useCallback等方式,React将这些问题跑给我们手动处理,并且一个致命的问题是盲目添加 useMemo 会导致应该 render 的没 render,因此在实际开发中我们往往很难做到像Vue一样的更新,会有很多不必要的render

总结

Vue相对于React来说需要先创建响应式数据,意味着往往在首屏渲染需要大量组件初始化的时候Vue做了更多的工作,但正因如此Vue在界面更新的时候能做到更精确的找到需要更新的组件,在React中经常会出现CPU计算量大的场景,其实这也是为何React具有时间切片,而Vue不需要的原因。