基于项目实战阐述vue3.0新型状态管理和逻辑复用方式

3,373 阅读8分钟

前言

背景: 2019年2月6号,React 发布 16.8.0 版本,vue紧随其后,发布了vue3.0 RFC

Vue3.0受React16.0 推出的hook抄袭启发(咳咳...),提供了一个全新的逻辑复用方案。使用基于函数的 API,我们可以将相关联的代码抽取到一个 "composition function"(组合函数)中 —— 该函数封装了相关联的逻辑,并将需要暴露给组件的状态以响应式的数据源的方式返回出来。

本文目的

本文会简略得总结vue3.0带来的所有新特性,之后会介绍Vue3.0组合api的用法和注意点。最后会用一个 Todolist 的项目实战,向大家介绍Vue3.0的逻辑复用写法以及借用provide和inject的新型状态管理方式

本文提纲:

正文

vue3.0主要特点

  • 更小;

常驻代码体积在10kb左右。

  • Object.defineProperty 替换为 Proxy;
  • 支持typescript;

vue3.0本身就是用typescript重写的,完美的支持了tsx,且不会影响不使用ts的用户。

  • 优化vdom渲染函数。

通过模版静态分析,将模版分类(if for slot),记录动态节点的位置,更新时仅遍历动态节点,vdom的性能从原先的与模版大小相关变成和动态节点的数量相关。

  • 去除class api,改成function api;

了解react的同学可能这时候会闻到一股熟悉的闻到吧~其实就是撤销类写法,将所有逻辑放在一个**纯函数** 里面。

 setup (props) {
    const name = reactive({
      name: 'hello 番茄'
    })
    return { name }
  }

以及只是简单的总结了一些重要的新特性。

如何新建一个使用vue3.0的项目

接下来向大家简单介绍下如何尝鲜 -- 自己创建一个vue3.0的项目。
  1. 安装vue0-cli

我这边使用的是最新版本的vue-cli 4.4.0

npm install -g @vue/cli
# OR
yarn global add @vue/cli
  1. 将vue升级到bata版本
vue add vue-next

ok了。就这么简单!

conposition api

#### 目录
基本例子
<template>
  <div>
    <div>count is {{ count.count }}</div>
    <div>plusOne is {{ plusOne }}</div>
    <button @click="increment">count++</button>
  </div>
</template>

<script>
// eslint-disable-next-line no-unused-vars
import { reactive, computed, watch, onMounted } from 'vue'
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  setup () {
    // reactive state
    const count = reactive({ count: 0 })
    console.log("setup -> count", count.count)
    // computed state
    const plusOne = computed(() => count.count + 1)
    // method
    const increment = () => { count.count++ }
    // watch
    watch(() => count.count * 2, val => {
      console.log(`count * 2 is ${val}`)
    })
    // lifecycle
    onMounted(() => {
      console.log(`mounted`)
    })
    // expose bindings on render context
    return {
      count,
      plusOne,
      increment
    }
  }
}
</script>
setup

该setup功能是新的组件选项。它是组件内部暴露出所有的属性和方法的统一API。

调用时机

创建组件实例,然后初始化 props ,紧接着就调用setup 函数。从生命周期钩子的视角来看,它会在 beforeCreate 钩子之前被调用

模板中使用

如果 setup 返回一个对象,则对象的属性将会被合并到组件模板的渲染上下文

<template>
  <div>{{ count }} {{ object.foo }}</div>
</template>
<script>
  import { ref, reactive } from 'vue'
  export default {
    setup() {
      const count = ref(0)
      const object = reactive({ foo: 'bar' })
      // 暴露给模板
      return {
        count,
        object,
      }
    },
  }
</script>
setup 参数
  1. props 第一个参数接受一个响应式的props,这个props指向的是外部的props。如果你没有定义props选项,setup中的第一个参数将为undifined。 props和vue2.x并无什么不同,仍然遵循以前的原则;
  • 不要在子组件中修改props;如果你尝试修改,将会给你警告甚至报错。
  • 不要解构props。解构的props会失去响应性。

2.上下文对象 第二个参数提供了一个上下文对象,从原来 2.x 中 this 选择性地暴露了一些 property。

const MyComponent = {
  setup(props, context) {
    context.attrs
    context.slots
    context.emit
  },
}
Tip:

由于vue3.x向下兼容vue2.x,所以我在尝试之后发现,一个vue文件中你可以同时写两个版本的东西。

import { reactive, computed, watch, onMounted } from 'vue'
export default {
  name: 'HelloWorld',
  props: {
    count: Number,
  },
  data () {
    return {
      msg: "我是vue2.x中的this"
    }
  },
  methods: {
    test () {
      console.log(this.msg)
    }
  },
  mounted () {
    console.log('vue2.x mounted')
  },
  // eslint-disable-next-line no-unused-vars
  setup (props, val) {
    console.log(this, 'this') // undefined
    onMounted(() => {
      console.log('vue3.x mounted')
    })
    return {
      ...props
    }
  }
}

当然这边不推荐你在项目中这么用,但是抱着尝鲜和探究的态度,我们势必要弄清如果这么写要注意哪些?

  1. 如果我写了mounted(2.x),在setup函数中又写了onMounted(3.x),谁先执行?

setup中的先执行。因为setup() 在解析 2.x 选项前被调用;

  1. 我在vue2.x选项中中定义在this上的变量,在setup上可以通过this访问吗?可以重复定义吗?可以return吗?

首先在setup中的this将不再指向vue,而是undefined;所以在setup函数内部自然无法访问到vue实例上的this。

setup内部定义的变量和外表的变量并无冲突;

但是如果你要将其return 暴露给template,那么就会产生冲突。

reactive

接收一个普通对象然后返回该普通对象的响应式代理。等同于 2.x 的 Vue.observable()

const obj = reactive({ count: 0 })
ref

接受一个参数值并返回一个响应式且可改变的 ref 对象。ref 对象拥有一个指向内部值的单一属性 value。

const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1

tip:

  1. ref常用于基本类型,reactive用于引用类型。如果ref传入对象,其实内部会自动变为reactive.
  2. 当 ref 作为渲染上下文的属性返回(即在setup() 返回的对象中)并在模板中使用时,它会自动解套,无需在模板内额外书写 .value;
<template>
  <div>{{ count }}</div>
</template>
<script>
  export default {
    setup() {
      return {
        const count = ref(0)
        count: count, // 而不是 count.value
      }
    },
  }
</script>
  1. 当 ref 作为 reactive 对象的 property 被访问或修改时,也将自动解套 value 值,其行为类似普通属性。
const count = ref(0)
const state = reactive({
  count,
})
console.log(state.count) // 0
state.count = 1
console.log(count.value) // 1
  1. 注意当嵌套在 reactive Object 中时,ref 才会解套。从 Array 或者 Map 等原生集合类中访问 ref 时,不会自动解套:
const arr = reactive([ref(0)])
// 这里需要 .value
console.log(arr[0].value)
const map = reactive(new Map([['foo', ref(0)]]))
// 这里需要 .value
console.log(map.get('foo').value)
computed

computed和vue2.x版本保持一致,支持getter和setter

  • 传入一个 getter 函数,返回一个默认不可手动修改的 ref 对象。
const count = ref(1)
const plusOne = computed(() => count.value + 1)
console.log(plusOne.value) // 2
plusOne.value++ // 错误!
  • 或者传入一个拥有 get 和 set 函数的对象,创建一个可手动修改的计算状态。
const count = ref(1)
const plusOne = computed({
  get: () => count.value + 1,
  set: (val) => {
    count.value = val - 1
  },
})
plusOne.value = 1
console.log(count.value) // 0
watchEffect

传入的一个函数,并且立即执行,响应式追踪其依赖,并在其依赖变更时重新运行该函数。

注册监听
import {watchEffect}from 'vue' // 导入api
const count = ref(0) // 定义响应数据
watchEffect(() => console.log(count.value)) // 注册监听函数
// -> 打印出 0
setTimeout(() => {
  count.value++
  // -> 打印出 1
}, 100)
注销监听
- 默认情况下是在**组件卸载**的时候停止监听; - 也可以显式**调用返回值**以停止侦听;
const stop = watchEffect(() => {
  /* ... */
})
// 之后
stop()
清除副作用
> 有时副作用函数会执行一些异步的副作用, 这些响应需要在其失效时清除(即完成之前状态已改变了)。所以侦听副作用传入的函数可以接收一个 onInvalidate 函数作入参, 用来注册清理失效时的回调。

当以下情况发生时,这个失效回调会被触发:

  • 副作用即将重新执行时
  • 侦听器被停止
const count = ref(0)
watchEffect(
  (onInvalidate) => {
    console.log(count.value, '副作用')
   const token =  setTimeout(() => {
      console.log(count.value, '副作用')
    }, 4000)
    onInvalidate(() => {
    // id 改变时 或 停止侦听时
    // 取消之前的异步操作
    token.cancel()
  })
  }
)
副作用刷新时机
> Vue 的响应式系统会缓存副作用函数,并异步地刷新它们,这样可以避免同一个 tick 中多个状态改变导致的不必要的重复调用。在核心的具体实现中, 组件的更新函数也是一个被侦听的副作用。当一个用户定义的副作用函数进入队列时, 会在所有的组件更新后执行:
<template>
  <div>{{ count }}</div>
</template>
<script>
  export default {
    setup() {
      const count = ref(0)
      watchEffect(() => {
        console.log(count.value)
      })
      return {
        count,
      }
    },
  }
</script>

在这个例子中:

  • count 会在初始运行时同步打印出来
  • 更改 count 时,将在组件更新后执行副作用。

如果副作用需要同步或在组件更新之前重新运行,我们可以传递一个拥有 flush 属性的对象作为选项(默认为 'post'):

// 同步运行
watchEffect(
  () => {
    /* ... */
  },
  {
    flush: 'sync',
  }
)
// 组件更新前执行
watchEffect(
  () => {
    /* ... */
  },
  {
    flush: 'pre',
  }
)
watch
> watch API 完全等效于 2.x this.$watch (以及 watch 中相应的选项)。watch 需要侦听特定的数据源,并在回调函数中执行副作用。默认情况是懒执行的,也就是说仅在侦听的源变更时才执行回调。
  • 对比 watchEffect,watch 允许我们:

    • 懒执行副作用;
    • 更明确哪些状态的改变会触发侦听器重新运行副作用;
    • 访问侦听状态变化前后的值。
  • 侦听单个数据源

侦听器的数据源可以是一个拥有返回值的 getter 函数,也可以是 ref:

// 侦听一个 getter
const state = reactive({ count: 0 })
watch(
  () => state.count,
  (count, prevCount) => {
    /* ... */
  }
)
// 直接侦听一个 ref
const count = ref(0)
watch(count, (count, prevCount) => {
  /* ... */
})
  • 侦听多个数据源
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
  /* ... */
})
  • 与 watchEffect 共享的行为

watch 和 watchEffect 在停止侦听, 清除副作用 (相应地 onInvalidate 会作为回调的第三个参数传入),副作用刷新时机 和 侦听器调试 等方面行为一致.

生命周期钩子函数

可以直接导入 onXXX 一族的函数来注册生命周期钩子,这些生命周期钩子注册函数只能在 setup() 期间同步使用,在卸载组件时,在生命周期钩子内部同步创建的侦听器和计算状态也将自动删除。

  • 与 2.x 版本生命周期相对应的组合式 API
    • beforeCreate -> 使用 setup()
    • created -> 使用 setup()
    • beforeMount -> onBeforeMount
    • mounted -> onMounted
    • beforeUpdate -> onBeforeUpdate
    • updated -> onUpdated
    • beforeDestroy -> onBeforeUnmount
    • destroyed -> onUnmounted
    • errorCaptured -> onErrorCaptured
  • 新增的钩子函数
    • onRenderTracked
    • onRenderTriggered

两个钩子函数都接收一个DebuggerEvent,与 watchEffect 参数选项中的 onTrack 和 onTrigger 类似:

export default {
  onRenderTriggered(e) {
    debugger
    // 检查哪个依赖性导致组件重新渲染
  },
}
依赖注入

provide 和 inject 提供依赖注入,功能类似 2.x 的 provide/inject。两者都只能在当前活动组件实例的 setup() 中调用。

这是本篇文章的重点。结合项目实战以此来探索一下未来的 Vue 状态管理模式和逻辑复用模式。

用法

provide 和 inject 提供依赖注入,功能类似 2.x 的 provide/inject。两者都只能在当前活动组件实例的 setup() 中调用。

import { provide, inject } from 'vue'
const ThemeSymbol = Symbol()
const Ancestor = {
  setup() {
    provide(ThemeSymbol, 'dark')
  },
}
const Descendent = {
  setup() {
    const theme = inject(ThemeSymbol, 'light' /* optional default value */)
    return {
      theme,
    }
  },
}

inject 接受一个可选的的默认值作为第二个参数。如果未提供默认值,并且在 provide 上下文中未找到该属性,则 inject 返回 undefined。

  • 注入的响应性

可以使用 ref 来保证 provided 和 injected 之间值的响应:

// 提供者:
const themeRef = ref('dark')
provide(ThemeSymbol, themeRef)
// 使用者:
const theme = inject(ThemeSymbol, ref('light'))
watchEffect(() => {
  console.log(`theme set to: ${theme.value}`)
})

如果注入一个响应式对象,则它的状态变化也可以被侦听。

逻辑组合与复用

引出问题:

我们通常会基于一堆相同的数据进行花样呈现,有列表展示、有饼图占比、有折线图趋势、有热力图说明频次等等,这些组件使用的是相同的一些数据和数据处理逻辑。对于数据处理逻辑,目前vue有

  • Mixins
  • 高阶组件 (Higher-order Components, aka HOCs)
  • Renderless Components (基于 scoped slots / 作用域插槽封装逻辑的组件)

但是上面的方案始存在一些弊端:

  1. 模版中的数据来源不清晰
  2. 命名空间冲突。
  3. 需要额外的组件实例嵌套来封装逻辑(性能问题);
