9
头图

前言

大家好,我是webfansplz.首先跟大家分享一个好消息,我加入VueUse团队啦,感谢@antfu的邀请,很开心成为团队的一员.今天跟大家聊聊VueUse的设计与实现.

介绍

大家都知道Vue3引入了组合式API,大大提升了逻辑复用能力.VueUse基于组合式API实现了很多易用、实用且有趣的功能.比如:

useMagicKeys

useMagicKeys 监听按键状态,并提供了组合热键的功能,非常的神奇和有趣.使用它,我们可以很容易的监听我们使用CV大法的次数 :)

useScroll

useScroll 提供了一些响应式的状态和值,比如滚动状态、抵达状态、滚动方向以及当前滚动位置.

useElementByPoint

useElementByPoint 用于实时获取当前坐标位置最顶层的元素,配合 useMouse,我们可以做一些有趣的交互和效果.

用户体验

使用者体验

VueUse无论是面向使用者还是开发者都做到了很棒的用户体验.我们先来看看使用者体验:

强类型支持

VueUse采用了TypeScript进行编写并且带有完整的TS文档,有良好的TS支持能力.

SSR支持

我们对SSR进行了友好的支持,它可以在服务端渲染场景工作的很好.

易用性

一些支持传入配置选项的函数我们会为使用者提供一套常用的默认选项,这样可以保证用户在大多数应用 场景下并不需要过多的关注你的功能实现和细节.以 useScroll 为例:

<script setup lang="ts">
import { useScroll } from '@vueuse/core'

const el = ref<HTMLElement | null>()
// 只需传入滚动元素就可以工作
const { x, y } = useScroll(el)
// 节流支持选项
const { x, y } = useScroll(el, { throttle: 200 })
</script>

useScroll 对一些有性能要求的开发者提供了节流选项.但是我们希望的是用户有需求的时候才关注到有这个配置,因为当配置参数一多的时候,理解参数含义和配置其实是一种心智负担.另外,通用默认配置其实也是开箱能力的一种体现 !

使用文档

使用文档我们提供了可交互的Demo和精简的Usage,用户可以通过把玩Demo进一步了解功能,也可以通过CV大法复制Usage很容易的就用上功能.真香 !

兼容性

前面我们提到了Vue3引入了组合式API的概念,但是得益于composition-api插件的实现,我们也能在Vue2项目使用组合式API.为了让更多的用户能够使用VueUse,Anthony Fu 实现了vue-demi ,它通过判断用户安装环境 (Vue2项目 引用composition-api插件,Vue3项目引用官方包),这样Vue2用户也能用上VueUse啦,奈斯 !

开发者体验

目录结构

在基于Monorepo的基础上,项目采用了扁平化目录结构,便于开发者查找相应的函数.

我们为每个函数的实现创建了一个独立的文件夹,这样开发者在修复Bug和新增功能的时候,只需要关注该文件夹下具体函数的实现,并不需要关注项目本身实现的细节,大大降低了上手的成本.Demo和文档的编写也在该文件夹下完成,避免了上下反复横跳寻找目录结构文件的糟糕研发体验.

贡献指南

我们提供了非常详细的贡献指南帮助想要贡献的开发者快速开始并且编写了一些自动化脚本帮助开发者避免一些手动的工作.

原子化CSS

项目使用原子化CSS作为CSS的编写方案,我个人觉得原子化CSS可以帮助我们快速的编写出演示Demo,并且每个函数的Demo独立不耦合,不会产生抽象复用的心智负担.

设计思想

可组合的函数

可组合的函数简单来说就是函数间可以建立组合关系,举个例子:

useScroll 的实现组合了三个函数,将一个个单一职责的函数组合形成另一个函数,达到逻辑复用的能力,我觉得这也便是组合式函数的魅力所在吧.当然,每个函数也都可以进行独立使用,用户可以根据自己的需要进行选择.

开发者在处理功能函数的时候可以做到更好的关注点分离,比如处理 useScroll 时我们只需要关注滚动功能的实现,并不需要关注防抖节流及事件绑定内部的逻辑与实现.

建立"连结"

Anthony Fu 在 Vue Conf 2021中分享了这样一个模式:

  • 建立输入->输出的连结
  • 输出会自动根据输入的改变而改变

我们在编写可组合式函数的时候建立数据和逻辑的连结,这样我们就不用关心如何更新数据,什么时候更新.举个例子:

<script setup lang="ts">
import { ref } from 'vue'
import { useDateFormat, useNow } from '@vueuse/core'

const now = useNow() // 返回一个ref值
const formatted = useDateFormat(now) // 将数据传入与逻辑建立连结

</script>

// useDateFormat实现
function useDateFormat(date, formatStr = 'HH:mm:ss') {
  return computed(() => formatDate(normalizeDate(unref(date)), unref(formatStr)))
}

从上面这个例子中我们可以看出, useDateFormat 在内部逻辑中使用了计算属性对输入进行包裹,这样我们就可以做到输出自动根据输入改变而改变,而用户只需传入一个响应式值,不需要关注具体更新逻辑.

尽可能使用ref替代reactive

refreactive有各自的优缺点,这里主要从用户角度谈谈我个人的看法 :

// reactive

function useScroll(element){
  const position = reactive({ x: 0, y: 0 });
  // impl...
  return { position,...}
}
// 解构丢失响应性
const { position } = useScroll(element)
// 用户需手动toRefs保持响应性
const { x, y } = toRefs(position)

