likes
comments
collection
share

浅谈 Vue3 组件开发 — template / jsx 🥳

作者站长头像
站长
· 阅读数 14

前言

Vue3 是目前JavaScript框架Vue.js的最新版本,也是默认版本,它于2020年9月正式发布,带来了许多新功能和优化改进。相比于上一个版本Vue2, 它有更高的性能、更小的体积,同时它的CompositionAPI带来了更好的代码复用,并对 typescript 提供了较好的支持,更便于构建大型项目。

在 Vue2 中我们通过 选项式API (Options API) 进行 Vue 组件的开发, 而在 Vue3 中我们则是通过组合式API (Composition API) 开发 Vue 组件。两者之间无论是组件的定义,还是Vue API的使用都存在较大的差异。

而本篇以一个 Vue3 的 Todo List 组件在 .vue 单文件 和 .tsx 单文件中的开发, 从 选项式API组合式API 逐一分析,并对 Vue3 template 和 Vue3 JSX 分别进行梳理和说明。

Todo List 组件

  • Vue3 (^3.3.4)
  • Vite (^4.4.9)
  • TypeScript (^5.1.6)
  • @vitejs/plugin-vue (^4.3.1)
  • @vitejs/plugin-vue-jsx (^3.0.2)

定义 Todo List 组件

Todo List 组件代码稍微有点多,我们可以根据图示进行学习,这样有助于选项式API组合式API的梳理和理解

浅谈 Vue3 组件开发  —  template / jsx  🥳

a) .vue 单文件 — Options API

  <template>
    <div class="todo-container">
      <div class="todo-header">
        <div class="todo-title">
          Todo List
        </div>
      </div>

      <div class="todo-content">
        <div class="todo-input-group">
          <input
            type="text"
            class="todo-input"
            :value="innerInput"
            placeholder="What needs to be done?"
            @input="(event: any) => onInput(event.target.value, event)"
            @keydown.enter="(event: any) => onAdd(innerInput, event)"
          >
          <div
            class="todo-button-add"
            @click.stop="(event: any) => onAdd(innerInput, event)"
          >
            Add
          </div>
        </div>

        <div
          class="todo-items-group"
          @scroll.passive="(event: any) => onScroll(event)"
        >
          <template
            v-for="(item, index) of innerFilter"
            :key="index"
          >
            <div class="todo-item-group">
              <div
                :class="['todo-item-content', { 'todo-item-done': item.state === true }]"
                @click.stop="(event: Event) => onClick(item, event)"
              >
                <slot
                  name="item"
                  :item="item"
                  :index="index"
                >
                  {{ index + 1 + ') ' + item.title }}
                </slot>
              </div>

              <div class="todo-item-buttons">
                <div
                  v-if="item.state !== true"
                  class="todo-button-done"
                  @click.stop="(event: any) => onChange(item, event)"
                >
                  Done
                </div>

                <div
                  v-if="item.state === true"
                  class="todo-button-reset"
                  @click.stop="(event: any) => onChange(item, event)"
                >
                  Reset
                </div>
              </div>
            </div>
          </template>
        </div>

        <div class="todo-item-actions">
          <div class="todo-count">
            <span style="margin-right: 3px">Total: </span>
            <span style="color: #f34d4d">{{ innerCount }}</span>
          </div>

          <div class="todo-states">
            <div
              :class="['todo-state', { 'todo-state-active': innerState === null }]"
              @click.stop="(event: any) => onFilter(null, event)"
            >
              All
            </div>
  
            <div
              :class="['todo-state', { 'todo-state-active': innerState === false }]"
              @click.stop="(event: any) => onFilter(false, event)"
            >
              Uncompleted
            </div>

            <div
              :class="['todo-state', { 'todo-state-active': innerState === true }]"
              @click.stop="(event: any) => onFilter(true, event)"
            >
              Completed
            </div>
          </div>
        </div>
      </div>
    </div>
  </template>

  <script lang="ts">
  import {
    PropType,
    SlotsType,
    defineComponent
  } from 'vue'

  export interface Item {
    title: string;
    state: boolean;
  }

  export default defineComponent({
    name: 'TodoList',
    props: {
      input: {
        type: String as PropType<string>,
        default: ''
      },
      items: {
        type: Array as PropType<Item[]>,
        default: () => []
      },
      state: {
        type: Boolean as PropType<boolean | null>,
        default: null
      }
    },
    emits: {
      'update:items': (input: Array<Item>) => true,
      'update:state': (state: boolean | null) => true,
      'scroll': (event: Event) => true,
      'click': (item: Item) => true
    },
    slots: {} as SlotsType<{
      item: {
        index: number;
        item: Item;
      }
    }>,
    data() {
      return {
        innerInput: this.input,
        innerState: this.state,
        innerItems: [...this.items]
      }
    },
    computed: {
      innerFilter() {
        return this.innerState !== null
          ? this.innerItems.filter(item => item.state === this.innerState)
          : this.innerItems
      },
      innerCount() {
        return this.innerFilter.length
      }
    },
    watch: {
      input() {
        this.innerInput = this.input
      },

      state() {
        this.innerState = this.state
      },
  
      items() {
        this.innerItems = [...this.items]
      }
    },
    methods: {
      onInput(input: string, event: Event) {
        this.innerInput = input
      },
      onAdd(input: string, event: Event) {
        if (input.trim()) {
          this.innerItems.push({
            title: input.trim(),
            state: false
          })
          this.$emit('update:items', [...this.innerItems])
        }
        this.innerInput = ''
      },
      onChange(item: Item, event: Event) {
        item.state = !item.state
        this.$emit('update:items', [...this.innerItems])
      },
      onFilter(state: boolean | null, event: Event) {
        this.innerState = state
        this.$emit('update:state', state)
      },
      onClick(item: Item, event: Event) {
        this.$emit('click', item)
      },
      onScroll(event: Event) {
        this.$emit('scroll', event)
      }
    }
  })
  </script>

  <style lang="less" scoped>
  @import './style.less';
  </style>

