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

350 阅读15分钟

移动端适配

移动端适配我比较推荐的是 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 的大小

  // 动态设置 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。

  • 方案一
$rootFontSize: 375 / 10;
// 定义 px 转化为 rem 的函数
@function px2rem ($px) {
    @return $px / $rootFontSize + rem;
}
.demo {
    width: px2rem(100);
    height: px2rem(100);
}
  • 方案2

目前在前端的工程化开发中,我们可以借助于 postcss-pxtorem 插件来完成自动的转化

npm install postcss postcss-pxtorem --save-dev

postcss.config.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 举例

{
  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

//webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'postcss-loader'
          }
        ]
      }
    ]
  }
}
//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 用来避免这个问题

<meta name=viewport
  content="width=device-width, initial-scale=1.0, minimum-scale=1.0 maximum-scale=1.0, user-scalable=no">

阻止页面放大(meta不起作用时)

  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;

元素被点击时产生的半透明灰色遮罩怎么去掉

当使用原生表单的时候,点击的时候会有个灰色点击特效,如果不需要可以使用如下样式去掉。

a,button,input,textarea,select {
    -webkit-tap-highlight-color: transparent;
}

输入框文字居中对齐

有时候我们就算设置了line-height的值等于height,文字看起来也不居中,我们可以使用如下样式。

input {
  line-height: normal;
}

图片模糊问题

这个类似于1px问题,在Retina 高清屏上才会出现,由于高清屏用多个物理像素显示一个css像素,比如iphone6(750个物理像素375个css像素),由于dpr为2,所以1css像素会用2个物理像素显示,所以1像素的图片用2个物理像素显示出来就会模糊。

建议直接使用 svg 替代

<img src="randy.svg" />

横屏和竖屏

这个判断一般在ipad上比较常见

// 竖屏检测
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;

禁止网页复制

const html = document.querySelector('html');
html.oncopy = () => false;

判断是否在微信浏览器

export const isWechatBrowser = () => {
  const ua = navigator.userAgent.toLowerCase();
  return ua.indexOf('micromessenger') != -1;
}

检查是否在苹果设备上

const isAppleDevice = /Mac|iPod|iPhone|iPad/.test(navigator.platform);

console.log(isAppleDevice);

检查设备是移动设备还是电脑

const detectDeviceType = () =>
  /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|OperaMini/i.test(navigator.userAgent)
    ? 'Mobile'
    : 'Desktop';

console.log(detectDeviceType()); // "Mobile" or "Desktop"

removeEventListener内存泄漏

取消事件监听可能存在内存泄漏的情况

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 来集成事件

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

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里面使用

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 事件中,通过“脏数据”标记判断是否需要提示。
// 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

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 实现

box-shadow: 0 0 0 1px #333, 0 0 0 2px #999;

hover/active 等状态切换时会“跳动”

因为 border 会改变元素大小。

比如:

.btn { border: 0; }
.btn:hover { border: 1px solid #000; }

鼠标移上去会抖动,因为尺寸变大了。

box-shadow 的话就不会跳。

按钮重复点击

这种其实封装一个 asyncButton ,接口相应前 button 都loading态即可

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
  };
}

组件中使用

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来判断文本是否溢出

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 };
}

逻辑组件:

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;
};

使用:

QQ2025121-145511.gif

智能地址解析

这个主要是用了找的轮子 address-smart-parse

文档有例子,这里不例举代码了,直接看效果吧

QQ2025125-112541-77.gif

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

image.png

去掉这个库打包的体积:

image.png

高性能判断奇数偶数

// 不推荐
if (num % 2) {
  console.log(`${num}是奇数`);
} else {
  console.log(`${num}是偶数`);
}

// 推荐
if (num & 1) {
  console.log(`${num}是奇数`);
} else {
  console.log(`${num}是偶数`);
}

不同环境判断

判断是否在浏览器环境

const isBrowser = () => {
  return (
    typeof window !== 'undefined' &&
    typeof window.document !== 'undefined' &&
    typeof window.document.createElement !== 'undefined'
  );
};

判断是否在移动端

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

文字滚动

QQ2025128-173946.gif

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,否则不起作用

<div className="w-[300px] h-[100px] bg-[gary]">
  <span className="inline-block bg-[burlywood]">这是子元素的内容</span>
</div>

给 span 上设置 fill-available 时的不同表现

image.png

fill-available 实现等高布局

<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>

image.png

Taro 相关

Taro 微信手机号登录

前端的工作较少,主要是掉下 login 获取 code,通过code换取手机号