// ref

function useScroll(element){
  const x = ref(0);
  const y = ref(0);
  // impl...
  return {x,y,...}
}
// 不会丢失响应性,用户可直接拿来渲染,watch..
const { x, y } = useScroll(element)

从上面这个例子中我们可以看到,如果我们使用reactive的话,用户需要考虑解构会丢失响应性的问题,这也从一定程度上限制了用户使用解构的自由度和降低了这个函数的易用性.

可能有的人会吐槽ref.value使用,其实在大多数情况下,我们可以通过一些技巧减少它的使用:

  • unref API
const x = ref(0)
console.log(unref(x)) // 0
  • 使用reactive解包ref
const x = ref(0)
const y = ref(0)
const position = reactive({x, y})
console.log(position.x, position.y) // 0 0
  • 还在实验阶段的Reactivity Transform
<script setup>
let count = $ref(0)
count++
</script>

使用options对象作为参数

在实现一个函数时,如果有选项参数的场景,我们通常建议开发者使用对象来作为入参,举个例子 :

// good

function useScroll(element, { throttle, onScroll, ...}){...}

// bad

function useScroll(element, throttle, onScroll, ....){...}

大家可以很清晰的看到两者的区别,毫无疑问第一种写法的扩展性会更强,在之后迭代中也不容易对功能本身造成一些破坏性的改动.

文档实现

关于函数的具体实现就不细说了,毕竟我们有200个那么多 😝 . 这里跟大家分享一下VueUse构建文档部分比较有意思的实现,我觉得做的很棒.

文档组成

我们先来看下一个功能函数文档的组成部分 :

构建流程

VueUse 使用了 VitePress 作为文档构建工具,下面我们来看下比较有意思的部分:

  • 以packages文件夹为入口启动 VitePress 服务

VitePress 使用了约定式路由 (文件即路由),所以访问http://xxx.com/core/onClickOutside实际上就会解析我们对应的index.md文件.看到这里大家就会有疑问了,index.md文件里只包含了usage啊,其他的信息是哪里来的呢 ? 有趣的部分来了~

  • 编写 Vite 插件 MarkdownTransform对Markdown文件进行处理 :
export function MarkdownTransform(): Plugin {
 
  return {
    name: 'vueuse-md-transform',
    enforce: 'pre',
    async transform(code, id) {
      if (!id.endsWith('.md'))
        return null

      const [pkg, name, i] = id.split('/').slice(-3)

      if (functionNames.includes(name) && i === 'index.md') {
        // 对index.md进行处理
        // 使用拼接字符串的方式拼接Demo,类型声明,贡献者信息和更新日志
        const { footer, header } = await getFunctionMarkdown(pkg, name)

        if (hasTypes)
          code = replacer(code, footer, 'FOOTER', 'tail')
        if (header)
          code = code.slice(0, sliceIndex) + header + code.slice(sliceIndex)
      }

      return code
    },
  }
}

通过这个 Vite 插件的处理,我们的文档部分就完整了.这里又有一个疑问,贡献者的数据和更新日志数据是怎么来的呢 ? 这两个数据处理的方式都差不多,我就拿其中一个来说明实现 :

  • 获取 git 提交者信息
import Git from 'simple-git'

export async function getContributorsAt(path: string) {
    const list = (await git.raw(['log', '--pretty=format:"%an|%ae"', '--', path]))
      .split('\n')
      .map(i => i.slice(1, -1).split('|') as [string, string])
    return list
}

我们通过simple-git插件读取到相关文件提交者的信息,有了数据之后,那么我们怎么将它们渲染到页面中呢 ? 还是使用 Vite 插件,不过这次我们要做的是注册虚拟模块.

  • 注册虚拟模块
const ID = '/virtual-contributors'

export function Contributors(data: Record<string, ContributorInfo[]>): Plugin {
  return {
    name: 'vueuse-contributors',
    resolveId(id) {
      return id === ID ? ID : null
    },
    load(id) {
      if (id !== ID) return null
      return `export default ${JSON.stringify(data)}`
    },
  }
}

将我们刚才获取到的数据在注册虚拟模块的时候传入就可以了,接下来我们就可以在组件中引入虚拟模块对数据进行访问.

  • 使用数据
<script setup lang="ts">
import _contributors from '/virtual-contributors'
import { computed } from 'vue'

const props = defineProps<{ fn: string }>()

const contributors = computed(() => _contributors[props.fn] || [])
</script>

拿到数据后,我们就可以进行页面渲染了. 这就是文档中 Contributors 和 Changelog 部分的实现原理. 我们来看下效果 :

看完这个是不是觉得还蛮有意思的,Vite 插件其实还是可以用来搞很多事情的.

V8.0来啦 🎉

我们在前两天正式发布了 V8.0, 主要带来了:

  • 对一些函数的命名进行了规范化,并使用别名做了向下兼容
  • 新增了几个函数,目前函数数量达到了 200 +
  • @vueuse/core/nuxt => @vueuse/nuxt
  • 对一些函数做了指令支持,欢迎使用

结语

最后,感谢Anthony Fu对本文的指正和建议,瑞思拜 ! 如果我的文章对你有帮助,欢迎关注我一起学习.


null仔
4.9k 声望3.7k 粉丝

总是有人要赢的,那为什么不能是我呢