个人积累的一些前端问题解决方案(理论或实践,持续更新....)

移动端适配

移动端适配我比较推荐的是 rem 或者 vm 方案

rem + pxToRem

原理

  1. rem 单位是相对于 html 元素的 font-size 来设置的,通过在不同屏幕尺寸下,动态的修改 html 元素的 font-size 以此来达到适配效果。通常是 1rem = 屏幕尺寸/10。比如当前屏幕尺寸是 375px,则 1rem = 375/10 = 37.5px
  2. 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-scaleminimum-scaleuser-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实现

实现思路

  1. 初始化时保存表单初始状态:页面加载完成后,记录表单各字段的初始值(如输入框、下拉框、复选框等)。
  2. 监听表单变化:当用户修改表单时,标记为"脏数据"。
  3. 关闭时校验 :在 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;
  }
  // 表单未修改,不触发提示
});

但是需要注意特殊场景处理:

  1. 区分"关闭"和"刷新/跳转"

beforeunload 事件无法直接区分用户是"关闭窗口"还是"刷新/跳转",但可以通过其他方式间接判断:

  • 监听 onbeforeunload 时,配合 performance.navigation.type 判断跳转类型(但该 API 已被废弃,推荐用 navigation API)。
  • 实际场景中,无需严格区分,只要表单有未保存内容,无论用户做什么操作(关闭、刷新、跳转),都应提示。
  1. 兼容问题
  • 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;

这个解决方案有以下优点:

  1. 统一管理:将请求状态管理逻辑封装在一个 Hook 中,避免重复代码
  2. 自动处理 loading:不需要手动管理 loading 状态
  3. 防重复点击:在请求过程中自动禁用按钮或阻止重复请求
  4. 类型安全:使用 TypeScript 提供类型检查
  5. 灵活性:可以通过 options 配置成功/失败的回调函数
  6. 可复用性:可以在任何组件中重用这个 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>
相关推荐
前端不太难7 小时前
Vue 项目路由 + Layout 的最佳实践
前端·javascript·vue.js
程序员祥云7 小时前
港股证劵 社招 一面
前端·面试
qq_4783775157 小时前
python cut_merge video, convert video2gif, cut gif
java·前端·python
巴拉巴拉~~7 小时前
Flutter 通用列表刷新加载组件 CommonRefreshList:下拉刷新 + 上拉加载 + 状态适配
前端·javascript·flutter
梨子同志7 小时前
Express.js 基础
前端
梨子同志7 小时前
Node.js HTTP 服务器开发
前端
码途潇潇7 小时前
数据大屏常用布局-等比缩放布局(Scale Laylout)-使用 CSS Transform Scale 实现等比缩放
前端·css
犬大犬小7 小时前
从头说下DOM XSS
前端·javascript·xss
绿鸳7 小时前
Socket.IO实时通信
前端