在这里我们完全使用 Vue2 Options API 的方式定义了 Todo List 的组件。但与 Vue2 不同的是,我们提供了typescript 的支持,定义 Props、Emits、Slots的类型

配置 typescipt 支持

<script lang="ts">

定义 Prop 类型 (在父组件中提供类型提示)

  props: { 
    state: { 
      type: Boolean as PropType<boolean | null>,
      default: null 
    }
  }
    

浅谈 Vue3 组件开发  —  template / jsx  🥳

定义 emit 类型 (在当前组件中提供类型提示)

  emits: { 
    'update:items': (input: Array<Item>) => true
  }
    

浅谈 Vue3 组件开发  —  template / jsx  🥳

定义 slot 类型 (在父组件中提供类型提示)

  export interface Item { 
    title: string;
    state: boolean; 
  }
    
  slots: {} as SlotsType<{ 
    item: { index: number; item: Item; } 
  }>
    

浅谈 Vue3 组件开发  —  template / jsx  🥳

注意事项

定义 typescript 类型时,必须使用 export 进行导出,例如

浅谈 Vue3 组件开发  —  template / jsx  🥳

b) .vue 单文件 — Options Setup

  <template>
    <div class="todo-container">
      <div class="todo-header">
        <div class="todo-title">
          Todo List
        </div>
      </div>

      <div class="todo-content">
        <div class="todo-input-group">
          <input
            type="text"
            class="todo-input"
            :value="innerInput"
            placeholder="What needs to be done?"
            @input="(event: any) => onInput(event.target.value, event)"
            @keydown.enter="(event: any) => onAdd(innerInput, event)"
          >
          <div
            class="todo-button-add"
            @click.stop="(event: any) => onAdd(innerInput, event)"
          >
            Add
          </div>
        </div>

        <div
          class="todo-items-group"
          @scroll.passive="(event: any) => onScroll(event)"
        >
          <template
            v-for="(item, index) of innerFilter"
            :key="index"
          >
            <div class="todo-item-group">
              <div
                :class="['todo-item-content', { 'todo-item-done': item.state === true }]"
                @click.stop="(event: Event) => onClick(item, event)"
              >
                <slot
                  name="item"
                  :item="item"
                  :index="index"
                >
                  {{ index + 1 + ') ' + item.title }}
                </slot>
              </div>

              <div class="todo-item-buttons">
                <div
                  v-if="item.state !== true"
                  class="todo-button-done"
                  @click.stop="(event: any) => onChange(item, event)"
                >
                  Done
                </div>

                <div
                  v-if="item.state === true"
                  class="todo-button-reset"
                  @click.stop="(event: any) => onChange(item, event)"
                >
                  Reset
                </div>
              </div>
            </div>
          </template>
        </div>

        <div class="todo-item-actions">
          <div class="todo-count">
            <span style="margin-right: 3px">Total: </span>
            <span style="color: #f34d4d">{{ innerCount }}</span>
          </div>

          <div class="todo-states">
            <div
              :class="['todo-state', { 'todo-state-active': innerState === null }]"
              @click.stop="(event: any) => onFilter(null, event)"
            >
              All
            </div>

            <div
              :class="['todo-state', { 'todo-state-active': innerState === false }]"
              @click.stop="(event: any) => onFilter(false, event)"
            >
              Uncompleted
            </div>

            <div
              :class="['todo-state', { 'todo-state-active': innerState === true }]"
              @click.stop="(event: any) => onFilter(true, event)"
            >
              Completed
            </div>
          </div>
        </div>
      </div>
    </div>
  </template>

  <script lang="ts">
  import {
    ref,
    computed,
    PropType,
    SlotsType,
    defineComponent
  } from 'vue'

  export interface Item {
    title: string;
    state: boolean;
  }

  export default defineComponent({
    name: 'TodoList',
    props: {
      input: {
        type: String as PropType<string>,
        default: ''
      },
      items: {
        type: Array as PropType<Item[]>,
        default: () => []
      },
      state: {
        type: Boolean as PropType<boolean | null>,
        default: null
      }
    },
    emits: {
      'update:items': (input: Array<Item>) => true,
      'update:state': (state: boolean | null) => true,
      'scroll': (event: Event) => true,
      'click': (item: Item) => true
    },
    slots: {} as SlotsType<{
      item: {
        index: number;
        item: Item;
      }
    }>,
    setup(props, ctx) {
      const innerInput = ref(props.input)
      const innerState = ref(props.state)
      const innerItems = ref(props.items)

      const innerFilter = computed(() => {
        return innerState.value !== null
          ? innerItems.value.filter(item => item.state === innerState.value)
          : innerItems.value
      })

      const innerCount = computed(() => {
        return innerFilter.value.length
      })

      const onInput = (input: string, event: Event) => {
        innerInput.value = input
      }

      const onAdd = (input: string, event: Event) => {
        if (input.trim()) {
          innerItems.value.push({
            title: input.trim(),
            state: false
          })
          ctx.emit('update:items', [...innerItems.value])
        }
        innerInput.value = ''
      }

      const onChange = (item: Item, event: Event) => {
        item.state = !item.state
        ctx.emit('update:items', [...innerItems.value])
      }

      const onFilter = (state: boolean | null, event: Event) => {
        innerState.value = state
        ctx.emit('update:state', state)
      }

      const onClick = (item: Item, event: Event) => {
        ctx.emit('click', item)
      }

      const onScroll = (event: Event) => {
        ctx.emit('scroll', event)
      }

      return {
        innerInput,
        innerState,
        innerItems,
        innerFilter,
        innerCount,
        onInput,
        onAdd,
        onChange,
        onFilter,
        onScroll,
        onClick
      }
    }
  })
  </script>

  <style lang="less" scoped>
  @import './style.less';
  </style>
  

