vue 随记(5):性能的飞跃

时间:2022-07-22
本文章向大家介绍vue 随记(5):性能的飞跃,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。
性能的飞跃

1. compile

尤雨溪的B站直播介绍到更新相比于vue2有1.3~2倍的性能优势。那么vue3比vue2块在哪里?

•Proxy取代defineProperty。这个之前的文章已经提过了。•虚拟dom(v-dom)重写--->静态标记:主要体现在纯粹静态节点将被标记•diff算法:vue2是双端比较。vue3加入了最长递增子序列(一种算法)。

1.1 vue3的模板是html吗?

或许这个网址能给你一点启示:http://vue-next-template-explorer.netlify.app/。

当我在模板写下这段代码:

<div>
  <div>djtao</div>
  <div>{{age}}</div>
</div>

看似html的代码经过vue 3编译,其实是一段js。

import { createVNode as _createVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _createVNode("div", null, "djtao"),
    _createVNode("div", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
  ]))
}

留意到模板代码中存在变量的时候,_createVNode方法多了第四个参数1,提示node为文本节点。而模板中的djtao作为纯静态节点,第四个参数不传,就是纯静态节点,在vdom diff的时候,会被直接忽略。

我们再从模板中加一段:

<div :id="aaa">aaa</div>
// 编译后
_createVNode("div", { id: _ctx.aaa }, "aaa", 8 /* PROPS */, ["id"])

节点的动态部分,会维护在一个数组里。

vue3通过_createVNode方法的第四个参数,可以确定哪些是动态的,diff的时候判断是需要操作text,属性亦或是class。上面的例子中,第四个参数为1表示只需要关心text。第四个参数为8,表示只需要关心节点的id。

想阅读相关代码,可以在源码package/src/shared/src/patchFlags.ts中找到。

1.2 compile的本质

编译就是把看起来像html的模板字符串,转化为js的过程。

在jquery时代,原本就没有“模板字符串”这种说法。JS想要生成html都是非常暴力的html()操作。到了js库underscore问世之后,就发明了一种奇怪的写法:

<%= 标记变量•<% 标记js语法

于是你可能从那个时代看到了这种前端代码:

<script type="text/template" id="tpl">
<% _.each(data, function (item) { %>
  <div class="outer">
    <%= item.title %> - <%= item.url %> - <%= item.film %>
  </div>
<% }); %>
</script>

框架通过解析这段字符串,判断哪些是变量,那些是html节点,并通过innerHTML来生成html代码。

underscore的模板可以说是一种进步,因为前端可以在相对直观的视野之下渲染模版了。但是每当变量变化,整个代码块的内容都会被重新计算innerHTML。但是我们做个实验:

<div id="app"></div>
<script>
    const app = document.querySelector('#app');
    let arr = [];
    for (let k in app) {
      arr.push(k);
    }
    console.log(arr.length, arr);
</script>

单个空div居然有多达293个属性。

实际上,在js只要通过一个对象即可描述上面这个div:

{
  type:'div',
  props:{id:'app'},
  chidren:[]
}

到了MVVM普及的时代,前端开发者都有了共识:

•类似underscore的解决方案,每次渲染的成本太高了!•dom是万恶之源。应极力避免之•编译时,肯定不是全部编译,而应该是部分编译。(按需编译)

这时,mvvm 编译优化就集中在如何更好地按需编译

vue3 编译的要点在于:

•使用js来描述dom(虚拟dom)•数据修改,通过diff算法求出需要修改的最小部分——再进行修改。相当于加了一层“缓存”。

1.3 编译原理

作为前端,学习编译原理可以去阅读一个库的源码:

the-super-tiny-compiler :https://github.com/starkwang/the-super-tiny-compiler-cn

未来允许会写一下对这个库的解读笔记。

Vue3 的内容和之前差不多,还是:

1.模板字符串->抽象语法树(ast,用对象来描述dom)2.cransform(语意转换)3.codeGenerate:生成代码。

最简单的render比如——我需要把js编译下列html

