入职之前,狂补技术,4w字的前端技术解决方案送给你(vue3 + vite )
vite为什么比webpack快?
在webpack开发时构建时,默认会抓取并构建你的整个应用,然后才能提供服务,这就导致了你的项目中存在任何一个错误(即使当前错误不是首页引用的模块),他依然会影响到你的整个项目构建。所以你的项目越大,构建时间越长,项目启动速度也就越慢。
vite不会在一开始就构建你的整个项目,而是会将引用中的模块区分为依赖和源码(项目代码)两部分,对于源码部分,他会根据路由来拆分代码模块,只会去构建一开始就必须要构建的内容。
同时vite以原生 ESM的方式为浏览器提供源码,让浏览器接管了打包的部分工作。
vite快有什么问题?
当源码中有commonjs模块加载,那么将会出现模块加载失败的问题。通过依赖与构建的方式解决该问题。
例如axios库就有相关的issue
定制化、高可用前台样式处理方案
企业级项目下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的时候加上对应的类名即可快速搭建网站。
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
插件,提示类名,来帮助我们更好的开发。
VueUse Vue组合式API的实用工具集
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开发配置
配置路径别名
// 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图标作为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
}
})
// 生成图标唯一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-persistedstate, 作为vuex的一个插件,可以持久化store中的数据,防止因页面刷新等操作,数据丢失。(再次运行时,将缓存的数据作为对应state属性的初始值)
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
组件的实现思路:
-
创建一个
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组件
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()
来实现
早期版本Chrome浏览器
基于WebKit内核的浏览器需要添加webkit
前缀,使用webkitRequestFullScreen()
和webkitCancelFullScreen()
来实现。
早期版本IE浏览器
基于Trident内核的浏览器需要添加ms
前缀,使用msRequestFullscreen()
和msExitFullscreen()
来实现,注意方法里的screen的s为小写形式。
早期版本火狐浏览器
基于Gecko内核的浏览器需要添加moz
前缀,使用mozRequestFullScreen()
和mozCancelFullScreen()
来实现。
早期版本Opera浏览器
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库剪切图片
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存储
免费获取渠道
以阿里云 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页面跳转一样流畅
一般情况下,我们在移动端切换路由时,为了让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);
}
// 这里动画前一个页面只移动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>
用户反馈功能
可以通过第三方平台 兔小巢进行接入。
登录成功后,就可以创建产品。
创建完成后,就会生成一个返回网址。将其接入网站即可。
转载自:https://juejin.cn/post/7251878440327512124