企业级项目下 css 处理痛点
- 统一的变量维护困难。
- 大量的 className 负担。
- HTML, CSS 分离造成的编写负担。
- 响应式,主题切换实现复杂。
更多痛点,请看 CSS Utility Classes and "Separation of Concerns"
针对上述问题,我们可以通过 tailwindcss 来进行解决。下面我们来看其具体用法。
安装
yarn add tailwindcss postcss autoprefixer -D
初始化tailwindcss.config.js
配置文件,并且也会创建postcss.config.js
文件。
// -p 表示创建一个基础的配置文件
npx tailwindcss init -p
// tailwindcss.config.js
/** @type {import('tailwindcss').Config} */
export default {
// tailwindcss 应用的文件范围
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {}
},
plugins: []
}
将加载 Tailwind 的指令添加到你的 CSS 文件中
@tailwind base;
@tailwind components;
@tailwind utilities;
在元素上使用内置类名
tailwindcss 官方介绍为无需离开 HTML 即可快速构建现代网站。具体来说就是 tailwind 提供了很多类名,都定义了特定的 css,直接在编写 HTML 的时候加上对应的类名即可快速搭建网站。
首先我们先来看下 css 颗粒度设计形式
- 行内样式。自由度最高,可定制化最强。但是不方便样式的复用。
<div style="color: red; font-size: 20px">zh-llm</div>
- 原子化 css,每个类名都代表着一类 css 样式。自由度依旧很强,可定制化也很高,并且可以样式复用。但是会编写大量无意义的类名。其中 tailwindcss 就是这种设计。
<div class="text-sky-400">zh-llm</div>
- 传统形式,通过一个或几个具有语义化的 class 来描述一段 css 属性,封装性,语义化强,自由度和可定制性一般(大多类名都是编写对应元素整套 css 属性)。但是有大量的语义化 class,编写时需要 HTML 和 CSS 来回切换。
<div class="container clear"></div>
- 组件形式,在当前组件中直接定义好结构和样式。封装性极强,语义化强。但是自由度和可定制性比较差。并且风格固定,比较适合后台项目。比如 element-plus 等等。
<my-component />
对比四种设计方式,可以看出原子化 css 是自由度,可定制化,复用性都挺好,只有编写大量无意义类名缺点,对比他的优点,缺点也是可以忽略的。但是对于维护项目的人来说,如果不了解 tailwindcss 中定义的类名,那可能是非常头疼的一件事了。
对于高个性化,高交互性,高定制化前台项目样式解决方案,还是原子化 css 形式更合适。
在使用 vscode 开发时,我们可以安装一个Tailwind CSS IntelliSense
插件,提示类名,来帮助我们更好的开发。
VueUse, 基于 Vue 组合式 API 的实用工具集。
useWindowSize
api,响应式的获取窗口尺寸。当窗口尺寸发生变化时,实时获取。来判断是移动端 UI 还是 pc 端 UI。
import { computed } from 'vue'
import { PC_DEVICE_WIDTH } from '../constants'
import { useWindowSize } from '@vueuse/core'
const { width } = useWindowSize()
/**
* 是否是移动端设备; 判断依据: 屏幕宽度小于 PC_DEVICE_WIDTH
* @returns
*/
export const isMobileTerminal = computed(() => {
return width.value < PC_DEVICE_WIDTH
})
// 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')
}
}
})
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: '目标origin',
// 改变请求的origin为target的值
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
是命令行中指定的参数。
vite 的 Glob 导入功能:该功能可以帮助我们在文件系统中导入多个模块
const modules = import.meta.glob('./dir/*.js')
// 以上将会被转译为下面的样子:
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 属性,我们可以直接手动引入各组件模块,然后实现半自动注册。组件提供 name 的好处是,在 vue-devtools 中调试时方便查找各个组件。
在 vue 官网中,在 3.2.34 或以上的版本中,使用 <script setup>
的单文件组件会自动根据文件名生成对应的 name
选项,即使是在配合 <KeepAlive>
使用时也无需再手动声明。 但是对于我们文件名都为 index.vue 的开发者来说,就没办法了。
首先我们需要封装一个通用的 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 { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { join } from 'path'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
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 的一个插件,可以持久化 store 中的数据,防止因页面刷新等操作,数据丢失。
import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'
const store = createStore({
// ...
plugins: [
createPersistedState({
key: 'categoryList', // 缓存的key,
paths: ['category'] // 用于部分持久化状态的任何路径的数组。如果没有给出路径,完整的状态会被持久化。如果给定一个空数组,则不会保留任何状态。必须使用点表示法指定路径。如果使用模块,请包含模块名称。例如:“auth.user” 默认为undefined.
})
]
})
原理: 通过类名的切换使得 html 元素在不同类名下展示不同的样式
实现思路:(此方案基于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-light 、 theme-dark、theme-system、给 html 标签动态设置 class 的属性值。他就是在切换时,给 html 元素添加到对应主题 css 前缀。从而达到切换主题的效果
<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
对象。
主题修改工具函数
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)')
// 这里也是监听主题切换,然后调用修改html class
matchMedia.addEventListener('change', (event) => {
changeTheme(THEME_SYSTEM)
})
}
/**
* 主题匹配函数
* @param val {*} 主题标记
*/
const changeTheme = (val) => {
let htmlClass = ''
if (val === THEME_LIGHT) {
// 浅色主题
htmlClass = THEME_LIGHT
} else if (val === THEME_DARK) {
// 深色主题
htmlClass = THEME_DARK
} else {
// 跟随系统
changeSystemTheme()
// true是深色模式, false是浅色主题
htmlClass = matchMedia.matches ? THEME_DARK : THEME_LIGHT
}
document.querySelector('html').className = htmlClass
}
/**
* 初始化主题
*/
export default () => {
// 监听主题切换,修改html class的值
watch(() => store.getters.themeType, changeTheme, {
immediate: true
})
}
整个瀑布流组件的构建大体需要分成几部分
-
通过 props 传递关键数据
-
data:数据源
-
nodeKey:唯一标识
-
column:渲染的列数
-
columnSpacing:列间距
-
rowSpacing:行间距
-
picturePreReading:是否需要图片预渲染
-
-
瀑布流渲染机制:通过 absolute 配合 relative 完成布局,布局逻辑为:每个 item 应该横向排列,第二行的 item 顺序连接到当前最短的列中。
-
通过作用域插槽 将每个 item 中涉及到的关键数据,传递到 item 视图中。
计算大体方法就是,拿到容器宽度(不包括 margin,padding,border),
const useContainerWidth = () => {
const { paddingLeft, paddingRight } = getComputedStyle(
containerRef.value,
null
)
// 容器左边距
containerLeft.value = parseFloat(paddingLeft)
// 容器宽度
containerWidth.value =
containerRef.value.offsetWidth -
parseFloat(paddingLeft) -
parseFloat(paddingRight)
}
并且获取容器中每个 item 元素的总间距。
// 列间距总大小 (column - 1) * columnSpacing
const columnSpacingTotal = computed(() => {
return (props.column - 1) * props.columnSpacing
})
然后用当前容器减去总间距,再除以列数。
const useColumnWidth = () => {
// 获取容器宽度
useContainerWidth()
// 获取列宽
columnWidth.value =
(containerWidth.value - columnSpacingTotal.value) / props.column
}
图片是否定义了高度,如果定义高度,可以直接计算出每个 item 的高度
const useItemHeight = () => {
// 初始化item高度列表
itemsHeight = []
// 获取 item 元素
const itemElements = [...document.getElementsByClassName('hm-waterfall-item')]
// 获取item高度
itemElements.forEach((itemEl) => {
itemsHeight.push(itemEl.offsetHeight)
})
// 渲染位置
useItemLocation()
}
如果未定义高度,我们需要在图片加载完成后,才能计算高度。
- 获取 item 元素
- 获取 itm 元素中图片路径
/**
* 获取所有item中img元素
*/
export function getImgElements(itemElements) {
const imgElements = []
itemElements.forEach((el) => {
imgElements.push(...el.getElementsByTagName('img'))
})
return imgElements
}
/**
* 获取所有图片路径
*/
export function getAllImgSrc(imgElements) {
const allImgSrc = []
imgElements.forEach((item) => {
allImgSrc.push(item.getAttribute('src'))
})
return allImgSrc
}
- 通过 image 对象的 load 事件来判断图片是否加载完毕,然后计算高度。
export function allImgComplete(allImgSrc) {
// 存放所有图片加载的promise对象
const promises = []
// 循环allImgSrc
allImgSrc.forEach((imgSrc, index) => {
promises.push(
new Promise((resolve) => {
const imgObj = new Image()
imgObj.src = imgSrc
imgObj.onload = () => {
resolve({
imgSrc,
index
})
}
})
)
})
return Promise.all(promises)
}
const waitImgComplete = () => {
// 初始化item高度列表
itemsHeight = []
// 获取 item 元素
const itemElements = [...document.getElementsByClassName('hm-waterfall-item')]
// 获取所有元素的 img 标签
const imgElements = getImgElements(itemElements)
// 获取所有 img 图片路径
const allImgSrc = getAllImgSrc(imgElements)
// 计算图片预加载,然后计算高度
allImgComplete(allImgSrc).then(() => {
itemElements.forEach((itemEl) => {
itemsHeight.push(itemEl.offsetHeight)
})
})
// 渲染位置
useItemLocation()
}
都是通过获取列最小高度基础上计算的一些值。
需要先将每列高度初始化为 0,使用该对象作为容器,key 为列下标,值为列高度。
// 容器的总高度
const containerHeight = ref(0)
// 记录每列高度的容器。key:所在列 val:列高
const columnHeightObj = ref({})
/**
* 构建记录各列的高度的对象。初始化都为0
*/
const useColumnHeightObj = () => {
columnHeightObj.value = {}
for (let i = 0; i < props.column; i++) {
columnHeightObj.value[i] = 0
}
}
获取 left 偏移量时,我们需要拿到最小高度列。
/**
* 获取最小高度
*/
export function getMinHeight(columnHeightObj) {
const columnHeightValue = Object.values(columnHeightObj)
return Math.min(...columnHeightValue)
}
/**
* 获取最小高度的column
*/
export function getMinHeightColumn(columnHeightObj) {
// 获取最小高度
const minHeight = getMinHeight(columnHeightObj)
const columns = Object.keys(columnHeightObj)
const minHeightColumn = columns.find((col) => {
return columnHeightObj[col] === minHeight
})
return minHeightColumn
}
获取最小高度列后,直接乘以列宽和加上间距就行
/**
* 计算当前元素的left偏移量
*/
const getItemLeft = () => {
// 获取最小高度的列
const column = getMinHeightColumn(columnHeightObj.value)
// 计算left
return (
(columnWidth.value + props.columnSpacing) * column + containerLeft.value
)
}
top 偏移量的计算,我们可以直接拿到最小高度列高就行
/**
* 计算当前元素的top偏移量
*/
const getItemTop = () => {
// 获取列最小高度
const minHeight = getMinHeight(columnHeightObj.value)
return minHeight
}
需要注意的是,我们在完成每次元素偏移量赋值的时候,都需要将最小高度列重新计算高度。
/**
* 重新计算最小高度列高度
*/
const increasingHeight = (index) => {
// 获取最小高度的列
const column = getMinHeightColumn(columnHeightObj.value)
// 该列高度重新计算
columnHeightObj.value[column] =
columnHeightObj.value[column] + itemsHeight[index] + props.rowSpacing
}
最后将最大高度列高度赋值给容器高度即可。
// 渲染位置
const useItemLocation = () => {
props.data.forEach((item, index) => {
// 避免重复计算
if (item._style) return
// 拿到最小高度,计算_style中的left, top
item._style = {}
item._style.left = getItemLeft()
item._style.top = getItemTop()
// 每次设置完偏移量时,都需要更改最短列的高度。
increasingHeight(index)
})
// 当所有item设置好偏移量时,将容器高度设置为列最高的高度
containerHeight.value = getMaxHeight(columnHeightObj.value)
}
主要是通过监听底部 dom 是否出现在可视区域,然后做数据请求,处理一些特殊情况。使用到了 usevue 的 useIntersectionObserver api ,它就是简单了对 IntersectionObserver api进行了封装,让我们更轻易地实现可见区域交叉监听。
这个 IntersectionObserver 以前写过一篇文章 《如何判断元素是否在可视区域内呢?然后搞一些事情》介绍过,可以看看。
主要提供isLoading
展示加载更多动态图标, isFinished
判断数据是否请求完毕, load
事件请求数据 props 即可。
<script setup>
import { useVModel, useIntersectionObserver } from '@vueuse/core'
import { onUnmounted, ref, watch } from 'vue'
const props = defineProps({
isLoading: {
type: Boolean,
default: false
},
isFinished: {
type: Boolean,
default: false
}
})
// 定义loading绑定事件,加载更多事件
const emits = defineEmits(['update:isLoading', 'load'])
const loading = useVModel(props, 'isLoading', emits)
// 加载更多
const loadingRef = ref(null)
// 第一次加载,可见区域就是true,数据加载完成可见区域变成false
// 如果可见区域不是交替可见,那么回调不会执行
// 记录当前是否在底部(是否交叉)
const targetIsIntersecting = ref(false)
useIntersectionObserver(loadingRef, ([{ isIntersecting }]) => {
// console.log(isIntersecting, props.isFinished, loading.value)
targetIsIntersecting.value = isIntersecting
emitLoad()
})
const emitLoad = () => {
// 出现底部区域,数据未加载完成,loading为false时,请求数据
if (targetIsIntersecting.value && !props.isFinished && !loading.value) {
loading.value = true
emits('load')
}
}
/**
* 处理首次数据加载为盛满全屏时,可见区域判断回调只执行一次的bug
*
* 监听loading变化,重新触发执行
*/
let timer = null
watch(loading, () => {
// false => true(延迟请求数据,等上一次请求完毕后,在执行)=> false
// 触发 load,延迟处理,等待 渲染(虽然数据请求回来,但是ui为渲染,所以targetIsIntersecting依旧为true)和 useIntersectionObserver 的再次触发
// 当一次加载数据可以盛满容器,那么当loading发生变化时,不让其加载数据。因为targetIsIntersecting为false。这个延时的时间要大于targetIsIntersecting改变后的时间
// 但是对于一次加载数据不可以盛满容器的情况。targetIsIntersecting始终未true,就可以在首屏加载两次了。等下一次watch执行,刚好延迟让targetIsIntersecting改变为false后,在触发emitLoad。这时刚好阻止请求了
timer = setTimeout(() => {
emitLoad()
}, 500)
})
onUnmounted(() => {
clearTimeout(timer)
})
</script>
这里有一个容易出现的 bug,当我们数据量一次返回过少时,底部区域一直在可是区域内,我们将不能再次调用useIntersectionObserver
传入的回调,也就不能再次请求数据,加载更多了。
所以我们需要监听 loading 的变化,再次触发数据请求。但是这样又有一个问题了。当我们数据一次性加载过多时,我们依旧请求多次数据,这是因为虽然第一次请求的数据回来了,但是界面还没有渲染,这是底部区域依旧在可是区域内,导致数据再一次被请求。所以我们手动延迟数据在 watch 监听中的请求。
也是需要用到usevue 的 useIntersectionObserver api,首先将 src 置空,当进入可视区域,我们就将 src 赋值回去。
import { useIntersectionObserver } from '@vueuse/core'
export default {
mounted(el) {
// 保存图片路径
const imgSrc = el.getAttribute('src')
// 将图片src置空
el.setAttribute('src', '')
// 监听图片的可见
const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
if (isIntersecting) {
el.setAttribute('src', imgSrc)
// 停止监听
stop()
}
})
}
}
通过vite 的 Glob 的另一个方法来做到指令自动注册。使用 import.meta.globEager
,直接引入所有的模块。
export default {
install(app) {
// 获取到所有指令模块对象
const modules = import.meta.globEager('./modules/*.js')
for (let [key, value] of Object.entries(modules)) {
const directiveName = key.replace('./modules/', '').split('.')[0]
app.directive(directiveName, value.default)
}
}
}
confirm
组件的实现思路:
-
创建一个
confirm
组件 -
创建一个函数组件,并且返回一个
promise
-
同时利用 h 函数生成
confirm
组件的vnode
-
最后利用
render
函数,渲染vnode
到body
中
了解了组件的设计思路,我们就需要分析它应该具有的 props
const props = defineProps({
title: {
type: String
},
content: {
type: String,
required: true
},
// 按钮文字
cancelText: {
type: String,
default: '取消'
},
confirmText: {
type: String,
default: '确定'
},
// 取消和确认时触发事件, 例如移除dom
closeAfter: {
type: Function
},
/**
* 主要是区分点击了取消还是确定
*/
// 点击确定触发事件
handleConfirmClick: {
type: Function
},
// 点击取消触发事件
handleCancelClick: {
type: Function
}
})
对于 confirm 组件来说,我们通过一个响应式数据来控制显示和隐藏实现的动画。
-
在弹出框出现时,我们需要监听挂载的时刻,然后控 mask 和弹框的显示,不然动画会失效。
-
再点击关闭弹出框时,我们不能立刻让组件卸载,不然动画也会立刻消失,所以我们延时卸载。
// 动画时间 状态驱动的动态css
const actionDuration = '0.5s'
// 控制confirm显隐
const isVisible = ref(false)
// 组件挂载就让弹框显示,通过函数组件控制组件挂载卸载
// 通过mounted, 让其挂载时有动画效果
onMounted(() => {
isVisible.value = true
})
/**
* 关闭弹窗
* 通过定时器,让动画完成后在移除dom
*/
const handleClose = () => {
// 当隐藏时才会出现动画
isVisible.value = false
setTimeout(() => {
// 卸载confirm组件
props.closeAfter()
}, actionDuration.replace('0.', '').replace('s', '') * 100)
}
函数组件的封装,主要使用h, render
函数操作。
closeAfter
:主要就是在点击任何地方关闭弹框时,卸载组件。handleConfirmClick
: 主要是点击确认按钮时,让 promise 状态为 fulfilled,让外界使用函数组件时,在 then 中可以操作确认后的事情。handleCancelClick
: 主要是点击取消按钮时,让 promise 状态为 rejected,让外界使用函数组件时,在 catch 中可以操作取消后的事情。
后两个函数主要就是为了区分点击了取消还是确认。
import { h, render } from 'vue'
import Confirm from './index.vue'
export default function createConfirm({
title,
content,
cancelText = '取消',
confirmText = '确定'
}) {
return new Promise((resolve, reject) => {
/**
* 移除confirm
*/
const closeAfter = () => {
render(null, document.body)
}
/**
* 点击确定按钮,回调
*/
const handleConfirmClick = resolve
/**
* 点击取消按钮,回调
*/
const handleCancelClick = reject
// 生成vnode,并传入props
const vnode = h(Confirm, {
title,
content,
cancelText,
confirmText,
closeAfter,
handleConfirmClick,
handleCancelClick
})
// 渲染组件到body中
render(vnode, document.body)
})
}
message 组件的实现和 confirm 非常类似。
props 需要指定弹框时间和类型
const props = defineProps({
// message 类型
type: {
type: String,
required: true,
validate(val) {
if (types.includes(val)) {
return true
} else {
throw new Error('请传入正确的类型值(error, warn, success)')
}
}
},
// message 内容
content: {
type: String,
required: true
},
// 消息回调,在动画完成后,卸载message
closeAfter: {
type: Function
},
// 延时多久删除
delay: {
type: Number,
default: 3000
}
})
主要就是弹框的隐藏时机不同。message 中,是通过外界传入的时间控制隐藏的。
const isVisible = ref(false)
/**
* 为了保证出现时动画展示,我们需要在组件挂载后在显示对应的内容
*/
onMounted(() => {
isVisible.value = true
setTimeout(() => {
isVisible.value = false
}, props.delay)
})
// 在动画完成后,通过transition组件的after-leave钩子触发组件卸载。
函数组件实现
import { h, render } from 'vue'
import Message from './index.vue'
export function createMessage({ type, content, delay = 3000 }) {
/**
* 动画结束时的回调
*/
const closeAfter = () => {
// message 销毁
render(null, document.body)
}
// 生成vnode
const vnode = h(Message, {
type,
content,
delay,
closeAfter
})
// 渲染组件
render(vnode, document.body)
}
文件下载相关的库
- 小文件下载:file-saver
- 大文件下载: streamsaver
直接使用 api,传入下载路径即可
import { saveAs } from 'file-saver'
const handleDownload = (downloadPath) => {
saveAs(downloadPath)
}
我们知道在原生dom
上,提供了一些方法来供我们开启或关闭全屏:
Element.requestFullscreen()
Document.exitFullscreen()
Document.fullscreen
返回一个布尔值,表明当前文档是否处于全屏模式。已弃用Document.fullscreenElement
返回当前文档中正在以全屏模式显示的Element
节点,没有就返回 null。
使用requestFullscreen()
和exitFullscreen()
来实现
基于 WebKit 内核的浏览器需要添加webkit
前缀,使用webkitRequestFullScreen()
和webkitCancelFullScreen()
来实现。
基于 Trident 内核的浏览器需要添加ms
前缀,使用msRequestFullscreen()
和msExitFullscreen()
来实现,注意方法里的 screen 的 s 为小写形式。
基于 Gecko 内核的浏览器需要添加moz
前缀,使用mozRequestFullScreen()
和mozCancelFullScreen()
来实现。
Opera 浏览器需要添加o
前缀,使用oRequestFullScreen()
和oCancelFullScreen()
来实现。
考虑到兼容性,我们可以使用usevue
提供的useFullscreen
api
import { useFullscreen } from '@vueuse/core'
const imgRef = ref(null)
const { isFullscreen, enter, exit, toggle } = useFullscreen(imgRef)
const handleFullScreen = () => {
imgRef.value.style.backgroundColor = 'transparent'
enter()
}
我们可以通过driver.js
库实现。
定义好对应的引导步骤。
export default [
{
// 在哪个元素中高亮
element: '.guide-home',
// 配置对象
popover: {
// 标题
title: 'logo',
// 描述
description: '点击可返回首页'
}
},
{
element: '.guide-search',
popover: {
title: '搜索',
description: '搜索您期望的图片'
}
},
{
element: '.guide-theme',
popover: {
title: '风格',
description: '选择一个您喜欢的风格',
// 弹出的位置
position: 'left'
}
},
{
element: '.guide-my',
popover: {
title: '账户',
description: '这里标记了您的账户信息',
position: 'left'
}
},
{
element: '.guide-start',
popover: {
title: '引导',
description: '这里可再次查看引导信息',
position: 'left'
}
},
{
element: '.guide-feedback',
popover: {
title: '反馈',
description: '您的任何不满都可以在这里告诉我们',
position: 'left'
}
}
]
然后调用 driver 库提供的 api 即可
import Driver from 'driver.js'
import 'driver.js/dist/driver.min.css'
import steps from './steps'
import { onMounted } from 'vue'
/**
* 引导页处理
*/
let driver = null
onMounted(() => {
driver = new Driver({
// 禁止点击蒙版关闭
allowClose: false,
closeBtnText: '关闭',
nextBtnText: '下一个',
prevBtnText: '上一个'
})
})
/**
* 开始引导
*/
const handleGuideClick = () => {
// 定义引导步骤
driver.defineSteps(steps)
driver.start()
}
第三方表单校验库: vee-validate。
该库中,提供了三个重要的组件。分别为我们处理表单组件和表单验证错误提示。
import {
Form as VeeForm,
Field as VeeField,
ErrorMessage as VeeErrorMessage
} from 'vee-validate'
每个表单项,可以通过rules
props 绑定验证规则。message 与 field 中的 name 是相对应的。
<vee-form @submit="handleLogin">
<vee-field
class="dark:bg-zinc-800 dark:text-zinc-400 border-b-zinc-400 border-b-[1px] w-full outline-0 pb-1 px-1 text-base focus:border-b-main dark:focus:border-b-zinc-200 xl:dark:bg-zinc-900"
name="username"
:rules="validateUsername"
type="text"
placeholder="用户名"
autocomplete="on"
v-model="loginForm.username"
/>
<vee-error-message
class="text-sm text-red-600 block mt-0.5 text-left"
name="username"
>
</vee-form>
需要注意的是:验证函数,true 表示表单验证通过, String 表示表单验证未通过,给出的提示文本。
/**
* 用户名的表单校验
*/
export const validateUsername = (value) => {
if (!value) {
return '用户名为必填的'
}
if (value.length < 3 || value.length > 12) {
return '用户名应该在 3-12 位之间'
}
return true
}
对于需要依赖别的表单值进行关联验证的,我们需要通过defineRule
来定义规则。例如:确认密码输入框验证。
/**
* 确认密码的表单校验
*
* 参数二:表示关联表单值的数组
*/
export const validateConfirmPassword = (value, password) => {
if (value !== password[0]) {
return '两次密码输入必须一致'
}
return true
}
/**
* 定义关联规则, 例如确认密码
*/
defineRule('validateConfirmPassword', validateConfirmPassword)
rule 规则rules="validateConfirmPassword:@password"
<!-- 密码 -->
<vee-field
class="dark:bg-zinc-800 dark:text-zinc-400 border-b-zinc-400 border-b-[1px] w-full outline-0 pb-1 px-1 text-base focus:border-b-main dark:focus:border-b-zinc-200 xl:dark:bg-zinc-900"
name="password"
type="password"
placeholder="密码"
autocomplete="on"
:rules="validatePassword"
v-model="regForm.password"
/>
<vee-error-message
class="text-sm text-red-600 block mt-0.5 text-left"
name="password"
>
</vee-error-message>
<!-- 确认密码 -->
<vee-field
class="dark:bg-zinc-800 dark:text-zinc-400 border-b-zinc-400 border-b-[1px] w-full outline-0 pb-1 px-1 text-base focus:border-b-main dark:focus:border-b-zinc-200 xl:dark:bg-zinc-900"
name="confirmPassword"
type="password"
placeholder="确认密码"
autocomplete="on"
rules="validateConfirmPassword:@password"
v-model="regForm.confirmPassword"
/>
<vee-error-message
class="text-sm text-red-600 block mt-0.5 text-left"
name="confirmPassword"
>
</vee-error-message>
目的:明确当前操作是人完成的,而非机器。
人机验证通过对用户的行为数据、设备特征与网络数据构建多维度数据分析,采用完整的可信前端安全方案保证数据采集的真实性、有效性。
滑动验证码实现原理是什么?
滑动验证码是服务端随机生成滑块和带有滑块阴影的背景图片,然后将其随机的滑块位置坐标保存。前端实现互动的交互,将滑块把图拼上,获取用户的相关行为值。然后服务端进行相应值的校验。其背后的逻辑是使用机器学习中的深度学习,根据鼠标滑动轨迹,坐标位置,计算拖动速度,重试次数等多维度来判断是否人为操作。
滑动验证码对机器的判断,不只是完成拼图,前端用户看不见的是——验证码后台针对用户产生的行为轨迹数据进行机器学习建模,结合访问频率、地理位置、历史记录等多个维度信息,快速、准确的返回人机判定结果,故而机器识别+模拟不易通过。滑动验证码也不是万无一失,但对滑动行为的模拟需要比较强的破解能力,毕竟还是大幅提升了攻击成本,而且技术也会在攻防转换中不断进步。
分为两种: 一种是收费的、另一种是开源的
收费的代表有:
开源的有:
毫无疑问,就我们学习来说,开源的就是最好的。
该库主要是通过三个方法来进行验证回调操作的。
let captcha = null
onMounted(() => {
captcha = sliderCaptcha({
// 绑定的dom元素id名
id: 'captcha',
// 验证成功的回调 arr滑块移动轨迹
async onSuccess(arr) {
// 这里将行为轨迹发送到服务端进行验证。
const res = await getCaptcha({
behavior: arr
})
// 验证成功发出事件
if (res) emits('verifySuccess')
},
// 验证失败回调
onFail() {
console.error('人类行为验证失败')
},
// 默认的验证方法,咱们不在此处进行验证,而是选择在用户拼图成功之后进行验证,所以此处永远返回为 true
verify() {
return true
}
})
})
并且内部提供reset
方法来修改拼图图片。
案例代码
想要学习图片裁剪,我们需要获取图片并展示。在我们点击上传图片如何预览呢?我们来简单介绍一下。
-
URL.createObjectURL()
静态方法会创建一个DOMString
,其中包含一个表示参数中给出的对象的 URL。这个 URL 的生命周期和创建它的窗口中的document
绑定。这个新的URL
对象表示指定的File
对象或Blob
对象。通过URL.createObjectURL(blob)
可以获取当前文件的一个内存 URL。 -
FileReader.readAsDataURL(file)
,通过FileReader.readAsDataURL(file)
可以获取一段data:base64
的字符串。
执行时机:
createObjectURL
是同步执行(立即的)- `FileReader.readAsDataURL是异步执行(过一段时间)
内存使用:
-
createObjectURL
返回一段带hash
的url
,并且一直存储在内存中,直到document
触发了unload
事件(例如:document close
)或者执行revokeObjectURL
来释放。 -
FileReader.readAsDataURL
则返回包含很多字符的base64
,并会比blob url
消耗更多内存,但是在不用的时候会自动从内存中清除(通过垃圾回收机制) 兼容性方面两个属性都兼容 ie10 以上的浏览器。
优劣对比:
使用createObjectURL
可以节省性能并更快速,只不过需要在不使用的情况下手动释放内存 如果不太在意设备性能问题,并想获取图片的base64
,则推荐使用FileReader.readAsDataURL
。
cropperjs是一个非常强大的图片裁剪工具,它可以适用于:原生 js,vue,react 等等。而且操作也非常简单、只需要简单几步即可完成图片的裁剪工作。
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
// 移动端配置对象
const mobileOptions = {
// 将裁剪框限制在画布的大小
viewMode: 1,
// 移动画布,裁剪框不动
dragMode: 'move',
// 裁剪框固定纵横比:1:1
aspectRatio: 1,
// 裁剪框不可移动
cropBoxMovable: false,
// 不可调整裁剪框大小
cropBoxResizable: false
}
// PC 端配置对象
const pcOptions = {
// 裁剪框固定纵横比:1:1
aspectRatio: 1
}
/**
* 图片裁剪处理
*/
const imageRef = ref(null)
let cropper = null
onMounted(() => {
/**
* 接收两个参数:
* 1. 需要裁剪的图片 DOM
* 2. options 配置对象
*/
cropper = new Cropper(
imageRef.value,
isMobileTerminal.value ? mobileOptions : pcOptions
)
})
然后我们可以通过cropper.getCroppedCanvas().toBlob
拿到裁剪后的文件对象。
// 获取裁剪后的图片
cropper.getCroppedCanvas().toBlob((blob) => {
// 裁剪后的 blob 对象
console.log(blob)
})
免费获取渠道
以阿里云 oss 为例,安装ali-oss
封装创建 oss 对象实例方法
import OSS from 'ali-oss'
import { REGION, BUCKET } from '@/constants'
import { getSts } from '@/api/sys'
export const getOSSClient = async () => {
const res = await getSts()
return new OSS({
// yourRegion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
region: REGION,
// 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
accessKeyId: res.Credentials.AccessKeyId,
accessKeySecret: res.Credentials.AccessKeySecret,
// 从STS服务获取的安全令牌(SecurityToken)。
stsToken: res.Credentials.SecurityToken,
// 填写Bucket名称。
bucket: BUCKET,
// 刷新 token,在 token 过期后自动调用(但是并不生效,可能会在后续的版本中修复)
refreshSTSToken: async () => {
// 向您搭建的STS服务获取临时访问凭证。
const res = await getSts()
return {
accessKeyId: res.Credentials.AccessKeyId,
accessKeySecret: res.Credentials.AccessKeySecret,
stsToken: res.Credentials.SecurityToken
}
},
// 刷新临时访问凭证的时间间隔,单位为毫秒。
refreshSTSTokenInterval: 5 * 1000
})
}
/**
* 上传图片到oss
*/
const store = useStore()
const putObjectToOSS = async (file) => {
// 创建oss对象实例
const ossClient = await getOSSClient()
try {
// 因为当前凭证只具备 images 文件夹下的访问权限,所以图片需要上传到 images/xxx.xx 。否则你将得到一个 《AccessDeniedError: You have no right to access this object because of bucket acl.》 的错误
const fileTypeArr = file.type.split('/')
const fileName = `${store.getters.userInfo.nickname}/${Date.now()}.${
fileTypeArr[fileTypeArr.length - 1]
}`
// 文件存放路径,文件。上传文件对象。并返回对应的图片路径
const res = await ossClient.put(`images/${fileName}`, file)
// 通知父元素更改图片地址
emits('updateImgUrl', res.url)
createMessage({
type: 'success',
content: '图片上传成功'
})
} catch (e) {
createMessage({
type: 'error',
content: '图片上传失败'
})
} finally {
// 关闭动画
loading.value = false
// 关闭弹窗
handleClose()
}
}
一般情况下,我们在移动端切换路由时,为了让 h5 页面跳转可以与原生 app 媲美,都会使用 vue 提供的过度动效来实现。
主要实现逻辑就是,先定义进入和离开页面的动画,通过路由跳转动态改变 transition 动画名称。在跳转的时候动态改变缓存组件栈的组件,从而达到组件切换缓存效果。
<template>
<!-- 路由出口 -->
<router-view v-slot="{ Component }">
<!-- 动画组件 -->
<transition
:name="transitionName"
@before-enter="beforeEnter"
@after-leave="afterLeave"
>
<!-- 缓存组件 -->
<!-- :key="$route.fullPath" 防止动态路由间跳转有缓存 -->
<keep-alive :include="virtualTaskStack">
<component
:class="{ 'fixed top-0 left-0 w-screen z-50': isAnimation }"
:is="Component"
:key="$route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
</template>
<script>
const ROUTER_TYPE_NONE = 'none'
const ROUTER_TYPE_PUSH = 'push'
const ROUTER_TYPE_BACK = 'back'
</script>
<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
// 跳转类型。pc端不进行跳转动画 为none
routerType: {
type: String,
default: ROUTER_TYPE_NONE,
validate(val) {
if (
val == ROUTER_TYPE_BACK ||
val == ROUTER_TYPE_NONE ||
val == ROUTER_TYPE_PUSH
) {
return true
} else {
console.error(
`请传入${ROUTER_TYPE_NONE}、${ROUTER_TYPE_BACK}、${ROUTER_TYPE_PUSH}类型之一`
)
return false
}
}
},
// 首页的组件名称,对应任务栈中的第一个组件
mainComponentName: {
type: String,
required: true
}
})
// 缓存的组件
const virtualTaskStack = ref([props.mainComponentName])
/**
* 监听跳转类型,然后确定动画名称
*/
const transitionName = ref('')
watch(
() => props.routerType,
(val) => {
transitionName.value = val
}
)
/**
* 每次路由切换,改变缓存组件数组。
*/
const router = useRouter()
router.beforeEach((to, from) => {
// // 定义当前动画名称
// transitionName.value = props.routerType
if (props.routerType === ROUTER_TYPE_PUSH) {
// 入栈
virtualTaskStack.value.push(to.name)
} else if (props.routerType === ROUTER_TYPE_BACK) {
// 出栈
virtualTaskStack.value.pop()
}
// 进入首页默认清空栈
if (to.name === props.mainComponentName) {
clearTask()
}
})
/**
* 动画开始
*/
const isAnimation = ref(false)
const beforeEnter = () => {
isAnimation.value = true
}
/**
* 动画结束
*/
const afterLeave = () => {
isAnimation.value = false
}
/**
* 清空栈
*/
const clearTask = () => {
virtualTaskStack.value = [props.mainComponentName]
}
</script>
<style lang="scss" scoped>
// push页面时:新页面的进入动画
.push-enter-active {
animation-name: push-in;
animation-duration: 0.6s;
}
// push页面时:老页面的退出动画
.push-leave-active {
animation-name: push-out;
animation-duration: 0.6s;
}
// push页面时:新页面的进入动画
@keyframes push-in {
0% {
transform: translate(100%, 0);
}
100% {
transform: translate(0, 0);
}
}
// push页面时:老页面的退出动画
@keyframes push-out {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(-50%, 0);
}
}
// 后退页面时:即将展示的页面动画
.back-enter-active {
animation-name: back-in;
animation-duration: 0.6s;
}
// 后退页面时:后退的页面执行的动画
.back-leave-active {
animation-name: back-out;
animation-duration: 0.6s;
}
// 后退页面时:即将展示的页面动画
@keyframes back-in {
0% {
width: 100%;
transform: translate(-100%, 0);
}
100% {
width: 100%;
transform: translate(0, 0);
}
}
// 后退页面时:后退的页面执行的动画
@keyframes back-out {
0% {
width: 100%;
transform: translate(0, 0);
}
100% {
width: 100%;
transform: translate(50%, 0);
}
}
</style>
可以通过第三方平台 兔小巢进行接入。
登录成功后,就可以创建产品。
创建完成后,就会生成一个返回网址。将其接入网站即可。