Published on

Vue3 开发解决方案(一)

Authors
  • avatar
    Name
    Deng Hua
    Twitter

目录

定制化、高可用的前端样式处理方案

企业级项目下CSS处理的痛点:

  • 统一变量难以维护

  • 大量的 className

  • HTML和CSS分离带来的编码负担

  • 响应式布局、主题切换实现起来很复杂

有关更多痛点,请参阅 CSS 实用程序类和“关注点分离”

针对以上问题,我们可以通过tailwindcss来解决。

安装步骤可参考文档: Install Tailwind CSS with Vite

TailwindCSS的设计理念

首先我们看一下css的粒度

  • 内联样式。最高的自由度和最强的定制化。但样式复用不方便。
<div style="color: red; font-size: 20px">zh-llm</div>
  • 原子化的css,每个类名代表一种css样式。自由度还是很强的,定制化程度也很高,样式可以复用。但是会写很多无意义的类名。其中,tailwindcss就是这种设计。
<div class="text-sky-400">zh-llm</div>
  • 在传统开发中,使用一个或多个语义类来描述 CSS 属性。封装程度高,语义强,但自由度和可定制性一般(大多数类名都是用对应元素的整套CSS属性来写的),语义类较多,编写时需要在HTML和CSS之间来回切换。
<div class="container clear"></div>
  • 在组件形式中,结构和样式直接在当前组件中定义。它具有很高的封装程度和很强的语义。但自由度和可定制性比较差。而且样式是固定的,比较适合后台项目。比如element-plus等等。
<my-component />

对比四种设计方式可以看出,原子化CSS具有一定的自由度、可定制性、可重用性好。它的缺点只是写了大量无意义的类名。

可与其优点相比,缺点可以忽略不计。但对于维护项目的人来说,如果不理解tailwindcss中定义的类名,可能会很头疼。

对于个性化、强交互性、高度定制化的前端项目样式解决方案,原子化的CSS形式更适合。

在使用vscode开发时,我们可以安装一个 Tailwind CSS IntelliSense 插件来提示输入类名,以帮助我们更好的开发。

VueUse —— Vue3的实用Utils函数

VueUse,一套基于Vue组合API的实用工具。它封装了许多常用且可重用的功能。它支持 Vue2、Vue3 和 Nuxt。可以理解为Vue世界的lodash。开箱即用,非常方便。

核心包有140+组合功能,还提供10个扩展插件,总计约290+功能。

功能

  • State:管理用户状态(如:全局、本地存储、会话存储)

  • Elements:元素处理相关(如:元素拖动、元素可见性、窗口大小)

  • Browser:浏览器相关(例如:更新页面标题、媒体查询、剪贴板)

  • Sensors:监听不同的DOM事件、输入事件、网络事件等。

  • Network:网络请求相关

  • Animation:转换、超时和计时功能

  • component:提供不同组件方法的缩写

  • Watch:提供一些监控(如:监控Promise的变化、防抖监控、节流监控)

  • Reactivity:与响应式功能相关

  • Array:响应式数组处理

  • Time:提供响应时间格式化功能

  • Utilities:一般功能,比如节流、防抖等。

  • 插件:Electron、Firebase、Head、Integrations、Math、Motion、Router、RxJS、SchemaOrg、Sound

核心功能都在 packages/core 文件夹中。

安装:

  npm i @vueuse/core
  # or
  yarn add @vueuse/core
  # or
  pnpm add @vueuse/core

使用:

  • 直接作为函数调用:例如 const { toggle } = useFullScreen() ,这也是我们最常用的方法。

  • 作为组件使用:提供了一些功能,例如 <UseFullscreen><UseFullscreen>

当直接作为函数调用时,您可以接收响应参数。大多数函数都会返回一个 refs 对象,您可以使用对象解构来获取所需的内容。

对于组件的使用,除了安装 @vueuse/core 核心包之外,还需要安装 @vueuse/components

pnpm add @vueuse/core  @vueuse/components

VueUse封装了很多实用的功能,比如:剪贴板(useClipboard)、防抖(useDebounceFn)、设置网页标题(useTitle)、监听事件(useEventListener)等。

