Vue JSX

为什么要使用 JSX

前阵子在 Vue3 项目中封装一个基础组件的时候用到了 JSX 语法,使用下来的感受就是 —— 某些场景下,JSX 的灵活性对我们编写代码还是能够带来一定的帮助的。

举两个常见的例子:

递归调用组件时

假设我们现在有如下数据,需要渲染其中的 name 字段:

const data = [
  {
    name: 'name1',
    children: [{ name: 'name1-1' }]
  },
  { name: 'name2' }
]

普通模板写法

如果使用普通模板写法,为了递归我们可能不得不编写两个组件:

父组件 parent.vue:

parent.vue 父组件
<template>
  <div>我是父组件</div>
  <Children v-for="item in data" :subData="item" :key="item.name"></Children>
</template>

子组件 children.vue 递归调用自身:

// children.vue 子组件
<template>
  <span>{{ subData.name }}</span>
    // 递归调用
  <template v-if="subData.children">
    <Children v-for="item in subData.children" :subData="item" :key="item.name"></Children>
  </template>
</template>

JSX 写法:

而使用 JSX 则可以灵活地使用一个文件实现递归的逻辑:

// name.jsx
const renderChildren = (data: any) => {
  return data.map((item: any) => {
    if (item.children) {
      return renderChildren(item.children)
    } else {
      return <span>{ item.name }</span>
    }
  })
}
const render = () => (
  <>
    <div>我是父组件</div>
    {
      data.map((item: any) => {
        return (
          <>
            <span>{ item.name }</span>
            { item.children && renderChildren(item.children) }
          </>
        )
      })
    }
  </>
)

动态生成标签名称

这是一个来自 vue 官网中的例子,如果你需要根据传入的 level 动态生成 <h1></h1><h6></h6> 之间的标签。

普通模板写法

你可能会这样写:

<template>
    <h1 v-if="level === 1"></h1>
    <h2 v-else-if="level === 2"></h2>
    <h3 v-else-if="level === 3"></h3>
    <h4 v-else-if="level === 4"></h4>
    <h5 v-else-if="level === 5"></h5>
    <h6 v-else-if="level === 6"></h6>
</template>

JSX 写法

但是如果学会了 JSX 的写法,就可以像这样:

const render = () => {
  const level = props.level
  const Tag = `h${level}`
  return (
      <Tag></Tag>
  )
}

那么接下来我们就来一起看看,如何在 Vue 中使用 JSX 吧!

开启 JSX 特性

vue-cli 搭建

如果是使用 vue-cli 搭建的项目,默认就是支持 JSX 语法的,直接使用就可以。

webpack

如果不是 vue-cli 搭建的 webpack 项目,需要按照如下步骤开启:

下载 babel 插件

npm install @vue/babel-plugin-jsx -D

添加配置

在 babel 的配置文件中添加:

{
  "plugins": ["@vue/babel-plugin-jsx"]
}
根据版本的不同,babel 配置文件可能是 .babelrc 或者 babel.config.js,注意区分。

vite

使用 vite 的项目,同样需要先安装插件:

npm install @vitejs/plugin-vue-jsx -D

然后在 vite.config.js 文件中添加以下配置:

// vite.config.js
import vueJsx from '@vitejs/plugin-vue-jsx'

export default {
  plugins: [
    vueJsx({
      // options are passed on to @vue/babel-plugin-jsx
    }),
  ],
}

更多的配置请参考 babel-plugin-jsx

JSX 语法

使用 JS 表达式

在 JSX 语法中,可以通过一对大括号 {} 来使用 JS 表达式:

const name = 'zhangsan'
// 通过一对大括号 {} 来包裹 JS 表达式内容
const list1 = <div>{name}</div>

// 同样可以通过大括号 {} 来给标签传递动态属性
const id = 1
const list2 = <div id={id}>{name}</div>

或许你还看到过双大括号 {{}} 这种令人迷惑的写法,其实它表示 绑定的是个 JS 对象

const name = 'zhangsan'
// 双大括号 {{}} 表示的是绑定的是个 JS 对象
// 可以拆分成 {} 和 { width: '100px' } 来理解
const list1 = <div style={{ width:'100px' }}>{name}</div>

Fragment

Vue3 新增了新特性 Fragment,使得我们在模板语法中能够返回多个根节点:

<template>
  <div>Fragment</div>
  <span>yes</span>
</template>

Vue 的编译器在编译时,会把这种包含多个根节点的模板被表示为一个片段(Fragment)。

