前言
2020年初,vue3 发布了第一个版本,在随后的时间内,vue-next
一直保持着快速的更新,直到去年的 9.18 号发布了第一个版本 one piece
,这个备受关注的库带来了更小的体积,更好的类型支持以及一套能够更优雅地处理复杂逻辑的api。并且由于苦于 webpack
在开发模式下,热模块替换随着项目变大速度也会变慢,基于原生的 es module
开发出了一套新的打包工具 vite
。
本文会从宏观角度来拆解 vue3。vue3 主要分为以下三个核心模块:响应式、编译器及运行时。
响应式
vue3 的响应式系统着重解决了两个问题:
- vue3 减少了实例化组件带来的性能开销
- 提供了一种 vue2 所缺少全局状态共享方法
我们知道,在 vue2 中创建组件实例的时候,我们往往需要在组件的实例上也就是 this 上暴露很多属性,data、props、methods 等等,这些属性都是要通过基于 es5 的 Object.defineProperty
这个 api 来挂载在 this上,这个操作是比较耗时的。
而在新版本的响应式系统基于proxy进行实现,我们就可以把上述属性挂载过程丢弃掉,暴露给渲染函数的this实际上是一个 proxy
,我们要取值的时候,由于前置已经知道了这个属性是 data、还是 methods,就可以直接从 proxy
上动态返回,省去了提前定义的这个步骤。
其次,由于 vue2 中缺少一种全局状态的共享方法,虽然可以通过诸如provide
、inject
、event bus
这种方式进行,但是在业务中,还是不是很好用,就得考虑使用 vuex。
我们可以知道,要使得数据能够全局共享,需要满足两个基本要求:一数据要具备响应式能力,即当其发生变化时候,要同时依赖于他的数据进行更新。其次满足可用且单例,可用性比较直观,数据一定是被导出的才能被别的组件所应用,单例指的是这个状态是全局唯一的,不能存在多份数据不一致的情况。
而 vue3 提供的独立成包的 @vue/reactive
就具备上述能力,这里有一个简单的 demo,分为两部分,上面子组件,下面父组件,每个组件都没有在组件内部定义数据,所有组件的数据都是由store进行共享的。
// store.js
import { reactive } from "vue";
const createStore = (store) => reactive(store);
const baseStore = {
count: 0,
data: null
};
const store = createStore(baseStore);
const dispatch = (type, payload) => (store[type] = payload);
export const useStore = () => {
return {
store,
dispatch
};
};
export default store;
在 store 中做的事情就比较简单,我们把一个普通的对象通过 reactive
这个 api 包裹使其具有响应式的能力。我们在父组件点击改变 store 的值,子组件也能够实现状态的同步。
同时,我们在子组件内部模拟一个异步请求,等待一秒钟后父组件也能够同步从子组件中获取的数据。我们可以看到,在子组件内部是直接通过store.count++
来修改数据的,这种方式其实在多人协作及项目复杂的时候是不可控的,状态流转是不清楚的。那么我们可以在 store 的基础上进行增强,来定义一个 dispatch
方法,使用 useStore
来自定义一个 hook
,将其暴露出去,在组件内部使用 dispatch
来进行数据操作。
以上我们就通过简单的响应式 api 实现了一个简单的数据共享的模型。那么vuex 的存在意义是什么?
其意义在于保证状态修改及副作用的可控性,是一种用制约来换取可维护性的一种平衡策略。vuex 实际上是以一种插件形式存在,在插件内部就可以实现一些派发和监听事件,来作为我们常用的调试工具进行状态的回滚及查看操作。未来的 vuex api 会在响应式模块的基础上进行大幅度减化。
同时,由于 @vue/reactive
独立成包,因此可以被用于其他框架进行状态控制。下面推荐了两篇文章及一个开源库 reactivue 都是将响应式 api 集成到 React Hooks
中,在react 中,修改状态不再需要使用 setState
,而是直接修改状态。其中的核心都是在依赖收集的 effect
函数内,强制修改 react 内部的状态,这样子就能够做到当依赖发生变化时候,effect
函数重新执行,同时 React 内部的 state 发生变化,函数组件就会重新执行。
编译器
接下来我们看一下vue的编译器,编译器做的工作就是把单文件组件的 template 模板编译成 render 函数,具有同样能力的有 webpack 中的 vue-loader
。render 函数的定义如下图所示,实质上渲染函数返回的是虚拟dom,也就是一个纯 JavaScript 对象。
function h() {
return {
_isVNode: true,
flags: VNodeFlags.ELEMENT_HTML,
tag: 'h1',
data: null,
children: null,
childFlags: ChildrenFlags.NO_CHILDREN,
el: null
}
}
vue3 的编译器相比于 vue2 做了许多性能上的提升:
- 在写 template 的时候,我们可以把模板中的节点进行分类:动态节点和静态节点。动态节点指的是与数据挂钩及有逻辑操作的节点,静态节点就是内容固定的节点。vue在将节点进行编译的时候就将 template 中的节点进行动静态区分,对于静态节点,在渲染的之前,就会把节点的定义置顶放在 render 函数的外面,无需每次 rerender 去重新定义,相当于做了缓存。
- render 函数有第二个参数,用于存放组件的属性信息,相对于 vue2,vue3 会把该参数的对象进行打平。我们通过一个实例来具体看看编译器所做的优化。
<div id="app">
<div v-if="msg">{{msg}}</div>
<div v-else>pending...</div>
<template v-for="item in list" :key="item">
<div>{{item.name}}</div>
</template>
<comp title="hello" class="haha" />
<div>static element</div>
</div>
这里有一段代码,我们分别把它放到 vue2 和 vue3 的编译器中得到编译后的渲染函数。这段模板分为四个部分,分别是 v-if
的动态区块,v-for
的动态区块,自定义组件区块及静态区块。我们在 options 里面勾选 hoistStatic
就能够看到编译器把静态节点的创建做了提升,同时会把能做提前预定义的部分都做抽离。
对比 vue2 的编译器,我们格式化 with 语句之后,可以看出无论是静态节点还是动态节点,都会在渲染函数中定义,同时渲染函数的第二个参数具有较深的层级,而 vue3 的渲染函数的第二个参数对象层级只有一层。另外的一些细节可以看出,vue3 的 template 不再限制根节点的数量,同时 v-for
的 key 是可以绑定在 template 上的,vue2 只能绑定在实体节点上。
运行时
运行时是 vue 中的一个核心模块。我们知道 vue 写的程序是可以跑在 web端、小程序及原生app上,其跨平台的核心支持之一就是其运行时模块中的渲染器。
- mpvue:美团开发的小程序框架,readme中介绍其是fork了vue的源码,并且增强了运行时和编译器的能力。
- 在 vue2 的源码目录结构中可以看到,platform目录下有web和weex两个文件夹,里面分别有compiler和runtime的部分,web端还有关于服务端渲染的内容。
这个是因为vue2的历史原因,没有设计关于平台渲染相关的api。因此vue3在运行时模块中,有一部分自定义渲染器runtime-test,通过给渲染器配置不同平台的渲染操作的选项,就可以把vue程序跑在不同平台的应用上。
针对web开发,最常用的模块就是web相关的模块 runtime-dom
。我们知道在 vue3 中创建应用的时候,是利用了 vue3 暴露出来的 createApp
这个api,把根组件作为参数传递进去,然后挂载在真实的 dom 容器上。
function createApp(rootComponent) {
const app = ensureRenderer().createApp(rootComponent)
const {mount} = app
// 重写与 dom 有关的 mount 方法
app.mount = function(container) {
if (!container) return
container.innerHTML = ''
const proxy = mount(container)
return proxy
}
return app
}
createApp
的实现伪代码所示,我们可以看到 createApp
返回的 app是由一个 ensureRenderer
方法调用其返回值中的 createApp
方法得到的,而 ensureRenderer
这个方法返回了由 runtime-core
这个模块提供的与渲染平台逻辑无关的基础渲染器,在渲染器的基础上创建渲染实例。渲染实例里面存在一个 mount
方法,由于渲染平台是web,我们就重写与dom 相关的 mount
方法,并且重写的 mount
方法会调用原始的 mount
方法。
我们上述描述的过程可以用这张图来展示,runtime-dom
中的ensureRender
调用的来自 runtime-core
提供的 baseCreateRender
方法,在这个过程中我们需要传递给渲染器平台相关的渲染逻辑,web平台就需要传入 dom 操作方法,如 createElement
,removeChild
等。其返回值是一个包含了 render
方法和 createApp
方法的对象。createApp
返回了应用实例 app
,里面包含了原始的 mount
方法。
关于 vue3 的讨论
以上我们分析完了 vue3 的三个重要模块。接下来我们来看一些关于vue3的讨论。
- 第一个就是社区内争议比较大的
Ref
语法糖,可以帮助开发者节省代码冗余,但是迎来了一些负面评价,增加了学习和理解成本,在实际团队开发中,完全可以使用团队规范来进行制约是否使用该语法糖。 - 其次就是 vue3 为什么不用
class based api
,因为社区内有了class api 加装饰器的 ts 方案,据尤大介绍说,不考虑使用该语法的原因有:- 支持
mixin
困难,由于 vue 升级要考虑用户的使用习惯,不会抛弃mixin
语法 - 渐进式升级,不抛弃之前的 api 使用方法,class 语法与options api 对应起来比较困难
- class语法需要装饰器的能力增强,但是装饰器语法的es提案没有完全确定
- 支持
- 第三个讨论就是 vue3 的
composition api
和React Hooks
很像,接下来我们聊聊这块的话题。
与 React Hooks 对比
随着应用复杂程度增加,组件的逻辑复用在开发中十分关键。目前在 react和 vue 中有以下几种逻辑复用方案:
mixin
- 两个由社区提供的方案,
HOC
、Render props
vue 中高阶组件是可以应用的,但是由于 vue 插槽机制等原因,高阶组件不太常用也不太好用。Render props
可以以作用域插槽的形式应用。最后就是 hooks 这种方法。
在 composition api
出现前,vue 逻辑复用只有 mixin
一种方式,随着项目变得复杂,处理一个逻辑点的代码可能分散在多个 mixin
或者是代码块中,难以维护,因此 compsition api
的出现就能够使得逻辑点集中,易于维护。
尤大也是承认 composition api
的设计是受了react hooks 的启发,但是由于两个框架的运行机制不同,很多相似更多是代码书写方式上的。要比较两个语法,就必须提到社区内被提到比较多的词:”心智负担“。心智负担指的是新事物的出现可能会与人们的以往认知不一致的情况,这就增加了人们认知上的成本。事实上这两种语法都是会存在心智负担的。
vue 的心智负担只要集中在 ref
和 reactive
上,通过 reactive
包裹一个对象就能够将其变成响应式的,而 ref
的设计只暴露一个属性 value
,值为本身。
因此 ref
的实现有以下两种方式:
function ref(initState) {
return reactive({
value: initState
});
}
// 利用了对象的访问器
function ref(raw) {
const r = {
get value() {
track(r, "value");
return raw;
},
set value(newVal) {
raw = newVal;
trigger(r, "value");
}
};
return r;
}
第一种方式直接用 reactive
包裹一个包含 value
属性的对象,第二种实现利用了对象的访问器。由于 reactive
可以增加属性,违背了 ref
的初衷。并且在 vue3 中还暴露了 isRef
这个api来判断该对象是否是 ref
对象,因此 ref
的定义上会给对象增加一些内部属性。因此第二种方式才是真正实现 ref
的。同时 ref
相对于 reactive
性能更好,因为在把一个对象定义为 reactive
之前,要做很多逻辑判断。
同时我们在写 setup
函数的时候一定要记得把响应式对象和定义的方法return 出去,解构一个响应式对象的时候会使其丧失响应式的能力。以上就是 vue 中存在的心智负担。
由于 react hooks
是以函数形式定义的组件,由于函数天然存在的闭包特性,会导致 hooks 在使用过程中会存在很多需要注意的问题。包括函数组件内部使用定时器导致的旧值输出、useEffect
、useMemo
需要依赖正确的值,useCallback
做函数引用优化向子组件传递可能会导致函数内依赖旧值等等一系列问题。总而言之,就是开发者需要尽可能减少不必要的组件re-render。在 vue 中,响应式系统会自动处理依赖关系,所以不会存在引用旧值的问题。
同时,在写 vue 的时候,开发者很少会去关注组件的性能优化,由于 vue 框架自身做了很多工作,比如 vue 的响应式依赖收集只有在 effect
内部才会去做,因此开发者做 vue 的性能优化的时候主要集中在尽可能避免定义不必要的响应式数据以及减少不必要的依赖收集。而在 react 中,性能优化对于大型项目是不可或缺的。
但是也不得不说,react hooks
是伟大的设计。
jsx 与 template
关于vue3最后一部分,我们来说一说模板和 jsx。不论是模板还是 jsx 都是编写视图的一种方式,实际上有很多用 jsx 开发 vue 项目的应用,很早以前也存在着 react-template
这种 react 模板的方式。目前主流的 SFC 之于vue 和 jsx 之于 react 都是沉淀下来的最佳实践。
jsx 具有较强的动态性,灵活性强;模板是静态的,直观易懂。实质上,无论是模板还是 jsx 都是需要被编译的,react 中的 jsx 被编译成为 createElement
,vue中的模板被编译成渲染函数。在 vue3 中配合 jsx,确实能够享受到写纯 JavaScript 的流畅感,也有良好的 ts 类型支持和静态属性检查机制,并且可以在一个js文件中定义多个组件。
但是随着vue3周边生态的成熟,在 vscode 中配合 volar
插件,也能够在模板中做组件属性的静态类型检查,体验还是不错的,但是貌似会略微造成电脑卡顿。同时,由于模板做了很多编译优化,因此在性能上优于 jsx。
总结
对于前端开发从业者而言,前端开发三大框架之一的 vue 的大版本更新绝对是重磅消息,随之而来的便是面向新版本的新生态系统的建立。vue3 继承自vue2,也着重解决了之前存在的一些痛点。对于我们开发者来说,了解 vue3 的内部细节也是必要的。