对比说明

这里我们使用 Options API + Options Setup 混合方式定义组件。与上个小节的相比,最大的变化,是将原先 Options 里 datacomputedmethodswatch 的写法,改成 setup 函数中的 Composition API 定义和使用。

注意事项

  • Setup 函数中定义的变量、属性以及方法,必须通过 return 导出,才能在 template 中进行使用

  • Setup 函数中定义的变量、属性以及方法,必须通过 return 导出,父组件才能在引用该组件时,才能通过 ref 获取 Setup 函数中定义的变量、属性以及方法

c) .vue 单文件 — Options Setup Render

  <script lang="tsx">
  import {
    ref,
    computed,
    PropType,
    SlotsType,
    withModifiers,
    defineComponent
  } from 'vue'

  export interface Item {
    title: string;
    state: boolean;
  }

  export default defineComponent({
    name: 'TodoList',
    props: {
      input: {
        type: String as PropType<string>,
        default: ''
      },
      items: {
        type: Array as PropType<Item[]>,
        default: () => []
      },
      state: {
        type: Boolean as PropType<boolean | null>,
        default: null
      }
    },
    emits: {
      'update:items': (input: Array<Item>) => true,
      'update:state': (state: boolean | null) => true,
      'scroll': (event: Event) => true,
      'click': (item: Item) => true
    },
    slots: {} as SlotsType<{
      item: {
        index: number;
        item: Item;
      }
    }>,
    setup(props, ctx) {
      const innerInput = ref(props.input)
      const innerState = ref(props.state)
      const innerItems = ref(props.items)

      const innerFilter = computed(() => {
        return innerState.value !== null
          ? innerItems.value.filter(item => item.state === innerState.value)
          : innerItems.value
      })

      const innerCount = computed(() => {
        return innerFilter.value.length
      })

      const onInput = (input: string, event: Event) => {
        innerInput.value = input
      }

      const onAdd = (input: string, event: Event) => {
        if (input.trim()) {
          innerItems.value.push({
            title: input.trim(),
            state: false
          })
          ctx.emit('update:items', [...innerItems.value])
        }
        innerInput.value = ''
        event.stopPropagation()
      }

      const onChange = (item: Item, event: Event) => {
        item.state = !item.state
        ctx.emit('update:items', [...innerItems.value])
        event.stopPropagation()
      }

      const onFilter = (state: boolean | null, event: Event) => {
        innerState.value = state
        ctx.emit('update:state', state)
      }

      const onClick = (item: Item, event: Event) => {
        ctx.emit('click', item)
        event.stopPropagation()
      }

      const onScroll = (event: Event) => {
        ctx.emit('scroll', event)
      }

      ctx.expose({
        innerInput,
        innerState,
        innerItems,
        innerFilter,
        innerCount,
        onInput,
        onAdd,
        onChange,
        onFilter,
        onScroll,
        onClick
      })

      return () => (
        <div class='todo-container'>
          <div class='todo-header'>
            <div class='todo-title'>
            Todo List
            </div>
          </div>

          <div class='todo-content'>
            <div class='todo-input-group'>
              <input
                type='text'
                class='todo-input'
                value={innerInput.value}
                placeholder='What needs to be done?'
                onInput={(event: any) => onInput(event.target.value, event)}
                onKeydown={(event: any) => event.keyCode === 13 && onAdd(innerInput.value, event)}
              />

              <div
                class='todo-button-add'
                onClick={(event: any) => onAdd(innerInput.value, event)}
              >
                Add
              </div>
            </div>

            <div
              class='todo-items-group' // @ts-ignore
              onScrollPassive={(event: any) => onScroll(event)}
            >
              {
                innerFilter.value.map((item, index) => {
                  return (
                    <div class='todo-item-group'>
                      <div
                        class={['todo-item-content', { 'todo-item-done': item.state === true }]}
                        onClick={(event: Event) => onClick(item, event)}
                      >
                        { ctx.slots.item ? ctx.slots.item({ index, item }) : index + 1 + ') ' + item.title }
                      </div>

                      <div class='todo-item-buttons'>
                        <div
                          class={{ 'todo-button-done': item.state !== true, 'todo-button-reset': item.state === true }}
                          onClick={(event: any) => onChange(item, event)}
                        >
                          { item.state !== true ? 'Done' : 'Reset' }
                        </div>
                      </div>
                    </div>
                  )
                })
              }
            </div>

            <div class='todo-item-actions'>
              <div class='todo-count'>
                <span style='margin-right: 3px'>Total: </span>
                <span style='color: #f34d4d'>{ innerCount.value }</span>
              </div>

              <div class='todo-states'>
                <div
                  class={['todo-state', { 'todo-state-active': innerState.value === null }]}
                  onClick={withModifiers((event: any) => onFilter(null, event), ['stop'])}
                >
                  All
                </div>

                <div
                  class={['todo-state', { 'todo-state-active': innerState.value === false }]}
                  onClick={withModifiers((event: any) => onFilter(false, event), ['stop'])}
                >
                  Uncompleted
                </div>

                <div
                  class={['todo-state', { 'todo-state-active': innerState.value === true }]}
                  onClick={withModifiers((event: any) => onFilter(true, event), ['stop'])}
                >
                  Completed
                </div>
              </div>
            </div>
          </div>
        </div>
      )
    }
  })
  </script>

  <style lang="less" scoped>
  @import './style.less';
  </style>
  