但是在 JSX 中,一组元素 必须被包裹在一个闭合标签中返回;因此下面这种写法是不允许的:

// 错误写法 ❌
const render = () => (
  <div>Fragment</div>
  <span>yes</span>
)

正确做法是用一对闭合标签包裹:

// 正确写法 ✅
const render = () => (
  <div>
    <div>Fragment</div>
    <span>yes</span>
  </div>
)

那如果我们不想引入额外的标签该怎么办呢?可以用 <></> 来包裹我们想要返回的内容,如下:

const render = () => (
  <>
    <div>Fragment</div>
    <span>yes</span>
  </>
)

乍一看,你是不是觉得 <></> 和我们在使用模板写法时的 <template></template> 作用很相似?

但是实际上,JSX 中被 <></> 标签包裹的内容,会被当做 Fragment 来处理;并且针对 Fragment Vue 在编译和渲染时会有特定的优化策略。

而对于 <template></template>,Vue 只会将其作为一个普通的元素渲染;所以要注意别搞混咯。

Vue2 JSX 传递属性

在 Vue2 的时代,使用 JSX 时传递属性还是比较麻烦的。

因为 Vue2 中将属性又细分成了 组件属性HTML Attribute 以及 DOM Property 等等,不同的属性写法也大相径庭,如下:

const render = () => {
  return (
    <div
      // 传递一个 HTML Attribute,属性名称是 id 属性值是 'foo'
      id="foo"
      // 传递 DOM Property 需要使用前缀 `domProps` 来表示,这里表示传递给 innerHTML 这个 DOM 属性的值为 ‘bar’
      domPropsInnerHTML="bar"
      // 绑定原生事件需要以 `on` 或者 `nativeOn` 为前缀,相当于 @click.native
      onClick={this.clickHandler}
      nativeOnClick={this.nativeClickHandler}
      // 绑定自定义事件需要用 `props` + 事件名 的方式
      propsOnCustomEvent={this.customEventHandler}
      // class(类名)、style(样式)、key、slot 和 ref 这些特殊属性写法
      class={{ foo: true, bar: false }}
      style={{ color: 'red', fontSize: '14px' }}
      slot="slot"
      key="key"
      ref="ref"
      // 如果是循环生成的 ref(相当于 v-for),那么需要添加 refInFor 这个标识
      // 用来告诉 Vue 将 ref 生成一个数组,否则只能获取到最后一个
      refInFor>
    </div>
  )
}
而在 Vue3 的 JSX 中传递各种属性的方式已经简化了许多,下面会拆开细讲,各位小伙伴们请接着往下看~

Vue3 JSX 传递属性

DOM Property

传递 DOM Property 时去掉了 domProps 前缀,可以直接书写:

const render = () => {
  return (
    <div innerHTML="bar"></div>
  )
}

HTML Attribute

Vue3 JSX 传递 HTML Attribute 与 Vue2 JSX 相同,直接书写就行:

const render = () => {
  return (
    <div id="foo" type="email"></div>
  )
}

如果需要 动态绑定

const placeholderText = 'email';
const render = () => {
  return (
    <input
      type="email"
      placeholder={placeholderText}
     />
  )
};

class 与 style

Vue3 JSX 传递类名与样式的方法,与 Vue2 JSX 相同:

const render = () => {
  return (
    <div
      class={{ foo: true, bar: false }}
      style={{ color: 'red', fontSize: '14px' }}
    >
    </div>
  )
}

ref

定义好 ref 后用 JS 表达式绑定就行:

const divRef = ref()

const render = () => {
  return (
    <div ref={divRef}></div>
  )
}

绑定事件

在 Vue 模板写法中,绑定事件时我们使用 v-on 或者 @ 符号:

<template>
  <div @click="handleClick">事件绑定</div>
</template>

而在 Vue3 的 JSX 中,会把 on 开头,并紧跟着大写字母 的属性当作事件监听器来解析;

上面的模板写法换成 JSX 就是:

const render = (
    <div onClick={handleClick}>事件绑定</div>
)

注意:这里一定要以 on 开头,并且 紧跟着大写字母

错误写法 ❌:onclick、click;

正确写法 ✅:onClick、onClickChange、onClick-change (虽然但是,不会真的有人这么写吧?)

如果你不喜欢这种写法,还可以通过打开 babeltransformOn 配置,然后通过属性 on 绑定一个对象,一次性传递多个事件:

babel 配置:

// babel 配置
{
  "plugins": [
    [
      "@vue/babel-plugin-jsx",
      {
        "transformOn": true
      }
    ]
  ]
}