<ul id="ul">
  <li class="item">1</li>
  <li class="item">2</li>
  <li class="item">3</li>
</ul>

抽象之后的js代码(ast)可能是

    const dom = {
      type: 'ul',
      props: {
        id: 'ul'
      },
      children: [{
        type: 'li',
        props: {
          class: 'item',
        },
        children: ['1']
      }, {
        type: 'li',
        props: {
          class: 'item',
        },
        children: ['2']
      }, {
        type: 'li',
        props: {
          class: 'item',
        },
        children: ['3']
      }]
    }

代码是个简单的递归:

    const app = document.querySelector('#app');

    const render = (dom, parentNode) => {
      const { type, props, children } = dom;
      const wrap = document.createElement(dom.type);
      for (let attr in props) {
        wrap.setAttribute(attr, props[attr]);
      }

      if (children && children.length) {
        // if(typeof children == '')
        for (let i = 0; i < children.length; i++) {
          if (typeof children[i] == 'string') {
            wrap.innerHTML = children[i];
          } else {
            render(children[i], wrap);
          }
        }
      }

      parentNode.appendChild(wrap);
    }

    render(dom, app);

1.4 源码导读

打开packages/compiler-dom/src/index.ts

export function compile(
  template: string,
  options: CompilerOptions = {}
): CodegenResult {
  return baseCompile(
    template,
    // ...
  )
}

上述代码提示template是一个字符串。跳转到baseCompile:

// we name it `baseCompile` so that higher order compilers like
// @vue/compiler-dom can export `compile` while re-exporting everything else.
export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  const onError = options.onError || defaultOnError
  const isModuleMode = options.mode === 'module'
  /* istanbul ignore if */
  // ...

  // 1.basePaser 抽象语法树(ast)
  const ast = isString(template) ? baseParse(template, options) : template
  const [nodeTransforms, directiveTransforms] = getBaseTransformPreset(
    prefixIdentifiers
  )

  // 2. 语义转换
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )

  // 3. 生成代码
  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

反映了编译的三大过程。

2. vDOM

在执行这段代码时,发生了什么?

const App = {
  setup(){
    // ..
    watchEffect(()=>{
      // ..
    })
  }
}

watchEffect内的方法被执行时,意味着数据变化。

这时候响应式就会通知组件更新,具体怎么更新?就会触发vdom的diff算法。

2.1 传统vDOM的性能瓶颈

在传统的vdom(react <=15,vue <=2)中,组件每当收到watcher的依赖,虽然能保证自身按照最小规模的方向去更新数据,但是,仍然避免不了递归遍历整棵树。在这种情况,如果计算耗时于33.3ms(30fps情况下),就会导致肉眼可见的卡顿(丢帧)。

再比如上图,反映的是传统vdom的diff流程,一个dom,性能和模板大小正相关,和动态节点的数量无关。那么可能导致一个情况,一个大组件只有少量动态节点的情况下,依然完整的被遍历。

2.2 极致的按需分配

到了vue3,就不需要遍历整棵树了。

vue早就可以支持jsx了。但在vue3写template,可以获得较jsx更好的性能。

