滚动到当前元素位置
// 获取DOM元素
const element = document.getElementById('#element')
// 由于header的高度是固定的,所以需要减去header的高度
const headerHeight = 110
// getBoundingClientRect 返回元素的大小及其相对于视口的位置
const elementTop = element?.getBoundingClientRect().top || 0
const scrollTop = elementTop + window.pageYOffset - headerHeight
window.scrollTo({ top: scrollTop, behavior: 'smooth' })
浏览器全屏
浏览器进入全屏的方法:
const docElm = document.documentElement
if (docElm.requestFullscreen) {
//W3C
docElm.requestFullscreen()
} else if (docElm.mozRequestFullScreen) {
//FireFox
docElm.mozRequestFullScreen()
} else if (docElm.webkitRequestFullScreen) {
//Chrome等
docElm.webkitRequestFullScreen()
} else if (elem.msRequestFullscreen) {
//IE11
elem.msRequestFullscreen()
}
浏览器退出全屏的方法:
if (document.exitFullscreen) {
//W3C
document.exitFullscreen()
} else if (document.mozCancelFullScreen) {
//FireFox
document.mozCancelFullScreen()
} else if (document.webkitCancelFullScreen) {
//Chrome等
document.webkitCancelFullScreen()
} else if (document.msExitFullscreen) {
//IE11
document.msExitFullscreen()
}
浏览器分享
当我们需要将当前页面分享给其他人时,可以使用以下代码:
const handleShare = async () => {
if (navigator.share) {
// 获取host
const host = window.location.host
// 获取当前协议
const protocol = window.location.protocol
const url = `${ protocol }//${ host }${ YOURURL }`
try {
await navigator.share({
title: '分享的标题',
text: '分享的内容',
url: url // 也可以直接获取当前页面的url window.location.href
})
} catch (error) { /* empty */
}
} else {
message.error('你的浏览器不支持分享功能, 请更新浏览器.').then()
}
}
blob实现任意文件下载
可以做成一个公共方法,传入文件的url和文件名,就可以实现任意文件的下载
如果通过axios请求文件,需要设置responseType为blob
export function exportFile(number: string, exportType: string, other: number) {
return request.get<any, Blob>(`url?number=${number}&exportType=${exportType}&other=${other}`, { responseType: 'blob' })
}
具体实现代码:
import { message } from 'antd'
// 通过url下载文件 / download file by url
export function downloadFile(url: string, fileName: string) {
fetch(url).then((res) => res.blob()).then((content) => {
message.destroy()
// 创建blob地址 / create blob url
const blobUrl = URL.createObjectURL(content)
// 创建a标签 / create a tag
const a = document.createElement('a')
// 初始化a标签 / init a tag
a.download = fileName
// 将blob地址赋值给a标签的href / assign blob url to a tag href
a.href = blobUrl
// 将a标签插入到body中 / insert a tag into body
document.body.appendChild(a)
// 模拟点击 / simulate click
a.click()
// 移除a标签 / remove a tag
document.body.removeChild(a)
})
}
// 通过二进制流下载文件 / download file by binary stream
export function downloadFileByBinaryStream(fileData: Blob, fileName: string) {
message.destroy()
// 创建一个 Blob 对象
const blob = new Blob([fileData], { type: 'application/pdf;charset=UTF-8' })
// 创建url对象 / create blob url
const blobUrl = URL.createObjectURL(blob)
// 创建a标签 / create a tag
const a = document.createElement('a')
// 初始化a标签 / init a tag
a.download = fileName
// 将blob地址赋值给a标签的href / assign blob url to a tag href
a.href = blobUrl
// 将a标签插入到body中 / insert a tag into body
document.body.appendChild(a)
// 模拟点击 / simulate click
a.click()
// 移除a标签 / remove a tag
document.body.removeChild(a)
}
如果在axios配置responseType为blob,但是由于接口共用问题,有时候会返回url
这时候我们就需要将blob转回正常的json响应格式:
// 将JSON数据转化为Blob的对象 再转回去 / convert JSON data to Blob object and convert back
export async function convertBlobToJSON(BlobData: Blob): Promise<{
code: number;
data: string;
msg: string;
status: string;
url: string
}> {
let jsonObject: { code: number; data: string; msg: string; status: string; url: string }
// 将Blob对象转换为json对象
const reader = new FileReader()
// 设置读取完成后的回调函数
reader.onload = async function (event) {
// 步骤 2: 从事件对象中获取读取的数据
const jsonText = event.target?.result
// 将 JSON 文本解析为 JSON 对象
try {
jsonObject = JSON.parse(jsonText as string)
} catch (error) {
console.error('解析 JSON 时发生错误:', error)
}
}
// 开始读取 Blob 数据
reader.readAsText(BlobData)
// 等待读取完成
await new Promise((resolve) => setTimeout(resolve, 800))
// @ts-ignore
return jsonObject
}
将类型限定到对象的键
假如有以下对象:
const obj = {
a: {
name: {
first: 'John',
last: 'Doe',
},
address: {
city: 'New York',
state: 'NY',
}
},
b: {
name1: {
first: 'Jane',
last: 'Doe',
},
address1: {
city: 'New York',
state: 'NY',
}
}
}
我们定义一个方法
const obj = {
a: {
name: {
first: 'John',
last: 'Doe',
},
address: {
city: 'New York',
state: 'NY',
}
},
b: {
name1: {
first: 'Jane',
last: 'Doe',
},
address1: {
city: 'New York',
state: 'NY',
}
}
}
// 获取第一层的键
type FirstLevelKey = keyof typeof obj
// 获取第二层的键
type SecondLevelKey<T extends FirstLevelKey> = keyof typeof obj[T]
function getSecondLevelValue<K extends FirstLevelKey>(key: K, secondLevelKey: SecondLevelKey<K>) {
return obj[key][secondLevelKey]
}
getSecondLevelValue('a', 'name1')
这样就会有类型提示了
手机号输入框
当我们做国际项目时, 遇到各个国家不同的用户输入手机号, 同时需要显示其所在的国旗时, 我们可以使用react-phone-number-input
这个库
import PhoneInput, { parsePhoneNumber } from 'react-phone-number-input'
import 'react-phone-number-input/style.css'
// 电话号码改变
const handleChange = (newValue: string) => {
// 使用 parsePhoneNumber 方法解析手机号码并获取区号
try {
const selectInfo = parsePhoneNumber(newValue)
if (selectInfo) {
区号
const dialCode = selectInfo.countryCallingCode
电话号码
const phone_number = selectInfo.nationalNumber
}
} catch (error) {
console.error(error)
}
}
<PhoneInput
inputStyle={{ width: '100%', fontSize: '24px' }}
international
defaultCountry="CN"
onChange={handleChange}
/>
效果如下:
鼠标移入方向
<template>
<div id="app">
<div class="container" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<div class="content">Hover over this area</div>
<div class="description" :class="descriptionClass">Description Text</div>
</div>
</div>
</template>
<script>
export default {
name: 'MouseEnter',
data() {
return {
descriptionClass: '',
}
},
methods: {
handleMouseEnter(event) {
// 获取元素的边界矩形,rect包含元素的top、right、bottom、left、width和height属性。
const rect = event.currentTarget.getBoundingClientRect()
// 计算鼠标在元素内部的水平位置,x是鼠标相对于元素左边的距离。
const x = event.clientX - rect.left
// 计算鼠标在元素内部的垂直位置,y是鼠标相对于元素顶部的距离。
const y = event.clientY - rect.top
const top = y
// 鼠标到元素底部的距离。
const bottom = rect.height - y
const left = x
// 鼠标到元素左边的距离。
const right = rect.width - x
// 找到以上四个距离中的最小值,即鼠标进入元素时距离最小的边。
const min = Math.min(top, bottom, left, right)
if (min === top) {
this.descriptionClass = 'enter-top'
} else if (min === bottom) {
this.descriptionClass = 'enter-bottom'
} else if (min === left) {
this.descriptionClass = 'enter-left'
} else if (min === right) {
this.descriptionClass = 'enter-right'
}
},
handleMouseLeave(event) {
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const top = y
const bottom = rect.height - y
const left = x
const right = rect.width - x
const min = Math.min(top, bottom, left, right)
if (min === top) {
this.descriptionClass = 'leave-top'
} else if (min === bottom) {
this.descriptionClass = 'leave-bottom'
} else if (min === left) {
this.descriptionClass = 'leave-left'
} else if (min === right) {
this.descriptionClass = 'leave-right'
}
},
},
}
</script>
<style scoped>
.container {
position: relative;
width: 400px;
height: 400px;
border: 1px solid #000;
overflow: hidden;
}
.content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f0f0f0;
}
.description {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
transition: all 0.3s;
}
.enter-left {
top: 0;
left: -100%;
transform: translateX(100%) !important;
}
.enter-right {
top: 0;
left: 100%;
transform: translateX(-100%) !important;
}
.enter-top {
top: -100%;
left: 0;
transform: translateY(100%) !important;
}
.enter-bottom {
bottom: -100%;
left: 0;
transform: translateY(-100%) !important;
}
.leave-left {
top: 0;
left: 0;
transform: translateX(-100%);
}
.leave-right {
top: 0;
left: 0;
transform: translateX(100%) !important;
}
.leave-top {
top: 0;
left: 0;
transform: translateY(-100%);
}
.leave-bottom {
bottom: 0;
left: 0;
transform: translateY(100%);
}
.container:hover .description {
transform: translate(0, 0); /* animate into place */
}
</style>
自定义结果弹窗
主体结构
这个是弹窗的主体, 基于antd的Modal组件
import { Modal } from 'antd'
import React, { isValidElement, useEffect, useState } from 'react'
import { MessageAlertProps } from '@/hooks/useMessageAlert'
import './style/index.less'
import { ErrorOutlined, SuccessOutlined } from '@/components/CustomIcon'
interface IProps extends MessageAlertProps {
open?: boolean
}
const typeIcon = {
success: {
icon: <SuccessOutlined />,
color: '#52C41A',
},
error: {
icon: <ErrorOutlined />,
color: '#FF4D4F',
},
}
const MessageAlert = (props: IProps) => {
const [modalProps, setModalProps] = useState<IProps>(props)
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null)
// 初始化
useEffect(() => {
setModalProps(props)
// 设置定时器, 自动关闭弹出框
handleSetTimer()
}, [props])
// SVG动画
const svgAnimation = () => {
// 获取所有带有.svg类的SVG路径元素
const paths = document.querySelectorAll('.animation-svg') as unknown as SVGGeometryElement[]
if (!paths.length) return
// 遍历所有带有.svg类的路径元素
paths.forEach((path) => {
// 获取路径的长度
const length = String(path.getTotalLength())
if (props.animation) {
// 设置stroke-dasharray和stroke-dashoffset属性以应用描边动画
path.style.strokeDasharray = length
path.style.strokeDashoffset = length
}
// 设置动画颜色
if (props.type === 'success') {
path.style.setProperty('--from-color', 'yellow')
path.style.setProperty('--to-color', '#52C41A')
}
if (props.type === 'error') {
path.style.setProperty('--from-color', 'yellow')
path.style.setProperty('--to-color', '#FF4D4F')
}
})
}
// 点击关闭弹出框
const handleModalProps = () => {
setModalProps({ ...props, open: false })
clearTimeout(timer as NodeJS.Timeout)
setTimer(null)
}
// 鼠标一上去时, 清除定时器
const handleMouseEnter = () => {
clearTimeout(timer as NodeJS.Timeout)
setTimer(null)
}
// 鼠标移开时, 重新设置定时器
const handleSetTimer = () => {
if (props.duration && !timer) {
setTimer(
setTimeout(() => {
setModalProps({ ...props, open: false })
setTimer(null)
}, props.duration),
)
}
}
return (
<Modal
width={350}
onCancel={handleModalProps}
wrapClassName="customer-message-alert"
{...modalProps}
getContainer={false}
centered={true}
mask={false}
closeIcon={null}
footer={null}
>
<div
onClick={handleModalProps}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleSetTimer}
onAnimationStart={svgAnimation}
className="customer-message-alert-content"
>
{!!modalProps.icon && isValidElement(modalProps.icon) && (
modalProps.icon
)}
{!!modalProps.icon && (typeof modalProps.icon === 'string') && (
<img src={modalProps.icon} alt="emptyCart" />
)}
{!modalProps.icon && modalProps.type && typeIcon[modalProps.type].icon}
<p
className="alert-text"
style={{
color: modalProps.textColor || modalProps.type && typeIcon[modalProps.type].color
}}
>
{modalProps.description}
</p>
</div>
</Modal>
)
}
export default MessageAlert
弹窗的样式
.customer-message-alert {
--from-color: yellow;
--to-color: #52C41A;
.animation-svg {
stroke-width: 8;
stroke: var(--from-color);
fill: var(--to-color);
animation: draw 1s linear forwards, fill 1s forwards;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes fill {
from {
fill: var(--from-color)
}
to {
fill: var(--to-color);
}
}
.ant-modal-content, .ant-modal-header {
padding: 0;
box-shadow: none;
background-color: transparent;
}
.ant-modal-body {
text-align: center;
}
.customer-message-alert-content {
img {
width: 100%;
min-width: 200px;
max-width: 300px;
}
.alert-text {
margin: 0;
font-size: 22px;
font-family: "Times New Roman", Times, serif;
font-weight: bold;
color: #52C41A;
}
}
}
hook实现
这个组件是基于antd的Modal组件, 通过hook的方式实现调用, 可在非组件中调用(hook中也可以调用)
import MessageAlert from '@/components/MessageAlert'
import { ReactNode, useState, createElement, useEffect, useRef } from 'react'
import { ModalProps } from 'antd/es/modal/interface'
import ReactDOM from 'react-dom/client'
export interface MessageAlertProps extends ModalProps {
// 自定义图标
icon?: string | ReactNode
// 描述文本
description?: string
// 类型
type?: 'success' | 'error'
// 持续时间 - ms
duration?: number
// 文本颜色
textColor?: string
// 是否开启动画 默认关闭
animation?: boolean
}
/**
* @description 居中显示, 用于处理消息提示的hook
* */
export default function useMessageAlert() {
const [props, setProps] = useState<ModalProps>({})
const containerRef = useRef<HTMLDivElement | null>(null)
const rootRef = useRef<ReactDOM.Root | null>(null)
// 卸载组件
useEffect(() => {
return () => {
if (containerRef.current) {
// 卸载根组件
rootRef.current?.unmount()
// 移除容器
document.body.removeChild(containerRef.current)
}
}
}, [])
// 更新组件
useEffect(() => {
if (containerRef.current && rootRef.current) {
rootRef.current.render(<MessageAlert {...props} />)
}
}, [props])
const open = (props: MessageAlertProps) => {
return new Promise((resolve) => {
setProps({ ...props, open: true })
setTimeout(() => {
resolve('done')
}, props.duration || 1500)
})
}
const close = () => {
setProps({ ...props, open: false })
}
// 不手动渲染DOM直接调用(无法获取上下文以及主题定制), 返回一个Promise, 用于在外部调用
const showMessage = async (staticProps: MessageAlertProps) => {
return new Promise((resolve) => {
if (!containerRef.current) {
const container = document.createElement('div')
document.body.appendChild(container)
containerRef.current = container
rootRef.current = ReactDOM.createRoot(container)
}
const el = createElement(MessageAlert, { ...staticProps, open: true })
rootRef.current?.render(el)
// 延时关闭
if (staticProps.duration) {
setTimeout(() => {
resolve('done')
}, staticProps.duration)
} else {
resolve('done')
}
})
}
return {
alertApi: { open, close },
showMessage,
contextHolder: <MessageAlert {...props} />,
}
}
使用方法1
通过静态调用, 即DOM不需要手动渲染, 直接调用, 但是无法获取上下文以及主题定制
then方法会在弹窗关闭后(duration)执行
const { showMessage } = useMessageAlert()
showMessage({
type: 'success',
description: '测试用例',
duration: 99999
}).then()
使用方法2
通过手动渲染DOM, 通过contextHolder渲染, 可以获取上下文以及主题定制
const { contextHolder, alertApi } = useMessageAlert()
return (
<div>
{contextHolder}
<button onClick={() => alertApi.open({
type: 'success',
description: '测试用例',
duration: 99999
})}>点击</button>
</div>
)
效果演示
图片没对齐, 将就看😘😘😘
全局类型声明
在项目中, 有时候需要自定义类型声明文件.d.ts
, 用于全局使用
在项目根目录(非src)中创建typings.d.ts
文件, 并在tsconfig.json
中配置:
(如果已经配置了就不用配置)
{
"compilerOptions": {
"typeRoots": ["./typings"]
}
}
import { ActionType } from '@ant-design/pro-components'
import { Route } from '@ant-design/pro-layout/es/typing'
import '@umijs/max/typings'
// 显式声明全局变量
declare global {
namespace GLOBAL {
interface IRouteObject extends Route {
admin?: boolean
title?: string
locale?: string
name?: string
component?: string
layout?: boolean
path?: string
children?: IRouteObject[]
}
interface ITableRef<T> {
actionRef: ActionType
pagination: API.PaginationData
selectedRows: T[]
searchParams: any
handleAction: (status: keyof typeof actionFn, record?: T) => void
}
}
const process: {
env: {
UMI_ENV: string
BUILD_TIME: string
}
}
}
export {} // 使文件成为一个模块,避免命名冲突
由于typings.d.ts
是全局的, 所以在项目中可以直接使用
错误的写法
以下的下发不能在全局不引入的情况下使用, 因为在该文件的开头使用了import
导入
这种方法与上面那种正确的写法的区别:
declare global 用于显式地扩展全局命名空间。这样,文件中定义的类型就会被视为全局类型,不再局限于模块的作用域。因此,无论在项目的任何地方都可以直接使用这些类型,而不需要显式导入这个 .d.ts 文件。
为什么第一种方法可以全局使用
使用 declare global 是明确告诉 TypeScript 你在扩展全局作用域,这会让 TypeScript 将这些类型直接放入全局命名空间中,这样其他地方就不需要通过导入来引用这些类型。而这种的写法会导致 TypeScript 将整个文件视为模块,类型只能在导入该模块后使用。
import { ActionType } from '@ant-design/pro-components'
import { Route } from '@ant-design/pro-layout/es/typing'
import '@umijs/max/typings'
declare const process: {
env: {
UMI_APP_COM_BASE_API: string
UMI_APP_PH_BASE_API: string
UMI_APP_TH_BASE_API: string
UMI_APP_MY_BASE_API: string
UMI_APP_BASE_API: string
UMI_ENV: string
BUILD_TIME: string
}
}
declare namespace GLOBAL {
interface IRouteObject extends Route {
admin?: boolean
title?: string
locale?: string
name?: string
component?: string
layout?: boolean
path?: string
children?: IRouteObject[]
}
interface ITableRef<T> {
actionRef: ActionType
pagination: API.PaginationData
selectedRows: T[]
searchParams: any
handleAction: (status: keyof typeof actionFn, record?: T) => void
}
}
React实现KeepAlive
心心念念的KeepAlive, 一直想实现, 但是一直没有实现, 但是今天终于实现了呢
实现思路
使用Map存储组件, 通过useRef存储
实现代码
import useMenuName from '@/hooks/useMenuName'
import RightClickMenu from '@/layouts/RightClickMenu'
import { Tabs, TabsProps } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { useOutlet } from 'react-router-dom'
import { useLocation } from 'umi'
import './style/index.less'
/**
* 全局 Layout 组件
* 用于缓存组件、处理路由变化、设置当前激活的 tab
* */
const Layout = () => {
const location = useLocation()
const componentList = useRef(new Map())
const outlet = useOutlet()
const { getMenuName } = useMenuName()
const [tabs, setTabs] = useState<TabsProps['items']>([])
const [activeKey, setActiveKey] = useState<string>('')
useEffect(() => {
// 缓存组件
setAliveComponent(location.pathname)
// 处理路由变化
handleRouteChange(location.pathname)
// 设置当前激活的 tab
setActiveKey(location.pathname)
}, [location.pathname])
// 缓存组件
const setAliveComponent = (key: string) => {
// 如果缓存中没有当前组件,则添加
if (!componentList.current.has(key)) {
componentList.current.set(key, outlet)
}
}
// 当路径变化时,更新 tabs
const handleRouteChange = (path: string) => {
// 如果已存在,则不添加
if (tabs?.find((item) => item.key === location.pathname)) {
return
}
// 添加新 tab
const newTab = {
key: path,
label: getMenuName(path).sourceName,
children: componentList.current.get(path),
closable: tabs?.length !== 1,
}
setTabs((prev) => [...prev!, newTab])
}
return (
<Tabs
size="small"
type="editable-card"
hideAdd={true}
tabBarStyle={{
paddingLeft: 40,
}}
activeKey={activeKey}
onChange={(key) => setActiveKey(key)}
>
{tabs?.map((tab) => (
<Tabs.TabPane
key={tab.key}
tab={tab.label}
closable={tabs.length !== 1 && tab.key !== activeKey}
>
{tab.children}
</Tabs.TabPane>
))}
</Tabs>
)
}
export default Layout
通过这个思路可以实现KeepAlive, 但是这个只是一个简单的实现, 如果需要更多的功能, 可以自行扩展
重点是通过const componentList = useRef(new Map())
和const outlet = useOutlet()
一个umijs
的例子: KeepAlive
唤起摄像头
web或者h5的摄像头唤起, 通过navigator.mediaDevices.getUserMedia
方法实现
'use client'
import jsQR from 'jsqr'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'
// 组件 props 类型定义
export interface QRCodeScannerButtonProps {
// 扫码成功后回调
onScanSuccess?: (result: string) => void
// 是否自动跳转
autoRedirect?: boolean
// 自定义跳转地址转换逻辑
redirectMap?: (result: string) => string | null
}
// 暴露给外部的 ref 方法
export interface IQRCodeScannerButtonRef {
// 外部可调用的启动扫码方法
handleClick: () => void
}
const QRCodeScannerButton: React.ForwardRefRenderFunction<IQRCodeScannerButtonRef, QRCodeScannerButtonProps> = (
{ onScanSuccess, autoRedirect = true, redirectMap },
ref,
) => {
const router = useRouter()
const t = useTranslations('Cart')
// 是否正在扫描
const [scanning, setScanning] = useState(false)
// 用于预览摄像头
const videoRef = useRef<HTMLVideoElement | null>(null)
// 用于捕获图像数据
const canvasRef = useRef<HTMLCanvasElement | null>(null)
// requestAnimationFrame 控制扫描循环
const scanLoopRef = useRef<number>()
// 标记是否已经识别成功,防止重复触发
const hasScannedRef = useRef(false)
// 暴露 handleClick 方法给父组件
useImperativeHandle(ref, () => ({
handleClick,
}))
// 点击按钮时触发,必须在「用户点击事件」中调用摄像头权限(iOS 要求)
const handleClick = async () => {
// 更新 URL 中参数,可用于隐藏布局
updateUrlParam('no-layout', 'true')
hasScannedRef.current = false
// 显示 video 视频
setScanning(true)
try {
// 请求摄像头权限(iOS 必须在点击事件中触发)
const stream = await navigator.mediaDevices.getUserMedia({
// 使用后置摄像头
video: { facingMode: 'environment' },
})
// 将摄像头数据绑定到 video 元素
if (videoRef.current) {
videoRef.current.srcObject = stream
// 确保视频播放(部分浏览器需要)
await videoRef.current.play()
}
startScanLoop() // 启动扫描循环
} catch (err) {
// 弹出提示
// alert(typeof err)
// 回退到购物车页面
// router.push('/cart')
}
}
// 扫描二维码的主循环逻辑(基于 requestAnimationFrame)
const startScanLoop = () => {
const video = videoRef.current
const canvas = canvasRef.current
if (!video || !canvas) return
const ctx = canvas.getContext('2d')
const loop = () => {
// 确保视频已准备好并有尺寸
if (video.readyState === video.HAVE_ENOUGH_DATA && video.videoWidth > 0) {
// 同步画布尺寸与视频尺寸
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// 将当前视频帧绘制到画布中
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height)
// 获取图像像素数据
const imageData = ctx?.getImageData(0, 0, canvas.width, canvas.height)
// 识别二维码
if (imageData) {
const code = jsQR(imageData.data, canvas.width, canvas.height)
// 成功识别并防止多次触发
if (code && !hasScannedRef.current) {
hasScannedRef.current = true
stopCamera() // 停止摄像头与扫描循环
// 通知外部
onScanSuccess?.(code.data)
// 是否自动跳转
if (autoRedirect) {
const url = redirectMap ? redirectMap(code.data) : code.data
if (url) {
router.push(url) // 跳转到识别地址
} else {
alert(t('invalidLink'))
}
}
return
}
}
}
// 继续下一帧扫描
scanLoopRef.current = requestAnimationFrame(loop)
}
scanLoopRef.current = requestAnimationFrame(loop)
}
// 停止摄像头与扫描
const stopCamera = () => {
if (videoRef.current?.srcObject) {
const tracks = (videoRef.current.srcObject as MediaStream).getTracks()
// 关闭每个媒体轨道
tracks.forEach((track) => track.stop())
videoRef.current.srcObject = null
}
if (scanLoopRef.current) {
// 停止扫描循环
cancelAnimationFrame(scanLoopRef.current)
}
// 隐藏摄像头
setScanning(false)
}
// 可选:更新 URL 中参数(用于控制页面 UI,如隐藏布局)
const updateUrlParam = (key: string, value: string) => {
const url = new URL(window.location.href)
url.searchParams.set(key, value)
window.history.replaceState({}, '', url.toString())
}
return (
<div>
{/* 扫码时显示全屏视频预览 */}
{scanning && (
<video
ref={videoRef}
autoPlay
playsInline // iOS Safari 需要加这个防止全屏播放
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
objectFit: 'cover',
zIndex: 50,
backgroundColor: 'black',
}}
/>
)}
{/* 用于捕获图像的隐藏画布 */}
<canvas ref={canvasRef} className="hidden" />
</div>
)
}
export default forwardRef(QRCodeScannerButton)
使用方法
如果某些设备唤起失败, 可尝试通过手动点击按钮唤起摄像头
'use client'
import React, { useEffect, useRef } from 'react'
import NativeScannerButton, { IQRCodeScannerButtonRef } from './components/NativeScannerButton'
/**
* 二维码扫描页面
* */
const Scanner: React.FC = () => {
const scannerButtonRef = useRef<IQRCodeScannerButtonRef>(null)
useEffect(() => {
if (scannerButtonRef.current) {
scannerButtonRef.current.handleClick()
}
if (window && window.document) {
document.title = '扫描二维码'
}
}, [])
// 跳转规格
const redirectMap = (result: string) => {
// 假设二维码是简单路径或完整 URL
if (result.startsWith('http')) {
return result
}
// 不跳转
return null
}
return (
<div>
<NativeScannerButton ref={scannerButtonRef} redirectMap={redirectMap} />
</div>
)
}
export default Scanner
滚动条恢复跳转前的滚动位置
目前监听position没有设置次数上线, 如果有性能问题, 可以考虑设置一个次数上线
import { RefObject, useEffect, useRef, useState } from 'react'
import { usePathname } from '@/i18n/navigation'
let position = 0
interface IProps {
// 是否立即恢复滚动
flag?: boolean
// 支持 window 或自定义容器
element?: RefObject<HTMLElement> | null
}
/**
* 页面滚动位置恢复 Hook
* */
export function useScrollRestoration(props?: IProps) {
const { flag = true, element } = props || {}
const pathname = usePathname()
const [y, setY] = useState(0)
const timer = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
timer.current = setInterval(() => {
if (position) {
setY(position)
clearInterval(timer.current as NodeJS.Timeout)
}
}, 500)
return () => {
if (timer.current) {
clearInterval(timer.current)
}
}
}, [])
// 滚动监听 & 保存位置
useEffect(() => {
const target = element?.current ?? window
const handleScroll = () => {
const scrollTop = target instanceof Window ? window.scrollY : (target as HTMLElement).scrollTop
// 用 pushState 储存位置
history.pushState({ scrollPosition: scrollTop }, '')
}
target.addEventListener('scroll', handleScroll)
return () => {
target.removeEventListener('scroll', handleScroll)
}
}, [pathname, element])
// popstate 恢复滚动位置
useEffect(() => {
const onPopState = (event: PopStateEvent) => {
position = event.state?.scrollPosition ?? 0
}
window.addEventListener('popstate', onPopState)
return () => {
window.removeEventListener('popstate', () => {})
}
}, [])
// 页面加载后滚动到目标位置
useEffect(() => {
if (flag) {
handleRestoreScroll()
}
}, [flag, y])
const handleRestoreScroll = () => {
const target = element?.current ?? window
if (target instanceof Window) {
window.scrollTo({ top: y })
} else if (target instanceof HTMLElement) {
target.scrollTop = y
}
}
return { handleRestoreScroll }
}