且卸载页面可以自动帮助我们删除事件监控,无需我们手动删除。

自己封装hook之前,可以先去官网看看是否已经封装好,以提高开发效率。

VueUse 还有很好的交互式文档和功能演示,是用TS写的。每个函数的核心源码都比较小,易于阅读。除了使用它封装的函数之外,我们还可以通过查看它的源码来学习函数封装技巧。学习良好的编码习惯。

vite 配置

配置路径别名

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {join} from "path"

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      "@": join(__dirname, "/src")
    }
  }
})

一般也会配置tsconfig.json:

  //...
  "paths": {
    "@/*": [
      "src/*"
    ],
    "@components": ["src/components/*"]
  },
  //...
  • 打包工具的配置,告诉打包工具打包时如何解析别名;
  • tsconfig的配置,告诉ts编译器如何解析别名;

开发环境解决跨域问题

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {join} from "path"

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      "@": join(__dirname, "/src")
    }
  },
  server: {
    proxy: {
      // 代理所有 '/api' 的请求
      "/api": {
        target: "target origin",
        // 将请求的origin更改为目标值
        changeOrigin: true,
      }
    }
  }
})

配置环境变量

项目基本都会区分很多开发环境。毕竟不能让测试数据污染线上数据。

Vite提供了一种配置我们环境文件的方式,让我们可以方便地通过一些环境来选择对应的开发环境等。

.env.[mode] 的格式可以在不同的模块中加载不同的内容。

环境加载优先级:

  • 指定模式的文件(例如 .env.production )将优先于通用形式(例如 .env )。

  • 另外,Vite执行时已经存在的环境变量具有最高优先级,不会被 .env 类文件覆盖。例如,运行 VITE_SOME_KEY=123 vite build 时。

  • .env 该类文件会在Vite启动时加载,并在重启服务器后生效。

我们可以通过在代码中使用import.meta.env.*来获取以VITE_开头的加载的环境变量。

# .env.development
VITE_BASE_API = "/api"
// package.json
"scripts": {
    "dev": "VITE_BASE_API=/oop vite",
}

执行 yarn dev 后,我们可以打印 import.meta.env.VITE_BASE_API变量,将会是命令行中指定的参数。

Vue Glob 组件自动注册

Vite的 Glob 导入功能:该功能可以帮助我们导入文件系统中的多个模块

const modules = import.meta.glob('./dir/*.js')
// translate:
const modules = {
  './dir/foo.js': () => import('./dir/foo.js'),
  './dir/bar.js': () => import('./dir/bar.js')
}

然后通过vue提供的注册异步组件的方式来引入。 Vue 的 defineAsyncComponent 方法:该方法可以创建按需加载的异步组件。基于以上两种方法,可以自动注册组件。

// import SvgIcon from './svg-icon/index.vue'
// import HmPopup from './popup/index.vue'

import { defineAsyncComponent } from 'vue'

// const components = [SvgIcon, HmPopup]

export default {
  install(app) {
    // components.forEach((element) => {
    //   app.component(element.name, element)
    // })
    // 获取当前路径下所有index.vue
    const components = import.meta.glob('./*/index.vue')
    // 遍历所有获取得组件模块
    for (let [key, component] of Object.entries(components)) {
      const componentName = 'hm-' + key.replace('./', '').split('/')[0]
      // 使用 defineAsyncComponent 在指定路径下异步导入组件
      app.component(componentName, defineAsyncComponent(component))
    }
  }
}

如果组件都提供了name属性,我们可以直接手动引入各个组件模块,然后实现半自动注册。为组件提供名称的好处是,在 vue-devtools 中调试时可以方便地找到各个组件。

vue官网中,在3.2.34及以上版本中,使用 <script setup> 的单文件组件会根据文件名自动生成对应的 name 属性,即使与 <KeepAlive>使用时无需手动声明。但对于文件名都是index.vue的开发者来说,就没办法了。

最佳实践: 组件名使用大驼峰命名。

封装svg图标组件

首先我们需要封装一个通用的svg组件来使用svg图标。