对比说明

这次我们使用 Options Setup Render 方式定义组件,将原先 template 元素定义改成 Setup Render 函数定义。

注意事项

  • 需要 jsx 语法的支持 (typescript@vitejs/plugin-vue-jsx) <script lang="tsx">

  • 由于 Setup return 导出的是渲染函数,所以如果想让父组件ref获取子组件Setup 函数中定义的属性、方法,必须使用 expose 进行导出,例

      ctx.expose({ 
        innerInput,
        innerState, 
        innerItems, 
        innerFilter, 
        innerCount, 
        onInput, onAdd, 
        onChange,
        onFilter,
        onScroll, 
        onClick 
      })
      
    
  • jsx 语法中,如何定义事件修饰符

    • 如果是 stoppreventselfctrlshiftaltmetaleftmiddlerightexact 修饰符,可以使用 withModifiers 进行处理

      浅谈 Vue3 组件开发  —  template / jsx  🥳

    • 如果是 .passive.capture 和 .once 等修饰符,可以使用驼峰写法将他们拼接在事件名后面

      浅谈 Vue3 组件开发  —  template / jsx  🥳

    • 还有一些修饰符,可以通过 Event 自定义,例如回车事件

      浅谈 Vue3 组件开发  —  template / jsx  🥳

d) .vue 单文件 — Script Setup + JSX (推荐)

  <template>
    <div class="todo-container">
      <div class="todo-header">
        <div class="todo-title">
          Todo List
        </div>
      </div>

      <div class="todo-content">
        <div class="todo-input-group">
          <input
            type="text"
            class="todo-input"
            :value="innerInput"
            placeholder="What needs to be done?"
            @input="(event: any) => onInput(event.target.value, event)"
            @keydown.enter="(event: any) => onAdd(innerInput, event)"
          >
          <div
            class="todo-button-add"
            @click.stop="(event: any) => onAdd(innerInput, event)"
          >
            Add
          </div>
        </div>

        <div
          class="todo-items-group"
          @scroll.passive="(event: any) => onScroll(event)"
        >
          <template
            v-for="(item, index) of innerFilter"
            :key="index"
          >
            <div class="todo-item-group">
              <div
                :class="['todo-item-content', { 'todo-item-done': item.state === true }]"
                @click.stop="(event: Event) => onClick(item, event)"
              >
                <slot
                  name="item"
                  :item="item"
                  :index="index"
                >
                  {{ index + 1 + ') ' + item.title }}
                </slot>
              </div>

              <div class="todo-item-buttons">
                <div
                  v-if="item.state !== true"
                  class="todo-button-done"
                  @click.stop="(event: any) => onChange(item, event)"
                >
                  Done
                </div>

                <div
                  v-if="item.state === true"
                  class="todo-button-reset"
                  @click.stop="(event: any) => onChange(item, event)"
                >
                  Reset
                </div>
              </div>
            </div>
          </template>
        </div>

        <div class="todo-item-actions">
          <div class="todo-count">
            <span style="margin-right: 3px">Total: </span>
            <span style="color: #f34d4d">{{ innerCount }}</span>
          </div>

          <div class="todo-states">
            <div
              :class="['todo-state', { 'todo-state-active': innerState === null }]"
              @click.stop="(event: any) => onFilter(null, event)"
            >
              All
            </div>

            <div
              :class="['todo-state', { 'todo-state-active': innerState === false }]"
              @click.stop="(event: any) => onFilter(false, event)"
            >
              Uncompleted
            </div>

            <div
              :class="['todo-state', { 'todo-state-active': innerState === true }]"
              @click.stop="(event: any) => onFilter(true, event)"
            >
              Completed
            </div>
          </div>
        </div>
      </div>
    </div>
  </template>

  <script setup lang="tsx">
  import { ref, computed } from 'vue'

  export interface Item {
    title: string;
    state: boolean;
  }

  export interface Emits {
    (e: 'update:items', input: Item[]): void;
    (e: 'update:state', state: boolean | null): void;
    (e: 'scroll', event: Event): void;
    (e: 'click', item: Item): void;
  }

  export interface Slots {
    item(props: { index: number; item: Item; }): any;
  }

  export interface Props {
    input: string;
    items: Item[];
    state: boolean | null;
  }

  defineOptions({
    name: 'TodoList',
    inheritAttrs: false
  })

  // eslint-disable-next-line no-unused-vars
  const slots = defineSlots<Slots>()
  const props = defineProps<Props>()
  const emit = defineEmits<Emits>()

  const innerInput = ref(props.input)
  const innerState = ref(props.state)
  const innerItems = ref(props.items)

  const innerFilter = computed(() => {
    return innerState.value !== null
      ? innerItems.value.filter(item => item.state === innerState.value)
      : innerItems.value
  })

  const innerCount = computed(() => {
    return innerFilter.value.length
  })

  const onInput = (input: string, event: Event) => {
    innerInput.value = input
  }

  const onAdd = (input: string, event: Event) => {
    if (input.trim()) {
      innerItems.value.push({
        title: input.trim(),
        state: false
      })
      emit('update:items', [...innerItems.value])
    }
    innerInput.value = ''
    event.stopPropagation()
  }

  const onChange = (item: Item, event: Event) => {
    item.state = !item.state
    emit('update:items', [...innerItems.value])
    event.stopPropagation()
  }

  const onFilter = (state: boolean | null, event: Event) => {
    innerState.value = state
    emit('update:state', state)
  }

  const onClick = (item: Item, event: Event) => {
    emit('click', item)
    event.stopPropagation()
  }

  const onScroll = (event: Event) => {
    emit('scroll', event)
  }

  defineExpose({
    innerInput,
    innerState,
    innerItems,
    innerFilter,
    innerCount,
    onInput,
    onAdd,
    onChange,
    onFilter,
    onScroll,
    onClick
  })
  </script>

  <style lang="less" scoped>
  @import './style.less';
  </style>

配置 typescipt + jsx 支持

<script setup lang="tsx">

定义 组件名称 name

  defineOptions({ 
    name: 'TodoList', 
    inheritAttrs: false 
  })
    

定义 Prop 类型 (在父组件中提供类型提示)

  export interface Props { 
    input: string; 
    items: Item[]; 
    state: boolean | null; 
  }
  
  const props = defineProps<Props>()
    

定义 emit 类型 (在当前组件中提供类型提示)

  export interface Emits { 
    (e: 'update:items', input: Item[]): void; 
    (e: 'update:state', state: boolean | null): void; 
    (e: 'scroll', event: Event): void; 
    (e: 'click', item: Item): void; 
  }
    
  const emit = defineEmits<Emits>()
    

浅谈 Vue3 组件开发  —  template / jsx  🥳

定义 slot 类型 (在父组件中提供类型提示)

  export interface Slots { 
    item(props: { index: number; item: Item; }): any; 
  }
  
  const slots = defineSlots<Slots>()
    

定义 导出的变量、属性以及方法

  defineExpose({
    innerInput,
    innerState,
    innerItems,
    innerFilter,
    innerCount,
    onInput,
    onAdd,
    onChange,
    onFilter,
    onScroll,
    onClick
  })
    

定义 script setup 中的 Vue Node

