vue性能优化

vue性能优化

  • 前言
  • 函数式组件
  • 冻结列表数据
  • 子组件拆分
  • 局部变量(缓存变量)
  • computed 的缓存特性
  • v-if 和 v-for 不要同时出现
  • 不需要渲染在视图的数据不要写在 data 中
  • v-for 中的 key
  • keep-alive
  • 渐进式渲染
  • 时间片切割
  • 组件懒加载
  • 总结

前言

本文主要记录日常开发中常见的优化技巧。主要是针对2.x版本的。

函数式组件

函数式组件是使用 functional 字段来进行声明的。它是一个没有data响应式数据和this上下文,也没有生命周期钩子函数这些东西,只接受一个props。普通对象类型的组件在patch的时候,如果遇见一个节点是组件,就会递归执行子组件的的初始化话过程。而函数式组件render生成的是普通vnode,不会有递归子组件的过程,因此渲染开销会低很多。实际上可以理解成把DOM抽离了出来,是一种在DOM层面的复用。

我们可以从源码中看见:

function createComponent(Ctor, data, context, children, tag) {
  // ...
  // 根据 functional 字段来判断是否为函数式组件
  if (isTrue(Ctor.options.functional)) {
    return createFunctionalComponent(Ctor, propsData, data, context, children);
  }
  // ...
  // 正常的组件是在此进行初始化方法(包括响应数据和钩子函数的执行)
  installComponentHooks(data);
  // ...
  return vnode;
}

从上面我们可以看见,在创建组件的时候,会根据functional字段来判断是否为函数式组件,是就会走函数式组件的创建过程,不是就会走正常组件的创建过程(初始化生命周期函数,响应式数据等等)。

函数式组件一般是使用在一些没有交互,不需要存储内部状态,纯展示 UI 的组件上面。比如新闻公告详情这些页面,就是单纯地把数据显示出来。

使用方式如下:

Vue.component("my-component", {
  functional: true,
  // Props 是可选的
  props: {
    // ...
  },
  // 为了弥补缺少的实例
  // 提供第二个参数作为上下文
  render: function (createElement, context) {
    // ...
  },
});

2.5.0以上的版本,还可以这样子写


冻结列表数据

在我们平常的开发中,会经常遇见一些列表的数据。这些列表数据是一个Array数组,数据的每一项又是一个普通对象,但是这些列表数据只是单纯的展示,每一项数据是不需要发生变化的。那么,我们可以使用Object.freeze([])来冻结列表数据,减少数据响应的层级(递归),提高性能。

我们可以从源码中看见:

export class Observer {
  constructor(value: any) {
    def(value, "__ob__", this);
    if (Array.isArray(value)) {
      // 将数组中的所有元素都转化为可被侦测的响应式
      this.observeArray(value);
    } else {
      // 普通对象
      this.walk(data);
    }
  }
  walk(data) {
    for (const key in data) {
      if (Object.hasOwnProperty.call(data, key)) {
        //   将普通对象转化为响应式数据
        definedRetive(data, key, data[key]);
      }
    }
  }
  observeArray(items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      // 监听数组的每一项
      observe(items[i]);
    }
  }
}
export function observe(value, asRootData) {
  // 如果监听的数据是一个非对象类型或者是一个vnode,则不进行监听
  if (!isObject(value) || value instanceof VNode) {
    return;
  }
  let ob;
  if (hasOwn(value, "__ob__") && value.__ob__ instanceof Observer) {
    //   已经监听过的数据上面会有__ob__属性
    ob = value.__ob__;
  } else {
    ob = new Observer(value);
  }
  return ob;
}

从上面我们可以看出,数组里面的数据会被递归进行数据监听,如果数组中的每一项拥有更深层次的对象,这些更深层次的对象也会被递归变成响应式数据。

Object.freeze是可以将一个对象变为不可配置的,也就是只能读,也就是将configurable设置为false,不能进行增删改这些操作。vue 进行数据响应的时候,如果发现是一个不可配置的对象后,就会return返回,不会执行下面的逻辑,也就是不会把数据变成响应式数据的逻辑。

我们可以从源码中看见:

export function defineReactive(
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 获取对象的描述信息
  const property = Object.getOwnPropertyDescriptor(obj, key);
  //   configurable判断是否为可配置的
  if (property && property.configurable === false) {
    return;
  }
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // ...
    },
    set: function reactiveSetter(newVal) {
      // ...
    },
  });
}

冻结列表数据一般是使用在那些数据量大,但是又不需要对每一项数据进行修改的场景,通常这些列表数据只是用来展示。比如新闻公告列表。

代码示例:



子组件拆分

当我们的页面上有如下代码时:




从上面可以看见,该页面由于有一个定时器,所以每秒会触发一次更新。由于 vue 的更新是组件粒度的(只更新发生数据变化的组件,不会递归更新子组件),整个页面都会被重新更新,当我们的页面上还有其他比较复杂的逻辑时,这个更新过程是很耗时的(先转化为 vnode->在进行 patch 对比新旧 vnode->更新)。所以我们要把上面的代码封装成一个组件,减少重新更新的范围。代码如下

count-component 组件







局部变量(缓存变量)

我们先看一下下面的代码:




从上面可以看见,result 这个计算属性在计算结果的时候会频繁访问this.base这个数据。

我们再看看 vue 中关于数据响应的源码:

function defineReactive(obj: Object, key: string, val: any) {
  const dep = new Dep();
  let childOb = observe(val);
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // ...
      // getter的时候进行依赖收集
      if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
          if (Array.isArray(val)) {
            dependArray(val);
          }
        }
      }
      return val;
    },
    set: function reactiveSetter(newVal) {
      if (newVal === val) {
        return;
      }
      val = newVal;
      childOb = observe(newVal);
      dep.notify();
    },
  });
}