JSX 写法:

// 通过属性 on 绑定对象 批量传递多个事件
const render = () => (
  <div on={{ click: handleClick, input: handleInput }}> 事件绑定 </div>
)

事件修饰符

对于 .passive.capture.once 事件修饰符,可以 使用驼峰写法将他们拼接在事件名称后面

比如 onClick + Once,就代表监听 click 事件触发,且只触发一次:

const render = () => (
  <input
    onClickCapture={() => {}}
    onKeyupOnce={() => {}}
    onMouseoverOnceCapture={() => {}}
  />
)

而像其余的 .self.prevent 等事件和按键修饰符,则需要使用 withModifiers 函数。

withModifiers 函数

withModifiers 函数接收两个参数:

  1. 第一个是我们的回调函数;

  2. 第二个参数是修饰符组成的数组。

import { withModifiers } from 'vue'

const count = ref(0)
const render = () => (
  <input
    onClick={
      withModifiers(
        // 第一个参数是回调函数
        () => count.value++,
        // 第二个参数是修饰符组成的数组
        ['self', 'prevent']
      )
    }
  />
)

上面的写法就相当于我们在 Vue 模板中这样写:

<template>
  <input @click.stop.prevent="() => count++" />
</template>

v-for

JSX 中是没有 v-for 这个自定义指令的,我们需要用 map 方法来替代

const render = () => (
 <ul>
   {
     items.value.map(({ id, text }) => {
       return (
         <li key={id}>{text}</li>
       )
     })
   }
 </ul>
)

上面的写法,其实就相当于我们在模板中这样写:

<ul>
  <li v-for="{ id, text } in items" :key="id">
    {{ text }}
  </li>
</ul>

v-if

同样,在 JSX 中也是没有 v-if 这个指令的~

但是细想一下,其实 v-if 的功能就是做判断嘛,比如我们的模板长这样:

<div>
  <div v-if="ok">yes</div>
  <span v-else>no</span>
</div>

那么换成用 JSX 就可以使用 三元表达式 或者 && 连接符 来实现这个功能,我们可以这样写:

// 使用三元表达式
const render = () => (
  <div>
    { ok.value ? <div>yes</div> : <span>no</span> }
  </div>
)
// 使用 && 连接符
const render = () => (
  <div>
    { ok.value && <div>yes</div> }
    { !ok.value && <span>no</span> }
  </div>
)

v-show

可以直接使用 v-show 指令,也可以写成 vShow 这种形式:

const show = ref(false)
// v-show
const render = () => (
  <div v-show={show}></div>
)
// 或者 vShow
const render = () => (
  <div vShow={show}></div>
)

v-model

正常情况下与我们在模板中使用 v-model 无异:

const value = ref('')
const render = () => (
  <input v-model={value} />
)
const value = ref('')
// 将默认的 arg 从 modelValue 修改为 childrenProp
const render = () => (
  <input v-model:childrenProp={value} />
)

修饰符

但如果你需要在 JSX 中使用 v-model 的内置修饰符,如 .lazy.trim.number,那么你需要 传递一个数组

const render = () => (
  <input v-model={[value, ['trim']]} />
)

如果你想 同时修改默认 arg,并且使用修饰符;那么传递的数组的 第二个参数需要定义为你设置的 arg,且是个字符串

const render = () => (
  <input v-model={[value, 'childrenProp', ['trim']]} />
)

上面的写法相当于模板中:

<input v-model:childrenProp.trim="value"></input>

自定义指令

JSX 中自定义指令的使用方法和 v-model 十分相似,只需要把 v-model 替换成你对应的自定义指令就可以啦:

const App = {
  directives: { custom: customDirective },
  setup() {
    const value = ref()
    return () => <children v-custom:childrenProp={value} />;
  },
};

修饰符

带修饰符的自定义指令,写法如下:

const App = {
  directives: { custom: customDirective },
  setup() {
    const value = ref()
    return () => (
      <children v-custom={[value, 'childrenProp', ['a', 'b']]} />;
    )
  },
};

插槽

在 Vue3 的 jsx 中,使用插槽同样分为两步走:

预留插槽

首先,在接收插槽的组件中,给插槽留个 “座位”;我们可以从 setup 函数的第二个参数解构出 slots,拿到外部传入的所有插槽:

