23

Vue 3.0实践:使用Vue 3.0做JSX(TSX)风格的组件开发

 4 years ago
source link: https://github.com/hujiulong/blog/issues/11
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前言

我日常工作都是使用React来做开发,但是我对React一直不是很满意,特别是在推出React Hooks以后。

不可否认React Hooks极大地方便了开发者,但是它又有非常多反直觉的地方,让我难以接受。所以在很长一段时间,我都在尝试寻找React的替代品,我尝试过不少别的前端框架,但都有各种各样的问题或限制。

在看到了Vue 3.0 Composition-API 的设计,确实有眼前一亮的感觉,它既保留了React Hooks的优点,又没有反复声明销毁的问题,而Vue一直都是支持JSX语法的,3.0对TypeScript的支持又非常好,所以我开始尝试用Vue + TSX来做开发。

Vue 3.0已经发布了alpha版本,可以通过以下命令来安装:

npm install vue@next --save

简单示例

先来看看用Vue3.0 + TSX写一个组件是什么什么样子的。

实现一个Input组件:

import { defineComponent } from 'vue';

interface InputProps {
  value: string;
  onChange: (value: string) => void;
}
const Input = defineComponent({
  setup(props: InputProps) {
    const handleChange = (event: KeyboardEvent) => {
      props.onChange(event.target.value);
    }

    return () => (
      <input value={props.value} onInput={handleChange} />
    )
  }
})

可以看到写法和React非常相似,和React不同的是,一些内部方法,例如 handleChange ,不会在每次渲染时重复定义,而是在 setup 这个准备阶段完成,最后返回一个“函数组件”。

这算是解决了React Hooks非常大的一个痛点,比React Hooks那种重复声明的方式要舒服多了。

Vue 3.0对TS做了一些增强,不需要像以前那样必须声明 props ,而是可以通过TS类型声明来完成。

这里的 defineComponent 没有太多实际用途,主要是为了实现让ts类型提示变得友好一点。

Babel插件

为了能让上面那段代码跑起来,还需要有一个Babel插件来转换上文中的JSX,Vue 3.0相比2.x有一些变化,不能再使用原来的vue-jsx插件。

我们都知道JSX(TSX)实际上是语法糖,例如在React中,这样一段代码:

const input = <input value="text" />

实际上会被babel插件转换为下面这行代码:

const input = React.createElement('input', { value: 'text' });

Vue 3.0也提供了一个对应 React.createElement 的方法 h 。但是这个 h 方法又和vue 2.0以及React都有一些不同。

例如这样一段代码:

<div class={['foo', 'bar']} style={{ margin: '10px' }} id="foo" onClick={foo} />

在vue2.0中会转换成这样:

h('div', {
  class: ['foo', 'bar'],
  style: { margin: '10px' }
  attrs: { id: 'foo' },
  on: { click: foo }
})

可以看到vue会将传入的属性做一个分类,会分为 classstyleattrson 等不同部分。这样做非常繁琐,也不好处理。

在vue 3.0中跟react更加相似,会转成这样:

h('div', {
  class: ['foo', 'bar'],
  style: { margin: '10px' }
  id: 'foo',
  onClick: foo
})

基本上是传入什么就是什么,没有做额外的处理。

当然和 React.createElement 相比也有一些区别:

  • 子节点不会作为以 children 这个名字在 props 中传入,而是通过 slots 去取,这个下文会做说明。
  • 多个子节点是以数组的形式传入,而不是像React那样作为分开的参数

所以只能自己动手来实现这个插件,我是在 babel-plugin-transform-react-jsx 的基础上修改的,并且自动注入了 h 方法。

实际使用

在上面的工作完成以后,我们可以真正开始做开发了。

渲染子节点

上文说到,子节点不会像React那样作为 children 这个 prop 传递,而是要通过 slots 去取:

例如实现一个Button组件

// button.tsx
import { defineComponent } from 'vue';
import './style.less';

interface ButtonProps {
  type: 'primary' | 'dashed' | 'link'
}
const Button = defineComponent({
  setup(props: ButtonProps, { slots }) {
    return () => (
      <button class={'btn', `btn-${props.type}`}>
        {slots.default()}
      </button>
    )
  }
})

export default Button;

然后我们就可以使用它了:

import { createApp } from 'vue';
import Button from './button';

// vue 3.0也支持函数组件
const App = () => <Button>Click Me!</Button>

createApp().mount(App, '#app');

渲染结果:

qi2iErj.png!web

Reactive

配合vue 3.0提供的 reactive ,不需要主动通知Vue更新视图,直接更新数据即可。

例如一个点击计数的组件Counter:

import { defineComponent, reactive } from 'vue';

const Counter = defineComponent({
  setup() {
    const state = reactive({ count: 0 });
    const handleClick = () => state.count++;
    return () => (
      <button onClick={handleClick}>
        count: {state.count}
      </button>
    )
  }
});

渲染结果:

IjqIRrA.gif

这个Counter组件如果用React Hooks来写:

import React, { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleClick = () => setCount(count + 1);
  return (
    <button onClick={handleClick}>
      count: {count}
    </button>
  )
}

对比之下可以发现Vue 3.0的优势:

在React中, useState 和定义 handleClick 的代码会在每次渲染时都执行,而Vue定义的组件重新渲染时只会执行 setup 中最后返回的渲染方法,不会重复执行上面的那部分代码。

而且在Vue中,只需要更新对应的值即可触发视图更新,不需要像React那样调用 setCount