这种追求性能极致的灵感,来源于facebook的开源项目prepack(https://prepack.io/)

Prepack是一个JavaScript源代码优化工具:实际上它是一个JavaScript的部分求值器(Partial Evaluator),可在编译时执行原本在运行时的计算过程,并通过重写JavaScript代码来提高其执行效率。Prepack用简单的赋值序列来等效替换JavaScript代码包中的全局代码,从而消除了中间计算过程以及对象分配的操作。对于重初始化的代码,Prepack可以有效缓存JavaScript解析的结果,使得优化效果最佳。

2.3 vDOM发展简史

说到性能提升,离不开虚拟dom的历史。

Vue1.x时代是没有虚拟dom的概念的。它的核心只有依赖(depends),观察者(watcher)还有真实dom。

如上图,每个动态的节点,都对应一个watcher。数据变了,直接去改dom。但是当节点越来越大,结构愈发复杂,随着watcher都增多,会造成性能雪崩。

而对于React 16.4及以下版本,创造性的提出了虚拟dom的概念。但是,React本身是没有响应式系统的。它的更新,依赖于虚拟dom树的diff算法:

如图,先后两个状态,比较发现不同,则更新。

vue2吸取了react的虚拟dom的核心优点。于是wathcer不再通知到真实dom,只通知到“组件(vdom)”,再通过组件去diff,再触发更新。这个举措让vue实现了质的飞跃。

但是,老版本的react依然存在弱点:如果diff时间超过16.6ms(60fps所需单位时间),就会造成卡顿。于是react16再次创造了fibber架构

所谓fibber树,本质上是一个链表。而链表的特性是可以中断的。当渲染任务超过16.6ms,就把控制权还给主线程。待主线程空闲时,再继续。

而对于vue3来说,提升就在于静态标记。也就是前面所提及的内容。

3. mount & reRender

项目地址:https://github.com/dangjingtao/vue2-vs-vue3.git

我们新建一个项目,直接在项目中引入vue3和vue2.并调用loadash的shuffle方法作为乱序依据。

    // 模板
    const template =
      `<div>
        <h1>item length: {{datas.length}}</h1>
        <p><b>{{action}}</b> tooks {{time}} ms</p><br>
        <button @click="shuffle">shuffle</button>
        <ul v-for="item in datas" :key="item.index">
          <li>{{item.name}}-{{item.index}}</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
        </ul>
      </div>`;

    // 数据生成器
    const getData = (n) => {
      let ret = [];
      for (let i = 0; i < n; i++) {
        ret.push({ name: 'djtao', index: Math.round(1000000 * Math.random()) })
      }
      return ret;
    }

    // 以500条为测试数量
    const datas = getData(500);

生成50,500,5000,50000条数据文件,其中动态节点约占1/4。为便于比较,均采用options API写法。

<!--vue2 -->
<script>
      let s = window.performance.now();

    const vm = new Vue({
      el: '#app',
      template,
      data: {
        action: 'render',
        time: 0,
        datas,
      },
      mounted() {
        this.time = window.performance.now() - s;
      },
      methods: {
        shuffle() {
          this.action = 'shuffle';
          this.datas = _.shuffle(this.datas);
          let s = window.performance.now();
          this.$nextTick(() => {
            this.time = window.performance.now() - s;
          })
        }
      }
    })
</script>

Vue3 写法:

<!--vue3 -->
<script>
    Vue.createApp({
      template,
      data() {
        return {
          action: 'render',
          time: 0,
          datas
        }
      },
      mounted() {
        this.time = window.performance.now() - s;
      },
      methods: {
        shuffle() {
          this.action = 'shuffle';
          this.datas = _.shuffle(this.datas);
          let s = window.performance.now();
          this.$nextTick(() => {
            this.time = window.performance.now() - s;
          })
        }
      }
    }).mount('#app');
</script>

分别测试5次,取平均值。统计如下

数据量(条)

50

500

5000

50000

vue2平均渲染(ms)

18.88

46.26

225.88

1746.78

vue3平均渲染(ms)

23.58

40.32

137.4

900.24

vue2平均乱序(ms)

4.06

17.78

146.42

1935.94

vue3平均乱序(ms)

2.42

13.98

94.92

1328.88

由图可见,在5000及以上条数据量时,vue3比vue3要快50%-100%。

4. SSR

在服务端渲染(ssr)场景下,vue3的性能优势更为明显。

在 https://vue-next-template-explorer.netlify.app/ 沙盒,把选项设置为SSR:

先看纯静态节点的渲染:

<div>
  <div>djtao</div>
  <div>djtao1</div>
  <div>djtao2</div>
</div>

编译之后,发现他们全部被转化为了字符串:

// 编译后
import { mergeProps as _mergeProps } from "vue"
import { ssrResolveCssVars as _ssrResolveCssVars, ssrRenderAttrs as _ssrRenderAttrs } from "@vue/server-renderer"

export function ssrRender(_ctx, _push, _parent, _attrs) {
  const _cssVars = ssrResolveCssVars({ color: _ctx.color })
  _push(`<div${_ssrRenderAttrs(_mergeProps(_attrs, _cssVars))}><div>djtao</div><div>djtao1</div><div>djtao2</div></div>`)
}

// Check the console for the AST

接下来手写一下vue的ssr。通过express做服务器。以wrk作为压测工具。

Mac 安装wrk(https://github.com/wg/wrk)

brew install wrk

4.1 ssr@vue2

新建项目ssr 2,安装express/vue/vue-server-renderer/vue-template-compiler

npm init -y
npm i express vue vue-server-renderer vue-template-compiler -S

新建一个server.js

/**
 * server side render(SSR) 
 * seo 首屏渲染的解决方案
 */


// vue3的ssr主要时静态节点字符串,只有一个buffer,不停地推字符串
const App = {
  template:`
    <div>
      <div v-for="n in 1000" :key="n"> 
        <ul>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li style="color:red;" v-for="todo in todos">{{n}}-{{todo}}</li>
        </ul>
      </div>
    </div>
  `,
  data(){
    return {
      todos: ['eating','sleeping'],
    }
  }
}

const express = require('express');
const app = express();

const Vue = require('vue');
const render = require('vue-server-renderer').createRenderer();
const vue2compiler = require('vue-template-compiler');

App.render = new Function(vue2compiler.ssrCompile(App.template).render)

app.get('/',async (req,res)=>{
  let vApp = new Vue(App);
  let html = await render.renderToString(vApp);
  // vue 组件解析为字符串。
  res.send(`
    <h1>vue 2 ssr</h1>
    ${html}
  `);
});

app.listen('9001',err=>{
  if(!err){
    console.log('server started...')
  }
});

看到界面:

Vue2 的服务端渲染就完成了。

执行压测(4进程,100并发,持续15秒):

wrk -t4 -c100 -d15 http://localhost:9001

每秒请求大约在162次。

4.2 ssr@vue3

新建项目ssr 3

npm init -y
npm i express vue@next @vue/server-renderer @vue/compiler-ssr -S

新建server.js

/**
 * server side render(SSR) 
 * seo 首屏渲染的解决方案
 */

// vue3的ssr主要时静态节点字符串,只有一个buffer,不停地推字符串
const App = {
  template:`
    <div>
      <div v-for="n in 1000" :key="n"> 
        <ul>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li>Lorem ipsum dolor sit amet</li>
          <li style="color:red;" v-for="todo in todos">{{n}}-{{todo}}</li>
        </ul>
      </div>
    </div>
  `,
  data(){
    return {
      todos: ['eating','sleeping'],
    }
  }
}

const express = require('express');
const app = express();

const Vue = require('vue');
const render = require('@vue/server-renderer');
const vue3compiler = require('@vue/compiler-ssr');

App.ssrRender = new Function('require',vue3compiler.compile(App.template).code)(require);
app.get('/',async (req,res)=>{
  let vApp = Vue.createApp(App);
  let html = await render.renderToString(vApp);
  // vue 组件解析为字符串。
  res.send(`
    <h1>vue 3 ssr</h1>
    ${html}
  `);
});

app.listen('9002',err=>{
  if(!err){
    console.log('server started...')
  }
});

访问本地9002端口,vue3 ssr就访问成功了。

执行压测(4进程,100并发,持续15秒):

wrk -t4 -c100 -d15 http://localhost:9002

vue3 ssr每秒请求大约在374次。vue3 ssr性能是vue2 2倍以上的差距。

vue3的ssr渲染器的逻辑,是尽可能的把虚拟节点转到字符串。

vue3中复杂组件树,ssr场景下会最大化利用node的异步状态,每个组件是一个buffer, 是一个promise 可以直接await, 服务端任何组件节点,都有可能会有异步数据的依赖。