// 自定义组件 customComp.jsx
export default {
  name: 'CustomComp',
  props: ['message'],
  // 外部传入的插槽信息都在 slots 中
  setup(props, { slots }) {
    return () => (
      <>
        // 传入的 default 默认插槽会被展示这里,如果没有传入默认插槽,则展示文本 foo
        <h1>{slots.default ? slots.default() : 'foo'}</h1>
        // 传入的具名插槽 bar 会被展示在这里
        <h2>{slots.bar?.()}</h2>
        // 作用域插槽 footer,外部可以通过作用域拿到 text 的值
        <h2>{slots.footer?.({ text: props.message })}</h2>
      </>
    )
  }
}

传入插槽

在 Vue3 的 jsx 中传入插槽时,需要使用 v-slots 代替 v-slot

// 定义好我们需要的插槽
const slots = {
  // 这部分内容会传到具名插槽 bar 中
  bar: () => <span>B</span>,
  // 这部分内容会传到作用域插槽 footer 中
  footer: ({ text }) => <span>{text}</span>
};
const render = () => (
  // 使用 v-slots 将定义好的插槽 slots 传入自定义组件 CustomComp
  <CustomComp v-slots={slots}>
     // 这部分内容,会传入组件 CustomComp 的默认插槽中
    <div>A</div>
  </CustomComp>
);

或者还可以直接用一个对象同时定义好默认插槽和具名插槽:

export default {
  setup() {
    // 将默认插槽和具名插槽用对象的形式定义好:
    const slots = {
      default: () => <div>A</div>,
      bar: () => <span>B</span>,
      // 作用域插槽,能够拿到对应的信息 text
      footer: ({ text }) => <span>{text}</span>
    };
    // 直接使用 v-slots 将整个插槽对象传递给自定义组件 CustomComp
    return () => <CustomComp v-slots={slots} />
  }
}

另外,如果 babel 的配置项 enableObjectSlots 不为 false 时,传入多个插槽还可以写成对象的形式,对象的 key 为插槽名称, value 为一个函数,函数的返回就是插槽的默认占位

修改 babel 配置:

{
  "plugins": [
    [
      "@vue/babel-plugin-jsx",
      {
        "enableObjectSlots": true
      }
    ]
  ]
}

jsx 具体写法:

const render = () => (
  <>
    <CustomComp>
      {{
        // 给自定义组件 CustomComp 传入一个默认插槽,内容是 <div>A</div>
        default: () => <div>A</div>,
        // 给自定义组件 CustomComp 传入一个具名插槽 bar,内容是 <span>B</span>
        bar: () => <span>B</span>,
        // 给自定义组件 CustomComp 传入一个具名插槽 footer,内容是 <span>B</span>
        footer: ({ text }) => <span>{ text }</span>,
      }}
    </CustomComp>
    // 相当于: {{ default: () => 'foo' }},给自定义组件 CustomComp 传入了一个默认插槽
    <CustomComp>{() => 'foo'}</CustomComp>
  </>
)
以函数的形式传递插槽,子组件就可以懒调用这些插槽了。

在 Vue3 中使用 JSX

在函数式组件中使用

在函数式组件中使用 JSX 十分简单,直接返回相关内容就行:

const App = () => <div></div>;

在 SFC 中使用

我们知道 Vue3 中的 setup 函数如果 返回的是个函数,那么这个函数的返回值会被作为模板内容渲染,并且会忽略 template 的内容

因此在普通的单文件组件中,我们可以直接在 setup 函数中返回我们的 JSX 内容:

import { defineComponent } from 'vue';

const App = defineComponent({
  setup() {
    const count = ref(0);

    return () => (
      <div>{count.value}</div>
    );
  },
});

Vue3 中使用 JSX 简明语法

文本插值

Vue 里面文本插值默认是用双大括号:

<h1>{{ msg }}</h1>

在 JSX 中变成了单大括号:

const name = 'Vue DevUI'
const element = <h1>Hello, { name }</h1>

和 Vue 模板语法中的文本插值一样,大括号内支持任何有效的 JavaScript 表达式,比如:2 + 2user.firstNameformatName(user) 等。

属性绑定

const properties = {a: 1, b: 2}

SFC 中 <div v-bind="properties"></div> 批量绑定标签属性。

在 JSX 中的替换方案是 <div {...properties}></div>

条件渲染

jsx 本身也是一个条件表达式,不再需要使用 v-if 指令。

使用 if/else

const element = (name) => {
  if (name) {
    return <h1>Hello, { name }</h1>
  } else {
    return <h1>Hello, Stranger</h1>
  }
}

以上代码等效于:

const element = (name) => <h1>Hello, { name || 'Stranger' }</h1>

使用三目运算符

const element = icon ? <span class="icon"></span> : null;