综合来看,在读取this.base这个属性的时候会触发它的getter,进而会执行依赖收集相关逻辑代码。result这个计算属性中,每一次 for 循环都会读取 6 次this.base属性,一共循环了 1000 次,所以getter依赖收集相关逻辑代码会被执行 6000 次。这 6000 次做的都是无用功的,从而导致性能下降了。

实际上来说,this.base只需要被读取一次,然后执行一次依赖收集就可以了。所以我们可以使用局部变量来缓存this.base属性的值,后续我们就是用这个局部变量代替this.base,就不会在走依赖收集的相关逻辑了。优化后的代码如下:




在实际的开发中,我看见有很多人每次取变量的时候都是喜欢直接写this.xxx,当访问次数多了(特别是在 for 循环里面),性能的缺陷就会凸显出来了。所以当你在一个函数中频繁的读取某个变量值的时候,请记得使用局部变量来缓存变量值。

局部变量这个性能优化其实不单单可以使用在 vue 上面,还可以使用在其他地方。比如我们需要循环一个数组的时候,可以缓存数组的长度,而不是在每次循环的时候读取数组的length属性(实际上很多人喜欢在循环的直接读取数组的length属性)。操作 DOM 的时候也要把 DOM 使用局部变量缓存下来,因为 DOM 的读取是相当消耗性能的。

computed 的缓存特性




从上面我们可以看见,style计算属性返回的东西跟getStyle函数返回的东西实际上是一样的。但是当我们的定时器启动的时候,就会每一秒触发一次视图的更新。我们可以从控制台中可以看见,每一秒都会打印出一次getStyle,而style只打印了一次。这个得益于 vue 的computed计算属性具有缓存的特性,只有当width的值发生变化的时候,style这个计算属性才会重新计算,count这个属性并不是style计算属性依赖的变量,所以count的变化不会影响到count计算属性。所以我们要善于利用computed这个计算属性,而不是通过一个methods函数返回一个值,methods函数会随着每次视图更新而触发,重新执行一次。如果methods函数中包含了大量的逻辑运算,就会造成大量的性能损耗。

vue 的计算属性源码如下:

const computedWatcherOptions = { lazy: true };
function initComputed(vm: Component, computed: Object) {
  // 往组件实例上面添加一个_computedWatchers属性,保存所有computed watcher
  const watchers = (vm._computedWatchers = Object.create(null));
  // 遍历computed上面的所有属性
  for (const key in computed) {
    const userDef = computed[key];
    // computed可以是一个函数或者是对象
    const getter = typeof userDef === "function" ? userDef : userDef.get;
    // 数据响应的watcher
    watchers[key] = new Watcher(
      vm,
      getter || noop,
      noop,
      computedWatcherOptions
    );
    if (!(key in vm)) {
      defineComputed(vm, key, userDef);
    }
  }
}

function defineComputed(target: any, key: string, userDef: Object | Function) {
  if (typeof userDef === "function") {
    sharedPropertyDefinition.get = createComputedGetter(key);
    sharedPropertyDefinition.set = noop;
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop;
    sharedPropertyDefinition.set = userDef.set || noop;
  }
  // 重写get,set
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

function createComputedGetter(key) {
  // 返回的是一个`getter`
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key];
    // watcher存在说明computed属性存在
    if (watcher) {
      // 如果computed依赖的响应式数据发生了变化,就会触发watcher.update,把dirty置为true,重新计算computed属性
      // 如果没有发生变化,那么返回的还是上一次的值
      if (watcher.dirty) {
        // evaluate函数内部会重新获取watcher.value的值,并把watcher.dirty设置为false,下一次就不会被重新计算了
        watcher.evaluate();
      }
      return watcher.value;
    }
  };
}

function createGetterInvoker(fn) {
  return function computedGetter() {
    return fn.call(this, this);
  };
}

class Watcher {
  constructor(vm, expOrFn, cb, options, isRenderWatcher) {
    this.vm = vm;
    if (isRenderWatcher) {
      vm._watcher = this;
    }
    vm._watchers.push(this);
    if (options) {
      // 初始化为true
      this.lazy = !!options.lazy;
    }
    this.getter = expOrFn;
    // 初始化为true
    this.dirty = this.lazy;
    // 默认是undefined
    this.value = this.lazy ? undefined : this.get();
  }
  update() {
    if (this.lazy) {
      // computed依赖的数据发生变化的时候,会把dirty置为true
      this.dirty = true;
    }
  }
  evaluate() {
    // 重新获取值
    this.value = this.get();
    this.dirty = false;
  }
}

v-if 和 v-for 不要同时出现

v-for 指令是用来循环列表的。v-if是用来隐藏组件的,使用v-if隐藏的组件是不会执行内部的渲染逻辑的。我们看一下如下代码:



v-ifv-for同时出现的时候,v-for的优先级会比v-if的高。也就是说class='item'的 div 首先会被渲染成 10 个 div,然后再判断下标索引号是否为偶数,不是就隐藏掉。其中有 5 次(5 个奇数)渲染是做无用功的。5 次的无用功无疑就会造成性能上面的浪费。所以我们可以借助computed先过滤掉那些不需要显示的数据,然后在使用v-for循环列表。代码如下:



有时候我们需要根据某个字段来控制列表是否显示,代码如下:



从上面可以看见,show 为 false,也就意味着做了 10 次没有意义的渲染。我们可以将v-forv-if指令分离,让v-if先执行,这样就不会做 10 次无意义的渲染了。代码如下: