探索 Vue 3 中的 JSX

avatar
研发 @字节跳动

作者:大力智能技术团队-前端 林成璋

今天(5月22日),字节跳动大力智能团队前端工程师林成璋参加了《Vue Conf 21》大会,与各位前端技术爱好者进行了交流,并在会上做了一篇题为《探索 Vue 3 中的 JSX》的分享。以下为此次分享的全部内容,最后的总结是亮点

引言

各位同学下午好,我是来自字节跳动大力智能前端团队的林成璋,最近半年的业余时间(再加上一些摸鱼的时间)主要在维护 Vue 3 的 Babel JSX Plugin,今天来给大家做一个关于 JSX 的分享。

下面是我的 Github 账号,全网除了 P 站应该都是这个头像。其实最早做这个插件主要是为了帮助 Ant Design Vue 和 Vant 能够快速升级到 Vue 3,看过他们源码的同学应该知道,他们的源码大部分都是用 JSX 来撸的。

虽然目前在 NPM 上的周下载量是 56 万多(甚至超过了 Vue 3 🤪),但是这里的下载量非常大的原因主要是通过 vue-cli 创建的项目(不管是 Vue 2 还是 Vue 3)都会下载 @vue/babel-plugin-jsx 这个包,实际使用 JSX 的用户应该远比这个数字要小,到底有多少用户是通过的 JSX 的方式开发的也没有办法统计到,绝大用户还是使用 template 的开发方式为主。

基本概念

  • template

在 Vue 里,sfc 是一个以 .vue 结尾的文件,通常包含三种类型的顶级语言块 <template><script><style>,可以理解为 HTML 、JS 以及 CSS 的组合。每一个 .vue 文件结尾的文件都是一个组件,而且只能 export default 出一个组件。

  • JSX

本身就是 JS

为什么在 Vue 里也支持 JSX

Vue 官方推荐的开发方式是 template,从 Vue 2 开始,template 在运行之前,会被编译成 JavaScript 的 render function。这些 render function 在运行时阶段,就是传说中的 Virtual DOM

每当讲到 template 和 JSX,可能就会讨论到一个比较大的问题,ReactVue 哪个好。一些人可能就不太喜欢通过 JavaScript 直接来表示 UI,然而也会有相当一部分人会认为用 template 来写可能比较烦,特别是 React 资深玩家。由于 vue 是全球最友好的 UI 框架,有广大的群众基础,一些群众习惯于直接用 HTML 和 CSS 来干代码,对他们来说,把写 UI 的逻辑从 HTML 转到 template ,比让他们的思路完全变更到开始思考如何用 JavaScript 来构建 UI 要简单得多。 但是也不得不承认,对于一些之前是搞后端的同学, 或者 iOS 和 Android 的开发者来说,之前没有怎么接触过 HTML 的,通过字符串模板的方式来编写 UI 也不太行。

不同用户的口味不太一样,萝卜白菜,各有所爱。就像这张 PPT,有些人看了可能很兴奋,一些人可能觉得我是个傻X。你可以说一堆模板怎么怎么不好的例子,他也同样也给 JSX 一顿喷,谁也说服不了谁。所以 Vue 干脆把两个事都干了。

什么是「真正的」JSX

JSX 最早是由 facebook 起草的一个规范,后面的这个 X 可以理解为它是 JavaScript 的语法扩展,感兴趣的同学可以从这个链接进去看看里面的具体内容。由于各个前端框架的实现不一样,所以它不会由引擎或浏览器实现,需要 Transform 之后转成常规的 JS 之后,这一步操作我们可以理解为「赋能」,才能在浏览器里面运行。JSX 其实也和模板语言类似,但它具有 JavaScript 的全部功能,但是由于在模板中的一些限制,用模板写出来的代码性能要比 JSX 好得多。

<h1>Hello, world!</h1>;

这里的 JSX 语法编译之后其实就是:

import { createVNode as _createVNode } from "vue"

_createVNode("h1", null, "Hello, world!");

Vue 3 带来的改变

Vue 2 早期是用纯 JavaScript 来编写的,随着项目越来越庞大,引入了 Facebook 的 Flow。虽然 Flow 在一定程度上起到了帮助作用,但还是存在一些问题,尤大也曾经公开表示当初没有选择 TypeScript 选择了 Flow 是「押错宝」了。

在 Vue 2 中,JSX 的编译需要依赖 @vue/babel-preset-jsx@vue/babel-helper-vue-jsx-merge-props 这两个包。前面这个包来负责编译 JSX 的语法,后面的包用来引入运行时的 mergeProps 函数。

但是如果你要用 TSX 的环境来写,还需要额外安装 vue-tsx-support

在 Vue 3 中,只要安装一个 Babel 插件就完事了,可以理解为不再需要额外的第三方库,源码中就有 jsx.d.ts 用来支持 JSX 的类型检查

使用 JSX 的场景

我们现在来看下有哪些场景用 JSX 会比模板更加优雅。

一个文件写多个组件

一个 .vue 文件里面只能写一个组件,这个说实话在一些场景下还是不太方便,很多时候我们写一个页面的时候其实可能会需要把一些小的节点片段拆分到小组件里面进行复用,这些小组件其实写个简单的函数组件就能搞定了。如果你现在没有这个习惯可能就是因为 SFC 的限制让你习惯了全部写在一个文件里面。

比如这里我们封装了一个 Input 组件,我们希望同时导出 Password 组件和 Textarea 组件来方便用户根据实际需求使用,而这两个组件本身内部就是用的 Input 组件,只是定制了一些 props。在 JSX 里面就很方便,写个简单的函数组件基本上就够用了,通过 interface 来声明 props 就好了。但是如果是用模板来写,可能就要给拆成三个文件,或许还要再加一个 index.js 的入口文件来导出三个组件,摸鱼的时间又少了。

强依赖编译时的检查

模板中引用了一个未在 script 中声明的 a,vscode 插件可以帮忙检查出来,但是仍然可以跑起来。

如果是用 TS 来写,这里引用了一个未声明的 c 变量,TS 在编译阶段就能让代码直接跑不起来。目前模板还是会被直接编译成 JS,因此还做不到在 template 就进行编译时的类型检查。

拥有 JS 完全编程能力

由于 JSX 的本质就是 JavaScript,所以它具有 JavaScript 的完全编程能力。举个例子,我们需要通过一段逻辑来对一组 DOM 节点做一次 reverse,如果在模板里面写,那估计要写两段代码。

虽然这个例子可能不太常见,但是不得不承认,在一些场景下,JSX 还是要比模板写起来更加顺手。

范型组件

在模板里面,由于一些历史的原因,目前范型组件确实还支持不了,但是不代表以后不行。如果非要用范型,可以先用函数组件给包一层,但是注意不要声明 FunctionalComponent 的类型。这里我们在 .tsx 文件里面声明 Foo 组件,Props 是一个范型。声明完之后,再回到模板里面,可以我们看到,刚刚定义的范型组件已经生效了。SFC 的 TS IDE 支持可以用 volar。volar 还支持了范型组件,用起来感觉和 TSX 已经没多大区别了。

使用 JSX 需要注意的点

对 Props 的处理

在模板中,对 props 的处理是 merge。为了满足不同用户的需求,开了一个可以覆盖的口子。

对插槽的处理

插槽是一种内容分发(content distribution)的 API,洋文叫 Slot,也就是 createVNode 的最后一个参数。适合用在结果比较复杂,组件内容可以复用的地方,简单来说就是在组件中可以预留空间,从父级把内容给传进去。在 JSX 中,父组件给子组件来传递 VNode 通过属性来传递就完事了。

但是在模板中,传递属性的时候,template 里面是不能写 VNode 的,因此 Vue 里出现了插槽这个概念,插槽只在组件的 children 里面才有。

我们来看下 Vue 是怎么处理插槽的:

Vue 对插槽的要求最好是一个 function,对运行时的性能提升会有很大的帮助。因此 A 组件的子节点会被编译成,{ default: () => [123] }。对应到 JSX 中,按照正常用户的心智模式,只有一个 children 的时候,写成{ default: () => [123] }也不太现实,正常的写法就是直接塞一个 children。但是在编译阶段要处理成 function,否则在开发时会报 warning,对开发者来说是非常不友好的体验。对编译器来说其实也好办,给子节点的 VNode 包一层函数就完事了。

在多个插槽的情况下,稍微比单个的场景要复杂点。除了 default 之外的插槽,通过 props 的方式来传是不可能的,只能想办法通过类似「指令」的方式来传递,因此最早设计了 v-slots 的命令来处理插槽。但是 v-slots 对于一些开发者来说可能会不够直观。更直观的方式应该像这样,也就是 obejct slots