##### 基于组合api 的解决方案
function useMouse() {
  const x = ref(0)
  const y = ref(0)
  const update = e => {
    x.value = e.pageX
    y.value = e.pageY
  }
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  return { x, y }
}
// 在组件中使用该函数
const Component = {
  setup() {
    const { x, y } = useMouse()
    // 与其它函数配合使用
    const { z } = useOtherLogic()
    return { x, y, z }
  },
  template: `<div>{{ x }} {{ y }} {{ z }}</div>`
}
项目预览

源码: https://github.com/961998264/todolist-vue-3.0

项目介绍
  1. 已完成事件列表
  2. 未完成事件列表
  3. 查看事件详情
  4. 修改事件完成状态和事件详情
项目src目录

hooks文件夹是专门放hook的 context文件夹以模块划分

先来看下context编写(我这边是用的ts)

import { provide, ref, Ref, inject, computed, } from 'vue' //vue api
import { getListApi } from 'api/home' // mock的api
// 以下为定义的ts类型,你也可以单独建一个专门定义类型的文件。
type list = listItem[]
interface listItem {
  title: string,
  context: string,
  id: number,
  status: number,
}
interface ListContext {
  list: Ref<list>,
  getList: () => {},
  changeStatus: (id: number, status: number) => void,
  addList: (item: listItem) => void,
  delList: (id: number) => void,
  finished: Ref<list>,
  unFinish: Ref<list>,
  setContext: (id: number, context: string) => void,
  setActiveItem: () => void,
}

provide名称,推荐用Symbol

const listymbol = Symbol()

提供provide的函数

export const useListProvide = () => {
  // 全部事件 
  const list = ref<list>([]);
  // 当前查看的事件id
  const activeId = ref<number | null>(null)
  // 当前查看的事件
  const activeItem = computed(() => {
    if (activeId.value || activeId.value === 0) {
      const item = list.value.filter((item: listItem) => item.id === activeId.value)
      return item[0]
    } else {
      return null
    }
  })
  // 获取list
  const getList = async function () {
    const res: any = await getListApi()
    console.log("useListProvide -> res", res)
    if (res.code === 0) {
      list.value = res.data
    }
  }
  // 新增list
  const addList = (item: listItem) => {
    list.value.push(item)
  }
  //修改状态
  const changeStatus = (id: number, status: number) => {
    console.log('status', status)
    const removeIndex = list.value.findIndex((listItem: listItem) => listItem.id === id)
    if (removeIndex !== -1) {
      list.value[removeIndex].status = status
    }
  };
  // 修改事件信息
  const setContext = (id: number, context: string) => {
    const Index = list.value.findIndex((listItem: listItem) => listItem.id === id)
    if (Index !== -1) {
      list.value[Index].context = context
    }
  }
  // 删除事件
  const delList = (id: number) => {
    console.log("delList -> id", id)
    for (let i = 0; i < list.value.length; i++) {
      if (list.value[i].id === id) {
        list.value.splice(i, 1)
        break
      }
    }
  }
  // 未完成事件列表
  const unFinish = computed(() => {
    return list.value.filter(item => item.status === 0)
  })
  // 已完成事件列表
  const finished = computed(() => {
    return list.value.filter(item => item.status === 1)
  })
  
  provide(listymbol, {
    list,
    unFinish,
    finished,
    changeStatus,
    getList,
    addList,
    delList,
    setContext,
    activeItem,
    activeId
  })
}

在这个函数中定义 待办事件,并且定义一系列增删改查函数,通过provide暴露出去。

提供inject的函数

export const useListInject = () => {
  const listContext = inject<ListContext>(listymbol);
  if (!listContext) {
    throw new Error(`useListInject must be used after useListProvide`);
  }
  return listContext
};

全局状态肯定不止一个模块,所以在 context/index.ts 下做统一的导出

import { useListProvide, useListInject } from './home/index'
console.log("useListInject", useListInject)
export { useListInject }
export const useProvider = () => {
  useListProvide()
}

然后在 App.vue 的根组件里使用 provide,在最上层的组件中注入全局状态。

import { useProvider } from './context/index'
export default {
  name: 'App',
  setup () {
    useProvider()
    return {
    }
  }
}

在组件中获取数据:

import { useListInject } from '../../context/home/index'
setup () {
  const { list, changeStatus, getList, unFinish, finished, addList, a   ctiveItem, setContext } = useListInject()
}

不管是父子组件还是兄弟组件,或者是嵌关系套更深的组件,我们都可以通过useListInject来获取到响应式的数据。

  1. 逻辑聚合 同一份数据的相关逻辑我们可以写在一个usexxxx的函数中,不再像以前,按照选择将逻辑分开。在methods,computed,watch,created,mounted中来回跳转。

  2. 取代vuex 在比较小的项目中,你可以用这种状态管理的方式取代vuex。(反正我用react基本不用redux,不管项目大小)。

欢迎关注公众号:前端开发指南