当然Vue的这种定义组件的方式也带来了一些限制, setup 的参数 props 是一个 reactive 对象,不要对它进行解构赋值,使用时要格外注意这一点:

例如实现一个简单的展示内容的组件:

// 错误示例
import { defineComponent, reactive } from 'vue';

interface LabelProps {
  content: string;
}
const Label = defineComponent({
  setup({ content }: LabelProps) {
    return () => <span>{content}</span>
  }
})

这样写是有问题的,我们在 setup 的参数中直接对 props 做了解构赋值,写成了 { content } 这样在后续外部更新传入的 content 时,组件是不会更新的 ,因为破坏了 props 的响应机制。以后可以通过eslint之类的工具来避免这种写法。

正确的写法是在返回的方法里再对 props 做解构赋值:

import { defineComponent, reactive } from 'vue';

interface LabelProps {
  content: string;
}
const Label = defineComponent({
  setup(props: LabelProps) {
    return () => {
      const { content } = props;  // 在这里对props做解构赋值
      return <span>{content}</span>;
    }
  }
})

生命周期方法

在Vue 3.0中使用生命周期方法也非常简单,直接将对应的方法import进来即可使用。

import { defineComponent, reactive, onMounted } from 'vue';

interface LabelProps {
  content: string;
}
const Label = defineComponent({
  setup(props: LabelProps) {
    
    onMounted(() => { console.log('mounted!'); });
  
    return () => {
      const { content } = props;
      return <span>{content}</span>;
    }
  }
})

vue 3.0对tree-shaking非常友好,所有API和内置组件都支持tree-shaking。

如果你所有地方都没有用到 onMounted ,支持tree-shaking的打包工具会自动将起去掉,不会打进最后的包里。

指令和过渡效果

Vue 3.0还提供了一系列组件和方法,来使JSX也能使用模板语法的指令和过渡效果。

使用 Transition 在显示/隐藏内容块时做过渡动画:

import { defineComponent, ref, Transition } from 'vue';
import './style.less';

const App = defineComponent({
  setup() {
    const count = ref(0);
    const handleClick = () => {
      count.value ++;
    }

    return () => (
      <div>
        <button onClick={handleClick}>click me!</button>
        <Transition name="slide-fade">
          {count.value % 2 === 0 ?
            <h1>count: {count.value}</h1>
          : null}
        </Transition>
      </div>
    )
  }
})
// style.less
.slide-fade-enter-active {
  transition: all .3s ease;
}
.slide-fade-leave-active {
  transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.slide-fade-enter, .slide-fade-leave-to {
  transform: translateX(10px);
  opacity: 0;
}

渲染结果:

neYvU3V.gif

也可以通过 withDirectives 来使用各种指令,例如实现模板语法 v-show 的效果:

import { defineComponent, ref, Transition, withDirectives, vShow } from 'vue';
import './style.less';

const App = defineComponent({
  setup() {
    const count = ref(0);
    const handleClick = () => {
      count.value ++;
    }

    return () => (
      <div >
        <button onClick={handleClick}>toggle</button>
        <Transition name="slide-fade">
          {withDirectives(<h1>Count: {count.value}</h1>, [[
            vShow, count.value % 2 === 0
          ]])}
        </Transition>
      </div>
    )
  }
})

这样写起来有点繁琐,应该可以通过babel-jsx插件来实现下面这种写法:

<h1 vShow={count.value % 2 === 0}>Count: {count.value}</h1>

优缺点

在我看来Vue 3.0 + TSX完全可以作为React的替代,它既保留了React Hooks的优点,又避开了React Hooks的种种问题。

但是这种用法也有一个难以忽视的问题:它没办法获得Vue 3.0编译阶段的优化。

Vue 3.0通过对模板的分析,可以做一些前期优化,而JSX语法是难以做到的。

例如“静态树提升”优化:

如下一段模板(这是模板,并非JSX):

<template>
 <div>
   <span>static</span>
   <span>{{ dynamic }}</span>
 </div>
</template>

如果不做任何优化,那么编译后得到的代码应该是这样子:

render() {
 return h('div', [
   h('span', 'static'),
   h('span', this.dynamic)
 ]);
}

那么每次重新渲染时,都会执行3次 h 方法,虽然未必会触发真正的DOM更新,但这也是一部分开销。

通过观察,我们知道 h('span', 'static') 这段代码传入的参数始终都不会有变化,它是静态的,而只有 h('span', this.dynamic) 这段才会根据 dynamic 的值变化。

在Vue 3.0中,编译器会自动分析出这种区别,对于静态的节点,会自动提升到 render 方法外部,避免重复执行。

Vue 3.0编译后的代码:

const __static1 = h('span', 'static');

render() {
   return h('div', [
       __static1,
       h('span', this.dynamic)
    ])     
}

这样每次渲染时就只会执行两次 h 。换言之,经过静态树提升后,Vue 3.0渲染成本将只会和动态节点的规模相关,静态节点将会被复用。

除了静态树提升,还有很多别的编译阶段的优化,这些都是JSX语法难以做到的,因为JSX语法本质上还是在写JS,它没有任何限制,强行提升它会破坏JS执行的上下文,所以很难做出这种优化(也许配合 prepack 可以做到)。

考虑到这一点,如果你是在实现一个对性能要求较高的基础组件库,那模板语法仍然是首选。

另外JSX也没办法做 ref 自动展开,使得 refreactive 在使用上没有太大区别。

后话

我个人对Vue 3.0是非常满意的,无论是对TS的支持,还是新的 Composition API ,如果不限制框架的话,那Vue以后肯定是我的首选。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK