故事的文件合集

建议更名:引用自己作品次数最多的文章
科目:计算机科学
本文含有大量图片,推荐web版打开

序言

物理实验室网页版发布之际,@MapMath 反馈了一个卡顿问题,虽然经过debug发现,卡顿主要是服务器+提示逻辑原因,但是调试过程中也发现了性能问题,我试图着手解决这个东西,在过程中也了解了一项方兴未艾的技术:Vue-Vapor Mode,今天就来和大家分享一下

他的运作原理是什么?

vue的核心目的就是为了符合"数据驱动"这一开发原则,是众多响应式(基于siagnal)的实现方式之一⁽¹⁾,plweb采用vue3框架开发,例如,在渲染user profile dialog⁽²⁾的时候,我们定义一个需要渲染的界面,并将dom元素(比如显示出来的用户名)绑定到变量username(我们称他为一个signal),初始时,username为loading,在获取服务器数据后,只需要更改username的值,vue就会自动渲染更新用户名(我们称他为effect)(记好啦后面会考),这样就很方便。

username变更后,如果依照现在的状态修改整个页面结构,就会导致很多的dom操作,从而影响效率⁽³⁾,vue是如何知道要修改具体哪个元素的呢?

Vue目前稳定版本使用的是虚拟dom⁽⁴⁾(简称vdom)

简单来说,我们的网页是由一个个元素组成的,比如一个文本框,一个放置文字的区域,一张图片,一行不可见的包裹按钮整齐排列的框等等。Dom树⁽⁵⁾就是描述页面之间元素关系的一个对象。我们后面还会提及AST、IR这些类似的表示页面关系的对象,别搞混啦。

在signal变化时,vue会生成新状态的虚拟dom树,并使用diff算法遍历新状态和当前状态的虚拟dom树,然后确定需要更改哪个元素。

Doctor Wu 展示Vue Vapor关于dignal处理的部分机制

Diff算法在包管理工具等方面都有着重要的运用,其中一种基础的实现方式叫做最长递归子序列算法,这个我之前也提到过,感兴趣的同学可以自己了解一下⁽⁶⁾。

一种常见的误解是:使用虚拟dom技术比操作真实dom更省时间。事实上,虚拟dom最终在渲染的时候也是必须去操作真实dom的,能够有更高的效率的原因就是它可以把这个修改操作最小化。虚拟d om技术并不是避免了dom的操作,而只是减少了无谓的dom更新。

为什么使用Vapor Mode

两周前的VueConf会议结束后发布了鸽(写)了三年Vapor Mode(vue 3.6.0 alpha2)⁽⁷⁾,抛弃了vdom技术,Why?

之前和@Arendelle 聊天的时候,发现他对于编译时期确定产物和(使用除了RewriteItInRust以外的任何方法)提高性能有一种执念(这也是使用基于cpp的wasm重构plweb富文本引擎的原因)。在编译阶段哪些量会变,哪些量不会变,or 变化后要渲染在哪里,其实在很多情况下都是可以确定的。在我们上面的执行流程中,vue需要对两颗vdom树进行一个详细的比对才会得出具体要修改哪里,这也就是vue目前的性能瓶颈所在,前端苦vdom久矣。当然,vue团队早就知道了,花了几年时间终于能让编辑器静态分析js这个逆天玩意)

Vapor所需要做的事就是:它可以在构建的时候就确定日后什么发生变化时需要改动哪些真实Dom,在运行时就无需维护一个虚拟dom的对象,可以极大地提升运行效率。在高性能要求的场景:比如说需要渲染几百万行的表单数据,或者说要做高性能的60帧动画,在Vapor出现之前,常规的做法就是抛弃任何框架,使用原生JavaScript。Vapor的出现就让我们在原生的高性能和框架的便捷性之间提供了一个新的平衡点

编译产物对比

升级到Vue Vapor

使用vite7.0.0+,vue3.6.0alpha1/2,手动安装terser之后,可以在创建app时使用createVaporApp全局启用vapor,或者app.use注册vapor插件而局部启用。

尤雨溪骄傲(?)地说,这是由于vue3架构设计好,挑战性强;同时,这也是因为Vue Vapor Mode Runtime直接复用了reactivity,我从 三咲智子
(Kevin Deng) 在Vue Conf上所做的演讲里面截取了几张图片:

这个是常规的方式

引入ref之后,可以更好的掌握signal

引入effect之后,就可以无须手动render了

这三张图片的核心主题是:复用与高效

我们计划使用Vapor重构plweb,主要是因为尤雨溪在VueConf上提到Vapor完美符合渐进式更新的要求,指定部分组件使用Vapor Mode编译,正如复兴物实所reveal的, <discussion=688e3bde2805940f585336c4>尝试失败了,因为性能敏感组件所依赖的多语言插件不支持Vapor Mode。