先简单讲一下两个概念:编译和运行时。编译就是把我们的代码转成 JavaScript 引擎可以看懂的代码,运行时就是 JavaScript 引擎开始跑你的代码。就好比我们招聘中的简历筛选和面试,简历筛选可以对应编译,面试来运行时。这个候选人到底怎么样,单纯看简历是看不出来的。再回到刚刚的问题,如果直接把 children 写成一个内联的对象还好办,但如果是一个变量的话,在编译的时候,编译器是无法知道传过来的到底是个什么玩意儿,是 slots 还是 VNode 其实编译的时候看不出来。如果是一个文件里面的,编译器或许还能判断,但是从另一个文件 import 进来,是无法判断的。Babel 处理每一个的文件都是一个「闭环」 。所以这时候就需要加一个运行时的判断:

虽然解决了判断是不是 slots 的问题,但是每一个变量给加上运行时判断,会对编译产物的体积有一些影响。jsx-next #255

为了保持编译产物体积和直观语义上的平衡,就让开发自己来选择是否需要上述的 feature,提供了 enableObjectSlots 的开关。

模板与 JSX 的性能对比

刚刚说了一些在哪些场景下用 JSX 可能会更加地合适。这里简单地对比了下实现相同功能,JSX 和模板的性能差异。左右两个 demo 里面,整了两万个节点,奇数节点里面 class 是动态的,偶数节点的 textContent 是动态的,点击 shuffle。在这个例子里面,用模板写的代码 比用 JSX 写的要快十几毫秒。在实际的场景中,组件的层级嵌套远比这里给出的 demo 要复杂,这个时候就更加能够体现模板的优势了。

在传统的 VDOM 树中,我们在运行时不能够得到用于优化的信息。在 Vue 3 中,充分利用了模板静态信息,最终体现到 VDOM 树上。比方说在 diff 的时候,可以知道哪些节点是动态的,节点的哪些属性是动态的。有了这些信息我们就可以在创建 VNode 的时候来标记哪些属性是不是动态的(靶向更新),也就是传说中 PatchFlags。除了 PatchFlags 之外,Vue 3 的 VDOM 在运行时,还做了一些缓存,比如 children 的缓存。

先来解释一下 PatchFlags 是怎么运作的,其实它就是一个数字,只不过在运行的时候被赋予了不同的含义:

  • 数字 2 (PatchFlags.CLASS):表示 class 是动态的

  • 数字 4 (PatchFlags.STYLE):表示 style 是动态的

可能一些同学不太明白这样来表示有啥好处 CLASS = 1 << 1,这其实就是用二进制来表示,在上面的代码中:

TEXT = 0000000001

CLASS = 0000000010

STYLE = 0000000100

比如一个节点的 class 和 style 都是动态的,就给标记上 PatchFlags.CLASS | PatchFlags.STYLE,得到 0000000011 。想要判断它的 TEXT 是不是动态的,只需要FLAG & TEXT > 0 就行。

这么看起来只要把 props 的属性做标记好像 JSX 里面也能对 VDOM 做标记了?

我们来看稍微复杂点的场景。我们看到 textarea 依赖了 attrs,所以编译完对应的 PatchFlag 应该是

_createVNode("textarea", _mergeProps({
  "id": "textarea"
}, attrs), null, 16);

单独把这段代码拿出来跑是没问题的,但是由于 textarea 的外层还套了一些组件,attrs 是单独定义的一个变量,并不是响应式的。我们先不管 attrs 这个变量,把这段代码当做是模板里面的。在模板编译的时候,A 的 children 在编译的时候其实做了一层缓存,每次重新渲染的时候,不需要再去创建 children 的 VNODE,同时对于 children 来说,形成了一个闭包。如果这段代码编译的时候,把 children 做了缓存,会打上一个静态的标记,那么 attrs 拿到永远是第一次渲染的值。

<A>
  {{
      default: () => (
          // children
      )
   }}
</A>

所以当点击 button 的时候,并不会触发视图的更新。这个时候只能放弃组件 A 的优化,children 不做缓存。因此一旦在某个子节点传入了一个非响应式的变量,它的所有父节点的 children 就要放弃缓存,因此在每次 re-render 的时候都会重新创建,优化并不是很明显。然而上面这种写法在 JSX 中还挺常见的。

除了 PatchFlags 之外,Vue 里有一个叫 SlotFlags 概念,来处理 children 的不同情况。上面的情况,需要把 children 标记为 DYNAMIC,来放弃对 children 的缓存。因此如果你用 JSX 来写 Vue 的话,基本上是享受不到 Vue 3 对模板做的优化。

总结