这里用 Taro 来演示

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中添加方法,在下一页返回前触发该方法将值传回去(方法名要一致)

// 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)
        },
    },
})
// 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 上传头像

<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 使用微信名称

<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 封装上传图片组件

//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>
  );
};
//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 使用

<Form.Item name="evidences" noStyle>
    <TXUpload />
</Form.Item>

无感刷新token

无感刷新需要两个 token,用户登录成功后,后端会同时返回accessTokenrefreshToken。前端将accessToken存在内存(或LocalStorage)里,然后在后续的请求中,通过refreshToken来刷新

  1. 响应拦截器里,捕获到后端返回的accessToken已过期的错误(通常是401状态码)。
  2. 当捕获到这个错误时,暂停所有后续的API请求。
  3. 使用refreshToken,悄悄地在后台发起一个获取新accessToken的请求。
  4. 拿到新的accessToken后,更新我们本地存储的Token
  5. 最后,把之前失败的请求和被暂停的请求,用新的Token重新发送出去。

这个过程对用户来说,是完全透明的。他们最多只会感觉到某一次API请求,比平时慢了一点点。

伪代码:

import axios from 'axios';

// 创建一个新的axios实例
const api = axios.create({
  baseURL: '/api',
  timeout: 5000,
});

// ------------------- 请求拦截器 -------------------
api.interceptors.request.use(config => {
  const accessToken = localStorage.getItem('accessToken');
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
}, error => {
  return Promise.reject(error);
});


// ------------------- 响应拦截器 -------------------

// 用于标记是否正在刷新token
let isRefreshing = false;
// 用于存储因为token过期而被挂起的请求
let requestsQueue = [];

api.interceptors.response.use(
  response => {
    return response;
  }, 
  async error => {
    const { config, response } = error;
    
    // 如果返回的HTTP状态码是401,说明access_token过期了
    if (response && response.status === 401) {
      
      // 如果当前没有在刷新token,那么我们就去刷新token
      if (!isRefreshing) {
        isRefreshing = true;

        try {
          // 调用刷新token的接口
          const { data } = await axios.post('/refresh-token', {
            refreshToken: localStorage.getItem('refreshToken') 
          });
          
          const newAccessToken = data.accessToken;
          localStorage.setItem('accessToken', newAccessToken);

          // token刷新成功后,重新执行所有被挂起的请求
          requestsQueue.forEach(cb => cb(newAccessToken));
          // 清空队列
          requestsQueue = [];

          // 把本次失败的请求也重新执行一次
          config.headers.Authorization = `Bearer ${newAccessToken}`;
          return api(config);

        } catch (refreshError) {
          // 如果刷新token也失败了,说明refreshToken也过期了
          // 此时只能清空本地存储,跳转到登录页
          console.error('Refresh token failed:', refreshError);
          localStorage.removeItem('accessToken');
          localStorage.removeItem('refreshToken');
          // window.location.href = '/login';
          return Promise.reject(refreshError);
        } finally {
          isRefreshing = false;
        }
      } else {
        // 如果当前正在刷新token,就把这次失败的请求,存储到队列里
        // 返回一个pending的Promise,等token刷新后再去执行
        return new Promise((resolve) => {
          requestsQueue.push((newAccessToken) => {
            config.headers.Authorization = `Bearer ${newAccessToken}`;
            resolve(api(config));
          });
        });
      }
    }
    
    return Promise.reject(error);
  }
);

export default api;
  1. isRefreshing 状态锁:

    这是为了解决并发问题。想象一下,如果一个页面同时发起了3个API请求,而accessToken刚好过期,这3个请求会同时收到401。如果没有isRefreshing这个锁,它们会同时去调用/refresh-token接口,发起3次刷新请求,这是完全没有必要的浪费,甚至可能因为并发问题导致后端逻辑出错。

    有了这个锁,只有第一个收到401的请求,会真正去执行刷新逻辑。

  2. requestsQueue 请求队列:

    当第一个请求正在刷新Token时(isRefreshing = true),后面那2个收到401的请求怎么办?我们不能直接抛弃它们。正确的做法,是把它们的resolve函数推进一个队列(requestsQueue)里,暂时挂起。

    等第一个请求成功拿到新的accessToken后,再遍历这个队列,把所有被挂起的请求,用新的Token重新执行一遍。

轮询接口

一般就是每间隔几秒钟调一次后端接口。然后处理成功、失败、超时的情况

由于项目中前端只需要上述操作,所以可以用 settimeout + 递归 或者 setInterval 来实现