我们还可以把 Todo List 列表部分,从 template 剥离出来,定义在 script setup 中。

  • script setup 中定义 TodoItems

     const  TodoItems = () => innerFilter.value.map((item, index) => {
        return (
          <div class='todo-item-group'>
            <div
              class={['todo-item-content', { 'todo-item-done': item.state === true }]}
              onClick={(event: Event) => onClick(item, event)}
            >
              { slots.item ? slots.item({ index, item }) : index + 1 + ') ' + item.title }
            </div>
    
            <div class='todo-item-buttons'>
              <div
                class={{ 'todo-button-done': item.state !== true, 'todo-button-reset': item.state === true }}
                onClick={(event: any) => onChange(item, event)}
              >
                { item.state !== true ? 'Done' : 'Reset' }
              </div>
            </div>
          </div>
        )
      })
    
  • 在 template 中 class="todo-items-group" 部分替换成如下即可

      <div
        class="todo-items-group"
        @scroll.passive="(event: any) => onScroll(event)"
      >
        <TodoItems />
      </div>
    
  • 但是美中不足,这样一来,样式上不能设置组件作用域,需移除 scoped

  <style lang="less">
  @import './style.less';
  </style>

e) .tsx 单文件 — Options Setup Render

 import './style.less'

 import {
   ref,
   computed,
   PropType,
   SlotsType,
   withModifiers,
   defineComponent
 } from 'vue'

 export interface Item {
   title: string;
   state: boolean;
 }

 export default defineComponent({
   name: 'TodoList',
   props: {
     input: {
       type: String as PropType<string>,
       default: ''
     },
     items: {
       type: Array as PropType<Item[]>,
       default: () => []
     },
     state: {
       type: Boolean as PropType<boolean | null>,
       default: null
     }
   },
   emits: {
     'update:items': (input: Array<Item>) => true,
     'update:state': (state: boolean | null) => true,
     'scroll': (event: Event) => true,
     'click': (item: Item) => true
   },
   slots: {} as SlotsType<{
     item: {
       index: number;
       item: Item;
     }
   }>,
   setup(props, ctx) {
     const innerInput = ref(props.input)
     const innerState = ref(props.state)
     const innerItems = ref(props.items)

     const innerFilter = computed(() => {
       return innerState.value !== null
         ? innerItems.value.filter(item => item.state === innerState.value)
         : innerItems.value
     })

     const innerCount = computed(() => {
       return innerFilter.value.length
     })

     const onInput = (input: string, event: Event) => {
       innerInput.value = input
     }

     const onAdd = (input: string, event: Event) => {
       if (input.trim()) {
         innerItems.value.push({
           title: input.trim(),
           state: false
         })
         ctx.emit('update:items', [...innerItems.value])
       }
       innerInput.value = ''
       event.stopPropagation()
     }

     const onChange = (item: Item, event: Event) => {
       item.state = !item.state
       ctx.emit('update:items', [...innerItems.value])
       event.stopPropagation()
     }

     const onFilter = (state: boolean | null, event: Event) => {
       innerState.value = state
       ctx.emit('update:state', state)
     }

     const onClick = (item: Item, event: Event) => {
       ctx.emit('click', item)
       event.stopPropagation()
     }

     const onScroll = (event: Event) => {
       ctx.emit('scroll', event)
     }

     ctx.expose({
       innerInput,
       innerState,
       innerItems,
       innerFilter,
       innerCount,
       onInput,
       onAdd,
       onChange,
       onFilter,
       onScroll,
       onClick
     })

     return () => (
       <div class='todo-container'>
         <div class='todo-header'>
           <div class='todo-title'>
           Todo List
           </div>
         </div>

         <div class='todo-content'>
           <div class='todo-input-group'>
             <input
               type='text'
               class='todo-input'
               value={innerInput.value}
               placeholder='What needs to be done?'
               onInput={(event: any) => onInput(event.target.value, event)}
               onKeydown={(event: any) => event.keyCode === 13 && onAdd(innerInput.value, event)}
             />

             <div
               class='todo-button-add'
               onClick={(event: any) => onAdd(innerInput.value, event)}
             >
               Add
             </div>
           </div>

           <div
             class='todo-items-group' // @ts-ignore
             onScrollPassive={(event: any) => onScroll(event)}
           >
             {
               innerFilter.value.map((item, index) => {
                 return (
                   <div class='todo-item-group'>
                     <div
                       class={['todo-item-content', { 'todo-item-done': item.state === true }]}
                       onClick={(event: Event) => onClick(item, event)}
                     >
                       { ctx.slots.item ? ctx.slots.item({ index, item }) : index + 1 + ') ' + item.title }
                     </div>

                     <div class='todo-item-buttons'>
                       <div
                         class={{ 'todo-button-done': item.state !== true, 'todo-button-reset': item.state === true }}
                         onClick={(event: any) => onChange(item, event)}
                       >
                         { item.state !== true ? 'Done' : 'Reset' }
                       </div>
                     </div>
                   </div>
                 )
               })
             }
           </div>

           <div class='todo-item-actions'>
             <div class='todo-count'>
               <span style='margin-right: 3px'>Total: </span>
               <span style='color: #f34d4d'>{ innerCount.value }</span>
             </div>

             <div class='todo-states'>
               <div
                 class={['todo-state', { 'todo-state-active': innerState.value === null }]}
                 onClick={withModifiers((event: any) => onFilter(null, event), ['stop'])}
               >
                 All
               </div>

               <div
                 class={['todo-state', { 'todo-state-active': innerState.value === false }]}
                 onClick={withModifiers((event: any) => onFilter(false, event), ['stop'])}
               >
                 Uncompleted
               </div>

               <div
                 class={['todo-state', { 'todo-state-active': innerState.value === true }]}
                 onClick={withModifiers((event: any) => onFilter(true, event), ['stop'])}
               >
                 Completed
               </div>
             </div>
           </div>
         </div>
       </div>
     )
   }
 })