以上代码等效于:

const element = icon && <span class="icon"></span>;

列表渲染

列表渲染直接使用 JS 数组的 map 方法即可,不需要使用 v-for 指令。

const data = [{
  id: 1,
  title: '通用'
}, {
  id: 2,
  title: '导航'
}]

const element = data.map(item => {
  return <div>{ item.title }</div>
})

标签属性绑定

属性绑定也是使用大括号包裹,不需要使用 v-bind 指令。

const href = 'https://devui.design/'

const element = <a href={href}>DevUI Design</a>

class 类名绑定

直接使用 JS 模板字符串即可。

const element = <div className={`devui-accordion-item-title ${ disabled ? 'disabled' : '' }`}></div>

也可以使用数组:

const element = <div class={
    [
        'devui-accordion-item-title',
        disabled && 'disabled'
    ]
  }
>Item</div>

使用 CSS Modules,引入局部样式,相当于 SFC 中的 scoped

import styles from './index.module.scss'

<div class={styles.wrap}></div>

style 样式绑定

样式绑定需要使用双大括号。

const width = '100px'

const element = <button style={{ width, fontSize: '16px' }}></button>

事件绑定

绑定事件也是用大括号,注意事件名前面要加上 on 前缀,比如 click 事件要写成 onClick,mouseenter 事件要写成 onMouseenter

const confirm = () => {
  // 确认提交
}

<button onClick={confirm}>确定</button>

如果要带参数,需要使用箭头函数进行包裹:

const confirm = (name) => {
  // 确认提交
}

<button onClick={() => confirm('devui')}>确定</button>

事件修饰符

jsx 中给事件增加修饰符需要借助 withModifiers 方法。

import { withModifiers, defineComponent, ref } from 'vue'

const App = defineComponent({
  setup() {
    const count = ref(0);

    const inc = () => {
      count.value++;
    };

    return () => (
      <div onClick={ withModifiers(inc, ['self']) }>{ count.value }</div>
    );
  },
})

Vue 模板中 ref 变量是可以直接解构的,但是在 jsx 中不行,需要记得添加 .value,比如上面的 { count.value }

v-model 双向绑定

绑定 modelValue

这种情况比较简单。

JSX 写法:

<d-flexible-overlay v-model={ menuShow.value }></d-flexible-overlay>

SFC 写法:

<d-flexible-overlay v-model="menuShow"></d-flexible-overlay>

绑定自定义名称

比如绑定 visible,JSX 中不能直接用 v-model:visible 的语法,需要传入一个数组 [menuShow.value, 'visible'],数组的第二个参数就是要绑定的自定义名称。

JSX 写法:

<d-flexible-overlay v-model={[menuShow.value, 'visible']}></d-flexible-overlay>

SFC 写法:

<d-flexible-overlay v-model:visible="menuShow"></d-flexible-overlay>

slot 插槽

jsx 中没有 <slot> 标签,定义插槽需要使用双大括号。

如果是具名插槽,则将 default 改成具名插槽的名称,比如 mySlot,则使用 ctx.slots.mySlot?.()

插槽从 setup 的第二个参数 ctx 中获取,不需要加 $ 前缀。

import { defineComponent } from 'vue'

export default defineComponent({
  setup(props, { slots }) { // 逻辑
    return () => {
      return <button>{ slots.default?.() }</button>
    }
  },
})

还可以使用 renderSlot 方法:

import { renderSlot } from 'vue'

<button>
  { renderSlot(slots, 'default') }
</button>

Scoped Slots 作用域插槽

使用作用域插槽可以实现插槽传参,以下是具体的示例。

JSX 和 SFC 中插槽使用的写法对比。

JSX 写法:

<d-tree data={data}>
  {{
    mySlot: (item) => (item.open ? <IconOpen /> : <IconClose />),
  }}
</d-tree>

还可以通过 v-slots 的方式使用:

<d-tree data={data} v-slots={{
  mySlot: (item) => (item.open ? <IconOpen /> : <IconClose />)
}}>
</d-tree>

SFC 写法:

<d-tree :data="data">
  <template #mySlot="item">
    <IconOpen v-if="item.open" />
    <IconClose v-else />
  </template>
</d-tree>

其中的 item 是插槽的参数,通过

ctx.slots.mySlot(item)

的方式给插槽传入参数。

或者使用 renderSlot 方法,第三个参数就是要传给插槽的参数:

import { renderSlot, useSlots } from 'vue'

<button>
  { renderSlot(useSlots(), 'mySlot', item) }
</button>