setTimeout + 递归

  async checkCompleted() {
    const id = this.initData?.transId;
    if (!id) {
      return;
    }
    const [err, res] = await to(
      trades_id_is_paid({
        id,
      }),
    );

    runInAction(() => {
      if (res?.data?.paidFlag) {
        this.completed = true;
        this.orderInfo = res.data;
        return;
      }
      if (this.timmer) {
        clearTimeout(this.timmer);
      }
      this.timmer = setTimeout(() => {
        this.checkCompleted();
      }, 3000);
    });
  }

setInterval

// 轮询检查登录状态
function pollLoginStatus(token) {
  const poll = setInterval(async () => {
    try {
      const response = await fetch(`/api/qr/status?token=${token}`);
      const { status, userInfo } = await response.json();
      
      switch(status) {
        case 'scanned':
          showScannedStatus();
          break;
        case 'confirmed':
          clearInterval(poll);
          handleLoginSuccess(userInfo);
          break;
        case 'expired':
          clearInterval(poll);
          showExpiredStatus();
          break;
      }
    } catch (error) {
      console.error('轮询失败:', error);
    }
  }, 2000);
}

一般前端涉及到轮询的场景,常见的有扫码登录。那为什么扫码登录用轮询不用websocket呢?

  • 轮询实现简单,兼容性好,易于调试
  • 扫码登录场景对实时性要求不是特别高,2-3秒延迟可接受
  • WebSocket需要考虑连接稳定性、重连机制、服务器资源消耗

css绘制虚线框

