- Published on
Vue3 开发解决方案(一)
- Authors
- Name
- Deng Hua
目录
- 定制化、高可用的前端样式处理方案
- VueUse —— Vue3的实用Utils函数
- vite 配置
- Vue Glob 组件自动注册
- 封装svg图标组件
- 持久化状态数据 vuex-persistedstate
- 切换主题方案
- 封装长列表加载组件
定制化、高可用的前端样式处理方案
企业级项目下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-light
、theme-dark
、theme-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监听器中的数据请求。