vue性能优化

Posted 在厕所喝茶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了vue性能优化相关的知识,希望对你有一定的参考价值。

前言

本文主要记录日常开发中常见的优化技巧。主要是针对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以上的版本,还可以这样子写

<template functional></template>

冻结列表数据

在我们平常的开发中,会经常遇见一些列表的数据。这些列表数据是一个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) {
      // ...
    },
  });
}

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

代码示例:

<template>
  <div>
    <div v-for="(item, index) in list" :key="index">
      {{ item.label }}
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      list: [],
    };
  },
  created() {
    let list = [];
    for (let i = 1; i < 1000; i++) {
      list.push({
        id: i,
        label: `第${i}个`,
      });
    }
    // 冻结数据
    list = Object.freeze(list);
    this.list = list;
  },
};
</script>

子组件拆分

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

<template>
  <div>
    首页
    {{ message }}
    {{ count }}
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
      message: "hello world",
    };
  },
  mounted() {
    this.timer = setInterval(() => {
      this.count += 1;
    }, 1000);
  },
};
</script>

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

count-component 组件

<template>
  <span>
    {{ count }}
  </span>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
    };
  },
  mounted() {
    this.timer = setInterval(() => {
      this.count += 1;
    }, 1000);
  },
};
</script>
<template>
  <div>
    首页
    {{ message }}
    <count-component />
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: "hello world",
    };
  },
};
</script>

局部变量(缓存变量)

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

<template>
  <div>{{ result }}</div>
</template>

<script>
export default {
  data() {
    return {
      start: 1,
      base: 24,
    };
  },
  computed: {
    result() {
      let result = this.start;
      for (let i = 0; i < 1000; i++) {
        result +=
          this.base * this.base + this.base + this.base * 2 + this.base * 3;
      }
      return result;
    },
  },
};
</script>

从上面可以看见,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,就不会在走依赖收集的相关逻辑了。优化后的代码如下:

<template>
  <div>{{ result }}</div>
</template>

<script>
export default {
  data() {
    return {
      start: 1,
      base: 24,
    };
  },
  computed: {
    result({ base, start }) {
      let result = start;
      for (let i = 0; i < 1000; i++) {
        result +=
          Math.sqrt(Math.cos(Math.sin(base))) +
          base * base +
          base +
          base * 2 +
          base * 3;
      }
      return result;
    },
  },
};
</script>

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

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

computed 的缓存特性

<template>
  <div>
    <div :style="style">bar1--{{ count }}</div>
    <div :style="getStyle()">bar2--{{ count }}</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      width: 100,
      count: 0,
    };
  },
  computed: {
    style() {
      console.log("style");
      return { width: `${this.width}px` };
    },
  },
  mounted() {
    setInterval(() => {
      this.count += 1;
    }, 1000);
  },
  methods: {
    getStyle() {
      console.log("getStyle");
      return { width: `${this.width}px` };
    },
  },
};
</script>

从上面我们可以看见,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隐藏的组件是不会执行内部的渲染逻辑的。我们看一下如下代码:

<template>
<div>
<div v-for='(item,index) in list' v-if='index%2===0' class='item'>{{item}}</div>
<div>
</template>
<script>
export default {
  data(){
    return {
      list:[1,2,3,4,5,6,7,8,9,10]
    }
  }
}
</script>

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

<template>
<div>
<div v-for='(item,index) in showList' class='item'>{{item}}</div>
<div>
</template>
<script>
export default {
  data(){
    return {
      list:[1,2,3,4,5,6,7,8,9,10]
    }
  },
  computed:{
    showList(){
      const list = this.list
      return list.filter((item,index)=>index%2===0)
    }
  }
}
</script>

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

<template>
<div>
<div v-for='(item,index) in list' v-if='show'>{{item}}</div>
<div>
</template>
<script>
export default {
  data(){
    return {
      list:[1,2,3,4,5,6,7,8,9,10],
      show:false
    }
  }
}
</script>

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

<template>
<div>
<template v-if='show'>
<div v-for='(item,index) in list' >{{item}}</div>
</tempalte>
<div>
</template>
<script>
export default {
  data(){
    return {
      list:[1,2,3,4,5,6,7,8,9,10],
      show:false
    }
  }
}
</script>

我们来看看 vue 的源码:

function genElement(el: ASTElement, state: CodegenState): string {
  if (el.parent) {
    el.pre = el.pre || el.parent.pre;
  }

  if (el.staticRoot && !el.staticProcessed) {
    // 静态节点
    return genStatic(el, state);
  } else if (el.once && !el.onceProcessed) {
    // v-once指令
    return genOnce(el, state);
  } else if (el.for && !el.forProcessed) {
    // v-for指令
    return genFor(el, state);
  } else if (el.if && !el.ifProcessed) {
    // v-if指令
    return genIf(el, state);
  } else if (el.tag === "template" && !el.slotTarget && !state.pre) {
    return genChildren(el, state) || "void 0";
  } else if (el.tag === "slot") {
    return genSlot(el, state);
  } else {
    // ...
  }
}

从上面的 if-else 判断条件中,我们可以看见v-for的执行要优先于v-if

不需要渲染在视图的数据不要写在 data 中

渲染在视图的数据是指在<template></template>html 模板中使用到的数据,这些数据都是响应式数据来的。而定义在 data 字段中的数据都会变成响应式数据,但是有些数据我们是不需要显示在视图中的,就不要把数据声明在 data 中了,比如在移动端中进行滚动加载,需要使用到分页参数,这些分页参数就不应该写在 data 中的(实际上就我看见的,很多人喜欢吧分页参数pageSizepageIndex写在 data 中的)。

优化前代码如下:

export default {
  data() {
    return {
      pageSize: 10,
      pageIndex: 1,
    };
  },
  methods: {
    getList() {
      axios
        .get("xxx", {
          params: { pageSize: this.pageSize, pageIndex: this.pageIndex },
        })
        .then(() => {});
    },
    scrollBottom() {
      this.pageIndex += 1;
      this.getList();
    },
  },
};

上面我们可以看见,pageSizepageIndex被定义在了data中,这就意味着这 2 个数据将会变成响应式数据,但是实际上pageSizepageIndex不需要像是在视图中。当我们对pageSizepageIndex就进行读操作时候,就会走getter依赖收集的逻辑,进行写操作的时候setter通知更新的逻辑,由于不需要反馈到视图中,所以gettersetter中的逻辑就是在做无用功,损耗了性能。

优化后代码如下:

export default {
  created() {
    this.pageSize = 10;
    this.pageIndex = 1;
  },
  methods: {
    getList() {
      axios
        .get("xxx", {
          params: { pageSize: this.pageSize, pageIndex: this.pageIndex },
        })
        .then(() => {});
    },
    scrollBottom() {
      this.pageIndex += 1;
      this.getList();
    },
  },
};

从上面可以看见,我们把pageSizepageIndex挂在到了实例this上面,这样既可以在组件的每个函数中访问到,又可以避免把它们变成响应式数据。

我们看看 vue 的源码:

function initState(vm: Component) {
  vm._watchers = [];
  const

以上是关于vue性能优化的主要内容,如果未能解决你的问题,请参考以下文章

vue项目性能优化

浅谈Vue 项目性能优化 经验

Vue之性能优化篇

Vue.js 应用性能优化,给你专业的分析+解决方案

vue-router和webpack懒加载,页面性能优化篇

vue中关于v-for性能优化---track-by属性