.dotted-line {
  width: 200px;
  margin: 20px;
  padding: 20px;
  border: 1px dashed transparent;
  background: linear-gradient(white, white) padding-box,
    repeating-linear-gradient(-45deg, red 0, #ccc 0.25em, white 0, white 0.75em);
}
<p className=" dotted-line">
  庭院深深,不知有多深?杨柳依依,飞扬起片片烟雾,一重重帘幕不知有多少层。
</p>

image.png

css绘制卡券

.coupon {
 width: 300px;
  height: 100px;
  line-height: 100px;
  margin: 50px auto;
  text-align: center;
  position: relative;
  background: radial-gradient(circle at right bottom, transparent 10px, #ffffff 0) top right /50% 51px no-repeat,
  radial-gradient(circle at left bottom, transparent 10px, #ffffff 0) top left / 50% 51px no-repeat,
  radial-gradient(circle at right top, transparent 10px, #ffffff 0) bottom right / 50% 51px no-repeat,
  radial-gradient(circle at left top, transparent 10px, #ffffff 0) bottom left / 50% 51px no-repeat;
  filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, .2));
}
.coupon span {
  display: inline-block;
  vertical-align: middle;
  margin-right: 10px;
  color: red;
  font-size: 50px;
  font-weight: 400;
}
<p class="coupon">
 <span>200</span>优惠券
</p>

image.png

css 绘制不同形状

45个值得收藏的 CSS 形状

css 实现吸顶效果

监听滚动事件,滚动指定距离后,设置 css + 动画实现

在tab上放的容器添加 code-card-fade-out 和 code-card-fade-in 类名

由于tab下面的内容是 flex:1 自适应,所以把tab上面的内容显示隐藏 + 动画实现吸顶效果

css为

  @keyframes codeCardFadeOut {
    0% {
      height: 'auto';
      opacity: 1;
      visibility: visible;
    }

    100% {
      height: 0;
      opacity: 0;
      visibility: hidden;
      padding: 0;
    }
  }

  @keyframes codeCardFadeIn {
    0% {
      height: 0;
      opacity: 0;
      visibility: hidden;
    }

    100% {
      height: 'auto';
      opacity: 1;
      visibility: visible;
    }
  }

  .code-card-fade-out {
    animation: codeCardFadeOut 0.4s cubic-bezier(0.2, 0, 0.4, 1) forwards;
  }

  .code-card-fade-in {
    animation: codeCardFadeIn 0.2s cubic-bezier(0.2, 0, 0.2, 1) forwards;
  }

tab上方的box

import { classNames } from '@/utils/Tool';
import { observer } from '@mobx/index';
import { View } from '@tarojs/components';
import { useStore } from '../store/RootStore';
import { HeaderLeftNoLogin } from './HeaderLeftNoLogin';
import { HeaderWithLogin } from './HeaderWithLogin';

export const Header = observer(function Header_() {
  const root = useStore();
  const { global, logic } = root;

  return (
    <View
      className={classNames({
        'code-card': true,
        'code-card-fade-out': logic.isSticky,
        'code-card-fade-in': logic.isExitSticky,
      })}
    >
      {!global.loginInfo?.token && <HeaderLeftNoLogin />}
      {!!global.loginInfo?.token && <HeaderWithLogin />}
    </View>
  );
});

QQ2026112-153359-HD.gif

小红书瀑布流组件

import React, { useEffect, useRef, useState } from "react";

// 定义内容项的类型
interface WaterfallItem {
  id: number;
  imgUrl: string;
  title: string;
  height: number; // 模拟不同高度的内容
}

// 模拟生成随机高度的图片数据
const generateMockData = (start: number, count: number): WaterfallItem[] => {
  return Array.from({ length: count }, (_, i) => ({
    id: start + i,
    imgUrl: `https://picsum.photos/300/${
      200 + Math.floor(Math.random() * 300)
    }?random=${start + i}`,
    title: `小红书笔记 ${start + i}`,
    height: 200 + Math.floor(Math.random() * 300),
  }));
};

const Waterfall: React.FC = () => {
  // 状态管理
  const [items, setItems] = useState<WaterfallItem[]>([]);
  const [columns, setColumns] = useState<number>(2); // 默认2列
  const [loading, setLoading] = useState<boolean>(false);
  const [page, setPage] = useState<number>(1);
  const containerRef = useRef<HTMLDivElement>(null);
  const columnHeights = useRef<number[]>([]); // 记录每列的高度

  // 1. 初始化和响应式设置列数
  useEffect(() => {
    // 初始化数据
    setItems(generateMockData(1, 20));

    // 监听窗口大小变化,设置列数
    const handleResize = () => {
      if (window.innerWidth >= 1200) {
        setColumns(4);
      } else if (window.innerWidth >= 768) {
        setColumns(3);
      } else {
        setColumns(2);
      }
      // 重置列高度记录
      columnHeights.current = Array(columns).fill(0);
    };

    handleResize();
    window.addEventListener("resize", handleResize);

    return () => window.removeEventListener("resize", handleResize);
  }, []);

  // 2. 滚动加载更多
  useEffect(() => {
    const handleScroll = () => {
      const scrollTop =
        document.documentElement.scrollTop || document.body.scrollTop;
      const scrollHeight =
        document.documentElement.scrollHeight || document.body.scrollHeight;
      const clientHeight =
        document.documentElement.clientHeight || window.innerHeight;

      // 距离底部200px时加载更多
      if (scrollTop + clientHeight >= scrollHeight - 200 && !loading) {
        loadMore();
      }
    };

    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, [loading, items]);

  // 加载更多数据
  const loadMore = () => {
    setLoading(true);
    // 模拟接口请求延迟
    setTimeout(() => {
      const newItems = generateMockData(items.length + 1, 10);
      setItems([...items, ...newItems]);
      setPage(page + 1);
      setLoading(false);
    }, 800);
  };

  // 3. 计算每个item应该放在哪一列
  const getColumnIndex = (itemHeight: number): number => {
    // 找到当前最矮的列
    let minHeight = Math.min(...columnHeights.current);
    let columnIndex = columnHeights.current.indexOf(minHeight);

    // 更新该列的高度
    columnHeights.current[columnIndex] += itemHeight + 20; // 加上间距

    return columnIndex;
  };

  return (
    <div className="w-full max-w-7xl mx-auto px-4 py-8">
      {/* 瀑布流容器 */}
      <div
        ref={containerRef}
        className="grid gap-4"
        style={{
          gridTemplateColumns: `repeat(${columns}, 1fr)`,
        }}
      >
        {items.map((item) => (
          <div
            key={item.id}
            className="rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow duration-300 bg-white"
            style={{
              // 确保每个item在自己的列中
              gridRowEnd: `span ${Math.ceil(item.height / 20)}`, // 自适应行高
            }}
          >
            {/* 图片区域 */}
            <div className="w-full overflow-hidden">
              <img
                src={item.imgUrl}
                alt={item.title}
                className="w-full object-cover transition-transform duration-500 hover:scale-105"
                style={{ height: item.height }}
              />
            </div>
            {/* 标题区域 */}
            <div className="p-3">
              <p className="text-sm text-gray-800 line-clamp-2">{item.title}</p>
            </div>
          </div>
        ))}
      </div>

      {/* 加载中提示 */}
      {loading && (
        <div className="flex justify-center items-center py-8">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-500"></div>
          <span className="ml-2 text-gray-600">加载中...</span>
        </div>
      )}
    </div>
  );
};

export default Waterfall;

效果如下:

QQ2026115-145454.gif