**然而,如果你在web版查看本作品,这个页面上一些组件正是使用Vapor编译和渲染的!**当然你现在直接打开F12看到的是经过压缩混淆的,压缩前,你可以看到这一段:

function _sfc_render(_ctx, $props, $emit, $attrs, $slots) {
    const n0 = _createIf( () => $props.tag && !$props.tag.startsWith("Type-"), () => {
        const n2 = t0();
        n2.$evtclick = _withModifiers(_ctx.jump, ["stop"]);
        const x2 = _child(n2);
        _renderEffect( () => {
            const _tag = $props.tag;
            _setText(x2, _toDisplayString(_tag.startsWith("C-") ? _tag.slice(2) : _ctx.tagName));
        }
        );
        return n2;
    }
    );
    const n3 = t1();
    return [n0, n3];
}

vue 3.6.0 Alpha2

本文的分析基于此版本 可以早vuejs/core下翻到原代码,这里贴一个项目架构:

├── packages/
│   ├── compiler-core/
│   ├── compiler-dom/
│   ├── compiler-sfc/
│   ├── runtime-core/
│   ├── runtime-dom/
│   ├── reactivity/
│   ├── compiler-vapor/
│   ├── runtime-vapor/

Valor运行时

尤雨溪在演讲中指出:传统vdom模式返回一个render的渲染函数,Vapor模式中返回的函数运行后会直接生成真实的Dom。Vapor的函数第一次运行时,会获取到所修改的Dom元素的引用,在之后,直接通过引用修改元素(然而,这个概念并不新鲜,这是一个不难想出来的方案,关键是如何实现)

正如前文所说,vapor是直接依赖vue之前的reactivity相关的内容。创建原子状态单元:会返回一个具有getter(可以理解为获取变量值的途径)和setter(可以理解为设置变量值的途径)的元组(javascript里面其实没有元组这个概念,不过typescript里面有,它就是长度固定的数组,这里就是[getter,setter])当在其他地方调用getter时,他会注册某个effect作为它的定订阅者,而setter函数在调用时,会通知并调度所有订阅了signal和effect。其实这里也是传统的发布订阅模式一个更细化的表达, Signal系统会更精确的监听具体的属性⁽⁸⁾。同时effect会被管理它的派生状态与缓存,形成高效的依赖图。

而具体关联变量和代码的执行依赖的则是一个创建副作用单元的函数,把其他函数传入这个函数,其他函数会被执行一次,而在执行期间对signal的读取操作会被捕获。它内部会维护一个effect的调用栈,一个signal的getter触发之后直接将栈顶部的effect加入自己的订阅者列表里,当写入操作发生,会依照一些特定的逻辑执行任务队列中的东西

上述是响应时的部分,而关于run time wapor所执行的代码,我们在Vapor演练场编译了下面这个示例组件:

<template>
  <div class="tag" v-html="tag"></div>
  <div @click="fn">
    <div>click1</div>
    <div @click.stop>click2</div>
  </div>
  <div @click="fn">click3</div>
</template>

<script setup vapor>
import { ref } from "vue";
const tag = ref("Tag");

function fn(){
  tag.value = tag.value.repeat(2)
}

</script>

<style scoped>
.tag {
  display: inline-block;
  padding: 1px 5px;
  border-radius: 20%;
  background-color: rgba(240, 240, 240, 0.8);
  color: #333;
  font-size: 10px;
  margin: 0 15px 0 0;
}
</style>

上面所提到的代码其实就是一个SFC单文本组件,它具有模板部分脚本部分和样式部分,这三个部分在经过compiler-sfc后会进行不同的处理。下面的内容就是alpha2的编辑结果,我们删去了一些不涉及到的部分

/* Analyzed bindings: {
  "ref": "setup-const",
  "tag": "setup-ref",
  "fn": "setup-const"
} */

const tag = ref("Tag");

function fn(){
  tag.value = tag.value.repeat(2)
}


const t0 = _template("<div data-v-7ba5bd90 class=\"tag\"></div>")
const t1 = _template("<div data-v-7ba5bd90><div data-v-7ba5bd90>click1</div><div data-v-7ba5bd90>click2</div></div>")
const t2 = _template("<div data-v-7ba5bd90>click3</div>")
_delegateEvents("click")
function render(_ctx, $props, $emit, $attrs, $slots) {
  const n0 = t0()
  const n2 = t1()
  const n3 = t2()
  const n1 = _next(_child(n2))
  n1.$evtclick = _withModifiers(() => {}, ["stop"])
  n2.$evtclick = _ctx.fn
  n3.$evtclick = _ctx.fn
  _renderEffect(() => _setHtml(n0, _ctx.tag))
  return [n0, n2, n3]
}