<template>
  <svg aria-hidden="true">
    <use :xlink:href="symbolId" :fill="color" :fillClass="fillClass" />
  </svg>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  name: {
    type: String,
    required: true
  },
  color: {
    type: String
  },
  fillClass: {
    type: String
  }
})

// 生成一个唯一的图标ID #icon-xxx
const symbolId = computed(() => `#icon-${props.name}`)
</script>

然后全局注册svg通用组件。这里我们使用插件的方式。

import SvgIcon from "./svg-icon/index.vue"

export default {
  install(app) {
    app.component("SvgIcon", SvgIcon)
  }
}

直接在main.js中通过use注册后即可使用。

<svg-icon name="back"></svg-icon>

但是项目中无法得知svg图标的路径。我们需要使用 vite-plugin-svg-icons 插件来指定搜索路径。

vite.config.js 中配置svg相关内容

import {createSvgIconsPlugin} from "vite-plugin-svg-icons"

    //...
    createSvgIconsPlugin({
      // 指定需要缓存的图标文件夹
      iconDirs: [join(__dirname, "/src/assets/icons")],
      // 指定 symbolId 格式,这是 svg.use 使用的href
      symbolId: "icon-[name]"
    })
    // ...

在main.js中导入并注册svg-icons,它将注册主页指定文件夹中的所有svg图像。

// 注册svg-icons
import "virtual:svg-icons-register"

持久化状态数据 vuex-persistedstate

vuex-persistedstate作为vuex的插件,可以将store中的数据持久化,防止由于页面刷新等操作导致数据丢失(再次运行时,会使用缓存的数据作为对应状态属性的初始值)。

import { createStore } from "vuex";
import createPersistedState from "vuex-persistedstate";

const store = createStore({
  // ...
  plugins: [createPersistedState({
      key : 'categoryList', // 缓存的key
      paths: ['category'], //部分路径可部分保留状态的数组。如果没有给出路径,则将保留完整状态。如果给出一个空数组,则不会保留任何状态。必须使用点符号指定路径。如果使用模块,请包括模块名称(默认:[])
  })],
});

切换主题方案

实现思路(本方案基于tailwindcss插件):

添加 tailwind.config.js 配置文件

darkMode: 'class'

将当前主题类型存储在vuex中:

// 当前主题
import { THEME_LIGHT } from '@/constants'
export default {
  namespaced: true,
  state: () => ({
    themeType: THEME_LIGHT
  }),
  mutations: {
    setThemeType(state, theme) {
      state.themeType = theme
    }
  }
}

切换主题时修改vuex中的主题类型:

const handleHeaderTheme = (item) => {
  store.commit('theme/setThemeType', item.type)
}

监听Theme类型的变化:theme-lighttheme-darktheme-system,并动态设置html标签的class属性值。在切换时将对应主题的css前缀添加到html元素中。实现切换主题的效果

<html lang="en" class="dark">
    <!-- 要添加深色模式CSS样式,只需在它前加上“dark”前缀即可 -->
    <div class="bg-zinc-300 dark:bg-zinc-900" ></div>
</html>

html的class属性值改变后,就会匹配对应主题的class,从而显示对应主题的颜色。

为标签设置两组类名称:一组用于白色,一组用于黑色。

<div class="bg-zinc-300 dark:bg-zinc-900" ></div>

要跟随系统的主题变化,需要使用 Window.matchMedia() 。此方法接收 mediaQueryString (由媒体查询解析的字符串)。

我们可以将 prefers-color-scheme 传递给这个字符串,即 window.matchMedia('(prefers-color-scheme: dark)') 方法返回一个 MediaQueryList 对象。

  • 该对象有一个 change 事件,可以侦听系统主题更改。

  • 事件对象的 matches 属性可以确定主题(true:深色主题,false:浅色主题)。

主题修改工具函数:

import { watch } from 'vue'
import store from '../store'
import { THEME_DARK, THEME_LIGHT, THEME_SYSTEM } from '../constants'

/**
 * 监听系统主题的变化
 */