定义说明

近乎等同于 .vue 单文件 — Options Setup Render,一般用于开发公共组件(非业务组件)

定义 Todo List 父组件

现在,我们已经梳理好了 Todo List 组件各种方式的定义和各自的差异。接下来我们定义使用 Todo List 的父组件 UseTodoList 组件

a) .vue 单文件 — Script Setup + JSX (推荐)

  <template>
    <TodoList
      ref="todo"
      v-model:items="items"
      v-model:state="state"
      :input="input"
    >
      <template #item="{index, item}">
        <span>{{ index + 1 + '、' + item.title }}</span>
      </template>
    </TodoList>
  </template>

  <script setup lang="ts">
  import TodoList from '@/components/TodoList.vue'
  import { onMounted, ref, watch } from 'vue'

  defineOptions({
    name: 'UseTodoList'
  })

  const input = ref('')
  const state = ref(null)
  const todo = ref(null)

  const items = ref([
    {
      title: '晨跑3公里',
      state: true
    },
    {
      title: '午休30分钟',
      state: true
    },
    {
      title: '看书2小时',
      state: false
    }
  ])

  watch(state, () => { console.log('watch/state: ', state.value) })
  watch(items, () => { console.log('watch/items: ', items.value) })

  onMounted(() => { console.log(todo.value) })
  </script>
  