在处理样式方面,样式文件会编译为单独的css文件,同时,标记有scoped的style会根据其所处的作用域处理其类名。

在script setup里面的源码,大部分会被原封不动的照搬到编译结果中。

模板传入_template得到函数,以t0为例调用t0可以得到真实的dom元素,并把他赋值到n0,在这一步,vapor拿到了真实dom元素的引用。而后,按照IR确定的元素关系也(_next(child(n2)),不同类型的事件逻辑使用不同的函数进行绑定,最终将effect绑定。(注:ctx是上下文的意思)

Vapor编译时

接下来我们简单探讨一下vapor的编译原理

Template → parse → Templated AST → transform → Transformed AST → IR Optimization → generate → Runtime DOM Code


某技术社区上的图片,与VueConf的图有一些出入,在矛盾的地方,本文参考后者

根据尤雨溪在今年 VueConf 上所做的演讲,传统的vue 组件或者compiler-sfc经过单文件组件编译后,会经compiler-core,再进入compare-dom(2)。而启用vapor之后,会跳过(2)而进入compiler-vapor,这也就是vite称无感更新的原因,vapor mode 和vdom mode共用一套中间层。

compile 有三个阶段,分别为 parse,transform,generate

在vdom和vapor mode中,构建AST都使用Tokenizer,把代码拆解成一个个有意义的元素,称作Parse(解析)。在这里,标签,属性等都是一个个Token。首先会形成Templated AST,此时依然会保留Vue相关指令。这里会遍历模板字符串,在遇到特殊标记的时候,就调用相关方法,给大家截一段:

  onopentagname(start, end) {
    const name = getSlice(start, end)
    currentOpenTag = {
      type: NodeTypes.ELEMENT,
      tag: name,
      ns: currentOptions.getNamespace(name, stack[0], currentOptions.ns),
      tagType: ElementTypes.ELEMENT, // will be refined on tag close
      props: [],
      children: [],
      loc: getLoc(start - 1, end),
      codegenNode: undefined,
    }
  },

parse之后,经过transform之后会形成 Transformed AST ,在这里会处理Vue相关指令:transformElement, transformChildren, transformTemplateRef, transformText, transformVBind, transformVHtml, transformVOn, transformVOnce, transformVShow, transformVText, transformVIf, transformVFor, transformVModel, transformComment, transformSlotOutlet, transformVSlot

有文章提出:在 Vapor 模式中,transform的结果Transformed AST 就是 IR,IR作为中间产物我没有看到明确的定义。在源代码里,IR只在Vapor模式里面有提现,并且全是类型定义

具体咋处理的,看这里:https://juejin.cn/post/7530501154183462952,截取结论:字符串 → 状态机 → 状态函数 → 回调触发 → Parser处理 → AST构建。

而后AST转换为IR,像 v-ifv-for 这样的条件渲染和列表渲染指令,或者处理动态绑定的表达式和事件处理函数,在 IR 层面可以设计得更利于执行静态分析、应用优化策略。在这个阶段会进行静态分析提升,编辑机会遍历整个抽象语法书判断节点是静态还是动态的。而静态序列会合并成一个单一的html字符串,这些东西在运行时会被一次性创建,也就是会被提升。而对于动态内容,它会绑定属性文本插值和处理相关的渲染指令,并创建一个动态绑定对象,比如绑定类型,依赖源头,和dom路径之类的(当然这里的路径并不是浏览器渲染出来的路径,在我们上面的运行时也看到了它会使用next,firstChild之类的函数表达关系)这些关键的优化,也为运行时获取signal绑定情况奠定了基础。

后记

感触颇多,无从下笔,下图是我的创作记录

注释

1,一个signal就是一个对于响应式的封装,你可以监听它变化,并实施对应操作,我们称之为effect
2,参见:<discussion=688378792805940f5852b036>物实常见术语(?)[词汇]
3,参见:<discussion=6814c925b9250aa5e0455e43>从源码到网页,浏览器做了什么?
4,参见:<discussion=64e9dacd5b2d2f2c3acadafd>虚拟DOM技术鉴赏
5,关于DOM,CSSOM,Layout,可以看看<discussion=6814c925b9250aa5e0455e43>从源码到网页,浏览器做了什么?
6,参见:<discussion=661a4abcd10e3b2940763b9a>悬赏题 金币*2300
7,参见:<discussion=65c2fd26000124686a0dccfc>版本号的管理与含义
8,参见:<discussion=67a63ca5a770fa2f7b92e8f7>发布-订阅模式

参考信息来源

为了方式渲染抽风,这里丢一张图

好吧,我不得不承认,我整了一个特别恶心的托管网站,不点到他的那个域名里面就查看不了这个视频。希望大家引以为戒,不要再找这么恶心的托管网站了。

1000170700.mp4