let matchMedia = ''
function changeSystemTheme() {
  // 只需要初始化一次
  if (matchMedia) return
  matchMedia = window.matchMedia('(prefers-color-scheme: dark)')

  // 同样监听theme切换,然后调用修改HTML class
  matchMedia.addEventListener('change', (event) => {
    changeTheme(THEME_SYSTEM)
  })
}

/**
 * Theme 匹配函数
 * @param val {*} Theme tag
 */
const changeTheme = (val) => {
  let htmlClass = ''
  if (val === THEME_LIGHT) {
    // Light theme
    htmlClass = THEME_LIGHT
  } else if (val === THEME_DARK) {
    // Dark theme
    htmlClass = THEME_DARK
  } else {
    // Follow the system
    changeSystemTheme()
    // true 是黑暗模式,false 是浅色主题
    htmlClass = matchMedia.matches ? THEME_DARK : THEME_LIGHT
  }
  document.querySelector('html').className = htmlClass
}

/**
 * 初始化主题
 */
export default () => {
  // 监听主题切换并修改HTML类的值
  watch(() => store.getters.themeType, changeTheme, {
    immediate: true
  })
}

封装长列表加载组件

主要是通过监听DOM底部判断是否出现在可见区域,然后进行数据请求,并处理一些特殊情况。使用了VueUse的 useIntersectionObserver api,它对IntersectionObserver api进行了简单的封装,可以让我们更方便地实现可见区域的监听。

主要提供 isLoading 来显示加载更多图标, isFinished 来判断数据请求是否完成, load 来为事件请求数据props。

<script setup>
import { onUnmounted, ref, watch } from 'vue'
import { useVModel, useIntersectionObserver } from '@vueuse/core'

const props = defineProps({
  isLoading: {
    type: Boolean,
    default: false
  },
  isFinished: {
    type: Boolean,
    default: false
  }
})

const emits = defineEmits(['update:isLoading', 'load'])
const loading = useVModel(props, 'isLoading', emits)
const loadingRef = ref(null)
// 首次加载时,可见区域为真,并在数据加载后可见区域变为假
// 如果可见区域不断交替可见,则回调函数不会被执行。

// 是否在底部(是否相交于监听的元素)
const targetIsIntersecting = ref(false)

useIntersectionObserver(loadingRef, ([{ isIntersecting }]) => {
  // console.log(isIntersecting, props.isFinished, loading.value)
  targetIsIntersecting.value = isIntersecting
  emitLoad()
})

const emitLoad = () => {
  if (targetIsIntersecting.value && !props.isFinished && !loading.value) {
    loading.value = true
    emits('load')
  }
}

/**
 * 处理bug:在第一次数据加载满屏时,可见区域判断回调只执行一次
 */
let timer = null
watch(loading, () => {
  // false => true(false => true(延迟请求数据,等待前一个请求完成,然后执行)=> false)=> false
  // 触发加载,延迟处理,等待渲染(尽管数据返回,但 UI 不渲染,所以 targetIsIntersecting 仍为 true),然后再次使用 useIntersectionObserver 触发
  //当有一个load可以填充容器时,当加载更改时,不要让它加载数据。因为 targetIsIntersecting 为假。此延迟时间必须大于目标交叉变化后的时间

  // 但有一个情况,一个load无法填充容器。 targetIsIntersecting 一直为 true,因此可以在第一个屏幕上load两次。等待下一个 watch 执行,只需延迟让 targetIsIntersecting 更改为 false,然后触发 emitLoad。这时它停止请求。
  timer = setTimeout(() => {
    emitLoad()
  }, 500)
})

onUnmounted(() => {
  clearTimeout(timer)
})
</script>

这里有一个很容易出现的bug。当我们一次返回的数据量太小时,底部区域总是出现在区域内的。我们将无法再次调用 useIntersectionObserver 中传入的回调,也无法再次请求数据。

所以我们需要监听loading的变化并再次触发数据请求。但还有另一个问题。当我们一次加载太多数据时,我们仍然会多次请求数据。

这是因为第一次请求的数据虽然已经到达前端,但是UI还没有渲染出来。这意味着底部区域仍在该区域内,导致数据被再次读取。所以我们手动延迟watch监听器中的数据请求。