b) .tsx 单文件 — Options Setup Render

  import { defineComponent, onMounted, watch, ref } from 'vue'
  import TodoList, { Item } from '@/components/TodoList'

  export default defineComponent({
    name: 'UseTodoList',

    setup() {
      const input = ref('')
      const state = ref(null)
      const todo: any = ref(null)

      const items = ref([
        {
          title: '晨跑3公里',
          state: true
        },
        {
          title: '午休30分钟',
          state: true
        },
        {
          title: '看书2小时',
          state: false
        }
      ])

      watch(state, () => { console.log('watch/state: ', state.value) })
      watch(items, () => { console.log('watch/items: ', items.value) })

      onMounted(() => { console.log(todo.value) })

      return () => (
        <TodoList
          ref={el => { todo.value = el }}
          input={input.value}
          v-models={[[items.value, 'items'], [state.value, 'state']]}
          v-slots={{ item: (opts: { index: number, item: Item }) => opts.index + 1 + '、' + opts.item.title }}
        />
      )
    }
  })

注意事项

jsx 语法中需注意 refv-modelsslots 的定义和使用,其中 v-modelv-models 都只是语法糖:

  • 单个 v-model: 例 v-model={[items.value, 'items']}
  • 多个 v-model: 例 v-models={[[items.value, 'items'], [state.value, 'state']]}

它等价于

  <TodoList
     {
       ...{
         'items': items.value,
         'state': state.value,
         'onUpdate:items': value => { items.value = value },
         'onUpdate:state': value => { state.value = value }
       }
     }
     ref={el => { todo.value = el }}
     input={input.value}
     v-slots={{ item: (opts: { index: number, item: Item }) => opts.index + 1 + '、' + opts.item.title }}
  />

相关链接