移动端适配
移动端适配我比较推荐的是 rem 或者 vm 方案
rem + pxToRem
原理
- rem 单位是相对于 html 元素的 font-size 来设置的,通过在不同屏幕尺寸下,动态的修改 html 元素的 font-size 以此来达到适配效果。通常是
1rem = 屏幕尺寸/10。比如当前屏幕尺寸是 375px,则 1rem = 375/10 = 37.5px - 将
px转换成rem, 常规方案有两种,一种是利用sass/less中的自定义函数pxToRem,写px时,利用pxToRem函数转换成rem。另外一种是直接写px,编译过程利用插件全部转成rem。这样dom中元素的大小,就会随屏幕宽度变化而变化了。
实现
1:设置 font-size。 在入口文件里监听 html 尺寸变化,动态设置 font-size 的大小
jsx
// 动态设置 font-size
const setRem = () => {
const clientWidth = document.documentElement.clientWidth;
// 375/10 = 37.5
const baseSize = clientWidth / 10;
document.documentElement.style.fontSize = baseSize + 'px';
};
setRem();
window.addEventListener('resize', setRem);
2:设置 px 转 rem。
- 方案一
css
$rootFontSize: 375 / 10;
// 定义 px 转化为 rem 的函数
@function px2rem ($px) {
@return $px / $rootFontSize + rem;
}
.demo {
width: px2rem(100);
height: px2rem(100);
}
- 方案2
目前在前端的工程化开发中,我们可以借助于 postcss-pxtorem 插件来完成自动的转化
js
npm install postcss postcss-pxtorem --save-dev
postcss.config.js文件
js
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 75, // 设计稿宽度/10,如750px设计稿就是75
propList: ['*'], // 需要转换的属性,这里选择全部
selectorBlackList: [], // 不转换的选择器
replace: true,
mediaQuery: false,
minPixelValue: 2, // 最小转换像素值
exclude: /node_modules/i // 排除node_modules目录
}
}
}
在工程化工具中配置,这里以 wepback 举例
js
{
test: /.css$/,
use: [
'style-loader',
'css-loader',
'postcss-loader'
]
}
vh + vw
原理
vw是把屏幕分成100分作为单位,即:1vm = 屏幕大小/100。
如果设计稿使用750px宽度,则100vw = 750px,即1vw = 7.5px。那么我们可以根据设计图上的px值直接转换成对应的vw值。
这样vw 相对于视窗宽度的单位,随宽度变化而变化。
实现
我们可以使用PostCSS的插件postcss-px-to-viewport,让我们可以直接在代码中写px。
js
//webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.css$/,
exclude: /node_modules/,
use: [
{
loader: 'postcss-loader'
}
]
}
]
}
}
js
//postcss.config.js
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport': {
viewportWidth: 375,
//options
},
},
};
h5一些问题的解决方案
页面放大或缩小
双击或者双指张开手指页面元素,页面会放大或缩小。
HTML 本身会产生放大或缩小的行为,但是在移动端,我们是不需要这个行为的。所以,我们需要禁止该不确定性行为,来提升用户体验。
HTML meta 元标签标准中有个 中 viewport 属性,用来控制页面的缩放,一般用于移动端。因此我们可以设置 maximum-scale、minimum-scale 与 user-scalable=no 用来避免这个问题
css
<meta name=viewport
content="width=device-width, initial-scale=1.0, minimum-scale=1.0 maximum-scale=1.0, user-scalable=no">
阻止页面放大(meta不起作用时)
js
window.addEventListener(
"touchmove",
function (event) {
if (event.scale !== 1) {
event.preventDefault();
}
},
{ passive: false }
);
input标签在safari苹果浏览器中的高度默认
input标签在safari苹果浏览器中的高度永远都是默认的,这时候解决的办法是加上line-height属性就可以设置;
如果Safari浏览器的input高度设置不管用,一定要设置line-height,然后去除iOS固有UI样式:-webkit-appearance: none;
元素被点击时产生的半透明灰色遮罩怎么去掉
当使用原生表单的时候,点击的时候会有个灰色点击特效,如果不需要可以使用如下样式去掉。
css
a,button,input,textarea,select {
-webkit-tap-highlight-color: transparent;
}
输入框文字居中对齐
有时候我们就算设置了line-height的值等于height,文字看起来也不居中,我们可以使用如下样式。
css
input {
line-height: normal;
}
图片模糊问题
这个类似于1px问题,在Retina 高清屏上才会出现,由于高清屏用多个物理像素显示一个css像素,比如iphone6(750个物理像素375个css像素),由于dpr为2,所以1css像素会用2个物理像素显示,所以1像素的图片用2个物理像素显示出来就会模糊。
建议直接使用 svg 替代
js
<img src="randy.svg" />
横屏和竖屏
这个判断一般在ipad上比较常见
js
// 竖屏检测
const mediaQuery = window.matchMedia("(orientation: portrait)");
// const mediaQuery = window.matchMedia("(orientation: landscape)"); 这是横屏
const darkModeHandler = () => {
if (mediaQuery.matches) {
console.log("竖屏");
} else {
console.log("横屏");
}
};
// 3种 监听模式变化
mediaQuery.addListener(darkModeHandler);
// mediaQuery.addEventListener("change", darkModeHandler);
// mediaQuery.onchange = darkModeHandler;
禁止网页复制
js
const html = document.querySelector('html');
html.oncopy = () => false;
判断是否在微信浏览器
js
export const isWechatBrowser = () => {
const ua = navigator.userAgent.toLowerCase();
return ua.indexOf('micromessenger') != -1;
}
检查是否在苹果设备上
js
const isAppleDevice = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
console.log(isAppleDevice);
检查设备是移动设备还是电脑
js
const detectDeviceType = () =>
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|OperaMini/i.test(navigator.userAgent)
? 'Mobile'
: 'Desktop';
console.log(detectDeviceType()); // "Mobile" or "Desktop"
removeEventListener内存泄漏
取消事件监听可能存在内存泄漏的情况
js
export default class MyClass {
constructor(container, options) {
this.container = container;
this.options = options;
// 绑定this,一个都不能少,不然就报错
this.handleResize = this.handleResize.bind(this);
this.handleScroll = this.handleScroll.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this);
this.init();
}
init() {
window.addEventListener('resize', this.handleResize);
this.container.addEventListener('scroll', this.handleScroll);
this.container.addEventListener('click', this.handleClick);
document.addEventListener('keydown', this.handleKeydown);
this.container.addEventListener('contextmenu', this.handleContextMenu);
//....
}
destroy() {
// 清理环节,经常漏几个
window.removeEventListener('resize', this.handleResize);
this.container.removeEventListener('scroll', this.handleScroll);
this.container.removeEventListener('click', this.handleClick);
document.removeEventListener('keydown', this.handleKeydown);
}
}
比如上面destory的时候可能会忘记事件,并且如果有新的事件要写两个地方
可以用 AbortController 来集成事件
js
export default class DataGrid {
constructor(container, options) {
this.container = container;
this.options = options;
this.controller = new AbortController();
this.init();
}
init() {
const { signal } = this.controller;
// 所有事件监听器统一管理
window.addEventListener('resize', (e) => {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => this.handleResize(e), 200);
}, { signal });
this.container.addEventListener('scroll', (e) => {
this.handleScroll(e);
}, { signal, passive: true });
this.container.addEventListener('click', (e) => {
this.handleClick(e);
}, { signal });
document.addEventListener('keydown', (e) => {
if (e.key === 'Delete' && this.selectedRows.length > 0) {
this.deleteSelectedRows();
}
}, { signal });
this.container.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.showContextMenu(e);
}, { signal });
}
destroy() {
// 一行代码注销所有监听
this.controller.abort();
}
}
比如可以封装个拖拽的class
js
class DragSort {
constructor(container) {
this.container = container;
this.isDragging = false;
this.dragElement = null;
this.initDrag();
}
initDrag() {
const dragController = new AbortController();
this.dragController = dragController;
const { signal } = dragController;
// 只在容器上监听mousedown
this.container.addEventListener('mousedown', (e) => {
const card = e.target.closest('.card');
if (!card) return;
this.startDrag(card, e);
}, { signal });
}
startDrag(card, startEvent) {
// 为每次拖拽创建独立的controller
const moveController = new AbortController();
const { signal } = moveController;
this.isDragging = true;
this.dragElement = card;
const startX = startEvent.clientX;
const startY = startEvent.clientY;
const rect = card.getBoundingClientRect();
// 创建拖拽副本
const ghost = card.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = rect.left + 'px';
ghost.style.top = rect.top + 'px';
ghost.style.pointerEvents = 'none';
ghost.style.opacity = '0.8';
document.body.appendChild(ghost);
// 拖拽过程中的事件
document.addEventListener('mousemove', (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
ghost.style.left = (rect.left + deltaX) + 'px';
ghost.style.top = (rect.top + deltaY) + 'px';
// 检测插入位置
this.updateDropIndicator(e);
}, { signal });
// 拖拽结束
document.addEventListener('mouseup', (e) => {
this.endDrag(ghost);
// 自动清理本次拖拽的所有事件
moveController.abort();
}, { signal, once: true });
// 防止文本选中
document.addEventListener('selectstart', (e) => {
e.preventDefault();
}, { signal });
// 防止右键菜单
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
}, { signal });
}
destroy() {
this.dragController?.abort();
}
}
这种写法的好处是,每次拖拽开始时创建独立的controller,拖拽结束时自动清理相关事件。不会出现事件监听器累积的问题。
以前用传统方式,我得手动管理mousemove和mouseup的清理,经常出现拖拽结束后事件还在监听的bug。
React里面使用
tsx
import { useEffect, useRef } from 'react';
function useEventController() {
const controllerRef = useRef();
useEffect(() => {
controllerRef.current = new AbortController();
return () => {
controllerRef.current?.abort();
};
}, []);
const addEventListener = (target, event, handler, options = {}) => {
if (!controllerRef.current) return;
const element = target?.current || target;
if (!element) return;
element.addEventListener(event, handler, {
signal: controllerRef.current.signal,
...options
});
};
return { addEventListener };
}
function MyComponent() {
const { addEventListener } = useEventController();
const buttonRef = useRef();
useEffect(() => {
addEventListener(window, 'resize', (e) => {
console.log('窗口大小变了');
});
addEventListener(buttonRef, 'click', (e) => {
console.log('按钮被点了');
});
}, []);
return <button ref={buttonRef}>点我</button>;
}
当然AbortController还可以取消接口请求,这里就不多说了
antd Image 预览大图优化
可以看这篇文章# antd Image base64缓存 + loading 态优化方案
antd Image preview、previewGroup 没有loading props,在网络较差时体验不太好,所以可以手动缓存+loading优化用户体验
页面刷新时保存表单内容
这个可以使用beforeunload实现
实现思路
- 初始化时保存表单初始状态:页面加载完成后,记录表单各字段的初始值(如输入框、下拉框、复选框等)。
- 监听表单变化:当用户修改表单时,标记为"脏数据"。
- 关闭时校验 :在
beforeunload事件中,通过"脏数据"标记判断是否需要提示。
tsx
// 1. 保存表单初始状态(假设表单有 id="myForm")
let formInitialState = {};
function saveInitialFormState() {
const form = document.getElementById('myForm');
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
// 记录每个字段的初始值(根据类型处理)
if (input.type === 'checkbox' || input.type === 'radio') {
formInitialState[input.name] = input.checked;
} else {
formInitialState[input.name] = input.value;
}
});
}
// 页面加载完成后保存初始状态
window.addEventListener('load', saveInitialFormState);
// 2. 监听表单变化,标记是否为脏数据
let isFormDirty = false;
function watchFormChanges() {
const form = document.getElementById('myForm');
form.addEventListener('input', () => {
// 每次输入时检查是否与初始状态不同
isFormDirty = checkFormDirty();
});
// 监听复选框、下拉框等变化
form.addEventListener('change', () => {
isFormDirty = checkFormDirty();
});
}
// 3. 检查表单是否有未保存内容
function checkFormDirty() {
const form = document.getElementById('myForm');
const inputs = form.querySelectorAll('input, select, textarea');
for (const input of inputs) {
const currentValue = input.type === 'checkbox' || input.type === 'radio'
? input.checked
: input.value;
// 与初始状态对比,有差异则视为脏数据
if (currentValue !== formInitialState[input.name]) {
return true;
}
}
return false;
}
// 初始化监听表单变化
watchFormChanges();
// 4. 结合 beforeunload 事件使用
window.addEventListener('beforeunload', function(e) {
if (isFormDirty) {
e.returnValue = '您有未保存的内容,确定要离开吗?';
return e.returnValue;
}
// 表单未修改,不触发提示
});
但是需要注意特殊场景处理:
- 区分"关闭"和"刷新/跳转"
beforeunload 事件无法直接区分用户是"关闭窗口"还是"刷新/跳转",但可以通过其他方式间接判断:
- 监听
onbeforeunload时,配合performance.navigation.type判断跳转类型(但该 API 已被废弃,推荐用navigationAPI)。 - 实际场景中,无需严格区分,只要表单有未保存内容,无论用户做什么操作(关闭、刷新、跳转),都应提示。
- 兼容问题
- Chrome/Firefox:为防止滥用,自定义提示文字会被忽略,弹窗显示浏览器默认文案(如"此页面要求您确认是否离开 - 您输入的数据可能不会被保存")。
- Safari :行为更严格,只有用户与页面有交互(如点击、输入)后,
beforeunload事件才会生效,否则直接关闭页面。 - 移动端浏览器 :大部分不支持
beforeunload弹窗,需通过其他方式提示(如页面内浮层)。
border问题
border页面改变布局(占据空间)
我们在给box添加border属性时,如果box是content-box,会导致 box 的宽度变宽。当然,可以使用 border-box来解决。
但是我遇到过有些设备上 border-box 也会影响布局的情况。
所以如果在排查问题时,一时半会儿不知道如何解决,可以用 shadow 去模拟 border
css
box-shadow: 0 0 0 1px #000
使用 box-shadow: 0 0 0 1px #000不会改变大小,看起来像 border,但不占空间。
border 在高 DPI 设备上容易出现"模糊/不齐"
特别是 0.5px border(发丝线),在某些浏览器上有锯齿、断线。
transform: scale(0.5) 或伪元素能做更稳定的发丝线。
border 圆角 + 发丝线 常出现不规则效果
border + border-radius 在不同浏览器的渲染不一致,容易出现不均匀、颜色不一致的问题。
用 outline / box-shadow 圆角更稳定。
border 多层边框
也可以通过 shadow 实现
css
box-shadow: 0 0 0 1px #333, 0 0 0 2px #999;
hover/active 等状态切换时会"跳动"
因为 border 会改变元素大小。
比如:
css
.btn { border: 0; }
.btn:hover { border: 1px solid #000; }
鼠标移上去会抖动,因为尺寸变大了。
用 box-shadow 的话就不会跳。
按钮重复点击
这种其实封装一个 asyncButton ,接口相应前 button 都loading态即可
tsx
import { useState, useCallback } from 'react';
interface RequestOptions {
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
}
export function useAsyncButton<T>(
requestFn: (...args: any[]) => Promise<T>,
options: RequestOptions = {}
) {
const [loading, setLoading] = useState(false);
const run = useCallback(
async (...args: any[]) => {
if (loading) return; // 如果正在加载,直接返回
try {
setLoading(true);
const data = await requestFn(...args);
options.onSuccess?.(data);
return data;
} catch (error) {
options.onError?.(error);
throw error;
} finally {
setLoading(false);
}
},
[loading, requestFn, options]
);
return {
loading,
run
};
}
组件中使用
tsx
import { useAsyncButton } from '../hooks/useAsyncButton';
const MyButton = () => {
const { loading, run } = useAsyncButton(async () => {
// 这里是你的接口请求
const response = await fetch('your-api-endpoint');
const data = await response.json();
return data;
}, {
onSuccess: (data) => {
console.log('请求成功:', data);
},
onError: (error) => {
console.error('请求失败:', error);
}
});
return (
<button
onClick={() => run()}
disabled={loading}
>
{loading ? '加载中...' : '点击请求'}
</button>
);
};
export default MyButton;
这个解决方案有以下优点:
- 统一管理:将请求状态管理逻辑封装在一个 Hook 中,避免重复代码
- 自动处理 loading:不需要手动管理 loading 状态
- 防重复点击:在请求过程中自动禁用按钮或阻止重复请求
- 类型安全:使用 TypeScript 提供类型检查
- 灵活性:可以通过 options 配置成功/失败的回调函数
- 可复用性:可以在任何组件中重用这个 Hook
useAsyncButton直接帮你进行了try catch,你不用再单独去做异常处理。
文本溢出隐藏,鼠标移入ToolTip
在前端开发中,我们经常会遇到接口返回的文本内容过长,无法完全显示的问题。为了处理这一问题,通常会设置固定的宽度并使用省略号样式(text-overflow: ellipsis)来隐藏超出的文本
然而,有时产品需求还希望用户能够通过悬停查看完整内容,这时就需要引入 Tooltip 进行展示。(没被省略的时候不要显示Tooltip)
我们可以写个hook来判断文本是否溢出
ts
import { useEffect, useRef, useState } from 'react';
type Options = {
lines?: number; // 支持多行
};
export function useEllipsis<T extends HTMLElement>({
lines = 1,
}: Options = {}) {
const ref = useRef<T>(null);
const [isEllipsis, setIsEllipsis] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const check = () => {
if (lines === 1) {
//单行溢出判断宽度
setIsEllipsis(el.scrollWidth > el.clientWidth);
} else {
//多行溢出判断高度
setIsEllipsis(el.scrollHeight > el.clientHeight);
}
};
check();
window.addEventListener('resize', check);
return () => {
window.removeEventListener('resize', check);
};
}, [lines]);
return { ref, isEllipsis };
}
逻辑组件:
tsx
import { cn } from "@/utils/tools";
import { Tooltip } from "antd";
import { useEllipsis } from "../hooks/useEllipsis";
export interface IEllipsisTooltipProps {
text: string;
className?: string;
onClick?: () => void;
lines?: number;
}
export const EllipsisTooltip: React.FC<IEllipsisTooltipProps> = ({
text,
className = "",
onClick,
lines = 1,
}) => {
const { ref, isEllipsis } = useEllipsis<HTMLDivElement>({ lines });
const lineClass =
lines === 1 ? "truncate whitespace-nowrap" : `line-clamp-${lines}`;
const content = (
<div ref={ref} className={cn(lineClass, className)} onClick={onClick}>
{text}
</div>
);
return isEllipsis ? <Tooltip title={text}>{content}</Tooltip> : content;
};
使用:

智能地址解析
这个主要是用了找的轮子address-smart-parse
文档有例子,这里不例举代码了,直接看效果吧

但是这个三方库小程序打包出来多了一个兆,所以有这种需求建议给后端做,调接口。毕竟后端这种要比前端更成熟

去掉这个库打包的体积:

高性能判断奇数偶数
js
// 不推荐
if (num % 2) {
console.log(`${num}是奇数`);
} else {
console.log(`${num}是偶数`);
}
// 推荐
if (num & 1) {
console.log(`${num}是奇数`);
} else {
console.log(`${num}是偶数`);
}
不同环境判断
判断是否在浏览器环境
js
const isBrowser = () => {
return (
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
);
};
判断是否在移动端
js
const userAgent = () => {
const u = navigator.userAgent;
return {
trident: u.includes('Trident'),
presto: u.includes('Presto'),
webKit: u.includes('AppleWebKit'),
gecko: u.includes('Gecko') && !u.includes('KHTML'),
mobile: !!u.match(/AppleWebKit.*Mobile.*/),
ios: !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/),
android: u.includes('Android') || u.includes('Adr'),
iPhone: u.includes('iPhone'),
iPad: u.includes('iPad'),
webApp: !u.includes('Safari'),
weixin: u.includes('MicroMessenger'),
qq: !!u.match(/\sQQ/i),
};
};
const isMobile = () => {
if (!isBrowser()) {
return false;
}
const { mobile, android, ios } = userAgent();
return mobile || android || ios || document.body.clientWidth < 750;
};
处理前端重复请求
建议现成的库,没必要造轮子。比如ahooks
文字滚动

tsx
import React, { useCallback, useEffect, useRef, useState } from "react";
// 定义组件Props类型
interface ScrollTextProps {
list: string[]; // 滚动文字列表
duration: number; // 滚动间隔时间(ms),即每段文字停留的时间
}
const ScrollText: React.FC<ScrollTextProps> = ({ list, duration }) => {
// 单个滚动项的高度(px),统一用内联样式+Tailwind类名保证高度固定
const ITEM_HEIGHT = 30;
// 动画过渡时长(ms)
const ANIMATION_DURATION = 500;
// 滚动容器Ref
const scrollWrapperRef = useRef<HTMLDivElement>(null);
// 定时器Ref
const timerRef = useRef<NodeJS.Timeout | null>(null);
// 当前滚动索引
const [currentIndex, setCurrentIndex] = useState(0);
// 合并列表(原列表+复制一份,实现无缝滚动)
const mergedList = list.length > 0 ? [...list, ...list] : [];
// 滚动处理函数
const handleScroll = useCallback(() => {
if (list.length === 0) return;
setCurrentIndex((prev) => {
const nextIndex = prev + 1;
// 当滚动到复制的列表开头时,快速重置位置(取消过渡动画)
if (nextIndex >= mergedList.length) {
if (scrollWrapperRef.current) {
scrollWrapperRef.current.style.transition = "none";
scrollWrapperRef.current.style.transform = `translateY(0px)`;
}
// 下一帧恢复过渡动画,并从索引1开始滚动
setTimeout(() => {
if (scrollWrapperRef.current) {
scrollWrapperRef.current.style.transition = `transform ${ANIMATION_DURATION}ms ease-in-out`;
}
setCurrentIndex(1);
}, 0);
return 0;
}
return nextIndex;
});
}, [list, mergedList.length]);
// 初始化/更新定时器
useEffect(() => {
if (list.length === 0 || duration < ANIMATION_DURATION) return; // 避免间隔时间小于动画时长
// 清除旧定时器
if (timerRef.current) clearInterval(timerRef.current);
// 首次停留duration时间后开始滚动
timerRef.current = setInterval(handleScroll, duration);
// 组件卸载时清除定时器
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [list, duration, handleScroll]);
// 监听currentIndex变化,更新滚动位置
useEffect(() => {
if (scrollWrapperRef.current && list.length > 0) {
const translateY = -currentIndex * ITEM_HEIGHT;
scrollWrapperRef.current.style.transform = `translateY(${translateY}px)`;
}
}, [currentIndex, list.length]);
// 空列表处理
if (list.length === 0) {
return (
<div className="h-[30px] w-full flex items-center justify-center text-gray-500 bg-white rounded-md shadow-sm">
暂无数据
</div>
);
}
return (
<div
className="relative w-full overflow-hidden bg-white rounded-md shadow-sm"
style={{ height: `${ITEM_HEIGHT}px`, lineHeight: `${ITEM_HEIGHT}px` }} // 行高与高度一致,辅助文字垂直居中
>
<div
ref={scrollWrapperRef}
className="absolute top-0 left-0 w-full"
style={{
transition: `transform ${ANIMATION_DURATION}ms ease-in-out`,
height: `${mergedList.length * ITEM_HEIGHT}px`, // 滚动容器总高度=项数*单项高度,避免布局偏移
}}
>
{/* 滚动项:强制单行+固定高度+overflow-hidden,防止内容换行导致高度变化 */}
{mergedList.map((item, index) => (
<div
key={index}
className="w-full overflow-hidden whitespace-nowrap text-ellipsis" // 强制单行+省略号
style={{
height: `${ITEM_HEIGHT}px`,
lineHeight: `${ITEM_HEIGHT}px`, // 行高与高度一致,垂直居中
padding: "0 16px", // 左右内边距,避免文字贴边
}}
>
{item}
</div>
))}
</div>
</div>
);
};
export default ScrollText;
props是滚动数据列表,然后可以设置滚动间隔,并且当一行内容超出时,显示省略号
但当前组件只实现了固定高度。非固定高度的场景暂未遇到
撑满剩余空间
fill-available
元素撑满可用空间。
参考的基准为父元素有多宽多高。
类似子元素的 div 撑满父元素的宽,fill-available 不仅可以撑满宽还能撑满高。
注意:display 必须是 inline-block 或者 block,否则不起作用
jsx
<div className="w-[300px] h-[100px] bg-[gary]">
<span className="inline-block bg-[burlywood]">这是子元素的内容</span>
</div>
给 span 上设置 fill-available 时的不同表现

fill-available 实现等高布局
html
<div className="h-[100px]">
<div
className="w-[100px] mx-[10px] inline-block align-middle bg-[pink]"
style={{
height: "-webkit-fill-available",
}}
>
HTML
</div>
<div
className="w-[100px] mx-[10px] inline-block align-middle bg-[pink]"
style={{
height: "-webkit-fill-available",
}}
>
CSS
</div>
<div
className="w-[100px] mx-[10px] inline-block align-middle bg-[pink]"
style={{
height: "-webkit-fill-available",
}}
>
JS
<br />
jQyery
<br />
Vue
</div>
</div>

Taro 微信手机号登录
前端的工作较少,主要是掉下 login 获取 code,通过code换取手机号
这里用 Taro 来演示
tsx
import { Button } from '@tarojs/components';
async toLogin() {
const { code } = await Taro.login();
console.log('这是code:', code);
const [err, res] = await to(accounts_login(code));
if (err || !res?.data) {
return null;
}
runInAction(() => {
LoginInfoHelper.getInstance().updateLoginInfo({
accountsLogin: res.data,
token: res.data.token,
});
});
return res.data;
}
async onGetPhoneNumber(detail: ButtonProps.onGetPhoneNumberEventDetail) {
if (!detail.code) {
return;
}
const info = await toLogin();
if (!info?.bindPhoneTicket) {
return;
}
const [err, res] = await to(
accounts_bind_phone({
ticket: info.bindPhoneTicket,
code: detail.code,
}),
);
if (err || !res?.data.token) {
return;
}
LoginInfoHelper.getInstance().updateLoginInfo({
token: res.data.token,
});
switchTab('pages/shop/index');
}
<Button
openType="getPhoneNumber"
onGetPhoneNumber={(e) => {
logic.onGetPhoneNumber(e.detail);
}}
>
手机号快捷登录
</Button>
Taro.login() 拿 code,然后调后端接口(后端去做剩下的事情,比如根据code去调微信官方的接口),把code传过去,剩下的就是记录用户信息,然后与后端协调流程参数那些了
Taro页面向上一页传参
需要到下一页调用接口拿到结果值,再返回到上一页通过结果值进行其他逻辑处理
navigateTo到下一页的时候往events中添加方法,在下一页返回前触发该方法将值传回去(方法名要一致)
tsx
// A页面
const [imgUrl, setImgUrl] = useState<string>()
Taro.navigateTo({
url: '/packageC/pages/clearPlate/index',
events: {
photoInfo: (data: {
currentImgUrl: string;
currentStatus: string;
}) => {
setImgUrl(data?.currentImgUrl)
setStatus(data?.currentStatus)
},
},
})
tsx
// B页面
const pages = getCurrentPages()
const current = pages[pages.length - 1] // 当前页
const prevPage = pages[pages.length - 2]; //上一页
if (prevPage.route && ['pageA/pages/A/index'].includes(prevPage.route)) {
const eventChannel = current.getOpenerEventChannel()
eventChannel.emit('photoInfo', {
currentImgUrl: fileUrl,
currentStatus: status,
});
Taro.navigateBack({ delta: 1 }) // delta默认1返回上一页,可以不写,写几就返回上几页
}
Taro 上传头像
tsx
<View className="user-icon">
<Avatar
src={global.userInfo.avatar?.fullPath}
size={'80px'}
shape="round"
></Avatar>
<View className="icon-tips">点击更新头像</View>
<Button
openType="chooseAvatar"
className="icon-picker"
onChooseAvatar={async (e) => {
const url = e.detail?.avatarUrl || '';
if (!url) {
return;
}
logic.updateLogo(url);
}}
></Button>
</View>
通过 Taro 的 button,设置 chooseAvatar,可以唤起上传头像组件。
在 onChooseAvatar 方法中可以通过 e.detail 拿到上传的头像图片信息
Taro 使用微信名称
tsx
<View className="nickRow">
<View>昵称</View>
<Input
type="nickname"
className="nick-ipt flex1"
controlled
value={logic.tepName}
onBlur={() => {
setTimeout(() => {
logic.updateNickName();
}, 200);
}}
maxlength={32}
onInput={(e) => {
logic.changeTepName(e.detail.value);
}}
/>
</View>
通过 Taro 的Input,设置 type=nickname ,点击Input的时候,会唤起微信名称
Taro 封装上传图片组件
tsx
//TXUpload.tsx
import { nanoid } from '@/utils/Tool';
import { Failure, Photograph } from '@nutui/icons-react-taro';
import { Image } from '@nutui/nutui-react-taro';
import { View } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useRef } from 'react';
import ImagePreviewPop from './ImagePreviewPop';
import { IImagePreviewPopRef } from './ImagePreviewPop/interface';
import './index.scss';
export interface ITXUploadRecord {
/** @param 上传后的地址 */
src?: string;
/** @param 临时文件地址 */
tepSrc?: string;
/** @param 唯一标识符 */
uuid: string;
}
export interface ITXUploadProps {
tips?: string;
maxlimit?: number;
value?: ITXUploadRecord[];
onChange?: (v: ITXUploadRecord[]) => void;
}
export const TXUpload = function TXUpload_(props: ITXUploadProps) {
const { tips = '上传凭证', value = [], onChange, maxlimit = 9 } = props;
const imgRef = useRef<IImagePreviewPopRef>(null);
return (
<View className="tx-upload">
<View
className="upload-btn"
onClick={() => {
if (value.length >= maxlimit) {
Taro.showToast({
title: `最多允许上传${maxlimit}张`,
icon: 'error',
});
return;
}
//从本地相册选择图片或使用相机拍照
Taro.chooseImage({
count: maxlimit - value.length,
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: function (res) {
onChange?.([
...value,
// tempFilePaths可以作为img的src使用
...res.tempFilePaths.map((r) => {
return {
tepSrc: r,
uuid: nanoid(),
};
}),
]);
},
});
}}
>
<Photograph color="#999999" className="upload-btn-icon" />
<View>{tips}</View>
<View>(最多{maxlimit}张)</View>
</View>
{value.map((i) => {
return (
<View
key={i.uuid}
className="upload-img-box"
onClick={() => {
//点击查看大图
imgRef.current?.openModal({
src: i.tepSrc || i.src,
});
}}
>
<Image
mode="aspectFill"
className="upload-img"
src={i.tepSrc || i.src}
/>
// 关闭按钮,点击时去掉当前图片
<Failure
className="upload-img-del"
color="#f36236"
size={16}
onClick={(e) => {
e.stopPropagation();
onChange?.(value.filter((r) => r.uuid !== i.uuid));
}}
/>
</View>
);
})}
// 二次封装 ImagePreview,预览大图组件
<ImagePreviewPop ref={imgRef} />
</View>
);
};
css
//index.css
.tx-upload {
display: grid;
grid-template-columns: repeat(4, 1fr); /* 每行 4 列,每列等宽 */
gap: 8px; /* 可选,设置列/行之间的间距 */
.upload-btn {
width: 70px;
height: 70px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 1px dashed rgba(0, 0, 0, 0.2);
font-size: 8px;
line-height: 11px;
color: #999;
.upload-btn-icon {
margin-bottom: 4px;
}
}
.upload-img-box {
position: relative;
.upload-img-del {
position: absolute;
top: -4px;
right: -4px;
}
}
.upload-img {
width: 70px;
height: 70px;
border-radius: 10px;
overflow: hidden;
}
}
配合 Form 使用
tsx
<Form.Item name="evidences" noStyle>
<TXUpload />
</Form.Item>