vue3有不懂得地方可以看看这个专栏(持续更新中。。。)
项目全部代码 欢迎star💓~
定制化、高可用前台样式处理方案
企业级项目下css处理痛点
-
统一的变量维护困难。
-
大量的 className 负担。
-
HTML, CSS分离造成的编写负担。
-
响应式,主题切换实现复杂。
更多痛点,请看 CSS Utility Classes and "Separation of Concerns"
针对上述问题,我们可以通过 tailwindcss 来进行解决。下面我们来看其具体用法。
安装
yarn add tailwindcss postcss autoprefixer -D
初始化tailwindcss.config.js
配置文件,并且也会创建postcss.config.js
文件。
npx tailwindcss init -p
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {},
},
plugins: [],
}
将加载 Tailwind 的指令添加到你的 CSS 文件中
@tailwind base;
@tailwind components;
@tailwind utilities;
在元素上使用内置类名
tailwindcss 官方介绍为无需离开HTML即可快速构建现代网站。具体来说就是tailwind提供了很多类名,都定义了特定的css,直接在编写HTML的时候加上对应的类名即可快速搭建网站。
tailwindcss的设计理念
首先我们先来看下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
插件,提示类名,来帮助我们更好的开发。
案例代码[5]
VueUse Vue组合式API的实用工具集
VueUse[6], 基于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
})
案例代码[7]
vite为什么比webpack快?
在webpack开发时构建时,默认会抓取并构建你的整个应用,然后才能提供服务,这就导致了你的项目中存在任何一个错误(即使当前错误不是首页引用的模块),他依然会影响到你的整个项目构建。所以你的项目越大,构建时间越长,项目启动速度也就越慢。
vite不会在一开始就构建你的整个项目,而是会将引用中的模块区分为依赖和源码(项目代码)两部分,对于源码部分,他会根据路由来拆分代码模块,只会去构建一开始就必须要构建的内容。
同时vite以原生 ESM的方式为浏览器提供源码,让浏览器接管了打包的部分工作。
vite快有什么问题?
当源码中有commonjs模块加载,那么将会出现模块加载失败的问题。通过依赖与构建[8]的方式解决该问题。
例如axios库就有相关的issue[9]
vite开发配置
配置路径别名[10]
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {join} from "path"
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": join(__dirname, "/src")
}
}
})
开发环境解决跨域[11]
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {join} from "path"
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": join(__dirname, "/src")
}
},
server: {
proxy: {
"/api": {
target: "目标origin",
changeOrigin: true,
}
}
}
})
配置环境变量[12]
企业级项目,都会区分很多环境,供我们测试试用。不能让我们的测试数据去污染线上的数据。所以vite也提供了我们环境配置文件的方式,让我们很轻松的去通过一些环境选择对应的接口地址等等。
.env.[mode]
的格式可以在不同模加载加载不同的内容。
环境加载优先级
一份用于指定模式的文件(例如
.env.production
)会比通用形式的优先级更高(例如.env
)。另外,Vite 执行时已经存在的环境变量有最高的优先级,不会被
.env
类文件覆盖。例如当运行VITE_SOME_KEY=123 vite build
的时候。
.env
类文件会在 Vite 启动一开始时被加载,而改动会在重启服务器后生效。
我们可以在源码中通过import.meta.env.*
的方式获取以VITE_
开头的已加载的环境变量。
VITE_BASE_API = "/api"
"scripts": {
"dev": "VITE_BASE_API=/oop vite",
}
执行yarn dev
后,我们可以发现,import.meta.env.VITE_BASE_API
是命令行中指定的参数。
通用组件自动注册
vite的Glob[13] 导入功能:该功能可以帮助我们在文件系统中导入多个模块
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[14]方法:该方法可以创建一个按需加载的异步组件 基于以上两个方法,实现组件自动注册。
import { defineAsyncComponent } from 'vue'
export default {
install(app) {
const components = import.meta.glob('./*/index.vue')
for (let [key, component] of Object.entries(components)) {
const componentName = 'hm-' + key.replace('./', '').split('/')[0]
app.component(componentName, defineAsyncComponent(component))
}
}
}
其实如果组件都提供了name属性,我们可以直接手动引入各组件模块,然后实现半自动注册。组件提供name的好处是,在vue-devtools中调试时方便查找各个组件。
在vue官网中[15],在 3.2.34 或以上的版本中,使用 <script setup>
的单文件组件会自动根据文件名生成对应的 name
选项,即使是在配合 <KeepAlive>
使用时也无需再手动声明。 但是对于我们文件名都为index.vue的开发者来说,就没办法了。
案例代码[16]
使用svg图标作为icon图标
首先我们需要封装一个通用的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
}
})
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"
export default defineConfig({
plugins: [
vue(),
createSvgIconsPlugin({
iconDirs: [join(__dirname, "/src/assets/icons")],
symbolId: "icon-[name]"
})
],
})
在main.js中导入并注册svg-icons,他会把指定文件夹下的svg图片都注册在首页。
import "virtual:svg-icons-register"
案例代码[17]
持久化状态数据 vuex-persistedstate
vuex-persistedstate[18], 作为vuex的一个插件,可以持久化store中的数据,防止因页面刷新等操作,数据丢失。(再次运行时,将缓存的数据作为对应state属性的初始值)
import { createStore } from "vuex";
import createPersistedState from "vuex-persistedstate";
const store = createStore({
plugins: [createPersistedState({
key : 'categoryList',
paths: ['category'],
})],
});
主题切换实现
以前写过主题替换的demo[19]
原理: 通过类名的切换使得html元素在不同类名下展示不同的样式
实现思路:(此方案基于tailwindcss插件[20])
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">
<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()[21],该方法接收一个mediaQueryString(媒体查询解析的字符串),该字符串我们可以传递prefers-color-scheme[22],即 window.matchMedia('(prefers-color-scheme: dark)')
方法即可返回一个`MediaQueryList`[23] 对象。
-
该对象存在一个`change`事件[24],可以监听系统主题发生变更。
-
事件对象`matches`属性[25]可以判断为啥主题。(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)')
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()
htmlClass = matchMedia.matches ? THEME_DARK : THEME_LIGHT
}
document.querySelector('html').className = htmlClass
}
* 初始化主题
*/
export default () => {
watch(() => store.getters.themeType, changeTheme, {
immediate: true
})
}
案例代码[26]
实现瀑布流布局
整个瀑布流组件的构建大体需要分成几部分
-
通过 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元素的总间距。
const columnSpacingTotal = computed(() => {
return (props.column - 1) * props.columnSpacing
})
然后用当前容器减去总间距,再除以列数。
const useColumnWidth = () => {
useContainerWidth()
columnWidth.value =
(containerWidth.value - columnSpacingTotal.value) / props.column
}
获取每个元素的高度
图片是否定义了高度,如果定义高度,可以直接计算出每个item的高度
const useItemHeight = () => {
itemsHeight = []
const itemElements = [...document.getElementsByClassName('hm-waterfall-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) {
const promises = []
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 = () => {
itemsHeight = []
const itemElements = [...document.getElementsByClassName('hm-waterfall-item')]
const imgElements = getImgElements(itemElements)
const allImgSrc = getAllImgSrc(imgElements)
allImgComplete(allImgSrc).then(() => {
itemElements.forEach((itemEl) => {
itemsHeight.push(itemEl.offsetHeight)
})
})
useItemLocation()
}
计算每个元素的偏移量
都是通过获取列最小高度基础上计算的一些值。
需要先将每列高度初始化为0,使用该对象作为容器,key为列下标,值为列高度。
const containerHeight = ref(0)
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)
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
item._style = {}
item._style.left = getItemLeft()
item._style.top = getItemTop()
increasingHeight(index)
})
containerHeight.value = getMaxHeight(columnHeightObj.value)
}
案例代码[27]
长列表加载组件
主要是通过监听底部dom是否出现在可视区域,然后做数据请求,处理一些特殊情况。使用到了 usevue的useIntersectionObserver api[28] ,它就是简单了对 IntersectionObserver api[29]进行了封装,让我们更轻易地实现可见区域交叉监听。
这个IntersectionObserver 以前写过一篇文章 《如何判断元素是否在可视区域内呢?然后搞一些事情》[30]介绍过,可以看看。
主要提供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
}
})
const emits = defineEmits(['update:isLoading', 'load'])
const loading = useVModel(props, 'isLoading', emits)
const loadingRef = ref(null)
const targetIsIntersecting = ref(false)
useIntersectionObserver(loadingRef, ([{ isIntersecting }]) => {
targetIsIntersecting.value = isIntersecting
emitLoad()
})
const emitLoad = () => {
if (targetIsIntersecting.value && !props.isFinished && !loading.value) {
loading.value = true
emits('load')
}
}
* 处理首次数据加载为盛满全屏时,可见区域判断回调只执行一次的bug
*
* 监听loading变化,重新触发执行
*/
let timer = null
watch(loading, () => {
timer = setTimeout(() => {
emitLoad()
}, 500)
})
onUnmounted(() => {
clearTimeout(timer)
})
</script>
这里有一个容易出现的bug,当我们数据量一次返回过少时,底部区域一直在可是区域内,我们将不能再次调用useIntersectionObserver
传入的回调,也就不能再次请求数据,加载更多了。
所以我们需要监听loading的变化,再次触发数据请求。但是这样又有一个问题了。当我们数据一次性加载过多时,我们依旧请求多次数据,这是因为虽然第一次请求的数据回来了,但是界面还没有渲染,这是底部区域依旧在可是区域内,导致数据再一次被请求。所以我们手动延迟数据在watch监听中的请求。
案例代码[31]
自定义懒加载指令
也是需要用到usevue的useIntersectionObserver api[32],首先将src置空,当进入可视区域,我们就将src赋值回去。
import { useIntersectionObserver } from '@vueuse/core'
export default {
mounted(el) {
const imgSrc = el.getAttribute('src')
el.setAttribute('src', '')
const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
if (isIntersecting) {
el.setAttribute('src', imgSrc)
stop()
}
})
}
}
通过vite的Glob[33] 的另一个方法来做到指令自动注册。使用 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)
}
}
}
案例代码[34]
confirm组件
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: '确定'
},
closeAfter: {
type: Function
},
* 主要是区分点击了取消还是确定
*/
handleConfirmClick: {
type: Function
},
handleCancelClick: {
type: Function
}
})
对于confirm组件来说,我们通过一个响应式数据来控制显示和隐藏实现的动画。
-
在弹出框出现时,我们需要监听挂载的时刻,然后控mask和弹框的显示,不然动画会失效。
-
再点击关闭弹出框时,我们不能立刻让组件卸载,不然动画也会立刻消失,所以我们延时卸载。
const actionDuration = '0.5s'
const isVisible = ref(false)
onMounted(() => {
isVisible.value = true
})
* 关闭弹窗
* 通过定时器,让动画完成后在移除dom
*/
const handleClose = () => {
isVisible.value = false
setTimeout(() => {
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
const vnode = h(Confirm, {
title,
content,
cancelText,
confirmText,
closeAfter,
handleConfirmClick,
handleCancelClick
})
render(vnode, document.body)
})
}
案例代码[35]
message组件
message组件的实现和confirm非常类似。
props需要指定弹框时间和类型
const props = defineProps({
type: {
type: String,
required: true,
validate(val) {
if (types.includes(val)) {
return true
} else {
throw new Error('请传入正确的类型值(error, warn, success)')
}
}
},
content: {
type: String,
required: true
},
closeAfter: {
type: Function
},
delay: {
type: Number,
default: 3000
}
})
主要就是弹框的隐藏时机不同。message中,是通过外界传入的时间控制隐藏的。
const isVisible = ref(false)
* 为了保证出现时动画展示,我们需要在组件挂载后在显示对应的内容
*/
onMounted(() => {
isVisible.value = true
setTimeout(() => {
isVisible.value = false
}, props.delay)
})
函数组件实现
import { h, render } from 'vue'
import Message from './index.vue'
export function createMessage({ type, content, delay = 3000 }) {
* 动画结束时的回调
*/
const closeAfter = () => {
render(null, document.body)
}
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()`[38]
-
`Document.exitFullscreen()`[39]
-
`Document.fullscreen`[40] 返回一个布尔值,表明当前文档是否处于全屏模式。已弃用
-
`Document.fullscreenElement`[41] 返回当前文档中正在以全屏模式显示的
Element
节点,没有就返回null。
一般浏览器
使用requestFullscreen()
和exitFullscreen()
来实现
早期版本Chrome浏览器
基于WebKit内核的浏览器需要添加webkit
前缀,使用webkitRequestFullScreen()
和webkitCancelFullScreen()
来实现。
早期版本IE浏览器
基于Trident内核的浏览器需要添加ms
前缀,使用msRequestFullscreen()
和msExitFullscreen()
来实现,注意方法里的screen的s为小写形式。
早期版本火狐浏览器
基于Gecko内核的浏览器需要添加moz
前缀,使用mozRequestFullScreen()
和mozCancelFullScreen()
来实现。
早期版本Opera浏览器
Opera浏览器需要添加o
前缀,使用oRequestFullScreen()
和oCancelFullScreen()
来实现。
考虑到兼容性,我们可以使用usevue
提供的`useFullscreen` api[42]
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` 库[43]实现。
定义好对应的引导步骤。
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[44]。
该库中,提供了三个重要的组件。分别为我们处理表单组件和表单验证错误提示。
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>
人类行为验证
目的:明确当前操作是人完成的,而非机器。
原理是什么?
人机验证是什么?如何实现人机验证?[45]
人机验证通过对用户的行为数据、设备特征与网络数据构建多维度数据分析,采用完整的可信前端安全方案保证数据采集的真实性、有效性。
滑动验证码实现原理是什么?
滑动验证码是服务端随机生成滑块和带有滑块阴影的背景图片,然后将其随机的滑块位置坐标保存。前端实现互动的交互,将滑块把图拼上,获取用户的相关行为值。然后服务端进行相应值的校验。其背后的逻辑是使用机器学习中的深度学习,根据鼠标滑动轨迹,坐标位置,计算拖动速度,重试次数等多维度来判断是否人为操作。
滑动验证码对机器的判断,不只是完成拼图,前端用户看不见的是——验证码后台针对用户产生的行为轨迹数据进行机器学习建模,结合访问频率、地理位置、历史记录等多个维度信息,快速、准确的返回人机判定结果,故而机器识别+模拟不易通过。滑动验证码也不是万无一失,但对滑动行为的模拟需要比较强的破解能力,毕竟还是大幅提升了攻击成本,而且技术也会在攻防转换中不断进步。
目前实现的方案有哪些?
分为两种: 一种是收费的、另一种是开源的
收费的代表有:
-
1、网易网盾[46]
-
2、数美[47]
-
3、极验[48]
开源的有:
-
slideCaptcha[49]
毫无疑问,就我们学习来说,开源的就是最好的。
该库主要是通过三个方法来进行验证回调操作的。
let captcha = null
onMounted(() => {
captcha = sliderCaptcha({
id: 'captcha',
async onSuccess(arr) {
const res = await getCaptcha({
behavior: arr
})
if (res) emits('verifySuccess')
},
onFail() {
console.error('人类行为验证失败')
},
verify() {
return true
}
})
})
并且内部提供reset
方法来修改拼图图片。
图片裁剪
想要学习图片裁剪,我们需要获取图片并展示。在我们点击上传图片如何预览呢?我们来简单介绍一下。
图片预览
以前在学习大文件上传时,介绍过相关的api[50]
-
`URL.createObjectURL()`[51] 静态方法会创建一个
DOMString
,其中包含一个表示参数中给出的对象的URL。这个 URL 的生命周期和创建它的窗口中的document
绑定。这个新的URL
对象表示指定的File
对象或Blob
对象。通过URL.createObjectURL(blob)
可以获取当前文件的一个内存URL。 -
`FileReader.readAsDataURL(file)`[52],通过
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库剪切图片
cropperjs[53]是一个非常强大的图片裁剪工具,它可以适用于:原生js,vue,react等等。而且操作也非常简单、只需要简单几步即可完成图片的裁剪工作。
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
const mobileOptions = {
viewMode: 1,
dragMode: 'move',
aspectRatio: 1,
cropBoxMovable: false,
cropBoxResizable: false
}
const pcOptions = {
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) => {
console.log(blob)
})
图片上传到阿里的oss存储
免费获取渠道
-
腾讯云 cos[54]
-
阿里云 oss[55]
以阿里云 oss 为例,安装`ali-oss`[56] 封装创建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({
region: REGION,
accessKeyId: res.Credentials.AccessKeyId,
accessKeySecret: res.Credentials.AccessKeySecret,
stsToken: res.Credentials.SecurityToken,
bucket: BUCKET,
refreshSTSToken: async () => {
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) => {
const ossClient = await getOSSClient()
try {
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()
}
}
案例代码[57]
让h5页面跳转和原生app页面跳转一样流畅
一般情况下,我们在移动端切换路由时,为了让h5页面跳转可以与原生app媲美,都会使用vue提供的过度动效[58]来实现。
主要实现逻辑就是,先定义进入和离开页面的动画,通过路由跳转动态改变transition动画名称。在跳转的时候动态改变缓存组件栈的组件,从而达到组件切换缓存效果。
<template>
<!-- 路由出口 -->
<router-view v-slot="{ Component }">
<transition
:name="transitionName"
@before-enter="beforeEnter"
@after-leave="afterLeave"
>
<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({
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) => {
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);
}
// 这里动画前一个页面只移动50%,但是新页面移动100%,所以会被挤出去。
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>
用户反馈功能
可以通过第三方平台 兔小巢[60]进行接入。
登录成功后,就可以创建产品。
创建完成后,就会生成一个返回网址。将其接入网站即可。