入职之前,狂补技术,4w字的前端技术解决方案送给你(vue3 + vite )

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]

实现瀑布流布局

整个瀑布流组件的构建大体需要分成几部分

  1. 通过 props 传递关键数据

    • data:数据源

    • nodeKey:唯一标识

    • column:渲染的列数

    • columnSpacing:列间距

    • rowSpacing:行间距

    • picturePreReading:是否需要图片预渲染

  2. 瀑布流渲染机制:通过 absolute 配合 relative 完成布局,布局逻辑为:每个 item 应该横向排列,第二行的item 顺序连接到当前最短的列中。

  3. 通过作用域插槽 将每个 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 组件的实现思路:

  1. 创建一个confirm组件

  2. 创建一个函数组件,并且返回一个 promise

  3. 同时利用h函数生成confirm组件的vnode

  4. 最后利用render函数,渲染vnodebody

了解了组件的设计思路,我们就需要分析它应该具有的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)
}

文件下载

文件下载相关的库

直接使用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'

每个表单项,可以通过rulesprops绑定验证规则。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返回一段带hashurl,并且一直存储在内存中,直到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]进行接入。

登录成功后,就可以创建产品。

创建完成后,就会生成一个返回网址。将其接入网站即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Web面试那些事儿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值