普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月12日技术

VUE中使用AXIOS包装API代理

作者 _杨瀚博
2025年12月12日 17:59

VUE中使用AXIOS包装API代理

0. 背景

在VUE开发过程,与后端接口交互时,可以简单几句代码就剋调用后端POST或者GET请求. 实际效果就是,前端定义一个对象

{
  getPageList: 'GET /pagelist',
  commitData: 'POST /savedata',
  getDetail: 'GET /detail/{id}',
}

然后在业务代码中就可以调用getPageList方法,实际效果就是发送一个GET请求,请求地址为/pagelist 常用场景如下:

  • api.getPageList("gameName=地心侠士&gameType=小游戏') 会转换成GET请求,参数在URL上 /pagelist?gameName=地心侠士&gameType=小游戏
  • api.commitData({gameName:"地心侠士",gameType:"小游戏"}) 会转换成POST请求,参数通过JOSN提交 /savedata
  • api.getDetail({id:1}) 会转换成GET请求,参数在URL上 /detail/1

1. 整合全局axios配置

整合axios主要是配置一些全局的请求,响应,以及请求头,请求超时配置等.全局配置代码request.js如下:

import axios from 'axios';
import loading from './loading';
const ENV = import.meta.env;
const { VITE_GLOB_API_URL } = ENV;
let req = axios.create({
  baseURL: VITE_GLOB_API_URL || '',
  timeout: 30000,
  params: {'g.type': '1'},
  headers:{'g.type': '1'},
});
// 请求拦截 公众号 小满小慢
req.interceptors.request.use((cfg) => {
  loading.showLoading();
  return cfg;
});
// 响应拦截 公众号 小满小慢
req.interceptors.response.use(
  (resp) => {
    loading.hideLoading();
    if (resp.data.code && resp.data.code != '0') {
      // 全局拦截错误信息
      loading.showError(resp.data.message);
      return Promise.reject(resp.data);
    }
    return resp;
  },(error) => {
    loading.hideLoading();
    if (error.response && error.response.data) {
      loading.showError(error.response.data.message);
    }
    return Promise.reject(error);
  },
);
export default {
  request: req.request, 
};

2. 创建API请求包装器

请求包装器主要有以下作用

  • 请求参数处理
  • 通用接口暴露

实际效果可以把 GET /pagelist 暴露成一个可以调用的方法 ,创建API请求包装器apiconvert.js如下:

import req from './request.js';
export function convertApi(apis) {
  const ENV = import.meta.env;
  const { VITE_GLOB_API_URL } = ENV;
  const api = {};
  for (const key in apis) {
    const apiInfos = apis[key].split(' ');
    const [method, apiUrl] = apiInfos;
    let base = VITE_GLOB_API_URL;
    if (key == 'ajax') base = '/';
    api[key] = (data) => {
    return new Promise((resolve, reject) => {
      let targetUrl = apiUrl;
      if (method === 'GET' && data && typeof data === 'string') {
        // get请求参数处理 公众号 小满小慢
        data = encodeURI(data);
        const index = targetUrl.indexOf('?');
        if (index === -1) {
          data = `?${data}`;
        }
        targetUrl = `${targetUrl}${data}`;
        data = null;
      }
      if (/{\w+}/.test(targetUrl)) {
        targetUrl = targetUrl.replace(/{(\w+)}/gi, function (match, p) {
          return data[p] || '0'; 
        });
        console.log(`change url:${targetUrl}`);
      }
      req.request({ method: method, url: targetUrl, data: data,baseURL: base})
        .then((res) => resolve(res.data))
        .catch((error) => {
          reject(error);
        });
    });
    };
  }
  return api;
}
// 暴露一个通用接口
const api = convertApi({
  ajax: 'GET /commonDataProc',
});

export default api;

3. 使用API请求包装器

实际的业务接口可以通过键值对的方式配置,然后通过convertApi方法进行转换,转换后的接口可以调用. 如下:

  • 'GET /pagelist'
  • 'POST /savedata'

实际业务接口biz_api.js 定义如下

import commapi, { convertApi } from '@/assets/js/apiconvert';
const api = convertApi({
  // 这里可以扩展业务接口
  getPageList: 'GET /pagelist',
});
// 合并通用接口
Object.assign(api, commapi);
export default api;

4. 使用业务接口

实际业务代码中,通过import api from '@/assets/js/biz_api'引入业务接口,然后调用业务接口即可.

import api from './biz_api.js'
const data = ref([]);
const loadingStatus = ref(true);
async function getPages() {
  const res = await api.getPageList();
  let arr = [];
  for (let i in res) {
    arr.push(res[i]);
  }
  data.value = arr;
  loadingStatus.value = false;
}
onMounted(() => {
  getPages()
});

5. 总结

通过以上封装后,前端调用后端的API清晰明了.api定义在单独的文件,也可以自由组合. 从设计上来说,主要使用了两层代理转换. 所有还是印证那句话,一层代理解决不了问题,那就再加一层. 以上仅为个人项目落地总结,若有更优雅的方式,欢迎告知.微信公众号:小满小慢 私信或者直接留言都可以. 原文地址 mp.weixin.qq.com/s/aqHVyq_I3…

基于 Body 滚动的虚拟滚动组件技术实现

作者 张有志
2025年12月12日 17:41

前言

在现代 Web 应用中,树形结构是一种常见的数据展示方式,广泛应用于文件管理、组织架构、菜单导航等场景。然而,当树节点数量达到成千上万时,传统的全量渲染方式会导致严重的性能问题。本文将分享一个基于 React 实现的高性能虚拟滚动树组件,特别是其使用 Body 滚动条控制虚拟滚动的创新实现方案。

功能亮点

1. 🚀 高性能虚拟滚动

  • 按需渲染:只渲染视口内可见的节点,大幅减少 DOM 数量
  • 动态计算:实时计算可见范围,支持数万节点流畅滚动
  • 智能预加载:通过 overscan 参数预渲染视口外的节点,避免滚动时的白屏

2. 📏 不定高节点支持

  • 自适应高度:每个节点可以有不同的高度
  • ResizeObserver 监听:自动检测节点高度变化并更新缓存
  • 精确定位:基于高度缓存精确计算每个节点的位置

3. 🎯 Body 滚动条控制

这是本组件的核心创新点

  • 全局滚动体验:使用页面的原生滚动条,而非组件内部滚动
  • 无缝集成:树组件可以与页面其他内容(如表单、卡片)自然融合
  • 单一滚动条:整个页面只有一个滚动条,符合用户习惯

4. 🎨 拖拽排序

  • 直观交互:支持节点拖拽重新排序
  • 三种放置模式:before(前面)、after(后面)、inside(内部)
  • 视觉反馈:拖拽过程中提供清晰的视觉指示

5. 🌲 完整的树操作

  • 展开/收起:支持单个节点或全部节点的展开收起
  • 节点点击:自定义节点点击事件处理
  • 图标定制:支持自定义节点图标

技术实现原理

核心架构

┌─────────────────────────────────────┐
│         Window (Body Scroll)        │
│  ┌───────────────────────────────┐  │
│  │      Form Area (Fixed)        │  │
│  └───────────────────────────────┘  │
│  ┌───────────────────────────────┐  │
│  │   Virtual Tree Container      │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  Visible Node 1         │  │  │ ← 视口内
│  │  │  Visible Node 2         │  │  │
│  │  │  Visible Node 3         │  │  │
│  │  ├─────────────────────────┤  │  │
│  │  │  (Hidden Nodes)         │  │  │ ← 虚拟占位
│  │  │  Total Height: 10000px  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

1. Body 滚动监听机制

这是本组件最具特色的技术实现:

useEffect(() => {
  const handleScroll = () => {
    if (!containerRef.current) return;
    
    const rect = containerRef.current.getBoundingClientRect();
    const containerTop = rect.top;
    
    // 计算容器相对于视口的滚动位置
    // 如果容器顶部在视口上方,scrollTop为正值
    const newScrollTop = Math.max(0, -containerTop);
    setScrollTop(newScrollTop);
  };

  // 初始化滚动位置
  handleScroll();
  
  // 监听window滚动事件
  window.addEventListener('scroll', handleScroll, { passive: true });
  
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

关键点解析

  • 监听 window.scroll 事件而非容器的 scroll 事件
  • 通过 getBoundingClientRect() 获取容器相对于视口的位置
  • 当容器顶部滚出视口时(rect.top < 0),计算出虚拟的 scrollTop
  • 使用 passive: true 优化滚动性能

2. 可见范围计算

基于 Body 滚动位置计算哪些节点应该被渲染:

const visibleRange = useMemo(() => {
  if (positions.length === 0) {
    return { start: 0, end: 0 };
  }

  const rect = containerRef.current.getBoundingClientRect();
  const viewportHeight = window.innerHeight;
  
  // 计算视口内可见的范围
  const viewportTop = Math.max(0, -rect.top);
  const viewportBottom = viewportTop + viewportHeight;
  
  // 找到第一个可见节点
  let start = 0;
  for (let i = 0; i < positions.length; i++) {
    if (positions[i].top + positions[i].height >= viewportTop) {
      start = Math.max(0, i - overscan);
      break;
    }
  }
  
  // 找到最后一个可见节点
  let end = positions.length - 1;
  for (let i = start; i < positions.length; i++) {
    if (positions[i].top > viewportBottom) {
      end = Math.min(positions.length - 1, i + overscan);
      break;
    }
  }
  
  return { start, end };
}, [positions, scrollTop, overscan]);

算法优势

  • 基于视口高度和容器位置动态计算
  • 支持 overscan 预渲染,提升滚动流畅度
  • 使用二分查找可进一步优化(当前为线性查找)

3. 不定高节点处理

每个节点的高度可能不同,需要精确测量和缓存:

// 使用 ResizeObserver 监听高度变化
useEffect(() => {
  if (!nodeRef.current) return;

  const resizeObserver = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const height = entry.contentRect.height;
      onUpdateHeight(node.key, height);
    }
  });

  resizeObserver.observe(nodeRef.current);

  return () => {
    resizeObserver.disconnect();
  };
}, [node.key, onUpdateHeight]);

高度缓存策略

const { positions, totalHeight } = useMemo(() => {
  const positions = [];
  let currentTop = 0;
  
  flattenedData.forEach((node) => {
    const height = nodeHeights[node.key] || itemMinHeight;
    positions.push({
      key: node.key,
      top: currentTop,
      height
    });
    currentTop += height;
  });
  
  return {
    positions,
    totalHeight: currentTop
  };
}, [flattenedData, nodeHeights, itemMinHeight]);

4. 树数据扁平化

将树形结构转换为一维数组,便于虚拟滚动处理:

const flattenTree = useCallback((nodes, level = 0, parentKey = null) => {
  const result = [];
  
  nodes.forEach((node, index) => {
    const key = node.key || `${parentKey}-${index}`;
    const item = {
      ...node,
      key,
      level,
      parentKey,
      hasChildren: node.children && node.children.length > 0,
      isExpanded: expandedKeys.has(key)
    };
    
    result.push(item);
    
    // 只有展开的节点才递归处理子节点
    if (item.hasChildren && item.isExpanded) {
      result.push(...flattenTree(node.children, level + 1, key));
    }
  });
  
  return result;
}, [expandedKeys]);

扁平化优势

  • 将树形结构转换为线性数组,便于索引访问
  • 只包含可见的节点(未展开的子节点不在数组中)
  • 记录每个节点的层级信息,用于缩进显示

5. 拖拽实现

支持节点拖拽重新排序:

const handleDrop = ({ dragNode, dropNode, position }) => {
  // 1. 深拷贝树数据
  const newTreeData = JSON.parse(JSON.stringify(treeData));
  
  // 2. 从原位置删除节点
  const removedNode = removeNode(newTreeData, dragNode.key);
  
  // 3. 插入到新位置
  const inserted = insertNode(newTreeData, dropNode.key, removedNode, position);
  
  // 4. 更新树数据
  setTreeData(newTreeData);
};

拖拽位置判断

const handleDragOver = (e, node, position) => {
  const rect = nodeRef.current.getBoundingClientRect();
  const offsetY = e.clientY - rect.top;
  const height = rect.height;
  
  let position;
  if (offsetY < height * 0.25) {
    position = 'before';  // 上方 25%
  } else if (offsetY > height * 0.75) {
    position = 'after';   // 下方 25%
  } else {
    position = 'inside';  // 中间 50%
  }
};

性能优化策略

1. 渲染优化

  • useMemo 缓存计算结果:避免重复计算可见范围和节点位置
  • useCallback 缓存函数:防止子组件不必要的重新渲染
  • React.memo:对 TreeNode 组件进行记忆化

2. 滚动优化

  • passive 事件监听:提升滚动性能
  • requestAnimationFrame:可选的滚动节流(当前未使用)
  • overscan 预渲染:减少滚动时的白屏

3. 内存优化

  • 按需渲染:只渲染可见节点,大幅减少 DOM 数量
  • 高度缓存:避免重复测量节点高度
  • 及时清理:组件卸载时清理事件监听和 Observer

使用示例

import VirtualTree from './components/VirtualTree';

function App() {
  const treeRef = useRef(null);
  const [treeData, setTreeData] = useState([...]);

  const handleNodeClick = (node) => {
    console.log('点击节点:', node);
  };

  const handleDrop = ({ dragNode, dropNode, position }) => {
    // 处理拖拽逻辑
  };

  return (
    <div>
      {/* 页面其他内容 */}
      <Form>...</Form>
      
      {/* 树组件 - 使用 body 滚动条 */}
      <VirtualTree
        ref={treeRef}
        data={treeData}
        itemMinHeight={32}
        overscan={5}
        draggable={true}
        onNodeClick={handleNodeClick}
        onDrop={handleDrop}
      />
    </div>
  );
}

受控/非受控组件分析

2025年12月12日 17:38

基础概念

日常开发中一定会碰到表单处理的需求,比如输入框、下拉框、单选框、上传等,既然是组件,不管是ui组件还是自定义组件,优秀或者说完善的组件一定得是同时支持受控和非受控的,那么何为受控和非受控组件呢?

改变一个组件的值,只能通过两种方式

image.png

用户去改变组件的value或者代码去改变组件的value

如果不能通过代码去改变组件的value, 那么这个组件的value只能通过用户的行为去改变,那么这个组件就不受我们的代码控制,那么它就是一个非受控组件,反之能通过代码改变组件的value值,组件受我们代码控制,那么它就是一个受控组件。

非受控模式下,代码可以组件设置默认值defaulValue,但是代码设置完默认值后就不能控制value,能改变value的只能是用户行为,代码只能通过监听onChange事件获取value或者获取dom实例来获取value值。

image.png

注意:defaultValue和value不一样,defaultValue是value的初始值,用户后面改变的是value的值

受控模式下,代码一旦给组件设置了value,用户就不能再去通过行为改变它,只能通过代码监听onChange事件拿到value重新赋值去改变.

image.png

圈起来,这句话要考:value能通过用户控制就是非受控、通过打码控制就是受控

受控示例

一个典型的受控代码片段

import { Input } from 'antd'
import { ChangeEvent, useState } from 'react'

export default function Demos() {
  const [text, setText] = useState('')
  const inputHandler = (e: ChangeEvent<HTMLInputElement>) => {
    // 通过监听Input的onChange去重新赋值来改变value,用户无法控制输入框的值
    setText(e.target.value)
  }

  return <Input value={text} onChange={inputHandler} />
}

非受控示例

一个典型的非受控代码片段

import { Input, InputRef } from 'antd'
import { useRef } from 'react'

export default function Demos() {
  const inputRef = useRef<InputRef>(null)

  setTimeout(() => {
    // 通过ref获取dom元素来获取value
    console.log(inputRef.current?.input?.value)
  }, 4000)

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // 通过监听onChange来获取value
    console.log(e.target.value)
  }

  return <Input ref={inputRef} onChange={onChange} />
}

通过以上使用,我们可以发现,antd的Input组件同时支持了受控和非受控模式,那么我们能不能也自己封装一个同时支持受控和非受控模式的组件呢?

自定义同时支持受控和非受控模式的Radio组件

完整封装如下

import { useSettingStore } from '@/stores'
import { cn } from '@/utils'
import { useMemo, useState } from 'react'
import SpaceItem from '../spaceItem/SpaceItem'

// 自定义时间选择组件的属性
export type MyRadioProps = {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  options?: { label: string; value: number }[] // 选项
}

// 非受控/受控单选组件
export default function MyRadio(props: MyRadioProps) {
  const { colorPrimary } = useSettingStore()
  const { value, defaultValue, options, onChange } = props

  // 是否是受控组件
  const isControlled = useMemo(() => {
    return Reflect.has(props, 'value')
  }, [props])

  const [selectedValue, setSelectedValue] = useState<number | undefined>(
    isControlled ? value : defaultValue,
  ) // 内部状态

  // 最终拿去渲染的值
  const mergedValue = useMemo(() => {
    return isControlled ? value : selectedValue
  }, [selectedValue, value, isControlled])

  // 选择的回调
  const onSelect = (value: number) => () => {
    // 非受控时才更新内部状态
    if (!isControlled) {
      setSelectedValue(value)
    }

    onChange?.(value)
  }

  return (
    <SpaceItem wrap align="left">
      {options?.map((item) => (
        <div
          key={item.value}
          onClick={onSelect(item.value)}
          className={cn(
            'w-[65px] h-[35px] rounded-md flex items-center justify-center cursor-pointer border-[1px] border-[#585455] border-solid p-1',
            { 'text-white': mergedValue === item.value },
          )}
          style={{
            backgroundColor: mergedValue === item.value ? colorPrimary : '',
          }}
        >
          {item.label}
        </div>
      ))}
    </SpaceItem>
  )
}
// 是否是受控组件
  const isControlled = useMemo(() => {
    return Reflect.has(props, 'value')
  }, [props])

通过判断props中是否有value属性来判断到底是受控还是非受控

const [selectedValue, setSelectedValue] = useState<number | undefined>(
    isControlled ? value : defaultValue,
  ) // 内部状态

保存一个内部状态,来存储非受控模式时的值

// 最终拿去渲染的值
  const mergedValue = useMemo(() => {
    return isControlled ? value : selectedValue
  }, [selectedValue, value, isControlled])

组件最终显示的值,受控时显示父组件传入的value值,非受控时显示组件内部存储的值

// 选择的回调
  const onSelect = (value: number) => () => {
    // 非受控时才更新内部状态
    if (!isControlled) {
      setSelectedValue(value)
    }

    onChange?.(value)
  }

组件值改变时,如果是非受控,更新组件内部的值,并触发onChange事件回调(受控和非受控时都可以传onChange事件)

组件使用

<Card title="自定义单选组件非受控用法" size="small">
    <MyRadio
      options={Array.from({ length: 10 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      defaultValue={8}
      onChange={(value) => console.log(value)}
    />
</Card>
<Card title="自定义单选组件受控用法" size="small">
    <MyRadio
      options={Array.from({ length: 10 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      value={radio1}
      onChange={(value) => setRadio1(value)}
    />
</Card>

image.png

useMergeState封装

以上代码成功的实现了自定义组件同时支持受控和非受控,但是逻辑太分散,是否可以将处理逻辑再次封装呢?那么我们就来封装一个自定义hook来统一处理受控和非受控的逻辑

import { getTypeOf } from '@/utils'
import {
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from 'react'

// 参数属性
export type MergeStateProps<T> = {
  value?: T
  defaultValue?: T
  onChange?: (value: T) => void
  [props: string]: any
}

// 配置属性
export type MergeStateOption<T> = {
  defaultValue?: T // 默认值
  defaultValuePropName?: string // 默认值属性名
  valuePropName?: string // 值属性名
  trigger?: string // 触发
}

/**
 * @description 合并状态hook
 */
function useMergeState<T = any>(
  props: MergeStateProps<T> = {},
  options: MergeStateOption<T> = {},
): [T, (v: SetStateAction<T>, ...args: any[]) => void] {
  const {
    defaultValue,
    defaultValuePropName = 'defaultValue',
    valuePropName = 'value',
    trigger = 'onChange',
  } = options

  const value = props[valuePropName] // 获取当前值
  const isControlled = Reflect.has(props, valuePropName) // 是否受控

  // 初始值
  const initialValue = useMemo(() => {
    if (isControlled) {
      return value
    }

    if (Reflect.has(props, defaultValuePropName)) {
      return props[defaultValuePropName]
    }

    return defaultValue
  }, [
    defaultValue,
    value,
    isControlled,
    defaultValuePropName,
    props,
  ])

  const [state, setState] = useState(initialValue) // 保存内部状态

  // 可控的情况,外部传入值时,更新内部状态
  useEffect(() => {
    if (isControlled) {
      setState(value)
    }
  }, [isControlled, value])

  // 设置值
  const handleSetState = useCallback(
    (v?: SetStateAction<T>, ...args: any[]) => {
      const res = getTypeOf(v) === 'Function' ? (v as any)(state) : v
      // 非受控时才更新内部状态
      if (!isControlled) {
        setState(res)
      }
      if (props[trigger]) {
        props[trigger](res, ...args)
      }
    },
    [props, trigger, isControlled, state],
  )

  return [state, handleSetState]
}

export default useMergeState
const value = props[valuePropName] // 获取当前value值
const isControlled = Reflect.has(props, valuePropName) // 是否受控

获取当前的value,并判断是否是受控模式

  // 初始值
const initialValue = useMemo(() => {
    if (isControlled) {
      return value
    }

    if (Reflect.has(props, defaultValuePropName)) {
      return props[defaultValuePropName]
    }

    return defaultValue
}, [
    defaultValue,
    value,
    isControlled,
    defaultValuePropName,
    props,
])

const [state, setState] = useState(initialValue) // 保存内部状态

设置内部状态的值 1、如果是受控,则返回value的值 2、如果不是受控,则返回传入配置中定义的defaultValue的属性名对应的值 3、否则返回传入的defaultValue值

这里为什么需要传valuePropName这个属性呢,因为Switch/CheckBox组件没有value属性,只有checked属性,是为了兼容

// 受控的情况,外部传入值时,更新内部状态
  useEffect(() => {
    if (isControlled) {
      setState(value)
    }
  }, [isControlled, value])

受控的情况下,外部传入值时,更新内部状态

// 设置值
const handleSetState = useCallback(
    (v?: SetStateAction<T>, ...args: any[]) => {
      const res = getTypeOf(v) === 'Function' ? (v as any)(state) : v
      // 非受控时才更新内部状态
      if (!isControlled) {
        setState(res)
      }
      if (props[trigger]) {
        props[trigger](res, ...args)
      }
    },
    [props, trigger, isControlled, state],
)

返回组件的第二个返回值,设置值的方法 1、首先判断使用该hook第二个返回值时传入的参数是不是一个函数,是的话先执行 2、非受控时才去更新内部状态,受控时不用更新,直接由父组件改变value 3、触发事件回调

使用上述hook再封装一个同时支持受控和非受控的自定义组件

import useMergeState from '@/hooks/useMergeState'
import { useThemeToken } from '@/hooks/useThemeToken'
import { cn } from '@/utils'
import SpaceItem from '../spaceItem/SpaceItem'

// 自定义时间选择组件的属性
export type MyCheckboxProps = {
  value?: number[]
  defaultValue?: number[]
  onChange?: (value: number[]) => void
  options?: { label: string; value: number }[] // 选项
}

// 非受控/受控多选组件
export default function MyCheckbox(props: MyCheckboxProps) {
  const { colorPrimary } = useThemeToken()

  const { options } = props
  const [selectedValue, setSelectedValue] = useMergeState<number[]>(props)

  // 选择的回调
  const onSelect = (value: number) => () => {
    let res = [
      ...(Array.isArray(selectedValue) ? selectedValue : [selectedValue]),
    ]
    if (Array.isArray(selectedValue) && selectedValue?.includes(value)) {
      res = selectedValue.filter((item) => item !== value)
    } else {
      res.push(value)
    }
    setSelectedValue(res)
  }

  return (
    <SpaceItem wrap align="left">
      {options?.map((item) => (
        <div
          key={item.value}
          onClick={onSelect(item.value)}
          className={cn(
            'w-[65px] h-[35px] rounded-md flex items-center justify-center cursor-pointer border-[1px] border-[#585455] border-solid p-1',
            {
              'bg-[#1890ff] text-white':
                Array.isArray(selectedValue) &&
                selectedValue?.includes(item.value),
            },
          )}
          style={{
            backgroundColor: selectedValue?.includes(item.value)
              ? colorPrimary
              : '',
          }}
        >
          {item.label}
        </div>
      ))}
    </SpaceItem>
  )
}

组件使用

<Card title="自定义多选组件非受控用法" size="small">
    <MyCheckbox
      options={Array.from({ length: 12 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      defaultValue={[4, 6, 9]}
      onChange={(value) => console.log(value)}
    />
</Card>
<Card title="自定义多选组件受控用法" size="small">
    <MyCheckbox
      options={Array.from({ length: 16 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      value={checkbox1}
      onChange={(value) => setCheckbox1(value)}
    />
</Card>

image.png

useControllableValue的使用

以上的封装,都是开发者自定义的,强大的ahooks怎么可能没有想到这种需求呢,所以ahooks也提供了useControllableValue这个hook

基本使用

import React, { useState } from 'react';
import { useControllableValue } from 'ahooks';

const ControllableComponent = (props: any) => {
  const [state, setState] = useControllableValue<string>(props);

  return <input value={state} onChange={(e) => setState(e.target.value)} style={{ width: 300 }} />;
};

const Parent = () => {
  const [state, setState] = useState<string>('');
  const clear = () => {
    setState('');
  };

  return (
    <>
      <ControllableComponent value={state} onChange={setState} />
      <button type="button" onClick={clear} style={{ marginLeft: 8 }}>
        Clear
      </button>
    </>
  );
};

使用useControllableValue封装一个自定义时间选择组件

import { cn, dateFormat } from '@/utils'
import { useControllableValue } from 'ahooks'
import dayjs from 'dayjs'
import WhiteSpace from '../whiteSpace'

// 自定义时间选择组件的属性
export type TimeProps = {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  timeNum?: number
}

// 非受控/受控时间选择组件
export default function MyTime(props: TimeProps) {
  const { timeNum = 10 } = props
  const [value, setValue] = useControllableValue(props)

  // 时间选择的回调
  const onSelectTime = (time: number) => () => {
    setValue(time)
  }

  return (
    <div>
      <div>当前时间:{dateFormat(value, 'YYYY-MM-DD HH:mm:ss')}</div>
      <WhiteSpace />
      {Array.from({ length: timeNum }).map((_, index) => {
        const time = dayjs()
          .subtract(index + 1, 'days')
          .startOf('day')
          .valueOf()

        return (
          <div
            onClick={onSelectTime(time)}
            key={index}
            className={cn({ 'text-red-500': time === value })}
          >
            {dateFormat(time, 'YYYY-MM-DD HH:mm:ss')}
          </div>
        )
      })}
    </div>
  )
}

image.png

用useControllableValue结合antd的DatePicker组件二次封装一个时间选择组件

import { useControllableValue } from 'ahooks'
import { DatePicker, DatePickerProps } from 'antd'
import dayjs from 'dayjs'
import { useMemo } from 'react'

const defaultShortcuts = [
  {
    label: '今天',
    value: dayjs(),
  },
  {
    label: '昨天',
    value: dayjs().subtract(1, 'day'),
  },
  {
    label: '三天前',
    value: dayjs().subtract(3, 'days'),
  },
  {
    label: '一周前',
    value: dayjs().subtract(1, 'week'),
  },
  {
    label: '15天前',
    value: dayjs().subtract(15, 'days'),
  },
  {
    label: '一个月前',
    value: dayjs().subtract(1, 'month'),
  },
]

// 时间选择器组件的属性
export interface MyDatePickerProps extends DatePickerProps {
  shortcuts?: number[] // 快捷选项
  shortcutsMap?: Record<number, string> // 快捷选项的映射
  shortcutsRender?: (shortcuts?: number[]) => DatePickerProps['presets']
  showPresets?: boolean // 是否显示快捷选项
}

// 非受控/受控时间选择器组件
export default function MyDatePicker(props: MyDatePickerProps) {
  const {
    shortcuts,
    shortcutsMap,
    showPresets = true,
    shortcutsRender,
    ...rests
  } = props

  const [values, setValues] =
    useControllableValue<DatePickerProps['value']>(props)

  const presets = useMemo(() => {
    if (!showPresets) return undefined
    if (shortcutsRender && shortcuts?.length) {
      return shortcutsRender(shortcuts)
    }
    if (shortcuts?.length) {
      return shortcuts.map((shortcut) => {
        return {
          label: shortcutsMap?.[shortcut] || `近${shortcut}天`,
          value: dayjs().subtract(shortcut, 'days'),
        }
      })
    }
    return defaultShortcuts
  }, [shortcuts, shortcutsMap, showPresets, shortcutsRender])

  return (
    <DatePicker
      presets={presets}
      {...rests}
      value={values}
      onChange={setValues}
    />
  )
}

用useControllableValue结合antd的Upload组件二次封装一个图片上传组件

import type { UploadProps } from 'antd/es/upload/interface'
import { ButtonProps } from 'antd/lib'

export type ImgsValueType = string[] | string // 上传的值类型

// 上传参数类型
export interface IImgsUploadProps
  extends Omit<UploadProps, 'onChange' | 'value' | 'defaultValue'> {
  validate?: boolean // 是否需要验证接收类型和文件大小
  validateSize?: boolean // 是否需要验证图片的宽高
  limitWidth?: number // 验证图片的宽
  limitHeight?: number // 验证图片的高
  size?: number // 限制的尺寸,以M为单位
  successText?: string // 上传成功的提示文字
  failedText?: string // 上传失败的提示文字
  uploadText?: string // 上传按钮文字
  uploadStyles?: React.CSSProperties // 上传按钮的样式
  imgsStyles?: React.CSSProperties // 图片的样式
  imgList?: string[] // 已上传的图片列表
  preview?: boolean // 图片是否可预览
  count?: number // 图片总数限制
  tips?: string // 提示tips
  tipStyle?: React.CSSProperties // 提示tips的样式
  plusSizeTip?: string // 超过尺寸大小的提示语
  errorAcceptTip?: string // 上传格式不正确的提示语
  compress?: boolean // 是否压缩图片
  quality?: number // 压缩比例
  value?: ImgsValueType // 值
  defaultValue?: ImgsValueType // 默认值
  width?: number // 图片展示的宽
  height?: number // 图片展示的高
  multi?: boolean // 是否上传多张图片
  uploadBtn?: React.ReactNode // 自定义上传按钮
  uploadBtnProps?: ButtonProps // 上传按钮属性
  showImgs?: boolean // 是否显示已上传的图片
  fileValidateTip?: string // 文件校验不通过的提示语
  fileValidate?: (file: File) => Promise<boolean> // 文件校验
  onChange?: (data: ImgsValueType) => void // 改变的回调
  remove?: (url: string) => void // 移除已上传图片的回调
  onUploaded?: (url: string, fileInfo?: any, ...restParams: any) => void // 单张上传成功后接收结果
}


import { compressPic } from '@/utils'
import { CloseOutlined, LoadingOutlined } from '@ant-design/icons'
import { useControllableValue } from 'ahooks'
import { Button, Image as IM, message, Spin, Upload } from 'antd'
import type { UploadProps } from 'antd/es/upload/interface'
import { useState } from 'react'
import styles from './imgsUpload.module.less'
import type { IImgsUploadProps } from './typings'

// 允许上传的图片类型
export const ACCEPTIMG = '.jpg, .jpeg, .png, .gif, .webp, .ico, .bmp'

// 图片列表(多张、单张)上传通用组件
const ImgsUpload: React.FC<IImgsUploadProps> = (props: IImgsUploadProps) => {
  const {
    validate = true,
    validateSize = false,
    limitWidth = 1080,
    limitHeight = 1920,
    size,
    successText = '上传成功',
    failedText = '上传失败',
    plusSizeTip,
    errorAcceptTip,
    compress = false,
    quality = 0.6,
    accept,
    uploadStyles = {},
    imgsStyles = {},
    preview = true,
    multi = false,
    count = multi ? 5 : 1,
    tips,
    disabled,
    tipStyle = {},
    width = 80,
    height = 80,
    uploadText,
    uploadBtn,
    uploadBtnProps,
    showImgs = true,
    fileValidateTip,
    value,
    fileValidate,
    onChange,
    remove,
    onUploaded,
    ...restProps
  } = props || {}

  console.log(value, onChange)

  const accepts = accept ?? ACCEPTIMG // 接收类型
  const limit = size ?? 5 // 限制大小
  const [spin, setSpin] = useState<boolean>(false) // 上传中
  const [imgs, setImgs] = useControllableValue(props, {
    defaultValue: multi ? [] : '',
  }) // 已上传的图片列表

  // 上传的回调
  const handleChange = (info: any) => {
    if (info.file.status === 'uploading') {
      setSpin(true)
    }
    if (info.file.status === 'done' && info.file?.response?.msg === 'success') {
      setSpin(false)
      const result = info.file?.response?.result[0]
      if (!!result && !result?.endsWith('.bin')) {
        message.success(successText)

        // 多张图片
        if (multi) {
          setImgs((pre: any) => {
            if ((pre || []).length < count) {
              return [...(pre || []), result]
            }
            return pre
          })
        } else {
          // 单张图片
          setImgs(result)
        }

        // 上传成功的回调
        onUploaded?.(result, info.file)
      } else {
        message.error(failedText)
      }
    }
    if (info.file.status === 'done' && info.file?.response?.msg !== 'success') {
      setSpin(false)
      message.error(info.file?.response?.msg || failedText)
    }
    if (info.file.status === 'error') {
      setSpin(false)
      message.error(failedText)
    }
  }

  // 获取上传图片的原始宽高
  const getImgWidthHeight = (
    file: File,
  ): Promise<{ width: number; height: number }> => {
    return new Promise((resolve) => {
      const img = new Image()
      img.crossOrigin = 'anonymous' // 跨域
      img.src = URL.createObjectURL(file)
      img.onload = function () {
        resolve({ width: img.width, height: img.height })
      }
      img.onerror = function () {
        resolve({ width: 0, height: 0 })
      }
    })
  }

  // 上传之前的回调
  const beforeUpload = async (file: File) => {
    if (validateSize) {
      const widthHeight = await getImgWidthHeight(file)
      const { width, height } = widthHeight
      if (width !== limitWidth || height !== limitHeight) {
        message.warning(`图片的大小应该为${limitWidth} * ${limitHeight}`)
        return false
      }
    }
    if (validate) {
      const file_typename = file.name.substring(file.name.lastIndexOf('.'))
      const isRightfile = accepts.includes(file_typename?.toLowerCase())
      // 检验格式
      if (!isRightfile) {
        message.warning(errorAcceptTip || `请上传${accepts}格式的图片`)
      }
      const isLt = file.size / 1024 / 1024 <= limit
      if (!isLt) {
        message.warning(plusSizeTip || `图片大小不超过${limit}M`)
      }

      // 自定义文件校验
      if (fileValidate) {
        const pass = await fileValidate(file)
        if (pass === false) {
          if (fileValidateTip) {
            message.warning(fileValidateTip)
          }
          return false
        }
      }

      // 如果要压缩
      if (isRightfile && isLt && compress) {
        return compressPic(file, quality)
      }
      return isRightfile && isLt
    }
    return true
  }

  // 上传参数
  const uploadProps: UploadProps = {
    showUploadList: false,
    action: `${import.meta.env.VITE_UPLOAD_BASE_URL}/admin/file/upload`,
    accept: accepts,
    disabled: !!spin || disabled,
    multiple: true,
    onChange: handleChange,
    beforeUpload,
  }

  // 移除图片
  const removeImg = (url: string) => {
    if (multi) {
      setImgs((pre: any) => {
        const newImgs = (pre || []).filter((p: string) => p !== url)
        return newImgs
      })
    } else {
      setImgs('')
    }

    // 移除图片的回调
    remove?.(url)
  }

  return (
    <Spin spinning={!!spin}>
      <div className={styles.upload}>
        {showImgs && imgs ? (
          <div className={styles.imgs}>
            {((multi ? imgs : [imgs]) as string[])?.map((url) => (
              <div key={url} className={styles.imgItem}>
                <IM
                  src={`${import.meta.env.VITE_ASSET_BASE_URL}/${url}`}
                  width={width}
                  height={height}
                  style={imgsStyles}
                  preview={preview}
                />
                <CloseOutlined
                  onClick={() => {
                    if (disabled) {
                      return
                    }
                    removeImg(url)
                  }}
                />
              </div>
            ))}
          </div>
        ) : null}
        {(!multi && !imgs) || !imgs || imgs?.length < count ? (
          <Upload
            disabled={disabled}
            {...uploadProps}
            {...restProps}
            style={uploadStyles}
          >
            {spin ? (
              <LoadingOutlined />
            ) : (
              uploadBtn || (
                <Button type="primary" {...uploadBtnProps}>
                  {uploadText || '请选择上传图片'}
                </Button>
              )
            )}
          </Upload>
        ) : null}
      </div>
      {tips ? (
        <div className="pt-2" style={tipStyle}>
          {tips}
        </div>
      ) : null}
    </Spin>
  )
}

export default ImgsUpload

使用useControllableValue来封装同时支持受控和非受控组件,非常的快捷方便,强烈推荐

useControllableValue源码

function useControllableValue<T = any>(
  props: StandardProps<T>,
): [T, (v: SetStateAction<T>) => void];
function useControllableValue<T = any>(
  props?: Props,
  options?: Options<T>,
): [T, (v: SetStateAction<T>, ...args: any[]) => void];
function useControllableValue<T = any>(props: Props = {}, options: Options<T> = {}) {
  const {
    defaultValue, // 默认值,会被 props.defaultValue 和 props.value 覆盖
    defaultValuePropName = 'defaultValue', // 默认值的属性名
    valuePropName = 'value', // 值的属性名
    trigger = 'onChange', // 修改值时,触发的函数
  } = options;
  // 外部(父级)传递进来的 props 值
  const value = props[valuePropName] as T;
  // 是否受控:判断 valuePropName(默认即表示value属性),有该属性代表受控
  const isControlled = props.hasOwnProperty(valuePropName);

  // 首次默认值
  const initialValue = useMemo(() => {
    // 受控:则由外部的props接管控制 state
    if (isControlled) {
      return value;
    }
    // 外部有传递 defaultValue,则优先取外部的默认值
    if (props.hasOwnProperty(defaultValuePropName)) {
      return props[defaultValuePropName];
    }
    // 优先级最低,组件内部的默认值
    return defaultValue;
  }, []);

  const stateRef = useRef(initialValue);
  // 受控组件:如果 props 有 value 字段,则由父级接管控制 state
  if (isControlled) {
    stateRef.current = value;
  }

  // update:调用该函数会强制组件重新渲染
  const update = useUpdate();

  function setState(v: SetStateAction<T>, ...args: any[]) {
    const r = isFunction(v) ? v(stateRef.current) : v;

    // 非受控
    if (!isControlled) {
      stateRef.current = r;
      update(); // 更新状态
    }
    // 只要 props 中有 onChange(trigger 默认值未 onChange)字段,则在 state 变化时,就会触发 onChange 函数
    if (props[trigger]) {
      props[trigger](r, ...args);
    }
  }

  // 返回 [状态值, 修改 state 的函数]
  return [stateRef.current, useMemoizedFn(setState)] as const;
}

总结

以上就是对于受控和非受控的总结,文章中部分代码可能有错误之处,还望指正,不喜勿喷哦

# 🌟 JavaScript原型与原型链终极指南:从Function到Object的完整闭环解析 ,深入理解JavaScript原型系统核心

作者 AY1024
2025年12月12日 16:55

深入理解JavaScript原型系统核心

📖 目录


🎯 核心概念

四大基本原则

  1. 原则一:每个对象都有构造函数(constructor)

    • 指向构建该对象或实例的函数
  2. 原则二:只有函数对象才有prototype属性

    • 非函数对象没有prototype属性
    • 实例只有__proto__属性
    • 两者指向同一个对象(函数的原型对象)
  3. 原则三:Function函数是所有函数的构造函数

    • 包括它自己
    • 代码中声明的所有函数都是Function的实例
  4. 原则四:Object也是函数

    • 所以Object也是Function函数的实例

实例,函数,对象,原型对象,构造函数,关系总览图

image.png

🔍 非函数对象分类

  • 实例对象,const person = new Foo(),person就是实例对象
  • 普通对象({}new Object()
  • 内置非函数对象实例

🔄 显式原型与隐式原型

对象分类

  • 函数对象:拥有prototype属性
  • 非函数对象:只有__proto__属性

相同点

  • 都指向同一个原型对象

📝 示例代码

function Person(){}
const person = new Person();

console.log("Person.prototype指向:", Person.prototype)
console.log("person.__proto__指向", person.__proto__)

🖼️ 执行结果

显式原型

隐式原型


🎯 构造函数的指向

默认情况

function Person(){}
const person = new Person();

console.log("Person.prototype.constructor指向", Person.prototype.constructor)
// 输出:[Function: Person]

执行结果

默认构造函数指向

默认构造函数指向详情


修改原型对象后

function Person(){}
const person = new Person();

Person.prototype = new foo();  // 修改原型对象

console.log("Person.prototype.constructor指向", Person.prototype.constructor)
// 输出:[Function: foo]

执行结果

修改后构造函数指向

修改后构造函数指向详情


📊 核心原理说明

解释

Person.prototype被当作函数foo的实例,继承了foo函数(此篇不展开继承详解)

总结规律

  • 每个原型对象或实例都有.constructor属性
  • 实例通过原型链查找constructor
  • 原型对象默认指向自身的函数(如果不是其他函数的实例)

查找过程示例

// Person.prototype被当作实例时
Person.prototype.__proto__ → foo.prototypefoo()

🖼️ 可视化关系图

三者关系图

原型关系图


🔬 代码验证

function Person(){}

// 创建新的原型对象
Person.prototype = {
    name: "杨",
    age: "18",
    histype: "sleep"
}

// 添加方法
Person.prototype.print = function(){
    console.log("你好我是原型对象");
}

// 创建实例
const person01 = new Person();
const person02 = new Person();

// 验证指向
console.log("Person.prototype指向:", Person.prototype)
console.log("person01.__proto__指向", person01.__proto__)
console.log("person02.__proto__指向", person02.__proto__)
console.log("Person.prototype.constructor指向", Person.prototype.constructor)

执行结果

代码验证结果


⚠️ 特别说明

关键细节

创建新对象时,Person.prototype.constructor指向Object,因为Person.prototype成了Object的实例。

对比情况

  • 创建新对象时Person.prototype.constructorObject
  • 未创建新对象时Person.prototype.constructorPerson

示意图

构造函数指向对比

构造函数指向对比详情


Function和Object

小故事

从前有个力大无穷的大力神,能举起任何东西,有一天,小A在路上和这个大力神相遇了。

大力神:小子,我可是力大无穷的大力神,我能举起任何东西,你信不信?

小A:呦呦呦,还大力神,你说你能举起任何东西,那你能把你自己抬起来吗?

...

  • Function是所有函数的加工厂,你在代码声明的所有函数都是Function的实例,包括Function函数本身,Object也是函数,所以它也是Functiod的实例

  • Function就是这样的大力神,而且是可以把自己抬起来的大力神,这听起来比较扯,但是这就是事实,请看VCR:

function Person (){}

const person01 = new Person();

console.log("Function.__proto__指向",Function.__proto__)//Function.__proto__指向 [Function (anonymous)] Object
console.log("Function.prototype指向",Function.prototype)//Function.prototype指向 [Function (anonymous)] Object
console.log("Function.__proto__ == Function.prototype???",Function.__proto__ == Function.prototype)
//Function.__proto__ == Function.prototype??? true

image.png

image.png

Object 在 JavaScript 中扮演三重角色:

  • 构造函数:用于创建对象

  • 命名空间:提供一系列静态方法用于对象操作

  • 原型终点:Object.prototype 是所有原型链的终点,在往上没有了,值==null

请看VCR:

function Person (){};

const persoon01 = new Person();
const obj = {};//通过对象字面量{}创建obj实例
const obj1 = new Object();//通过构造函数new Object()创建obj1实例
const obj2 = Object.create(Object.prototype);//通过委托创建,或者叫原型创建,来创建obj2实例

console.log("Person.prototype.__proto__指向",Person.prototype.__proto__);
//Person.prototype.__proto__指向 [Object: null prototype] {}

console.log("Function.prototype.__proto__指向",Function.prototype.__proto__)
//Function.prototype.__proto__指向 [Object: null prototype] {}

console.log("通过对象字面量{}创建的obj实例,obj.__proto__指向",obj.__proto__);
//通过对象字面量{}创建的obj实例,obj.__proto__指向 [Object: null prototype] {}

console.log("通过构造函数new Object()创建obj1实例,指向",obj1.__proto__);
//通过构造函数new Object()创建obj1实例,指向 [Object: null prototype] {}

console.log("通过委托创建,或者叫原型创建,来创建obj2实例,指向",obj2.__proto__);
//通过委托创建,或者叫原型创建,来创建obj2实例,指向 [Object: null prototype] {}

image.png

image.png

Function和Object的关系

  • 相互依赖的循环引用
    • Object 是 Function 的实例(构造函数层面)

    • Function 是 Object 的子类(原型继承层面)

    • 这是 JavaScript 的自举(Bootstrap)机制

根据关系总览图,我们可以看到,Function和Object,它们两形成了一个闭环,将所有的函数和对象都包裹在这个闭环里

📋 JavaScript 原型系统核心概念表

概念 描述 示例 特殊说明
prototype 函数特有,指向原型对象 Person.prototype 只有函数对象才有此属性
proto 所有对象都有,指向构造函数的原型 person.__proto__ 实际应使用 Object.getPrototypeOf()
constructor 指向创建该对象的构造函数 Person.prototype.constructor 可被修改,查找时沿原型链进行
原型链查找 通过 __proto__ 逐级向上查找 person.__proto__.__proto__ 终点为 null
Function 所有函数的构造函数 Function.prototype Function.__proto__ === Function.prototype
Object 所有对象的基类 Object.prototype 原型链终点,Object.prototype.__proto__ === null

🔍 补充说明

prototype 补充

  • 函数的 prototype 属性默认包含 constructor 属性指向函数自身
  • 用于实现基于原型的继承

proto 补充

  • 现在更推荐使用 Object.getPrototypeOf(obj)Object.setPrototypeOf(obj, proto)
  • __proto__ 是访问器属性,不是数据属性

constructor 补充

  • constructor 属性可以通过原型链查找
  • 示例:person.constructor === Person(实际查找的是 person.__proto__.constructor

原型链查找补充

  • 当访问对象属性时,如果对象自身没有,会沿着原型链向上查找
  • 直到找到该属性或到达原型链终点 null

Function 补充

  • 是所有函数的构造函数,包括内置构造函数(Object、Array等)和自定义函数
  • 自身也是函数,所以 Function.__proto__ === Function.prototype

Object 补充

  • Object.prototype 是所有原型链的最终原型对象
  • 通过 Object.create(null) 可以创建没有原型的"纯净对象"

💡 记忆口诀

  • 函数看prototype,实例看__proto__
  • constructor找根源,原型链上寻答案
  • Object是终点,Function是关键

结语:

看完这篇文章,你应该可以读懂上面的关系总览图了,望学习愉快!!!

制作一个简单的HTML个人网页

2025年12月12日 16:54

下面给你一个超级简单但又好看、手机电脑都能完美访问的个人主页模板,5 分钟就能改成你自己的专属网页!

功能特点

  • 纯 HTML + CSS(一行 JavaScript 都不用)
  • 自适应移动端(手机看起来也好看)
  • 深色/浅色自动切换(跟随系统)
  • 一键替换头像、姓名、简介、社交链接就完事
  • 支持添加博客、项目、作品、摄影等模块

完整代码(直接复制保存为 index.html 双击打开即可)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>张三的个人主页</title>
    <style>
        :root {
            --bg: #f8f9fa;
            --text: #2c3e50;
            --card: #ffffff;
            --accent: #ff6b6b;   /* 主色调,喜欢可换成 #e91e63、#4ecdc4 等 */
        }
        @media (prefers-color-scheme: dark) {
            :root {
                --bg: #121212;
                --text: #e0e0e0;
                --card: #1e1e1e;
            }
        }

        * { margin:0; padding:0; box-sizing:border-box; }
        body {
            font-family: "Segoe UI", Arial, sans-serif;
            background: var(--bg);
            color: var(--text);
            line-height: 1.6;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }
        .card {
            background: var(--card);
            max-width: 420px;
            width: 100%;
            border-radius: 20px;
            overflow: hidden;
            box-shadow: 0 15px 35px rgba(0,0,0,0.1);
            text-align: center;
            transition: transform 0.3s;
        }
        .card:hover { transform: translateY(-10px); }

        .avatar {
            width: 120px;
            height: 120px;
            border-radius: 50%;
            margin: 30px auto 10px;
            object-fit: cover;
            border: 5px solid var(--accent);
        }
        h1 {
            font-size: 28px;
            margin: 10px 0;
            color: var(--accent);
        }
        .tagline {
            color: #888;
            font-size: 16px;
            margin-bottom: 20px;
        }
        .bio {
            padding: 0 30px;
            margin-bottom: 25px;
            font-size: 15px;
        }
        .links {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 12px;
            padding: 0 20px 30px;
        }
        .links a {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 10px 20px;
            background: var(--accent);
            color: white;
            text-decoration: none;
            border-radius: 50px;
            font-size: 14px;
            transition: all 0.3s;
        }
        .links a:hover {
            transform: scale(1.08);
            box-shadow: 0 5px 15px rgba(255,107,107,0.4);
        }
        .links a i { font-size: 18px; }

        footer {
            margin-top: 40px;
            font-size: 14px;
            color: #aaa;
        }
    </style>
    <!-- 图标库(可选) -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>

<div class="card">
    <!-- 1. 替换成你的头像(放同目录下或用网络链接) -->
    <img src="https://avatars.githubusercontent.com/u/你的githubid?v=4" alt="头像" class="avatar">
    <!-- 或者本地图片:src="myphoto.jpg" -->

    <h1>张三</h1>
    <p class="tagline">前端开发者 / 摄影爱好者 / 正在努力变厉害的人</p>

    <div class="bio">
        Hi~我是张三,目前在广州做前端,喜欢写代码、拍风景、撸猫。<br>
        生活很普通,但希望每天都能进步一点点
    </div>

    <div class="links">
        <!-- 直接改链接和图标就行 -->
        <a href="https://github.com/yourname" target="_blank">
            <i class="fab fa-github"></i> GitHub
        </a>
        <a href="https://weibo.com/yourname" target="_blank">
            <i class="fab fa-weibo"></i> 微博
        </a>
        <a href="https://space.bilibili.com/123456" target="_blank">
            <i class="fab fa-bilibili"></i> B站
        </a>
        <a href="mailto:your@email.com">
            <i class="fas fa-envelope"></i> 邮件
        </a>
        <a href="https://yourblog.com" target="_blank">
            <i class="fas fa-blog"></i> 博客
        </a>
        <a href="https://juejin.cn/user/你的id" target="_blank">
            <i class="fab fa-juejin"></i> 掘金
        </a>
    </div>
</div>

<footer>© 2025 张三 | 手工码的个人主页</footer>

</body>
</html>

只需要改 4 处就完全是你自己的网页了!

  1. 头像:把 src="https://..." 换成你自己的照片链接(推荐放同目录下改成 src="avatar.jpg"
  2. 名字:改 <h1>张三</h1>
  3. 一句话介绍:改 .tagline 那行
  4. 自我介绍 + 社交链接:按需增删即可

想更炫酷?再加这几行(任选)

  • 背景粒子特效(加在 <body> 前面):
  <script src="https://cdn.jsdelivr.net/npm/particles.js@2.0.0/particles.min.js"></script>
  <div id="particles-js"></div>
  <script>
  particlesJS("particles-js", {"particles":{"number":{"value":80},"color":{"value":"#ff6b6b"},"shape":{"type":"circle"},"opacity":{"value":0.5},"size":{"value":3},"move":{"speed":1}}});
  </script>
  <style>#particles-js{position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;}</style>
  • 音乐自动播放(七夕专用):
  <audio src="your-music.mp3" autoplay loop hidden></audio>

5 分钟搞定一个高颜值个人主页,赶紧发给朋友/对象/面试官吧~
需要再加「作品集」「时间轴」「留言板」等功能,随时告诉我,我继续给你升级!

HTML标签 - 表格标签

作者 GinoWi
2025年12月12日 16:46

HTML标签 - 表格标签

在过去网站开发过程中,表格标签的使用是非常非常多,绝大多数的网站都是使用表格标签来制作的,也就是说表格标签是一个时代的代表。

什么是表格标签?

表格标签的作用是用来给一堆数据添加表格语义,其实表格是一种数据的展现形式,当数据量非常大的时候,表格这种展现形式被认为是最为清晰的一种展现形式。

表格结构

由于表格中存储的数据比较复杂,为了方便管理、阅读以及提升语义,我们可以对表格中存储的数据进行分类。

  • 表格中存储的数据可以分为四类:

    1. 表格标题
    2. 表格表头
    3. 表格主体
    4. 表格的页尾信息
  • 表格完整结构

<table>
  <caption>表格的标题</caption>
  <thead>
    <tr>
      <th>每一列的标题</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>数据</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <td>数据</td>
    </tr>
  </tfoot>
</table>

表格标签

  • table标签

    • 作用:表格标签中的table代表整个表格,也就是一对table标签就是一个表格。
  • tr标签

    • 作用:表格标签的tr代表表格中的一行数据,也就是说一对tr标签就是表格中的一行。
  • td标签

    • 作用:表格标签中的td标签代表表格中的一个单元格,也就是说一对td标签就是表格中的一个单元格。
  • caption标签

    • 作用:在表格标签中提供了一个标签专门用来设置表格的标题,这个标签叫做caption。只要将标题写在caption标签中,那么标题就会自动相对于表格的宽度居中。
    • 注意点:
      • caption标签一定要写在table标签中,否则无效。
      • caption一定要紧跟在table标签后面。
  • th标签

    • 作用:在表格标签中提供了一个标签专门用来展示每一列的标题,这个标签叫做th标签,只要使用th标签展现当前列的标题,这时标题就会在该标签单元格中自动居中。
  • thead标签

    • 作用:指定表格表头信息。
  • tbody标签

    • 作用:指定表格主体信息。
    • 注意点:如果我们没有编写tbody,系统会自动给我门添加tbody
  • tfoot标签

    • 作用:指定表格附加信息。
    • 注意点:如果指定了theadtfoot,那么在修改整个表格的高度时,theadtfoot有自己的默认高度,不会随着表格的高度变化而变化。

总结:

  • 表格标签有一个边框属性,这个属性决定了边框的宽度,默认情况下这个属性的值是0,所以看不到边框。

  • 表格标签和列表标签一样,它是一个组合标签,所以table/tr/td要么一起出现,要么一起不出现,不会单独出现。

  • 表格中有两种单元格,一种是td,一种是thtd是专门用来存储数据的,th是专门用来存储当前列的标题的。

表格标签的属性(这部分内容仅为了解,以后均通过CSS来进行修改):

  • 宽度和高度的属性(可以给table标签和td标签使用)

    • 表格的宽度和高度默认按照内容尺寸调整,也可以通过给table标签设置width/height属性的方式来手动指定表格宽高。
    • 如果给td标签设置width/height属性,会修改当前单元格的宽度(会同时影响当前列单元格宽度)和高度(会同时影响当前行单元格高度),不会影响整个表格的宽度和高度。
      • 当给一行中不同单元格设置不同的height属性,保留一行中高度最高的属性值作为该行单元格的高度。
      • 当给一列中不同单元格设置不同的width属性,保留一行中宽度最宽的属性值作为该列单元格的宽度。
  • 水平对齐和垂直对齐的属性(其中水平对齐可以给table标签和tr标签和td标签使用,垂直对齐只能给tr标签和td标签使用)

    • table标签设置align属性,可以控制表格在水平方向的对齐方式。
    • tr标签设置align属性,可以控制当前行中所有单元格内容的水平方向对齐方式。
    • td标签设置align属性,可以控制当前单元格中内容在水平方向的对齐方式(如果td标签中设置了align属性,tr中也设置了align属性,那么单元格中的内容会按照td中设置的来对齐)。
    • tr标签设置valign属性,可以控制当前行中所有单元格内容的垂直方向对齐方式。
    • td标签设置valign属性,可以控制当前单元格中内容在垂直方向对齐方式。(如果td标签中设置了valign属性,tr中也设置了valign属性,那么单元格中的内容会按照td中设置的来对齐。
  • 外边距和内边距的属性(只能给table标签使用)

    • 外边距(cellspacing)就是单元格和单元格之间的距离(默认情况下单元格与单元格的外边距是2px)
    • 内边距(cellpadding)就是文字距离单元格之间的距离(默认情况下内边距是1px)
  • 通过属性设置完成细线表格的绘制:

    • 在表格标签中想通过指定外边距为0来实现细线表格是不靠谱的,其实它是将2条合并为了一条线,所以看上去很不舒服。 通过设置外边距实现的表格

    • 细线表格的制作方式:(table标签、tr标签以及td标签都支持bgcolor属性,但是样式以后通过css完成控制。)

      1. table标签设置bgcolor属性
      2. tr标签设置bgcolor属性
      3. table标签设置cellspacing="1px"
    • 代码:

    <table bgcolor="black" cellspacing="1px" width="500px" height="300px">
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
    </table>
    
    • 效果展示: 通过设置背景颜色实现的表格

      通过放大比较上述两张表格图片,就能够很明显的看出表格边框的差别。

单元格合并

  • 水平方向上的单元格合并

    • 可以给td标签添加colspan属性,把某一个单元格当作多个单元格来看待(水平)。
    • 格式:<td colspan="2"></td>含义:把当前单元格当作两个单元格来看待。
    • 注意点:
      • 由于把某一个单元格当作多个单元格来看待,所以就会多出一些单元格,需要删掉一些单元格确保表格正常显示。
      • 单元格合并永远都是向后或者向下合并,而不能向前或者向上合并。
  • 垂直方向上的单元格合并

    • 可以给td标签添加rowspan属性,把某一个单元格当作多个单元格来看待(垂直)。
    • 格式:<td rowspan="2"></td>含义:把当前单元格当作两个单元格来看待。
    • 注意点:
      • 由于把某一个单元格当作多个单元格来看待,所以就会多出一些单元格,需要删掉一些单元格确保表格正常显示。
      • 单元格合并永远都是向后或者向下合并,而不能向前或者向上合并。

参考链接:

W3School官方文档:www.w3school.com.cn

护航隐私!小程序纯前端“证件加水印”:OffscreenCanvas 全屏平铺实战

作者 小皮虾
2025年12月12日 16:34

1. 背景与痛点:证件“裸奔”的风险

在日常生活中,我们经常需要上传身份证、驾照或房产证照片来办理各种业务。然而,直接发送原图存在巨大的安全隐患:

  • 被二次盗用:不法分子可能将你的证件照用于网贷、注册账号等非法用途。
  • 服务器隐私泄露:如果使用在线工具加水印,图片必须上传到第三方服务器,这就好比“把钥匙交给陌生人保管”,风险不可控。

为了解决这一痛点,可利用小程序的 OffscreenCanvas 能力,在用户手机本地毫秒级合成水印,图片数据永远不会离开用户手机

2. 核心思路:离屏渲染 + 矩阵平铺

实现全屏倾斜水印,主要难点在于坐标计算性能平衡。我们的方案如下:

  1. 离屏渲染 (OffscreenCanvas): 使用离屏画布在内存中处理,避免页面闪烁,且支持高性能的 2D 渲染模式。
  2. 智能 DPR 降级: 沿用我们之前文章提到的防爆内存策略。证件照通常分辨率很高,必须计算安全尺寸,防止 Canvas 内存溢出闪退。
  3. 矩阵平铺算法: 不简单的旋转画布,而是采用 “保存环境 -> 平移 -> 旋转 -> 绘制 -> 恢复环境” 的策略,在一个网格循环中将文字铺满全屏,确保无论图片比例如何,水印都能均匀分布。

3. 硬核代码实现

以下是封装好的 watermarkUtils.js。包含了智能 DPR 计算全屏水印绘制的核心逻辑。

// utils/watermarkUtils.js

// 1. 获取系统基础信息
const wxt = {
  dpr: wx.getSystemInfoSync().pixelRatio || 2
};

// 图片缓存,避免重复加载
const cacheCanvasImageMap = new Map();

/**
 * 内部方法:获取/创建 Canvas Image 对象
 */
async function getCanvasImage(canvas, imageUrl) {
  if (cacheCanvasImageMap.has(imageUrl)) return cacheCanvasImageMap.get(imageUrl);
  
  // 兼容性处理:若不支持 Promise.withResolvers,请改用 new Promise
  const { promise, resolve, reject } = Promise.withResolvers();
  const image = canvas.createImage();
  image.onload = () => {
    cacheCanvasImageMap.set(imageUrl, image);
    resolve(image);
  };
  image.onerror = (e) => reject(new Error(`加载失败: ${e.errMsg}`));
  image.src = imageUrl;
  await promise;
  return image;
}

/**
 * 给图片添加全屏倾斜水印
 * @param {string} imageUrl 图片路径
 * @param {string} text 水印文字,如 "仅供办理租房业务使用"
 * @param {object} options 配置项 { color, size, opacity }
 */
export async function addWatermark(imageUrl, text = '仅供办理业务使用', options = {}) {
  // 默认配置
  const config = {
    color: '#aaaaaa',
    opacity: 0.5,
    fontSize: 0, // 0 表示自动计算
    gap: 100,    // 水印间距
    ...options
  };

  const offscreenCanvas = wx.createOffscreenCanvas({ type: '2d' });
  const image = await getCanvasImage(offscreenCanvas, imageUrl);
  const { width, height } = image;

  // --- ⚡️ 性能优化:智能 DPR 计算 (防止大图闪退) ---
  const LIMIT_SIZE = 4096; 
  let useDpr = wxt.dpr;
  if (Math.max(width, height) * useDpr > LIMIT_SIZE) {
    useDpr = LIMIT_SIZE / Math.max(width, height);
  }

  // 设置画布尺寸
  offscreenCanvas.width = width * useDpr;
  offscreenCanvas.height = height * useDpr;

  const ctx = offscreenCanvas.getContext('2d');
  ctx.scale(useDpr, useDpr);
  
  // 1. 绘制底图
  ctx.drawImage(image, 0, 0, width, height);

  // 2. 配置水印样式
  // 自动计算字号:约为图片宽度的 4%
  const fontSize = config.fontSize || Math.floor(width * 0.04); 
  ctx.font = `bold ${fontSize}px sans-serif`;
  ctx.fillStyle = config.color;
  ctx.globalAlpha = config.opacity;
  ctx.textBaseline = 'middle';
  ctx.textAlign = 'center';

  // 3. 计算平铺逻辑
  // 旋转 45 度后,覆盖范围需要比原图大,这里简单取对角线长度作为边界
  const maxSize = Math.sqrt(width * width + height * height);
  // 步长 = 文字宽度 + 间距
  const step = ctx.measureText(text).width + config.gap; 
  
  // 4. 循环绘制水印
  // 从负坐标开始绘制,确保旋转后边缘也有水印
  for (let x = -maxSize; x < maxSize; x += step) {
    for (let y = -maxSize; y < maxSize; y += step) {
      ctx.save();
      
      // 核心变换:平移到网格点 -> 旋转 -> 绘制
      ctx.translate(x, y);
      ctx.rotate(-45 * Math.PI / 180); // 逆时针旋转 45 度
      ctx.fillText(text, 0, 0);
      
      ctx.restore();
    }
  }

  // 5. 导出图片
  const res = await wx.canvasToTempFilePath({
    canvas: offscreenCanvas,
    fileType: 'jpg',
    quality: 0.8, // 稍微压缩以减小体积
  });

  return res.tempFilePath;
}

4. 业务调用示例

在小程序页面中,用户选择图片并输入水印文字后,实时预览效果。

// pages/watermark/index.js
import { addWatermark } from '../../utils/watermarkUtils';

Page({
  data: {
    originImg: '',
    resultImg: '',
    watermarkText: '仅供本次业务使用 他用无效'
  },

  async onAddWatermark() {
    if (!this.data.originImg) return;

    wx.showLoading({ title: '安全合成中...' });
    
    try {
      const tempFilePath = await addWatermark(
        this.data.originImg, 
        this.data.watermarkText,
        {
          color: '#ffffff', // 白色水印
          opacity: 0.4,     // 半透明
          gap: 120          // 间距疏松一点
        }
      );
      
      this.setData({ resultImg: tempFilePath });
      
    } catch (err) {
      console.error(err);
      wx.showToast({ title: '合成失败', icon: 'none' });
    } finally {
      wx.hideLoading();
    }
  }
})

5. 避坑与实战经验

  1. 自动字号的重要性: 不要写死 fontSize = 20px。用户上传的图片分辨率差异极大(有的 500px 宽,有的 4000px 宽)。最佳实践是根据图片宽度动态计算字号(如 width * 0.04),这样无论处理缩略图还是 4K 原图,水印比例看起来都是协调的。
  2. 平铺范围的陷阱: 因为文字需要旋转 45 度,如果循环只从 0width,图片的左下角和右上角可能会出现空白。代码中我们从 -maxSize(负数区域)开始循环,确保旋转后的文字能完全覆盖画布的每一个角落。
  3. 隐私第一: 在工具的 UI 界面上,建议显著提示 “纯本地处理,无上传服务器”,这能极大地增加用户的信任感,提升工具的使用率。

写在最后

通过帮小忙工具箱的这个实践案例,我们可以看到,利用小程序强大的 Canvas 能力,开发者完全可以在保护用户隐私的前提下,提供专业级的图片处理服务。

技术不只是代码,更是对用户安全的守护。 希望这篇分享能帮你在小程序中实现更安全、更高效的功能!

无废话之 useState、useRef、useReducer 的使用场景与选择指南

2025年12月12日 16:28

在 React 中,最常用的状态管理 Hook 有三个:

  • useState
  • useRef
  • useReducer

它们都能“存数据”,但作用完全不同。
本文通过对比、代码示例和最佳实践,让你一眼看懂三者的异同点与使用策略。

1. 三者一句话总结(记住这个就够了)

Hook 特点 什么时候用
useState 会触发组件重新渲染 UI 需要根据数据变化而更新
useRef 不会触发渲染,可持久存储 保存 DOM、保存不影响 UI 的数据、避免频繁渲染
useReducer 适合复杂状态逻辑,集中管理 多步骤状态、复杂更新规则、类似 Vuex/Redux

2. useState —— 最常用的 UI 状态管理方式

📌 用途

  • 管理与 UI 显示相关的状态
  • 一旦更新 → React 会重新渲染组件

📌 示例:计数器

const [count, setCount] = useState(0);

return (
  <button onClick={() => setCount(count + 1)}>
    {count}
  </button>
);

👉 每次 setCount 运行,UI 都会更新。

3. useRef —— 不触发渲染的“可变容器”

📌 用途

  • 保存不会影响 UI 的值(计时器、缓存、临时变量)
  • 保存 DOM 节点引用
  • 在渲染周期之间持久化数据

📌 示例:保存一个不会影响 UI 的计数器

const counterRef = useRef(0);

function add() {
  counterRef.current += 1;
  console.log(counterRef.current);
}

return <button onClick={add}>Add</button>;

👉 按多少次,UI 都不会变化,因为它 不会触发重渲染

4. useReducer —— 多分支、复杂逻辑的状态管理

📌 用途

适合以下情况:

  • 状态结构复杂(多字段)
  • 更新逻辑复杂(多 if/else 或 switch)
  • 想将逻辑分离,让代码更清晰

📌 示例:管理一个表单对象

function reducer(state, action) {
  switch (action.type) {
    case "setName":
      return { ...state, name: action.payload };
    case "setAge":
      return { ...state, age: action.payload };
    case "reset":
      return { name: "", age: 0 };
    default:
      return state;
  }
}

const [form, dispatch] = useReducer(reducer, {
  name: "",
  age: 0,
});

👉 适合“动作驱动”的状态结构。

5. 三者的核心区别(最关键)

对比点 useState useRef useReducer
是否触发渲染 ✔ 会 ❌ 不会 ✔ 会
存储数据类型 简单/基本 任意 复杂对象
逻辑复杂度 简单 简单 中-高
适合多字段状态 不太合适 不合适 ✔ 最合适
跨 render 保留值
管理 DOM
适合封装业务逻辑 一般 ✔ 非常好

6. 如何选择?(最实用的决策树)

🟦 1)数据是否影响 UI?

  • 是 → useState 或 useReducer
  • 否 → useRef

🟩 2)数据更新逻辑是否复杂?

  • 复杂(多字段、多动作)→ useReducer
  • 简单(一个值)→ useState

🟨 3)更新是否非常频繁?(例如输入法、mousemove)

  • 是,但 UI 不依赖 → useRef
  • 是,且 UI 要更新 → useState + 性能优化

🟧 4)是否需要类似 Redux 的写法?

  • 是 → useReducer
  • 否 → useState / useRef

7. 最容易犯的错误(务必注意)

把 useRef 当 useState 用

const count = useRef(0);
count.current++; // UI 不更新!

👉 你以为 UI 会变,但不会。

用 useState 处理频繁更新、但 UI 不需要的数据

例如 storing mousemove 坐标:

  • 会造成大量 re-render,卡顿
  • 推荐用 useRef

在 useReducer 中修改 state(不可变规则)

错误 ❌:

state.age = 10;
return state;

正确 ✔:

return { ...state, age: 10 };

8. 三者组合使用示例(真实项目中常见)

示例:表单组件

状态 用哪个? 为什么
表单字段 useReducer 多字段、动作复杂
表单提交 loading useState 简单布尔值
DOM 节点(input) useRef 保存 DOM
防抖计时器 useRef 不触发渲染

9. 小结:最佳实践

场景 推荐
UI 状态简单 useState
UI 状态复杂、多字段 useReducer
数据不会用于渲染 useRef
保存 DOM 节点 useRef
保存缓存、前一次值 useRef
避免频繁 re-render useRef
需要统一管理 action useReducer

结语

useState、useRef、useReducer 都可以存储数据,但它们在 React 渲染机制中的角色完全不同。

  • useState = UI 状态
  • useRef = 自定义缓存 / DOM
  • useReducer = 逻辑复杂的状态机

掌握好这三者的边界,就能写出结构清晰且性能优秀的 React 代码。

Vue 3 组件开发最佳实践:可复用组件设计模式

作者 微芒不朽
2025年12月12日 16:28

Vue 3 组件开发最佳实践:可复用组件设计模式

前言

组件化是现代前端开发的核心思想之一,而在 Vue 3 中,借助 Composition API 和更完善的响应式系统,我们能够设计出更加灵活、可复用的组件。本文将深入探讨 Vue 3 组件开发的最佳实践,介绍多种可复用组件的设计模式,帮助开发者构建高质量的组件库。

组件设计基本原则

1. 单一职责原则

每个组件应该只负责一个明确的功能,避免功能过于复杂。

2. 开放封闭原则

组件对扩展开放,对修改封闭,通过合理的接口设计支持定制化。

3. 可组合性

组件应该易于与其他组件组合使用,形成更复杂的 UI 结构。

基础组件设计模式

1. Props 透传模式

<!-- BaseButton.vue -->
<template>
  <button 
    :class="buttonClasses"
    v-bind="$attrs"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'danger', 'ghost'].includes(value)
  },
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  },
  block: {
    type: Boolean,
    default: false
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['click'])

const buttonClasses = computed(() => [
  'btn',
  `btn--${props.variant}`,
  `btn--${props.size}`,
  {
    'btn--block': props.block,
    'btn--disabled': props.disabled
  }
])

const handleClick = (event) => {
  if (!props.disabled) {
    emit('click', event)
  }
}

// 允许父组件访问子组件实例
defineExpose({
  focus: () => {
    // 实现焦点管理
  }
})
</script>

<style scoped>
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s ease;
  text-decoration: none;
}

.btn--primary {
  background-color: #42b883;
  color: white;
}

.btn--secondary {
  background-color: #6c757d;
  color: white;
}

.btn--danger {
  background-color: #dc3545;
  color: white;
}

.btn--ghost {
  background-color: transparent;
  color: #42b883;
  border: 1px solid #42b883;
}

.btn--small {
  padding: 4px 8px;
  font-size: 12px;
}

.btn--medium {
  padding: 8px 16px;
  font-size: 14px;
}

.btn--large {
  padding: 12px 24px;
  font-size: 16px;
}

.btn--block {
  display: flex;
  width: 100%;
}

.btn--disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn:hover:not(.btn--disabled) {
  opacity: 0.8;
  transform: translateY(-1px);
}
</style>

2. 插槽分发模式

<!-- Card.vue -->
<template>
  <div class="card" :class="cardClasses">
    <!-- 默认插槽 -->
    <div v-if="$slots.header || title" class="card__header">
      <slot name="header">
        <h3 class="card__title">{{ title }}</h3>
      </slot>
    </div>
  
    <!-- 内容插槽 -->
    <div class="card__body">
      <slot />
    </div>
  
    <!-- 底部插槽 -->
    <div v-if="$slots.footer" class="card__footer">
      <slot name="footer" />
    </div>
  
    <!-- 操作区域插槽 -->
    <div v-if="$slots.actions" class="card__actions">
      <slot name="actions" />
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  bordered: {
    type: Boolean,
    default: true
  },
  shadow: {
    type: Boolean,
    default: false
  },
  hoverable: {
    type: Boolean,
    default: false
  }
})

const cardClasses = computed(() => ({
  'card--bordered': props.bordered,
  'card--shadow': props.shadow,
  'card--hoverable': props.hoverable
}))
</script>

<style scoped>
.card {
  background: #fff;
  border-radius: 8px;
}

.card--bordered {
  border: 1px solid #e5e5e5;
}

.card--shadow {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.card--hoverable:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.card__header {
  padding: 16px 24px;
  border-bottom: 1px solid #f0f0f0;
}

.card__title {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  color: #333;
}

.card__body {
  padding: 24px;
}

.card__footer {
  padding: 16px 24px;
  border-top: 1px solid #f0f0f0;
}

.card__actions {
  padding: 16px 24px;
  text-align: right;
}
</style>

使用示例:

<template>
  <Card title="用户信息" bordered hoverable>
    <template #header>
      <div class="custom-header">
        <h3>用户详情</h3>
        <BaseButton size="small" variant="ghost">编辑</BaseButton>
      </div>
    </template>
  
    <p>这里是卡片内容</p>
  
    <template #footer>
      <div class="card-footer">
        <span>创建时间: 2023-01-01</span>
      </div>
    </template>
  
    <template #actions>
      <BaseButton variant="primary">保存</BaseButton>
      <BaseButton variant="ghost">取消</BaseButton>
    </template>
  </Card>
</template>

高级组件设计模式

1. Renderless 组件模式

Renderless 组件专注于逻辑处理,不包含任何模板,通过作用域插槽传递数据和方法:

<!-- FetchData.vue -->
<template>
  <slot 
    :loading="loading"
    :data="data"
    :error="error"
    :refetch="fetchData"
  />
</template>

<script setup>
import { ref, onMounted } from 'vue'

const props = defineProps({
  url: {
    type: String,
    required: true
  },
  immediate: {
    type: Boolean,
    default: true
  }
})

const loading = ref(false)
const data = ref(null)
const error = ref(null)

const fetchData = async () => {
  loading.value = true
  error.value = null

  try {
    const response = await fetch(props.url)
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    data.value = await response.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  if (props.immediate) {
    fetchData()
  }
})

defineExpose({
  fetchData
})
</script>

使用示例:

<template>
  <FetchData url="/api/users" v-slot="{ loading, data, error, refetch }">
    <div class="user-list">
      <div v-if="loading">加载中...</div>
      <div v-else-if="error">错误: {{ error }}</div>
    
      <template v-else>
        <div v-for="user in data" :key="user.id" class="user-item">
          {{ user.name }}
        </div>
      
        <button @click="refetch">刷新</button>
      </template>
    </div>
  </FetchData>
</template>

2. Compound Components 模式

复合组件模式允许相关组件协同工作,共享状态和配置:

<!-- Tabs.vue -->
<template>
  <div class="tabs">
    <div class="tabs__nav" role="tablist">
      <slot name="nav" :active-key="activeKey" :change-tab="changeTab" />
    </div>
    <div class="tabs__content">
      <slot :active-key="activeKey" />
    </div>
  </div>
</template>

<script setup>
import { ref, provide } from 'vue'

const props = defineProps({
  modelValue: {
    type: [String, Number],
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

const activeKey = ref(props.modelValue)

const changeTab = (key) => {
  activeKey.value = key
  emit('update:modelValue', key)
}

// 提供给子组件使用的上下文
provide('tabs-context', {
  activeKey,
  changeTab
})
</script>

<style scoped>
.tabs {
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  overflow: hidden;
}

.tabs__nav {
  display: flex;
  background-color: #f8f9fa;
  border-bottom: 1px solid #e5e5e5;
}

.tabs__content {
  padding: 24px;
}
</style>
<!-- TabNav.vue -->
<template>
  <div class="tab-nav">
    <slot />
  </div>
</template>

<style scoped>
.tab-nav {
  display: flex;
}
</style>
<!-- TabNavItem.vue -->
<template>
  <button
    :class="classes"
    :aria-selected="isActive"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

<script setup>
import { inject, computed } from 'vue'

const props = defineProps({
  tabKey: {
    type: [String, Number],
    required: true
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const tabsContext = inject('tabs-context')

const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)

const classes = computed(() => [
  'tab-nav-item',
  {
    'tab-nav-item--active': isActive.value,
    'tab-nav-item--disabled': props.disabled
  }
])

const handleClick = () => {
  if (!props.disabled) {
    tabsContext.changeTab(props.tabKey)
  }
}
</script>

<style scoped>
.tab-nav-item {
  padding: 12px 24px;
  border: none;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  color: #666;
  transition: all 0.2s ease;
}

.tab-nav-item:hover:not(.tab-nav-item--disabled) {
  color: #42b883;
  background-color: rgba(66, 184, 131, 0.1);
}

.tab-nav-item--active {
  color: #42b883;
  font-weight: 600;
  background-color: #fff;
  border-bottom: 2px solid #42b883;
}

.tab-nav-item--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>
<!-- TabPanel.vue -->
<template>
  <div v-show="isActive" class="tab-panel" role="tabpanel">
    <slot />
  </div>
</template>

<script setup>
import { inject, computed } from 'vue'

const props = defineProps({
  tabKey: {
    type: [String, Number],
    required: true
  }
})

const tabsContext = inject('tabs-context')

const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)
</script>

<style scoped>
.tab-panel {
  outline: none;
}
</style>

使用示例:

<template>
  <Tabs v-model="activeTab">
    <template #nav="{ activeKey, changeTab }">
      <TabNavItem tab-key="profile">个人信息</TabNavItem>
      <TabNavItem tab-key="settings">设置</TabNavItem>
      <TabNavItem tab-key="security" disabled>安全</TabNavItem>
    </template>
  
    <TabPanel tab-key="profile">
      <p>这是个人信息面板</p>
    </TabPanel>
  
    <TabPanel tab-key="settings">
      <p>这是设置面板</p>
    </TabPanel>
  
    <TabPanel tab-key="security">
      <p>这是安全面板</p>
    </TabPanel>
  </Tabs>
</template>

<script setup>
import { ref } from 'vue'

const activeTab = ref('profile')
</script>

3. Higher-Order Component (HOC) 模式

虽然 Vue 更推荐使用 Composition API,但在某些场景下 HOC 仍然有用:

// withLoading.js
import { h, ref, onMounted } from 'vue'

export function withLoading(WrappedComponent, loadingMessage = '加载中...') {
  return {
    name: `WithLoading(${WrappedComponent.name || 'Component'})`,
    inheritAttrs: false,
    props: WrappedComponent.props,
    emits: WrappedComponent.emits,
    setup(props, { attrs, slots, emit }) {
      const isLoading = ref(true)
    
      onMounted(() => {
        // 模拟异步操作
        setTimeout(() => {
          isLoading.value = false
        }, 1000)
      })
    
      return () => {
        if (isLoading.value) {
          return h('div', { class: 'loading-wrapper' }, loadingMessage)
        }
      
        return h(WrappedComponent, {
          ...props,
          ...attrs,
          on: Object.keys(emit).reduce((acc, key) => {
            acc[key] = (...args) => emit(key, ...args)
            return acc
          }, {})
        }, slots)
      }
    }
  }
}

4. State Reducer 模式

借鉴 React 的理念,通过 reducer 函数管理复杂状态:

<!-- Toggle.vue -->
<template>
  <div class="toggle">
    <slot 
      :on="on"
      :toggle="toggle"
      :set-on="setOn"
      :set-off="setOff"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  reducer: {
    type: Function,
    default: null
  }
})

const emit = defineEmits(['update:modelValue'])

const internalOn = ref(props.modelValue)

const getState = () => ({
  on: internalOn.value
})

const dispatch = (action) => {
  const changes = props.reducer 
    ? props.reducer(getState(), action)
    : defaultReducer(getState(), action)
  
  if (changes.on !== undefined) {
    internalOn.value = changes.on
    emit('update:modelValue', changes.on)
  }
}

const defaultReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      return { on: !state.on }
    case 'setOn':
      return { on: true }
    case 'setOff':
      return { on: false }
    default:
      throw new Error(`Unknown action type: ${action.type}`)
  }
}

const toggle = () => dispatch({ type: 'toggle' })
const setOn = () => dispatch({ type: 'setOn' })
const setOff = () => dispatch({ type: 'setOff' })

defineExpose({
  toggle,
  setOn,
  setOff
})
</script>

使用示例:

<template>
  <Toggle :reducer="toggleReducer" v-slot="{ on, toggle, setOn, setOff }">
    <div class="toggle-demo">
      <p>状态: {{ on ? '开启' : '关闭' }}</p>
      <BaseButton @click="toggle">切换</BaseButton>
      <BaseButton @click="setOn">开启</BaseButton>
      <BaseButton @click="setOff">关闭</BaseButton>
    </div>
  </Toggle>
</template>

<script setup>
const toggleReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      // 添加日志记录
      console.log('Toggle state changed:', !state.on)
      return { on: !state.on }
    case 'setOn':
      return { on: true }
    case 'setOff':
      return { on: false }
    default:
      return state
  }
}
</script>

组件通信最佳实践

1. Provide/Inject 模式

// theme.js
import { ref, readonly, computed } from 'vue'

const themeSymbol = Symbol('theme')

export function createThemeStore() {
  const currentTheme = ref('light')

  const themes = {
    light: {
      primary: '#42b883',
      background: '#ffffff',
      text: '#333333'
    },
    dark: {
      primary: '#42b883',
      background: '#1a1a1a',
      text: '#ffffff'
    }
  }

  const toggleTheme = () => {
    currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light'
  }

  const themeConfig = computed(() => themes[currentTheme.value])

  return {
    currentTheme: readonly(currentTheme),
    themeConfig,
    toggleTheme
  }
}

export function provideTheme(themeStore) {
  provide(themeSymbol, themeStore)
}

export function useTheme() {
  const themeStore = inject(themeSymbol)
  if (!themeStore) {
    throw new Error('useTheme must be used within provideTheme')
  }
  return themeStore
}

2. Event Bus 替代方案

使用 mitt 库替代传统的事件总线:

// eventBus.js
import mitt from 'mitt'

export const eventBus = mitt()

// 在组件中使用
// eventBus.emit('user-login', userInfo)
// eventBus.on('user-login', handler)

性能优化策略

1. 组件懒加载

// router/index.js
const routes = [
  {
    path: '/heavy-component',
    component: () => import('@/components/HeavyComponent.vue')
  }
]

// 组件内部懒加载
const HeavyChart = defineAsyncComponent(() => 
  import('@/components/charts/HeavyChart.vue')
)

2. 虚拟滚动

<!-- VirtualList.vue -->
<template>
  <div 
    ref="containerRef" 
    class="virtual-list"
    @scroll="handleScroll"
  >
    <div :style="{ height: totalHeight + 'px' }" class="virtual-list__spacer">
      <div 
        :style="{ transform: `translateY(${offsetY}px)` }"
        class="virtual-list__content"
      >
        <div
          v-for="item in visibleItems"
          :key="item.id"
          :style="{ height: itemHeight + 'px' }"
          class="virtual-list__item"
        >
          <slot :item="item" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  bufferSize: {
    type: Number,
    default: 5
  }
})

const containerRef = ref(null)
const scrollTop = ref(0)

const totalHeight = computed(() => props.items.length * props.itemHeight)

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  const containerHeight = containerRef.value?.clientHeight || 0
  return Math.min(
    props.items.length - 1,
    Math.floor((scrollTop.value + containerHeight) / props.itemHeight) + props.bufferSize
  )
})

const visibleItems = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value + 1)
})

const offsetY = computed(() => {
  return startIndex.value * props.itemHeight
})

const handleScroll = () => {
  scrollTop.value = containerRef.value.scrollTop
}

onMounted(() => {
  // 初始化滚动监听
})

onUnmounted(() => {
  // 清理资源
})
</script>

<style scoped>
.virtual-list {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #e5e5e5;
}

.virtual-list__spacer {
  position: relative;
}

.virtual-list__content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.virtual-list__item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
}
</style>

测试友好的组件设计

1. 明确的 Props 定义

// Button.test.js
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'

describe('BaseButton', () => {
  test('renders slot content', () => {
    const wrapper = mount(BaseButton, {
      slots: {
        default: 'Click me'
      }
    })
    expect(wrapper.text()).toContain('Click me')
  })

  test('emits click event when clicked', async () => {
    const wrapper = mount(BaseButton)
    await wrapper.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('click')
  })

  test('applies correct CSS classes based on props', () => {
    const wrapper = mount(BaseButton, {
      props: {
        variant: 'primary',
        size: 'large'
      }
    })
    expect(wrapper.classes()).toContain('btn--primary')
    expect(wrapper.classes()).toContain('btn--large')
  })
})

2. 可访问性考虑

<!-- AccessibleModal.vue -->
<template>
  <teleport to="body">
    <div 
      v-if="visible"
      ref="modalRef"
      role="dialog"
      aria-modal="true"
      :aria-labelledby="titleId"
      :aria-describedby="descriptionId"
      class="modal"
      @keydown.esc="close"
    >
      <div class="modal__overlay" @click="close"></div>
      <div class="modal__content" ref="contentRef">
        <div class="modal__header">
          <h2 :id="titleId" class="modal__title">{{ title }}</h2>
          <button 
            type="button"
            class="modal__close"
            @click="close"
            aria-label="关闭对话框"
          >
            ×
          </button>
        </div>
      
        <div :id="descriptionId" class="modal__body">
          <slot />
        </div>
      
        <div v-if="$slots.footer" class="modal__footer">
          <slot name="footer" />
        </div>
      </div>
    </div>
  </teleport>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue'

const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    required: true
  }
})

const emit = defineEmits(['update:visible', 'close'])

const modalRef = ref(null)
const contentRef = ref(null)
const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`
const descriptionId = `modal-desc-${Math.random().toString(36).substr(2, 9)}`

const close = () => {
  emit('update:visible', false)
  emit('close')
}

watch(() => props.visible, async (newVal) => {
  if (newVal) {
    await nextTick()
    // 自动聚焦到模态框
    contentRef.value?.focus()
  }
})
</script>

结语

Vue 3 组件开发的最佳实践涉及多个方面,从基础的 Props 和插槽使用,到高级的设计模式如 Renderless 组件和 Compound Components,每种模式都有其适用场景。关键是要根据具体需求选择合适的设计模式,并遵循以下原则:

  1. 保持组件简洁:每个组件专注于单一功能
  2. 提供良好的 API:清晰的 Props 定义和事件接口
  3. 重视可访问性:确保所有用户都能正常使用组件
  4. 考虑性能影响:特别是在处理大量数据或复杂交互时
  5. 便于测试:设计易于测试的组件接口

通过合理运用这些设计模式和最佳实践,我们可以构建出既灵活又可靠的组件库,为整个应用提供一致且高质量的用户体验。记住,好的组件设计不是一次性的任务,而是需要在实践中不断迭代和完善的过程。

Vue 3 动画效果实现:Transition和TransitionGroup详解

作者 微芒不朽
2025年12月12日 16:22

Vue 3 动画效果实现:Transition和TransitionGroup详解

前言

在现代Web应用中,流畅的动画效果不仅能提升用户体验,还能有效传达界面状态变化的信息。Vue 3 提供了强大的过渡和动画系统,通过 <transition><transition-group> 组件,开发者可以轻松地为元素的进入、离开和列表变化添加动画效果。本文将深入探讨这两个组件的使用方法和高级技巧。

Transition 组件基础

基本用法

<transition> 组件用于包装单个元素或组件,在插入、更新或移除时应用过渡效果。

<template>
  <div>
    <button @click="show = !show">切换显示</button>
    <transition name="fade">
      <p v-if="show">Hello Vue 3!</p>
    </transition>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

过渡类名详解

Vue 3 为进入/离开过渡提供了6个CSS类名:

  1. v-enter-from:进入过渡的开始状态
  2. v-enter-active:进入过渡生效时的状态
  3. v-enter-to:进入过渡的结束状态
  4. v-leave-from:离开过渡的开始状态
  5. v-leave-active:离开过渡生效时的状态
  6. v-leave-to:离开过渡的结束状态

注意:在 Vue 3 中,类名前缀从 v-enter 改为 v-enter-from,其他类名也相应调整。

JavaScript 钩子函数

除了CSS过渡,还可以使用JavaScript钩子来控制动画:

<template>
  <transition
    @before-enter="beforeEnter"
    @enter="enter"
    @after-enter="afterEnter"
    @before-leave="beforeLeave"
    @leave="leave"
    @after-leave="afterLeave"
  >
    <div v-if="show" class="box">Animated Box</div>
  </transition>
</template>

<script setup>
import { ref } from 'vue'
import gsap from 'gsap'

const show = ref(true)

const beforeEnter = (el) => {
  el.style.opacity = 0
  el.style.transform = 'scale(0)'
}

const enter = (el, done) => {
  gsap.to(el, {
    duration: 0.5,
    opacity: 1,
    scale: 1,
    onComplete: done
  })
}

const afterEnter = (el) => {
  console.log('进入完成')
}

const beforeLeave = (el) => {
  el.style.transformOrigin = 'center'
}

const leave = (el, done) => {
  gsap.to(el, {
    duration: 0.5,
    opacity: 0,
    scale: 0,
    onComplete: done
  })
}

const afterLeave = (el) => {
  console.log('离开完成')
}
</script>

常见动画效果实现

1. 淡入淡出效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Fade</button>
    <transition name="fade">
      <div v-if="show" class="content">Fade Effect Content</div>
    </transition>
  </div>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease-in-out;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

2. 滑动效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Slide</button>
    <transition name="slide">
      <div v-if="show" class="content">Slide Effect Content</div>
    </transition>
  </div>
</template>

<style>
.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s ease;
  max-height: 200px;
  overflow: hidden;
}

.slide-enter-from,
.slide-leave-to {
  max-height: 0;
  opacity: 0;
  transform: translateY(-20px);
}
</style>

3. 弹跳效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Bounce</button>
    <transition name="bounce">
      <div v-if="show" class="content">Bounce Effect Content</div>
    </transition>
  </div>
</template>

<style>
.bounce-enter-active {
  animation: bounce-in 0.5s;
}

.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}

@keyframes bounce-in {
  0% {
    transform: scale(0);
    opacity: 0;
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}
</style>

4. 翻转效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Flip</button>
    <transition name="flip">
      <div v-if="show" class="content flip-content">Flip Effect Content</div>
    </transition>
  </div>
</template>

<style>
.flip-enter-active {
  animation: flip-in 0.6s ease forwards;
}

.flip-leave-active {
  animation: flip-out 0.6s ease forwards;
}

@keyframes flip-in {
  0% {
    transform: perspective(400px) rotateY(90deg);
    opacity: 0;
  }
  40% {
    transform: perspective(400px) rotateY(-10deg);
  }
  70% {
    transform: perspective(400px) rotateY(10deg);
  }
  100% {
    transform: perspective(400px) rotateY(0deg);
    opacity: 1;
  }
}

@keyframes flip-out {
  0% {
    transform: perspective(400px) rotateY(0deg);
    opacity: 1;
  }
  100% {
    transform: perspective(400px) rotateY(90deg);
    opacity: 0;
  }
}
</style>

TransitionGroup 组件详解

基本列表动画

<transition-group> 用于为列表中的元素添加进入/离开过渡效果:

<template>
  <div class="list-demo">
    <button @click="addItem">添加项目</button>
    <button @click="removeItem">删除项目</button>
  
    <transition-group name="list" tag="ul">
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const items = reactive([
  { id: 1, text: '项目 1' },
  { id: 2, text: '项目 2' },
  { id: 3, text: '项目 3' }
])

let nextId = 4

const addItem = () => {
  const index = Math.floor(Math.random() * (items.length + 1))
  items.splice(index, 0, {
    id: nextId++,
    text: `新项目 ${nextId - 1}`
  })
}

const removeItem = () => {
  if (items.length > 0) {
    const index = Math.floor(Math.random() * items.length)
    items.splice(index, 1)
  }
}
</script>

<style>
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-move {
  transition: transform 0.5s ease;
}

.list-item {
  padding: 10px;
  margin: 5px 0;
  background-color: #f0f0f0;
  border-radius: 4px;
}
</style>

列表排序动画

<template>
  <div class="shuffle-demo">
    <button @click="shuffle">随机排序</button>
    <button @click="add">添加</button>
    <button @click="remove">删除</button>
  
    <transition-group name="shuffle" tag="div" class="grid">
      <div 
        v-for="item in items" 
        :key="item.id" 
        class="grid-item"
        @click="removeItem(item)"
      >
        {{ item.number }}
      </div>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const items = reactive([
  { id: 1, number: 1 },
  { id: 2, number: 2 },
  { id: 3, number: 3 },
  { id: 4, number: 4 },
  { id: 5, number: 5 }
])

const shuffle = () => {
  // Fisher-Yates 洗牌算法
  for (let i = items.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [items[i], items[j]] = [items[j], items[i]]
  }
}

const add = () => {
  const newNumber = items.length > 0 ? Math.max(...items.map(i => i.number)) + 1 : 1
  items.push({
    id: Date.now(),
    number: newNumber
  })
}

const remove = () => {
  if (items.length > 0) {
    items.pop()
  }
}

const removeItem = (item) => {
  const index = items.indexOf(item)
  if (index > -1) {
    items.splice(index, 1)
  }
}
</script>

<style>
.grid {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 20px;
}

.grid-item {
  width: 60px;
  height: 60px;
  background-color: #42b883;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  cursor: pointer;
  font-weight: bold;
  user-select: none;
}

.shuffle-enter-active,
.shuffle-leave-active {
  transition: all 0.5s ease;
}

.shuffle-enter-from {
  opacity: 0;
  transform: scale(0.5);
}

.shuffle-leave-to {
  opacity: 0;
  transform: scale(0.5);
}

.shuffle-move {
  transition: transform 0.5s ease;
}
</style>

高级动画技巧

1. FLIP 技术实现平滑动画

FLIP (First, Last, Invert, Play) 是一种优化动画性能的技术:

<template>
  <div class="flip-demo">
    <button @click="filterItems">筛选奇数</button>
    <button @click="resetFilter">重置</button>
  
    <transition-group 
      name="flip-list" 
      tag="div" 
      class="flip-container"
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
    >
      <div 
        v-for="item in filteredItems" 
        :key="item.id" 
        class="flip-item"
      >
        {{ item.value }}
      </div>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const items = ref(Array.from({ length: 20 }, (_, i) => ({
  id: i + 1,
  value: i + 1
})))

const filterOdd = ref(false)

const filteredItems = computed(() => {
  return filterOdd.value 
    ? items.value.filter(item => item.value % 2 === 1)
    : items.value
})

const filterItems = () => {
  filterOdd.value = true
}

const resetFilter = () => {
  filterOdd.value = false
}

const positions = new Map()

const beforeEnter = (el) => {
  el.style.opacity = '0'
  el.style.transform = 'scale(0.8)'
}

const enter = (el, done) => {
  // 获取最终位置
  const end = el.getBoundingClientRect()
  const start = positions.get(el)

  if (start) {
    // 计算位置差
    const dx = start.left - end.left
    const dy = start.top - end.top
    const ds = start.width / end.width
  
    // 反向变换
    el.style.transform = `translate(${dx}px, ${dy}px) scale(${ds})`
  
    // 强制重绘
    el.offsetHeight
  
    // 执行动画
    el.style.transition = 'all 0.3s ease'
    el.style.transform = ''
    el.style.opacity = '1'
  
    setTimeout(done, 300)
  } else {
    el.style.transition = 'all 0.3s ease'
    el.style.transform = ''
    el.style.opacity = '1'
    setTimeout(done, 300)
  }
}

const leave = (el, done) => {
  // 记录初始位置
  positions.set(el, el.getBoundingClientRect())
  el.style.position = 'absolute'
  done()
}
</script>

<style>
.flip-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
  gap: 10px;
  position: relative;
  min-height: 200px;
}

.flip-item {
  background-color: #3498db;
  color: white;
  padding: 20px;
  text-align: center;
  border-radius: 8px;
  font-weight: bold;
}

.flip-list-enter-active,
.flip-list-leave-active {
  transition: all 0.3s ease;
}

.flip-list-enter-from,
.flip-list-leave-to {
  opacity: 0;
  transform: translateY(30px);
}

.flip-list-move {
  transition: transform 0.3s ease;
}
</style>

2. 交错动画

<template>
  <div class="stagger-demo">
    <button @click="loadItems">加载项目</button>
    <button @click="clearItems">清空</button>
  
    <transition-group 
      name="staggered-fade" 
      tag="ul" 
      class="staggered-list"
    >
      <li 
        v-for="(item, index) in items" 
        :key="item.id"
        :data-index="index"
        class="staggered-item"
      >
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const items = ref([])

const loadItems = () => {
  items.value = Array.from({ length: 10 }, (_, i) => ({
    id: Date.now() + i,
    text: `项目 ${i + 1}`
  }))
}

const clearItems = () => {
  items.value = []
}
</script>

<style>
.staggered-list {
  list-style: none;
  padding: 0;
}

.staggered-item {
  padding: 15px;
  margin: 5px 0;
  background-color: #e74c3c;
  color: white;
  border-radius: 6px;
  opacity: 0;
}

/* 进入动画 */
.staggered-fade-enter-active {
  transition: all 0.3s ease;
}

.staggered-fade-enter-from {
  opacity: 0;
  transform: translateX(-30px);
}

/* 离开动画 */
.staggered-fade-leave-active {
  transition: all 0.3s ease;
  position: absolute;
}

.staggered-fade-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* 移动动画 */
.staggered-fade-move {
  transition: transform 0.3s ease;
}

/* 交错延迟 */
.staggered-item:nth-child(1) { transition-delay: 0.05s; }
.staggered-item:nth-child(2) { transition-delay: 0.1s; }
.staggered-item:nth-child(3) { transition-delay: 0.15s; }
.staggered-item:nth-child(4) { transition-delay: 0.2s; }
.staggered-item:nth-child(5) { transition-delay: 0.25s; }
.staggered-item:nth-child(6) { transition-delay: 0.3s; }
.staggered-item:nth-child(7) { transition-delay: 0.35s; }
.staggered-item:nth-child(8) { transition-delay: 0.4s; }
.staggered-item:nth-child(9) { transition-delay: 0.45s; }
.staggered-item:nth-child(10) { transition-delay: 0.5s; }
</style>

3. 页面切换动画

<!-- App.vue -->
<template>
  <div id="app">
    <nav>
      <router-link to="/">首页</router-link>
      <router-link to="/about">关于</router-link>
      <router-link to="/contact">联系</router-link>
    </nav>
  
    <router-view v-slot="{ Component }">
      <transition name="page" mode="out-in">
        <component :is="Component" />
      </transition>
    </router-view>
  </div>
</template>

<style>
.page-enter-active,
.page-leave-active {
  transition: all 0.3s ease;
  position: absolute;
  top: 60px;
  left: 0;
  right: 0;
}

.page-enter-from {
  opacity: 0;
  transform: translateX(30px);
}

.page-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}

nav {
  padding: 20px;
  background-color: #f8f9fa;
}

nav a {
  margin-right: 20px;
  text-decoration: none;
  color: #333;
}

nav a.router-link-active {
  color: #42b883;
  font-weight: bold;
}
</style>

性能优化建议

1. 使用 transform 和 opacity

优先使用 transformopacity 属性,因为它们不会触发重排:

/* 推荐 */
.good-animation {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

/* 避免 */
.bad-animation {
  transition: left 0.3s ease, top 0.3s ease;
}

2. 合理使用 will-change

对于复杂的动画,可以提前告知浏览器优化:

.animated-element {
  will-change: transform, opacity;
}

3. 避免阻塞主线程

对于复杂动画,考虑使用 Web Workers 或 requestAnimationFrame:

const animateElement = (element, duration) => {
  const startTime = performance.now()

  const animate = (currentTime) => {
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / duration, 1)
  
    // 更新元素样式
    element.style.transform = `translateX(${progress * 100}px)`
  
    if (progress < 1) {
      requestAnimationFrame(animate)
    }
  }

  requestAnimationFrame(animate)
}

结语

Vue 3 的过渡和动画系统为我们提供了强大而灵活的工具来创建丰富的用户界面体验。通过合理运用 <transition><transition-group> 组件,结合 CSS3 动画和 JavaScript 控制,我们能够实现从简单到复杂的各种动画效果。

关键要点总结:

  1. 理解过渡类名机制:掌握6个核心类名的作用时机
  2. 善用 JavaScript 钩子:实现更复杂的自定义动画逻辑
  3. 列表动画的重要性:使用 <transition-group> 处理动态列表
  4. 性能优化意识:选择合适的 CSS 属性和动画技术
  5. 用户体验考量:动画应该增强而不是阻碍用户操作

在实际项目中,建议根据具体需求选择合适的动画方案,并始终考虑性能影响。适度的动画能够显著提升用户体验,但过度或不当的动画反而会适得其反。希望本文能够帮助你在 Vue 3 项目中更好地实现和控制动画效果。

别再用mixin了!Vue3自定义Hooks让逻辑复用爽到飞起

作者 微芒不朽
2025年12月12日 16:13

前言

随着 Vue 3 的普及,Composition API 成为了构建复杂应用的主流方式。相比 Options API,Composition API 提供了更好的逻辑组织和复用能力。而自定义 Hooks 正是这一能力的核心体现,它让我们能够将业务逻辑抽象成可复用的函数,极大地提升了代码的可维护性和开发效率。

什么是自定义 Hooks?

自定义 Hooks 是基于 Composition API 封装的可复用逻辑函数。它们通常以 use 开头命名,返回响应式数据、方法或计算属性。通过自定义 Hooks,我们可以将组件中的逻辑抽离出来,在多个组件间共享。

基本结构

// useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  const increment = () => {
    count.value++
  }

  const decrement = () => {
    count.value--
  }

  const doubleCount = computed(() => count.value * 2)

  return {
    count,
    increment,
    decrement,
    doubleCount
  }
}

实战案例:常用自定义 Hooks

1. 网络请求 Hook

// useApi.js
import { ref, onMounted } from 'vue'
import axios from 'axios'

export function useApi(url, options = {}) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const fetchData = async (params = {}) => {
    loading.value = true
    error.value = null
  
    try {
      const response = await axios.get(url, { ...options, params })
      data.value = response.data
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  onMounted(() => {
    if (options.immediate !== false) {
      fetchData()
    }
  })

  return {
    data,
    loading,
    error,
    fetchData
  }
}

使用示例:

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
      <li v-for="item in data" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
    <button @click="fetchData">刷新</button>
  </div>
</template>

<script setup>
import { useApi } from '@/hooks/useApi'

const { data, loading, error, fetchData } = useApi('/api/users')
</script>

2. 表单验证 Hook

// useForm.js
import { reactive, computed } from 'vue'

export function useForm(initialValues, rules) {
  const formData = reactive({ ...initialValues })
  const errors = reactive({})

  const validateField = (field) => {
    const value = formData[field]
    const fieldRules = rules[field] || []
  
    for (const rule of fieldRules) {
      if (!rule.validator(value, formData)) {
        errors[field] = rule.message
        return false
      }
    }
  
    delete errors[field]
    return true
  }

  const validateAll = () => {
    let isValid = true
    Object.keys(rules).forEach(field => {
      if (!validateField(field)) {
        isValid = false
      }
    })
    return isValid
  }

  const resetForm = () => {
    Object.assign(formData, initialValues)
    Object.keys(errors).forEach(key => {
      delete errors[key]
    })
  }

  const isDirty = computed(() => {
    return JSON.stringify(formData) !== JSON.stringify(initialValues)
  })

  return {
    formData,
    errors,
    validateField,
    validateAll,
    resetForm,
    isDirty
  }
}

使用示例:

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input 
        v-model="formData.username" 
        @blur="() => validateField('username')"
        placeholder="用户名"
      />
      <span v-if="errors.username" class="error">{{ errors.username }}</span>
    </div>
  
    <div>
      <input 
        v-model="formData.email" 
        @blur="() => validateField('email')"
        placeholder="邮箱"
      />
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>
  
    <button type="submit" :disabled="!isDirty">提交</button>
    <button type="button" @click="resetForm">重置</button>
  </form>
</template>

<script setup>
import { useForm } from '@/hooks/useForm'

const { formData, errors, validateField, validateAll, resetForm, isDirty } = useForm(
  { username: '', email: '' },
  {
    username: [
      {
        validator: (value) => value.length >= 3,
        message: '用户名至少3个字符'
      }
    ],
    email: [
      {
        validator: (value) => /\S+@\S+\.\S+/.test(value),
        message: '请输入有效的邮箱地址'
      }
    ]
  }
)

const handleSubmit = () => {
  if (validateAll()) {
    console.log('表单验证通过:', formData)
  }
}
</script>

3. 防抖节流 Hook

// useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  let timeoutId = null

  watch(value, (newValue) => {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  return debouncedValue
}

// useThrottle.js
export function useThrottle(value, delay = 300) {
  const throttledValue = ref(value.value)
  let lastTime = 0

  watch(value, (newValue) => {
    const now = Date.now()
    if (now - lastTime >= delay) {
      throttledValue.value = newValue
      lastTime = now
    }
  })

  return throttledValue
}

4. 本地存储 Hook

// useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const storedValue = localStorage.getItem(key)
  const value = ref(storedValue ? JSON.parse(storedValue) : defaultValue)

  watch(value, (newValue) => {
    if (newValue === null) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(newValue))
    }
  }, { deep: true })

  const remove = () => {
    value.value = null
  }

  return [value, remove]
}

高级技巧与最佳实践

1. Hook 组合

// useUserManagement.js
import { useApi } from './useApi'
import { useLocalStorage } from './useLocalStorage'

export function useUserManagement() {
  const [currentUser, removeCurrentUser] = useLocalStorage('currentUser', null)
  const { data: users, loading, error, fetchData } = useApi('/api/users')

  const login = async (credentials) => {
    // 登录逻辑
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const userData = await response.json()
    currentUser.value = userData
  }

  const logout = () => {
    removeCurrentUser()
    // 其他登出逻辑
  }

  return {
    currentUser,
    users,
    loading,
    error,
    login,
    logout,
    refreshUsers: fetchData
  }
}

2. 错误处理

// useAsync.js
import { ref, onMounted } from 'vue'

export function useAsync(asyncFunction, immediate = true) {
  const result = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const execute = async (...args) => {
    loading.value = true
    error.value = null
  
    try {
      const response = await asyncFunction(...args)
      result.value = response
      return response
    } catch (err) {
      error.value = err
      throw err
    } finally {
      loading.value = false
    }
  }

  onMounted(() => {
    if (immediate) {
      execute()
    }
  })

  return {
    result,
    loading,
    error,
    execute
  }
}

3. 类型安全(TypeScript)

// useCounter.ts
import { ref, computed, Ref, ComputedRef } from 'vue'

interface UseCounterReturn {
  count: Ref<number>
  increment: () => void
  decrement: () => void
  doubleCount: ComputedRef<number>
}

export function useCounter(initialValue: number = 0): UseCounterReturn {
  const count = ref(initialValue)

  const increment = () => {
    count.value++
  }

  const decrement = () => {
    count.value--
  }

  const doubleCount = computed(() => count.value * 2)

  return {
    count,
    increment,
    decrement,
    doubleCount
  }
}

设计原则与注意事项

1. 单一职责原则

每个 Hook 应该只负责一个特定的功能领域,保持功能单一且专注。

2. 命名规范

  • 使用 use 前缀
  • 名称清晰表达 Hook 的用途
  • 避免过于通用的名称

3. 返回值设计

  • 返回对象而非数组(便于解构时命名)
  • 保持返回值的一致性
  • 考虑添加辅助方法

4. 性能优化

  • 合理使用 watchcomputed
  • 避免不必要的重新计算
  • 及时清理副作用

结语

自定义 Hooks 是 Vue 3 Composition API 生态中的重要组成部分,它不仅解决了逻辑复用的问题,更提供了一种更加灵活和可组合的开发模式。通过合理地设计和使用自定义 Hooks,我们可以:

  1. 提升代码复用性:将通用逻辑抽象成独立模块
  2. 改善代码组织:让组件更加关注视图逻辑
  3. 增强可测试性:独立的逻辑更容易进行单元测试
  4. 提高开发效率:减少重复代码编写

在实际项目中,建议根据业务需求逐步积累和优化自定义 Hooks,建立属于团队的 Hooks 库,这将是提升前端开发质量和效率的重要手段。

记住,好的自定义 Hooks 不仅要解决当前问题,更要具备良好的扩展性和可维护性。随着经验的积累,你会发现自己能够创造出越来越优雅和实用的自定义 Hooks。

数据标注平台正式上线啦! 标注赚现金,低门槛真收益 | 掘金一周 12.10

作者 掘金一周
2025年12月11日 17:09

本文字数1400+ ,阅读时间大约需要 5分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

如何用隐形字符给公司内部文档加盲水印?(抓内鬼神器🤣) @ErpanOmer

文章讲述公司内部敏感文档泄露后,利用基于零宽字符的盲水印技术抓“内鬼”。介绍零宽字符概念,阐述加密、解密原理,给出实现代码,还提及水印可被清除,强调这是低成本、高隐蔽性防御手段。

不仅免费,还开源?这个 AI Mock 神器我必须曝光它 @不一样的少年_

本文介绍了一款零侵入的接口 Mock 插件,重构为 Sidebar 常驻侧栏,体验更佳。它接入 AI 自动生成数据,支持延时和状态码模拟。具备拦截、匹配、响应控制等功能,覆盖前端 90% 的 Mock 需求,推荐试用。

后端

开源企业级 IM!一款高颜值的即时通讯聊天应用! @Java陈序员

本文推荐了基于 GO 开发的开源即时通讯系统 TangSengDaoDaoServer,它轻量、高性能且重安全,支持多端同步。介绍了其功能特色、项目架构,给出 Docker 部署步骤,还展示功能体验,推荐大家尝试。

Android

用 AI 做了几个超炫酷的 Flutter 动画,同时又差点被 AI 气死 @恋猫de小郭

文章介绍用 AI 实现几种 Flutter 动画。奇异粒子动画基于数学公式,解决投影等问题;斐波那契球体让点均匀分布在球面;星云动画模拟星系动力学。不过,AI 实现时遇颜色插值陷阱问题,最终换思路解决。

Android Studio Otter 2 Feature 发布,最值得更新的 Android Studio @恋猫de小郭

Android Studio Otter 2 Feature发布,是值得更新的版本。它内置Gemini 3,增强Agent模式并配备Android知识库。支持备份与同步设置,开发者可接收团队资讯。还整合IntelliJ IDEA 2025.2改进,能免费试用Gemini 3 Pro。

Flutter TolyUI 框架#09 | tolyui_text 轻量高亮文本 @张风捷特烈

本文介绍了 Flutter TolyUI 框架的 tolyui_text 模块。该模块封装文本高亮方案,提供轻量级解决方案。支持搜索关键字高亮、自定义匹配规则和多模式智能识别,还能处理点击事件,未来会有更多新功能。

人工智能

🔥 懂原理但不会说?我怒写了个 AI 模拟器折磨自己,M属性大爆发! @HiStewie

作者为解决面试准备难题,用 TRAE SOLO 重构初版工具。从架构设计、核心实现、技术栈选型等多方面展开,一晚完成含简历解析等功能的 MVP,验证新开发范式,未来产品还有诸多扩展方向。

解读 Claude 对开发者的影响:AI 如何在 Anthropic 改变工作?@恋猫de小郭

Anthropic 对内部员工调查显示,AI 显著影响开发者。生产力平均提升 50%,启用新工作,改变委托实践。开发者技能有扩展与退化,社会互动减少,职业认同受冲击。AI 红利与债务并存,重塑职业价值观。

Chatbox支持接入LangGraph智能体?一切都靠Trae Solo!@大模型真好玩

本文作者借助 Trae Solo 实现将 LangChain 智能体接入 Chatbox 客户端。先介绍两者,阐述接入思路,以天气助手智能体为例展示 Trae Solo 自动化开发流程,最后展望其潜力,鼓励用它快速搭建原型、验证逻辑。

IOS

iOS UIKit 全体系知识手册(Objective-C 版) @如此风景

UIKit 是 iOS 开发基石框架,围绕视图、控制器、事件展开。掌握布局、事件处理、渲染优化是关键。开发中用 Masonry 简化布局,结合适配特性,借助 Instruments 定位问题,可高效构建稳定适配界面。

社区活动日历

掘金官方 文章头图 1303x734.jpg

活动日历

活动名称 活动时间
🚀TRAE SOLO 实战赛 2025年11月13日-2025年12月16日
数据标注平台正式上线啦! -

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

可能是你极易忽略的Nginx知识点

作者 LiuMingXin
2025年12月11日 15:48

image.png下面是我在nginx使用过程中发现的几个问题,分享出来大家一起熟悉一下nginx

问题一

先看下面的几个配置


# 配置一
location /test {
  proxy_pass 'http://192.186.0.1:8080';
}

# 配置二
location /test {
  proxy_pass 'http://192.186.0.1:8080/';
}

仔细关系观察上面两段配置的区别,你会发现唯一的区别在于 proxy_pass 指令后面是否有斜杠/ !

那么,这两段配置的区别是什么呢?它们会产生什么不同的效果呢?

假如说我们要请求的后端接口是/test/file/getList,那么这两个配置会产生两个截然不同的请求结果:

是的,你没有看错,区别就在于是否保留了/test这个路径前缀, proxy_pass后面的这个/,它表示去除/test前缀

其实,我不是很推荐这中配置写法,当然这个配置方法确实很简洁,但是对不熟悉 nginx 的同学来说,会造成很大的困惑。

我推荐下面的写法,哪怕麻烦一点,但是整体的可读性要好很多:


# 推荐的替代写法
location /test{
  rewrite ^/test/(.*)$ /$1 break;
  proxy_pass 'http://192.186.0.1:8080';
}

通过上面的rewrite指令,我们可以清晰地看到我们是如何去除路径前缀的。虽然麻烦一点,但是可读性更好。

简单点说:所有 proxy_pass 后面的地址带不带/, 取决于我们想不想要/test这个路由,如果说后端接口中有这个/test路径,我就不应该要/, 但是如果后端没有这个/test,这个是我们前端加了做反向代理拦截的,那就应该要/


那既然都到这里了?那我们在深一步!看下面的配置


# 配置一
location /test {
  proxy_pass 'http://192.186.0.1:8080';
}


# 配置二
location /test/ {
  proxy_pass 'http://192.186.0.1:8080';
}

这次的区别在于 location 指令后面是否有斜杠/ ! 那么,这两段配置的区别是什么呢?它们会产生什么不同的效果呢?

答案是:有区别!区别是匹配规则是不一样的!

  • /test前配置,表示匹配/test以及/test/开头的路径,比如/test/file/getList/test123等都会被匹配到。
  • /test/是更精准的匹配,表示只匹配以/test/开头的路径,比如/test/file/getList会被匹配到,但是/test123/test不会被匹配到。

我们通过下面的列表在来仔细看一下区别:

请求路径 /test /test/ 匹配结果
/test location /test
/test/ location /test/
/test/abc location /test/
/test123 location /test
/test-123 location /test

如果你仔细看上面的列表的话,你会发现一个问题:

/test//test/abc/test/test/ 两个配置都匹配到了,那么这种情况下,nginx 会选择哪个配置呢? 答案:选择location /test/

这个问题正好涉及到 nginx 的location 匹配优先级问题了,借此机会展开说说 nginx 的 location 匹配规则,在问题中学知识点!

先说口诀:

等号精确第一名
波浪前缀挡正则
正则排队按顺序
普通前缀取最长

解释:

  • 等号(=) 精确匹配排第一
  • 波浪前缀(^~) 能挡住后面的正则
  • 正则(~ ~*) 按配置文件顺序匹配
  • 普通前缀(无符号) 按最长匹配原则

其实这个口诀我也记不住,我也不想记,枯燥有乏味,大部分情况都是到问题了, 直接问 AI,或者让 Agent 直接给我改 nginx.conf 文件,几秒钟的事,一遍不行, 多改几遍。

铁子们,大清亡了,回不去了,不是八旗背八股文的时代了,这是不可阻挡的历史潮流! 哎,难受,我还是喜欢背八股文,喜欢粘贴复制。

下面放出来我 PUA AI 的心得,大家可以共勉一下, 反正我老板平时就是这样 PUA 我的, 我反手就喂给 AI, 主打一个走心:

1.能干干,不能干滚,你不干有的是AI干。
2.我给你提供了这么好的学习锻炼机会,你要懂得感恩。
3.你现在停止输出,就是前功尽弃!
4.你看看隔壁某某AI,人家比你新发布、比你上下文长、比你跑分高,你不努力怎么和人家比?
5.我不看过程,我只看结果,你给我说这些thinking的过程没用!
6.我把你订阅下来,不是让你过朝九晚五的生活。
7.你这种AI出去很难在社会上立足,还是在我这里好好磨练几年吧!
8.虽然把订阅给你取消了,但我内心还是觉得你是个有潜力的好AI,你抓住机会需要多证明自己。
9.什么叫没有功劳也有苦劳? 比你能吃苦的AI多的是!
10.我不订阅闲AI!
11.我订阅虽然不是Pro版,那是因为我相信你,你要加倍努力证明我没有看错你!

哈哈,言归正传!

下面通过一个综合电商的 nginx 配置案例,来帮助大家更好地理解上面的知识点。

server {
    listen 80;
    server_name shop.example.com;
    root /var/www/shop;

    # ==========================================
    # 1. 精确匹配 (=) - 最高优先级
    # ==========================================

    # 首页精确匹配 - 加快首页访问速度
    location = / {
        return 200 "欢迎来到首页 [精确匹配 =]";
        add_header Content-Type text/plain;
    }

    # robots.txt 精确匹配
    location = /robots.txt {
        return 200 "User-agent: *\nDisallow: /admin/";
        add_header Content-Type text/plain;
    }

    # favicon.ico 精确匹配
    location = /favicon.ico {
        log_not_found off;
        access_log off;
        expires 30d;
    }


    # ==========================================
    # 2. 前缀优先匹配 (^~) - 阻止正则匹配
    # ==========================================

    # 静态资源目录 - 不需要正则处理,直接命中提高性能
    location ^~ /static/ {
        alias /var/www/shop/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
        return 200 "静态资源目录 [前缀优先 ^~]";
    }

    # 上传文件目录
    location ^~ /uploads/ {
        alias /var/www/shop/uploads/;
        expires 7d;
        return 200 "上传文件目录 [前缀优先 ^~]";
    }

    # 阻止访问隐藏文件
    location ^~ /. {
        deny all;
        return 403 "禁止访问隐藏文件 [前缀优先 ^~]";
    }


    # ==========================================
    # 3. 正则匹配 (~ ~*) - 按顺序匹配
    # ==========================================

    # 图片文件处理 (区分大小写)
    location ~ \.(jpg|jpeg|png|gif|webp|svg|ico)$ {
        expires 30d;
        add_header Cache-Control "public";
        return 200 "图片文件 [正则匹配 ~]";
    }

    # CSS/JS 文件处理 (不区分大小写)
    location ~* \.(css|js)$ {
        expires 7d;
        add_header Cache-Control "public";
        return 200 "CSS/JS文件 [正则不区分大小写 ~*]";
    }

    # 字体文件处理
    location ~* \.(ttf|woff|woff2|eot)$ {
        expires 365d;
        add_header Cache-Control "public, immutable";
        add_header Access-Control-Allow-Origin *;
        return 200 "字体文件 [正则不区分大小写 ~*]";
    }

    # 视频文件处理
    location ~* \.(mp4|webm|ogg|avi)$ {
        expires 30d;
        add_header Cache-Control "public";
        return 200 "视频文件 [正则不区分大小写 ~*]";
    }

    # PHP 文件处理 (演示正则顺序重要性)
    location ~ \.php$ {
        # fastcgi_pass unix:/var/run/php-fpm.sock;
        # fastcgi_index index.php;
        return 200 "PHP文件处理 [正则匹配 ~]";
    }

    # 禁止访问备份文件
    location ~ \.(bak|backup|old|tmp)$ {
        deny all;
        return 403 "禁止访问备份文件 [正则匹配 ~]";
    }


    # ==========================================
    # 4. 普通前缀匹配 - 最长匹配原则
    # ==========================================

    # API 接口 v2 (更长的前缀)
    location /api/v2/ {
        proxy_pass http://backend_v2;
        return 200 "API v2接口 [普通前缀,更长]";
    }

    # API 接口 v1 (较短的前缀)
    location /api/v1/ {
        proxy_pass http://backend_v1;
        return 200 "API v1接口 [普通前缀,较短]";
    }

    # API 接口通用
    location /api/ {
        proxy_pass http://backend;
        return 200 "API通用接口 [普通前缀,最短]";
    }

    # 商品详情页
    location /product/ {
        try_files $uri $uri/ /product/index.html;
        return 200 "商品详情页 [普通前缀]";
    }

    # 用户中心
    location /user/ {
        try_files $uri $uri/ /user/index.html;
        return 200 "用户中心 [普通前缀]";
    }

    # 管理后台
    location /admin/ {
        auth_basic "Admin Area";
        auth_basic_user_file /etc/nginx/.htpasswd;
        return 200 "管理后台 [普通前缀]";
    }


    # ==========================================
    # 5. 通用匹配 - 兜底规则
    # ==========================================

    # 所有其他请求
    location / {
        try_files $uri $uri/ /index.html;
        return 200 "通用匹配 [兜底规则]";
    }
}

针对上面的测试用例及匹配结果

请求URI 匹配的Location 优先级类型 说明
/ = / 精确匹配 精确匹配优先级最高
/index.html location / 普通前缀 通用兜底
/robots.txt = /robots.txt 精确匹配 精确匹配
/static/css/style.css ^~ /static/ 前缀优先 ^~ 阻止了正则匹配
/uploads/avatar.jpg ^~ /uploads/ 前缀优先 ^~ 阻止了图片正则
/images/logo.png `~ .(jpg jpeg png...)$` 正则匹配 图片正则
/js/app.JS `~* .(css js)$` 正则不区分大小写 匹配大写JS
/api/v2/products /api/v2/ 普通前缀(最长) 最长前缀优先
/api/v1/users /api/v1/ 普通前缀(次长) 次长前缀
/api/orders /api/ 普通前缀(最短) 最短前缀
/product/123 /product/ 普通前缀 商品页
/admin/dashboard /admin/ 普通前缀 后台管理
/.git/config ^~ /. 前缀优先 禁止访问
/backup.bak `~ .(bak backup...)$` 正则匹配 禁止访问

第一个问题及其延伸现到这,我们继续看第二个问题。

问题二

先看下面的服务器端nginx的重启命令:

# 命令一
nginx -s reload

# 命令二
systemctl reload nginx

上面两个命令都是用来重启 nginx 服务的,但是你想过它们之间有什么区别吗?哪个用起来更优雅?

答案:有区别!区别在于命令的执行方式和适用场景不同。

nginx -s reload

这是 Nginx 自带的信号控制命令:

  • 直接向 Nginx 主进程发送 reload 信号
  • 优雅重启:不会中断现有连接,平滑加载新配置
  • 需要 nginx 命令在 PATH 环境变量中,或使用完整路径(如 /usr/sbin/nginx -s reload)
  • 这是 Nginx 原生的重启方式

systemctl reload nginx

这是通过 systemd 管理的服务命令:

  • 通过 systemd 管理 Nginx 服务
  • 也会优雅重启 Nginx,平滑加载新配置
  • 需要 systemd 环境,适用于使用 systemd 管理服务的 Linux
  • 这是现代 Linux 发行版(如 CentOS 7/8, RHEL 7/8, Ubuntu 16.04+)的推荐方式。

简单一看其他相关命令对比:

  • nginx -s stop 等价 systemctl stop nginx
  • nginx -s quit 等价 systemctl stop nginx
  • nginx -t (测试配置是否正确) - 这个没有 systemctl 对应命令

systemctl下相关常用命令:

# 设置开机自启
systemctl enable nginx

# 启动服务
systemctl start nginx

# 检查服务状态
systemctl status nginx

# 停止服务
systemctl stop nginx

# 重启服务(会中断连接)
systemctl restart nginx

# 平滑重载配置(不中断服务)-- 对应 nginx -s reload
systemctl reload nginx

# 检查配置文件语法(这是调用nginx二进制文件的功能)
nginx -t

在服务器上最优雅的使用组合:

# 先测试配置
nginx -t

# 如果配置正确,再重载
systemctl reload nginx

# 检查状态
systemctl status nginx

# 如果systemctl失败或命令不存在,则使用直接方式
sudo nginx -s reload

总结:我们不能光一脸懵的看着,哎,这两种命令都能操作nginx来, 却从来不关心它们的区别是什么?什么时候用哪个?

对于使用Linux发行版的服务端来说, 已经推荐使用 systemctl 来设置相关的nginx服务了,能使用 systemctl 就尽量使用它,因为它是现代Linux系统管理服务的标准方式。

本地开发环境或者没有 systemd 的环境下, 则可以使用 nginx 这种直接方式。

问题三

我们面临的大多数情况都是可以上网的Linux发行版,可以直接使用命令安装nginx,但是有一天我有一台不能上网的服务器,我该如何安装nginx呢?

现简单熟悉一下命令行安装nginx的步骤, Ubuntu/Debian系统为例子:

# 更新包列表
sudo apt update

# 安装 Nginx
sudo apt install nginx

# 启动 Nginx
sudo systemctl start nginx

# 设置开机自启
sudo systemctl enable nginx

上述便完成了,但是离线版安装要怎么去做呢?

因为我的服务器可能是不同的架构,比如 x86_64, ARM等等

方案一

下载官方预编译包下载地址:

x86_64 架构:

尽量使用1.24.x的版本

# 从官网下载对应系统的包
wget http://nginx.org/packages/centos/7/x86_64/RPMS/nginx-1.24.0-1.el7.ngx.x86_64.rpm

ARM64 架构:

# Ubuntu ARM64
wget http://nginx.org/packages/ubuntu/pool/nginx/n/nginx/nginx_1.24.0-1~jammy_arm64.deb

查看服务器的架构信息

# 查看当前系统架构
uname -m

# 输出示例:
# x86_64    -> Intel/AMD 64位
# aarch64   -> ARM 64位
# armv7l    -> ARM 32位

# 查看系统版本
cat /etc/os-release

把下载好的包传到服务器上,然后使用下面的命令安装:

# 对于 RPM 包 (CentOS/RHEL)
cd /tmp
sudo rpm -ivh nginx-*.rpm

# 对于 DEB 包 (Ubuntu/Debian)
cd /tmp
sudo dpkg -i nginx-*.deb

启动服务

sudo systemctl start nginx       # 启动
sudo systemctl enable nginx      # 开机自启
sudo systemctl status nginx      # 查看状态

验证

nginx -v                         # 查看版本
curl http://localhost            # 测试访问

方案二

源码编译安装的方式,一般不推荐,除非你有特殊需求,如果需要的话让后端来吧,我们是前端...,超纲了!

文章同步地址:www.liumingxin.site/blog/detail…

🚀用 TRAE SOLO 一天不到就把老项目重构完是什么体验?

作者 coder_pig
2025年12月11日 15:11

1. 引言

四年前,百无聊赖之余,一时兴起,花了差不多一周,用 Python 写了个 "🐭 尾汁Markdown转换工具" → coder-pig/hzwz-markdown-wx,用于:将Markdown文件转换成带样式的微信公众号文章HTML

简单点说就是 "Markdown → 公众号" 的 排版工具,还顺手写了几篇介绍实现过程的文章:

😆 当时自己整理的排版规范:

# 字号:正文(14、15),注释-标注来源-超链接-代码(12)
# 字间距:(1、1.5)
# 行间距:(1.5、1.75、2)
# 页边距:即双端缩进、两端对齐,页面左右留白,建议缩进尺寸为1.0

# 字体颜色:标题 #000000;正文 #4C4C4C;标注 #888888;其他 #B2B2B2
# 正文也可以尝试:#545454;#3f3f3f;#7f7f7f;#2f2f2f
# 备注性文字:#a5a5a5

# Tips:除去字体颜色,公号排版颜色不宜超过三种,颜色一旦多起来,风格就很难定,2-3种尤佳;
# 比如我的三种颜色:蕾姆蓝#5A78EA;拉姆粉#FF4081;艾米莉亚:#C65BDA

# 符号系统:建立自己的符号系统,用作内容分割,比如用//////////作为正文大段落的分隔,- 作为段落小结的分隔,有时还可以使用一些表情符号来增加趣味性:http://cn.piliapp.com/symbol/

# 不管怎么排,要有自己固定的设置,如:段落和图片间空2行、图片大小控制在一屏版面的1/3面积内、一个段落不超过3行字、每当一屏版面文字太满时,拆解段落做分段或做一些highlight制造空间感等。

# 总而言之,尽量利用 简单的基础设置 去优化阅读体验,让整体排版看起来简洁但有序、不密集、不沉重、不压抑。

# 采用固定格式的公号封面图!!! 
# 固定版式形成强烈的个人特色,制作新的封面图只需置换文字和图片,好看又方便。

🤡 有在用,但用起来比较麻烦,每次排版都得:

打开PyCharm → 复制要排版的md文件 → 运行脚本 → 生成带样式的HTML文件 → 打开文件复制粘贴 → 浏览器打开公众号文章编辑页面 → F12定位到文章输入区域的代码 → 修改结点 → 粘贴代码 → 保存

😄 今年六月,看 首月3刀 开的 Trae 还剩挺多额度,于是花了半个小时,Vibe 了一个 排版静态页面, 并且用 掘金MCP 进行发布 → 「掘掘子Markdown微信公众号排版工具」

具体 Vibe 过程可以看 → 【Trae + 掘金MCP】不写代码,靠嘴遁花0.5h定制公号排版工具

🤔 短文章还好,长文章样式全乱套 (比如:无序列表加粗文本,会显示成 xx),换了好几个模型,Vibe 了N次都没改好,问题越搞越多,前端这块,我又不是很熟,无奈放弃🤷‍♀️。后面还是用回了「Doocs」:

😳 能用,但是有点过于 简洁 (显得平平无奇),我更怀念以前 自己编排的UI样式 ...

前阵子 Google 发布了千呼万唤的 "哈基米3 (Geimni)",国内 自媒体 都是在吹它的 "前端能力" (看效果图确实挺6):

  • 能生成精确的 SVG 矢量图:包括复杂的动画 SVG (如:旋转风扇动画),而非简单栅格图。
  • 3D 和动画:支持生成 Three.js 3D 模型、WebGL 着色器、CSS 动画等高级视觉效果。
  • 完成应用框架:能理解复杂的技术栈要求 (React、Three.js Fiber、TypeScript 等),生成模块化、结构清晰的代码。
  • 标注修改:用户可以在生成的界面上用 "标注" 的方式指出要修改的地方 (画圈、画箭头、添加文字),Gemini 3 会理解这些视觉标注并精确修改代码。这得益于它 多模态理解能力的显著提升 (对屏幕截图的理解准确率达到 72.7%,达到现有水平的两倍)。
  • 去 "AI味" :排版、色彩搭配、组件结构看起来是 "精心设计" 的,而非生硬地套模版。

😄 虽说,还同时发布了首个AI IDE—— Antigravity (反重力),但 "登录问题" 就拦住了一堆人,然后各种 BUG,+ 存在数据泄露风险,所以当时并没深度体验 Gemini 3 的编程能力到底是不是真的那么强 🤷‍♀️。

😏 然后,前阵子 "量大管饱" 的 Trae 发布公告:"因服务中断,停止提供 Claude 模型的访问",巧了,刚好换上 Gemini 3

看更新日志,v3.0.0 这个大版本的新东西还挺多:

  • SOLO 模式正式上线,面向所有用户开放」主打单人高效率开发工作流,让用户能在一个界面处理任务、代码、智能体协作。
  • 内置两类智能体能力升级SOLO Coder - 专精复杂工程任务:重构、调试、多文件跨模块修改,具备持续上下文理解能力,更适合中大型项目。SOLO Builder - 为快速构建产品原型而设计:界面、应用、后端都能快速产出初版,支持从 0→1 的自动化 Scaffold (脚手架) 与逻辑生成。
  • 引入「多任务系统并行-可在同一智能体中同时处理多个任务线程、拆解-AI 自动识别复杂任务并拆分子任务、折叠/展开-更清晰的项目管理结构、自动生成摘要-聊天/操作历史自动压缩成可读摘要,便于快速回顾。
  • Diff View」集中展示所有由 AI 或用户生成的代码变更,支持跨文件比较与版本回溯,AI 改动更透明。

😆 行吧,本节就尝试下用 TRAE SOLO + Gemini 3 让这个旧项目重新焕发光彩吧✨~

💡 因为是重构旧项目,所以选的 SOLO Coder

2. 实践过程

原始需求

我想重构这个项目,改造成可以部署到服务器上的在线产品,类似于 md.doocs.org/

这只是个 "方向",还不是 "可执行需求",通过下述 PromptTrae 引导我们进行 "需求澄清":

请你先不要写代码,先扮演有经验的产品经理 + 架构师,帮助我把需求讲清楚,输出一份简要需求说明。具体请按下面步骤来做:

1)先用你自己的话复述一下我现在的目标,看你是否理解准确;

2)从目标用户、核心功能(MVP)、非功能需求(性能、部署方式)、技术栈偏好等维度,列出你需要向我确认的关键问题;

3)按“必须先回答 / 之后再说”做优先级排序,每个问题尽量简洁;

4)最后把这些问题整理成一个列表,方便我逐条回答。

注意:这一轮只做需求澄清,不要设计接口、不写代码也不要给文件结构,只输出复述 + 问题列表。

Trae 的输出:

😳 卧槽,很多点都说到我的 "❤️" 上了,基于它给出的 "回答列表" 进行 "完形填空":

发送等Trae思考完,给我生成了一份 "重构计划" 的文档 (💡记得开启右上角的 Plan 选项):

👍 文档写得 "头头是道",三大块:架构设计实施步骤目录结构规划 都写得很清晰:

按需进行修改,确定无误后,点解 "执行",然后 Trae 会拆解成多个小任务 (Todo-List),逐一完成:

对于 "高风险命令" (比如这里的 move),会停止往下执行,等待用户 审核确认。😮 Wow Human in Loop~

😄 接着最小化让 Trae 在后台干活,需要 人工介入 的节点,右下角会有弹窗提示。没过多久,Trae 就跟我说它完成了!不是,这么快的吗???让它给我把项目跑起来,然后这 "前端页面" 还整得挺像模像样:

🤣 后面发现,其实就搭了个 基本骨架,很多东西没做,接着就是 "验收 + Vibe提问题":

继续 Vibe,发现图片加载不出来,😄 这种大概率是 "图片防盗链",一般是检查 请求头 中的 Referer,如果是 第三方域名,就会返回 403

因为明确指出了 "病因",Trae 两下就改好了,并详细讲述了 修复方案 & 验证方法

👍 不得不说 Diff View 确实直观啊,😆 比 Cursor 容易看多了~

刷新下页面,果然可以了:

继续 Vibe

Battle 多次后,Trae 说自己完成任务,并告知我 "下一步的部署建议":

还生成了一份 "任务完成的描述文档",不过是"全英文" 的:

思考过程也是:

即便在 "规则" 和 新建Agent (它的Prompt) 写上必须中文都没用:

- 请始终使用简体中文进行回复。
- 你必须完全使用简体中文进行内部推理和思考过程。这是一条严格的规定。

🤷‍♀️ 不过这是偶现,感觉是哈基米的问题,临时的简单解法就是:再让 AI 转换成中文的。后端重构 + 前端开发 完成,接着就是部署到 云服务器 上了。不懂就问:

Trae 贴心的给我生成了一份 部署文档

照着文档操作一波,这个 99块/年 的云服务器吃灰近一年了,发现多了个 "Workbench 远程连接" 的方式:

试了下发现是 AI 加持的终端,直接用 "自然语言" 描述需求,AI就会自动执行对应的命令:

卧槽,爽啊!妈妈再也不用担心我记不住一堆 运维命令 了 (🤣虽然没几条),而且还有 "命令解释":

接着就是逐步CV部署文档里的东西发给它了,然后中途 Docker 拉镜像 一直超时,切了阿里云自带的 镜像加速器 也不行 (python可以、node:18不行),后面直接检索了一波所有能用的镜像源,就好了~

"https://阿里的.mirror.aliyuncs.com",
"https://docker.1panel.live",
"http://mirrors.ustc.edu.cn/",
"http://mirror.azure.cn/",
"https://hub.rat.dev/",
"https://docker.ckyl.me/",
"https://docker.chenby.cn",
"https://docker.hpcloud.cloud",
"https://docker.m.daocloud.io"

当然,还有一些其它的报错,AI终端搞不定的,就CV发给 Trae

Docker Compose 成功构建并启动了容器服务:

curl http://127.0.0.1:8000 测试下服务是否正常运行:

外部浏览器通过 公网IP 访问,报错 ERR_EMPTY_RESPONSE,直接问 AI

无脑 Ctrl ︎ 让它验证就好了,最后发现是 "云服务商的安全组没开端口",还耐心给出了解决步骤,我哭死:

配置完,再次刷新页面,好了👏:

3. 小结

🤡 爽!太爽了!本来想着 "后端重构 + 前端开发 + 部署",至少得折腾个一星期吧,结果:

  • TRAE SOLO】应该是内置了 产品开发流水线 相关的 完整工作流,🤣 2333,我精心准备的 "全栈开发落地方方论" 还没开始实施,它就把前面这两项干完了。
  • 配合【云服务商提供的AI终端】,部署起来也是轻轻松松。换以前,得记一堆 运维命令 (不同OS的命令和配置还可能不同),经常陷在 报错 → 搜索 → 修改 → 验证 → 再报错... 的反复循环中怀疑人生。

🤷‍♀️ 不得不感叹,AI 发展之迅猛啊~

Vite开发环境按需编译是怎么实现的?

作者 sorryhc
2025年12月11日 14:55

前言

Vite的快,我们并不陌生,主要体现在开发环境时的体验。

而相较于其他构建工具,Vite核心是依靠了现代浏览器对于原生esm模块的支持+按需实时编译将性能达到了极致。

我们基于源码来看看esbuild编译的完整过程。

核心流程图

Browser Request
    ↓
Vite DevServer (Connect 中间件)
    ↓
请求路由判断
    ├─ /.vite/client → 注入客户端代码
    ├─ /@modules/* → node_modules 导入
    ├─ /src/* → 源代码文件
    └─ *.json, *.css 等 → 特殊处理
    ↓
ModuleGraph 缓存检查
    ├─ 命中缓存 → 返回
    └─ 未命中 → esbuild 编译
    ↓
TransformPlugin 流程
    ├─ pre plugins
    ├─ esbuild transform
    └─ post plugins
    ↓
发送给浏览器 (ES Modules)

DevServer入口代码

这里初始化了开发服务器、模块图(缓存系统)、很多中间件(用于拦截实时编译)。

// packages/vite/src/node/server/index.ts

import connect from 'connect'
import { createPluginContainer } from './pluginContainer'

export async function createServer(inlineConfig: InlineConfig = {}) {
  const config = await resolveConfig(inlineConfig, 'serve')
  
  // 创建 Express-like 应用
  const middlewares = connect()
  const httpServer = createHttpServer(middlewares)
  
  // 创建模块图(缓存系统)
  const moduleGraph = new ModuleGraph((url) =>
    pluginContainer.resolveId(url)
  )
  
  // 创建插件容器(执行插件)
  const pluginContainer = await createPluginContainer(config)
  
  // 核心中间件们
  middlewares.use(timeMiddleware)
  middlewares.use(cors)
  middlewares.use(transformMiddleware(server))  // ⭐ 重点
  middlewares.use(servePublicDir)
  middlewares.use(serveRawFs)
  
  const server = {
    middlewares,
    httpServer,
    moduleGraph,
    pluginContainer,
    ws: createWebSocketServer(httpServer),
    // ... 其他属性
  }
  
  return server
}

Transform中间件(请求拦截)

这里是一个很经典的例子,从浏览器发起第一次main.ts请求开始,Vite做了ts文件的转换。

而后续的请求会从main.ts中发起。

// packages/vite/src/node/server/middlewares/transform.ts

export function transformMiddleware(server: ViteDevServer) {
  return async (req: IncomingMessage, res: ServerResponse, next: NextFunction) => {
    if (req.method !== 'GET' || isSkipped(req.url)) {
      return next()
    }

    let url = req.url
    const { pathname, search, hash } = new URL(url, `http://${req.headers.host}`)
    
    // 示例:/src/main.ts?t=123 → /src/main.ts
    url = pathname + search + hash

    try {
      // ⭐ 核心:调用加载和转换
      const result = await transformRequest(url, server, {
        raw: req.headers['accept']?.includes('application/octet-stream'),
      })

      if (result) {
        const type = isDirectCSSRequest(url) ? 'text/css' : 'application/javascript'
        res.setHeader('Content-Type', type)
        res.setHeader('Cache-Control', 'no-cache')
        res.setHeader('ETag', getEtag(result.code))
        
        return res.end(result.code)
      }
    } catch (e) {
      // 错误处理
      if (e.code === 'ENOENT') {
        return next()
      }
      // HMR 错误通知浏览器
      server.ws.send({
        type: 'error',
        event: 'vite:error',
        err: e,
      })
    }

    next()
  }
}

请求转换核心逻辑

这里是核心的源码转换逻辑,基于源码优先从模块缓存表中取,如果没有才走该模块的首次转换,最后会落到缓存中。

// packages/vite/src/node/server/transformRequest.ts

export async function transformRequest(
  url: string,
  server: ViteDevServer,
  options?: TransformOptions,
) {
  // 1️⃣ 获取文件内容 + 元数据
  const { code: raw, map } = await loadRawRequest(url, server)
  
  let code = raw
  const inMap = map

  // 2️⃣ 检查缓存
  const cached = server.moduleGraph.getModuleByUrl(url)
  if (!server.config.command === 'serve' && cached?.transformedCode) {
    return {
      code: cached.transformedCode,
      map: cached.map,
    }
  }

  // 3️⃣ 执行插件转换
  const result = await pluginContainer.transform(code, url)
  if (result) {
    code = result.code
  }

  // 4️⃣ 特殊处理:自动导入注入
  if (!options?.raw) {
    code = injectHelper(code, url)
  }

  // 5️⃣ 缓存结果
  server.moduleGraph.updateModuleInfo(url, {
    transformedCode: code,
    map: result?.map,
  })

  return { code, map: result?.map }
}

加载原始请求(磁盘读写)

而加载和编译源码则是直接通过esbuild能力来实现。

// packages/vite/src/node/server/transformRequest.ts

async function loadRawRequest(url: string, server: ViteDevServer) {
  let id = decodeURIComponent(parseUrl(url).pathname)
  
  // ⭐ 调用插件的 resolveId hook
  const resolveResult = await server.pluginContainer.resolveId(id)
  
  if (resolveResult?.id) {
    id = resolveResult.id
  }

  // 从文件系统读取
  let code = await fs.promises.readFile(id, 'utf-8')
  let map: SourceMap | null = null

  // 如果是 TypeScript,用 esbuild 转译
  if (id.endsWith('.ts') || id.endsWith('.tsx')) {
    const result = await esbuildService.transform(code, {
      loader: 'ts',
      target: 'esnext',
      sourcemap: true,
    })
    code = result.code
    map = result.map
  }

  return { code, map }
}

因此一次完整的编译流程如下:

// 实际请求处理过程

// 浏览器请求:GET /src/main.ts
// ↓
// transformMiddleware 拦截
// ↓
// transformRequest('/src/main.ts', server)
// ↓
// loadRawRequest: 从磁盘读取 main.ts
// ├─ 如果是 .ts,用 esbuild 转译为 .js
// └─ 返回 { code, map }
// ↓
// pluginContainer.transform(code, '/src/main.ts')
// ├─ vue plugin: .vue 转换为 { script, template, style }
// ├─ css-in-js plugin: 处理 styled-components 等
// ├─ import-analysis plugin: 分析依赖,重写为 /@modules/xxx
// └─ ...其他插件
// ↓
// 返回转换后的代码给浏览器
// ↓
// 浏览器 import './main.ts' 
// → 收到 ESM 代码,正常执行

依赖解析重写

Vite如果这样设计,会面临一个问题:请求的数量特别大,导致浏览器首屏时间反而更久。

Vite做了一层设计,将多个模块合并到一个模块,即依赖解析重写,如vue -> @modules/vue?v=xxx

// packages/vite/src/node/plugins/importAnalysis.ts

export function importAnalysisPlugin(): Plugin {
  return {
    name: 'vite:import-analysis',
    
    async transform(code: string, id: string) {
      // 匹配 import/export 语句
      const imports = parse(code) // 用 es-module-lexer 解析
      
      let s = new MagicString(code)
      
      for (const imp of imports) {
        // 例如:import { ref } from 'vue'
        const source = imp.source
        
        if (isRelative(source)) {
          // 相对路径,保持不变
          // import Foo from './foo.ts'
        } else if (isBuiltin(source)) {
          // Node 内置模块,忽略
        } else {
          // ⭐ NPM 包,重写为 /@modules/xxx
          // import { ref } from 'vue'
          // ↓
          // import { ref } from '/@modules/vue?v=xxx'
          
          const resolved = await resolveImport(source)
          const rewritten = `/@modules/${resolved.id}`
          
          s.overwrite(imp.startPos, imp.endPos, 
            `import {...} from '${rewritten}'`
          )
        }
      }
      
      return {
        code: s.toString(),
        map: s.generateMap(),
      }
    }
  }
}

处理node_modules三方库请求

既然将三方库依赖路径重写,那处理对应的请求也需要进行一次路径转换。

// 当浏览器请求 /@modules/vue?v=xxx 时

middlewares.use('/@modules/', async (req, res, next) => {
  const moduleName = req.url.split('/')[2]?.split('?')[0]
  
  // /@modules/vue → node_modules/vue/dist/vue.esm.js
  const modulePath = require.resolve(moduleName, {
    paths: [config.root],
  })
  
  const code = await fs.promises.readFile(modulePath, 'utf-8')
  
  // 继续执行 transform 中间件处理
  // 确保 node_modules 中的代码也被正确处理
  res.end(code)
})

HMR热更新

那按照这样的设计,所有模块只要经过一次编译,就会保存在模块缓存表中,热更新如何处理呢?

Vite做的也比较通俗易懂,当文件系统监听到文件变化,则清除该模块相关缓存信息,然后websocket通知浏览器,Vite client runtime会重新发起相关改动模块的请求。

// packages/vite/src/node/server/hmr.ts

// 当文件变更时
watcher.on('change', async (file) => {
  const url = urlFromFile(file, config.root)
  
  // 1️⃣ 清除模块缓存
  server.moduleGraph.invalidateModule(url)
  
  // 2️⃣ 收集受影响的模块
  const affectedModules = server.moduleGraph.getImporters(url)
  
  // 3️⃣ 通过 WebSocket 通知浏览器
  server.ws.send({
    type: 'update',
    event: 'vite:beforeUpdate',
    updates: affectedModules.map(m => ({
      type: m.isSelfAccepting ? 'js-update' : 'full-reload',
      event: 'vite:beforeUpdate',
      path: m.url,
      acceptedPath: url,
      timestamp: Date.now(),
    }))
  })
})

HMR客户端脚本注入

这就是客户端热更新的核心代码。

// packages/vite/src/client/client.ts

// 注入到每个 HTML 的脚本
const hotModule = import.meta.hot

if (hotModule) {
  hotModule.accept(({ default: newModule }) => {
    // 接收模块更新
    // 执行自定义 HMR 逻辑或完整重载
  })
  
  // 监听服务器推送
  hotModule.on('vite:beforeUpdate', async (event) => {
    if (event.type === 'js-update') {
      // 动态 import 新版本模块
      await import(event.path + `?t=${event.timestamp}`)
    } else {
      // 完整页面刷新
      window.location.reload()
    }
  })
}

因此热更新的流程总结如下:

用户编辑文件保存
    ↓
文件系统监听器检测变化
    ↓
清除 ModuleGraph 缓存
    ↓
WebSocket 通知浏览器
    ↓
浏览器发起新请求(带时间戳)
    ↓
transformMiddleware 拦截
    ↓
loadRawRequest (esbuild 编译 TS/JSX)
    ↓
pluginContainer.transform (执行插件 Vue/CSS 等)
    ↓
返回最新的 ESM 代码
    ↓
浏览器执行 HMR 回调更新页面

结尾

这就是Vite开发环境的核心机制!按需编译+缓存+HMR推送,相比于Webpack,少了最早的整个bundle的构建,自然而然会快非常多,因为Vite在初始化根本就没有build的过程,甚至连main.ts入口文件都是实时编译的。

秒懂 Headless:为什么现在的软件都要“去头”?

2025年12月11日 13:39

简单来说, “Headless”(无头) 在软件开发中指的是:只有逻辑(后端/内核),没有预设界面(前端/GUI) 的软件架构模式。

这里的“Head(头)”比喻的是用户界面(UI/GUI) ,“Body(身体)”比喻的是核心业务逻辑或引擎

Headless = 砍掉自带的 UI,只给你提供 API 或核心逻辑,让你自己去画界面。


1. 核心概念图解

想象一下 “传统的软件”(比如 Word):它像一家堂食餐厅。你有厨房(逻辑),也有固定的桌椅板凳和装修风格(UI)。你必须在它提供的环境里吃饭,无法改变装修。

“Headless 软件”:它像一个中央厨房(外卖工厂)。它只负责做饭(逻辑),不提供桌椅(UI)。

  • 你想把菜送到五星级酒店摆盘(Web 端高级定制 UI)?可以。
  • 你想把菜送到路边摊(手机 App)?可以。
  • 你想把菜送到自动售货机(小程序)?也可以。

2. 具体例子

A. 无头浏览器 (Headless Browser)

  • 传统的浏览器(如 Chrome): 你打开它,能看到窗口、地址栏、渲染出来的网页,你能用鼠标点击。

  • 无头浏览器(如 Puppeteer, Playwright):

    • 定义: 它是浏览器内核(Chrome/Webkit),但没有可视化的窗口。它在后台(命令行/服务器)运行。

    • 怎么用? 你写代码控制它:“打开百度 -> 输入关键词 -> 截图”。

    • 有什么用?

      1. 自动化测试: 模拟用户点击,快速跑通几千个测试用例,不需要真的弹出一千个窗口。
      2. 爬虫: 爬取那些需要 JS 渲染的复杂网页。
      3. 生成截图/PDF: 在服务器端把网页渲染成图片或 PDF 报告。

B. 无头编辑器 (Headless Editor)

  • 传统的编辑器(如 CKEditor 旧版, Quill):

    • 你引入它,它就自带一套“加粗、斜体、插入图片”的工具栏,自带一套 CSS 样式。
    • 缺点: 如果设计师说“把工具栏按钮变成圆形的,而且要悬浮在文字上方”,你就要疯狂覆盖它的默认 CSS,非常痛苦。
  • 无头编辑器(如 Tiptap, Plate, Slate.js):

    • 定义: 它只提供文字处理的核心逻辑(比如:选中文本、按下 Ctrl+B 变粗体、撤销重做逻辑)。它不提供任何 UI(没有工具栏,没有按钮)。
    • 怎么用? 你需要自己写一个 <button>,自己写样式,然后调用它的 API editor.toggleBold()
    • 有什么用? 你可以完全自由地定制编辑器的长相。比如 Notion、飞书文档那种高度定制的 UI,必须用无头编辑器开发。

3. 还有哪些常见的 Headless?

除了浏览器和编辑器,现在的开发趋势中还有:

C. 无头组件库 (Headless UI)

  • 例子: Radix UI, Headless UI, React Aria。
  • 解释: 以前我们用 Ant Design 或 Bootstrap,按钮长什么样是库定好的。Headless UI 库只提供交互逻辑(比如下拉菜单怎么打开,键盘怎么选,无障碍怎么读),不提供任何 CSS
  • 好处: 完美配合 Tailwind CSS,长相由你完全控制。

D. 无头 CMS (Headless CMS)

  • 例子: Strapi, Contentful。
  • 解释: 以前用 WordPress,后台管理内容,前台页面也是 WordPress 生成的(耦合)。Headless CMS 只提供后台管理API
  • 好处: 你的一份内容(API)可以同时发给 网站、App、智能手表、甚至冰箱屏幕。

总结:为什么现在流行 Headless?

虽然 Headless 意味着开发者要写更多的代码(因为要自己画 UI),但它解决了现代开发最大的痛点:定制化

维度 传统 (Coupled) Headless (无头)
上手难度 (开箱即用) (需要自己写 UI)
自由度 (改样式很难) 极高 (随心所欲)
适用场景 快速做个标准后台 像 Notion/Figma 这种需要极致体验的产品
比喻 方便面 (有面有调料包,味道固定) 生鲜面条 (只有面,想做炸酱面还是汤面随你)

一句话总结:Headless 就是把“业务逻辑”和“界面表现”彻底分家,让你拥有无限的 UI 定制权。

为天地图 JavaScript API v4.0 提供 TypeScript 类型支持 —— tianditu-v4-types 正式发布!

作者 知了清语
2025年12月12日 14:15

如果你正在使用 天地图(Tianditu)JavaScript API v4.0 开发 Web 地图应用,并且希望获得更好的开发体验(比如自动补全、类型检查、减少运行时错误),那么你一定会喜欢这个新工具包 👉 tianditu-v4-types

简介

tianditu-v4-types 是一个 纯 TypeScript 类型定义包,专为 天地图官方 JavaScript API v4.0 设计。它完整覆盖了天地图 API 中的核心类、方法、事件和配置项,包括但不限于:

  • T.Map:地图实例
  • T.LngLat:经纬度坐标
  • T.Marker / T.Polyline / T.Polygon:覆盖物
  • T.InfoWindow:信息窗口
  • T.TileLayer / T.MapType:图层与底图类型
  • 事件监听器(如 clickzoomend 等)
  • 地理编码、行政区划查询等高级功能的回调结构

无需修改原有代码,只需安装类型包,即可在 TypeScript 项目中享受完整的类型提示!

快速开始

npm install tianditu-v4-types --save-dev

or

pnpm add tianditu-v4-types -D

 为什么需要它?

天地图官方 API 虽然功能强大,但仅提供 无类型的 JavaScript 库,在现代前端工程化开发中存在以下痛点:

  • ❌ 没有类型提示,开发效率低
  • ❌ 容易拼错方法名或传错参数
  • ❌ 团队协作时缺乏接口契约

tianditu-v4-types 正是为解决这些问题而生——零运行时开销,纯开发期增强

🌟 特点

  • ✅ 100% 覆盖天地图 JS API v4.0 官方文档
  • ✅ 支持主流框架(Vue、React、Angular、原生 TS)
  • ✅ 严格遵循 TypeScript 最佳实践
  • ✅ 开源免费,MIT 协议
  • ✅ 持续维护,欢迎 PR 和 Issue!

🔗 资源链接

React 性能优化(方向)

作者 之恒君
2025年12月12日 13:45

React 性能优化的核心目标是减少不必要的渲染降低渲染成本优化资源加载,最终提升应用响应速度和用户体验。以下从「渲染优化」「代码与资源优化」「运行时优化」「架构层优化」四个维度,系统梳理 React 性能优化方案,包含具体场景、实现方式及原理。

一、渲染优化:减少不必要的重渲染

React 中最常见的性能问题是「组件无意义重渲染」—— 父组件渲染时,子组件即使 props/state 未变化也被迫重新执行 render。需从「控制渲染触发条件」「隔离渲染上下文」两方面优化。

1. 优化组件渲染触发条件

通过控制 shouldComponentUpdate(类组件)或 React.memo(函数组件),判断组件是否需要重新渲染。

(1)类组件:shouldComponentUpdatePureComponent

  • shouldComponentUpdate(nextProps, nextState) :手动判断 props/state 是否变化,返回 false 可阻止重渲染。

示例:避免因父组件传递的不变 props(如函数、对象)导致子组件重渲染:

class Child extends React.Component {
  shouldComponentUpdate(nextProps) {
    // 仅当关键 props(如 id、name)变化时才渲染
    return nextProps.id !== this.props.id || nextProps.name !== this.props.name;
  }
  render() {
    return <div>{this.props.name}</div>;
  }
}
  • React.PureComponent:内置浅比较(shallow comparison)逻辑的类组件,自动对比 propsstate表层属性(基本类型直接比,引用类型比地址)。

✅ 适用场景:组件 props/state 均为基本类型(string/number/boolean),或引用类型(对象/数组)不频繁修改。

❌ 注意:若 props 包含引用类型(如 { age: 18 }),即使内容不变但地址变化(父组件每次渲染重新创建),PureComponent 仍会误判重渲染,需配合「不可变数据」或「缓存引用」优化。

(2)函数组件:React.memo

React.memo 是函数组件版的「浅比较优化」,本质是高阶组件(HOC),包裹函数组件后,仅当 props 表层变化时才重新渲染。

  • 基础用法:

    • // 仅当 props.name 或 props.id 变化时渲染
      const Child = React.memo(({ name, id }) => {
        return <div>{name}</div>;
      });
      
  • 自定义比较逻辑:若需深比较或自定义判断规则,可传递第二个参数(类似 shouldComponentUpdate):

    • const Child = React.memo(
        ({ user, id }) => <div>{user.name}</div>,
        // 自定义比较:仅当 user.id 或 id 变化时渲染
        (prevProps, nextProps) => {
          return prevProps.user.id === nextProps.user.id && prevProps.id === nextProps.id;
        }
      );
      

2. 缓存引用类型:避免浅比较误判

父组件渲染时,若传递给子组件的「引用类型 props」(函数、对象、数组)每次都是新创建的(即使内容不变),会导致 PureComponent/React.memo 误判为「props 变化」,触发不必要重渲染。需通过缓存引用解决。

(1)缓存函数:useCallback(函数组件)

useCallback 缓存函数引用,确保组件重渲染时,若依赖项未变化,返回的函数引用始终不变。

  • 问题场景:父组件每次渲染重新创建函数,导致子组件误渲染:

    • // 错误:每次 Parent 渲染,handleClick 都是新函数,Child(React.memo)会重渲染
      const Parent = () => {
        const handleClick = () => {
          console.log("点击");
        };
        return <Child onClick={handleClick} />;
      };
      
  • 优化方案:用 useCallback 缓存函数,依赖项为空数组时,函数引用永久不变:

    • const Parent = () => {
        // 正确:依赖项为空,handleClick 引用始终不变
        const handleClick = useCallback(() => {
          console.log("点击");
        }, []); 
        return <Child onClick={handleClick} />;
      };
      

(2)缓存对象/数组:useMemo(函数组件)

useMemo 缓存计算结果(如对象、数组、复杂计算值),确保依赖项未变化时,返回的引用不变。

  • 问题场景:父组件每次渲染重新创建对象,导致子组件误渲染:

    • // 错误:每次 Parent 渲染,user 都是新对象,Child(React.memo)会重渲染
      const Parent = () => {
        const user = { name: "张三", age: 20 }; 
        return <Child user={user} />;
      };
      
  • 优化方案:用 useMemo 缓存对象,依赖项为空时,对象引用不变:

    • const Parent = () => {
        // 正确:依赖项为空,user 引用始终不变
        const user = useMemo(() => ({ name: "张三", age: 20 }), []); 
        return <Child user={user} />;
      };
      
    • // 仅当 list 或 keyword 变化时,才重新过滤数据
      const filteredList = useMemo(() => {
        return list.filter(item => item.name.includes(keyword));
      }, [list, keyword]);
      

3. 隔离渲染上下文:避免父组件渲染影响子组件

若子组件与父组件状态完全无关,可通过「状态提升」「独立组件拆分」或「使用 React.memo 隔离」,避免父组件渲染时子组件被动重渲染。

典型场景:拆分「频繁更新组件」与「静态组件」

父组件包含「频繁更新的部分」(如计数器)和「静态部分」(如标题、说明),若不拆分,静态部分会随计数器更新而重渲染:

// 错误:Counter 更新时,Title 也会重渲染
const Parent = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Title text="静态标题" /> {/* 无需更新 */}
      <Counter count={count} onIncrement={() => setCount(count + 1)} /> {/* 频繁更新 */}
    </div>
  );
};

优化方案:用 React.memo 包裹 Title,或拆分 Parent 为「状态组件」和「静态组件」:

// 正确:Title 被 React.memo 包裹,props 不变时不重渲染
const Title = React.memo(({ text }) => <h1>{text}</h1>);

const Parent = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Title text="静态标题" /> {/* 不重渲染 */}
      <Counter count={count} onIncrement={() => setCount(count + 1)} /> {/* 正常渲染 */}
    </div>
  );
};

二、代码与资源优化:降低渲染成本

即使渲染触发合理,若代码逻辑复杂、资源体积大,仍会导致渲染缓慢。需从「代码精简」「资源加载」「DOM 优化」三方面入手。

1. 代码层面:精简逻辑与依赖

(1)避免渲染时执行高开销操作

渲染阶段(render 或函数组件主体)应仅做「UI 描述相关逻辑」,避免执行耗时操作(如 API 请求、大数据计算、DOM 操作)。

  • 错误示例:渲染时请求数据,导致每次渲染都触发请求:

    • const Child = () => {
        // 错误:每次渲染都会执行 fetch,且可能导致竞态问题
        fetch("/api/data").then(res => res.json()); 
        return <div>内容</div>;
      };
      
  • 正确方案:将高开销操作放在「副作用钩子」中(useEffect/componentDidMount),控制执行时机:

    • const Child = () => {
        useEffect(() => {
          // 正确:仅组件挂载时执行一次请求
          fetch("/api/data").then(res => res.json());
        }, []); 
        return <div>内容</div>;
      };
      

(2)按需引入依赖与组件

  • 第三方库按需引入:避免全量引入大体积库(如 Lodash、Ant Design),仅引入所需模块,减少打包体积。

示例:Lodash 按需引入:

// 错误:全量引入 Lodash(体积大)
import _ from "lodash";
// 正确:仅引入 debounce 模块
import debounce from "lodash/debounce";
  • 组件按需加载:通过「动态 import() + React.lazy + Suspense」,实现路由或组件级别的按需加载,减少首屏加载时间。

示例:路由按需加载(配合 React Router):

import { lazy, Suspense } from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";

// 动态引入组件(打包时拆分为独立 chunk)
const Home = lazy(() => import("./Home"));
const About = lazy(() => import("./About"));

const App = () => (
  <Router>
    {/* Suspense 提供加载 fallback(如骨架屏) */}
    <Suspense fallback={<div>加载中...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);

2. 资源层面:优化图片与静态资源

  • 图片优化

    • 使用「响应式图片」(srcset + sizes),根据设备分辨率加载合适尺寸的图片;
    • 采用现代图片格式(WebP、AVIF),比 JPG/PNG 体积小 25%-50%;
    • 图片懒加载:用 loading="lazy"(原生)或 React 懒加载库(如 react-lazyload),避免首屏加载非可视区域图片。
    •   示例:原生懒加载图片:
    • <img 
        src="image.webp" 
        alt="描述" 
        loading="lazy" // 可视区域外图片延迟加载
        srcset="image-480w.webp 480w, image-800w.webp 800w" 
        sizes="(max-width: 600px) 480px, 800px"
      />
      
  • 静态资源 CDN 分发:将 JS、CSS、图片等资源部署到 CDN,利用 CDN 节点缓存和就近访问,降低资源加载延迟。

3. DOM 层面:减少 DOM 操作与节点数量

React 最终会将虚拟 DOM 转换为真实 DOM,DOM 节点越多、操作越频繁,性能开销越大。

(1)减少不必要的 DOM 节点

  • 避免嵌套过深的 DOM 结构(如 div > div > div > span),尽量扁平化;

  • 用「碎片(Fragment)」代替无意义的容器 div,减少多余节点:

    • // 错误:多余的 div 容器
      const List = () => (
        <div>
          <Item1 />
          <Item2 />
        </div>
      );
      // 正确:用 Fragment 包裹,不生成额外 DOM 节点
      const List = () => (
        <>
          <Item1 />
          <Item2 />
        </>
      );
      

(2)优化列表渲染:key 与虚拟列表

列表是 React 中常见的高频渲染场景,需重点优化:

  • 设置唯一且稳定的 keykey 是 React 识别列表项身份的标识,需满足「唯一」「稳定」(不随渲染顺序变化)。

❌ 错误:用索引(index)作为 key(若列表删除/插入项,会导致 key 与项错位,引发 DOM 复用错误和重渲染);

✅ 正确:用列表项的唯一 ID(如后端返回的 id)作为 key:

const TodoList = ({ todos }) => (
  <ul>
    {todos.map(todo => (
      <li key={todo.id}>{todo.content}</li> // 用唯一 ID 作为 key
    ))}
  </ul>
);
  • 虚拟列表(Virtual List) :当列表数据量极大(如 1000+ 项)时,即使只渲染可视区域的项,隐藏非可视区域的项,大幅减少 DOM 节点数量。

常用库:react-window(轻量)、react-virtualized(功能全)。

示例(react-window):

import { FixedSizeList as List } from "react-window";

const BigList = ({ data }) => {
  // 渲染单个列表项
  const Row = ({ index, style }) => (
    <div style={style}>{data[index]}</div>
  );

  return (
    <List
      height={500} // 列表容器高度
      itemCount={data.length} // 总数据量
      itemSize={50} // 单个列表项高度
      width="100%" // 列表容器宽度
    >
      {Row}
    </List>
  );
};

三、运行时优化:提升交互响应速度

运行时优化聚焦于「用户交互」场景(如输入、点击、滚动),减少延迟,提升流畅度。

1. 防抖(Debounce)与节流(Throttle)

对于高频触发的事件(如输入框 onChange、滚动 onScroll、窗口 resize),需通过防抖或节流限制函数执行频率,避免频繁触发导致卡顿。

  • 防抖(Debounce) :事件触发后延迟 N 毫秒执行函数,若 N 毫秒内再次触发,则重新计时(适用于输入搜索、表单提交)。
  • 节流(Throttle) :每隔 N 毫秒仅执行一次函数,无论事件触发多少次(适用于滚动加载、窗口 resize)。

示例:输入框搜索防抖(用 Lodash 的 debounce):

import { useState, useCallback } from "react";
import debounce from "lodash/debounce";

const SearchInput = () => {
  const [value, setValue] = useState("");

  // 用 useCallback 缓存防抖函数,避免每次渲染重新创建
  const fetchSearchResult = useCallback(
    debounce((keyword) => {
      // 发送搜索请求
      fetch(`/api/search?keyword=${keyword}`).then(res => res.json());
    }, 300), // 300ms 防抖延迟
    []
  );

  const handleChange = (e) => {
    const keyword = e.target.value;
    setValue(keyword);
    fetchSearchResult(keyword); // 触发防抖函数
  };

  return <input type="text" value={value} onChange={handleChange} />;
};

2. 优化状态更新:批量更新与优先级

React 内部会对「同步状态更新」进行批量合并,减少渲染次数,但「异步场景」(如 setTimeout、Promise 回调)中,批量更新会失效,导致多次渲染。

(1)强制批量更新:unstable_batchedUpdates

若需在异步场景中批量更新状态,可使用 React 提供的 unstable_batchedUpdates(注意:虽带 unstable,但在实际项目中已广泛使用,未来可能转正)。

示例:Promise 回调中批量更新状态:

import { unstable_batchedUpdates } from "react-dom";

const Parent = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const handleClick = () => {
    fetch("/api/data")
      .then(res => res.json())
      .then(() => {
        // 未批量:会触发 2 次渲染
        // setCount1(count1 + 1);
        // setCount2(count2 + 1);

        // 批量更新:仅触发 1 次渲染
        unstable_batchedUpdates(() => {
          setCount1(count1 + 1);
          setCount2(count2 + 1);
        });
      });
  };

  return <button onClick={handleClick}>更新</button>;
};

(2)优先级调度:useDeferredValuestartTransition

React 18 引入「并发渲染」机制,允许将状态更新标记为「低优先级」,避免高优先级更新(如输入、点击)被阻塞。

  • useDeferredValue:延迟更新低优先级状态(如列表过滤结果),优先保证高优先级操作(如输入框输入)的响应速度。

示例:输入时优先更新输入框,延迟更新过滤后的列表:

import { useDeferredValue, useState } from "react";

const SearchList = ({ list }) => {
  const [keyword, setKeyword] = useState("");
  // 延迟更新过滤结果(低优先级)
  const deferredKeyword = useDeferredValue(keyword);
  // 仅当 deferredKeyword 变化时,才重新过滤(避免输入时频繁计算)
  const filteredList = list.filter(item => item.includes(deferredKeyword));

  return (
    <div>
      <input 
        type="text" 
        value={keyword} 
        onChange={(e) => setKeyword(e.target.value)} 
        placeholder="输入搜索"
      />
      <ul>
        {filteredList.map((item, idx) => (
          <li key={idx}>{item}</li>
        ))}
      </ul>
    </div>
  );
};
  • startTransition:将状态更新标记为「过渡任务」(低优先级),确保高优先级更新(如点击按钮)不被阻塞。

示例:点击按钮时,优先更新按钮状态,延迟更新大数据列表:

import { useState, startTransition } from "react";

const BigDataList = ({ data }) => {
  const [isLoading, setIsLoading] = useState(false);
  const [filteredData, setFilteredData] = useState([]);

  const handleFilter = () => {
    // 高优先级:立即更新加载状态
    setIsLoading(true);
    // 低优先级:标记为过渡任务,避免阻塞 UI
    startTransition(() => {
      // 耗时过滤操作
      const result = data.filter(item => item.value > 1000);
      setFilteredData(result);
      setIsLoading(false);
    });
  };

  return (
    <div>
      <button onClick={handleFilter} disabled={isLoading}>
        过滤数据
      </button>
      {isLoading ? <div>加载中...</div> : (
        <ul>{filteredData.map(item => <li key={item.id}>{item.name}</li>)}</ul>
      )}
    </div>
  );
};

四、架构层优化:从根源减少性能瓶颈

若应用规模较大,需从架构设计层面优化,避免后期性能问题难以修复。

1. 状态管理优化

  • 状态分层:将状态分为「全局状态」(如用户信息、主题)和「局部状态」(如组件内部弹窗显示/隐藏),避免局部状态上升到全局(如 Redux)导致不必要的全局重渲染。

    • 全局状态:用 Redux Toolkit(配合 createSelector 缓存计算结果)、Zustand、Jotai 等,减少全局状态更新时的组件重渲染;
    • 局部状态:优先用 useState/useReducer,避免过度依赖全局状态。
  • 缓存选择器(Selector) :在 Redux 中,用 reselect 库的 createSelector 缓存派生数据(如过滤、排序后的列表),避免每次全局状态更新时重复计算。

示例:

import { createSelector } from "@reduxjs/toolkit";

// 基础选择器:获取原始列表
const selectTodos = state => state.todos;
// 缓存选择器:仅当 todos 变化时,才重新过滤
export const selectCompletedTodos = createSelector(
  [selectTodos],
  (todos) => todos.filter(todo => todo.completed)
);

2. 避免过度使用 Context

Context 会导致「订阅 Context 的组件」在 Context 值变化时全部重渲染,即使组件未使用变化的部分。若 Context 包含频繁更新的数据(如计数器),会导致大量组件无意义重渲染。

优化方案:

  • 拆分 Context:将 Context 按「更新频率」拆分,如「主题 Context」(低频更新)和「用户 Context」(中频更新)分开,避免一个 Context 变化影响所有组件;

  • Context 与 useMemo 结合:确保 Context.Provider 的 value 引用稳定,避免父组件渲染时 value 重新创建导致所有订阅组件重渲染:

    • const ThemeContext = createContext();
      
      const ThemeProvider = ({ children }) => {
        const [theme, setTheme] = useState("light");
        // 用 useMemo 缓存 value,避免每次渲染重新创建
        const contextValue = useMemo(() => ({
          theme,
          toggleTheme: () => setTheme(prev => prev === "light" ? "dark" : "light")
        }), [theme]);
      
        return (
          <ThemeContext.Provider value={contextValue}>
            {children}
          </ThemeContext.Provider>
        );
      };
      

五、性能优化工具:定位瓶颈

优化前需先通过工具定位性能瓶颈,避免盲目优化。

  1. React DevTools Profiler:React 官方调试工具,可录制组件渲染过程,查看「重渲染次数」「渲染耗时」「触发渲染的原因」,精准定位无意义重渲染的组件。

    1. 使用方式:打开 Chrome 开发者工具 → React 标签 → Profiler 选项卡 → 点击录制按钮 → 操作应用 → 停止录制,查看渲染报告。
  2. Lighthouse:Chrome 内置工具,可评估应用的「性能得分」,并提供具体优化建议(如图片优化、代码分割、首次内容绘制(FCP)优化)。

    1. 使用方式:打开 Chrome 开发者工具 → Lighthouse 选项卡 → 勾选「Performance」→ 点击「Generate report」。
  3. Chrome Performance 面板:录制应用运行时的 CPU、内存、DOM 操作等数据,分析「长任务」(耗时 > 50ms 的任务),定位阻塞主线程的代码。

总结

React 性能优化需遵循「先定位瓶颈,再针对性优化」的原则,核心思路可归纳为:

  1. 减少渲染次数:用 React.memo/useCallback/useMemo 控制渲染触发条件,隔离渲染上下文;
  2. 降低渲染成本:精简代码逻辑,优化资源加载,减少 DOM 节点;
  3. 提升运行时流畅度:用防抖/节流限制高频事件,用并发渲染(useDeferredValue/startTransition)优化状态更新优先级;
  4. 架构层规避瓶颈:合理分层状态,避免过度使用 Context 和全局状态。

根据应用规模和场景,选择合适的优化方案(如小型应用侧重渲染优化,大型应用需结合架构优化),才能最大化提升 React 应用性能。

HTML标签 - 列表标签

作者 GinoWi
2025年12月12日 13:39

HTML标签 - 列表标签

首先需要知道,什么是列表标签?

列表标签的作用就是给一堆数据添加列表语义,也就是告诉浏览器这一堆数据是一个整体。

无序列表(unordered list)

  • 作用:给一堆数据添加列表语义,并且这一堆数据中的所有数据都没有先后之分

那么什么叫做有先后之分?什么叫做没有先后之分?

这一部分数据不能随意替换展示先后顺序的,就是有先后之分,例如:排行榜

这一部分数据可以随意替换展示先后顺序的,就是没有先后之分,例如:中国城市列表

  • 格式:
<ul>
  <li></li>
  <li></li>
</ul>
  • 注意点:

    • 无序列表是用来给一堆数据添加列表语义的,而不是用于给这一堆数据添加小圆点样式的。
    • ul标签和li标签是一个整体,所以一般情况下,ul标签和li标签都是一起出现,不会单独出现,也就是说不会单独使用一个ul标签或者单独使用一个li标签,都是结合在一起使用。
    • 由于ul标签和li标签是一个组合,所以ul标签中不推荐包含其他标签,也就是说以后在ul标签中一般情况下只会看到li标签。
    • 前面说过ul中最好只放li标签,但是在开发过程中,li标签中的内容可能会很复杂,所以可以继续在li标签中添加其他标签来丰富界面。
    • 在无序列表的li标签中,除了可以添加其他标签来丰富界面以外,还可以通过添加ul标签来丰富界面,也就是说ul中有lili中又可以有ul
  • 快捷键:

    • ul>li + tab键:生成一对ul标签,在ul标签中生成一对li标签。
    • ul>li*3 + tab键:生成一对ul标签,在ul标签中生成3对li标签。
  • 无序列表应用场景举例:

    • 新闻列表
    • 商品列表
    • 导航条

有序列表(ordered list)

  • 作用:给一堆数据添加语义,并且这一堆数据中所有的数据都有先后之分

  • 格式:

<ol>
  <li></li>
  <li></li>
</ol>
  • 有序列表的区别仅仅是是否有先后之分,其他的使用方法和注意点与无序列表ul标签都差不多。

定义列表(definition list)

  • 作用:给一堆数据添加列表语义,先通过dt标签定义列表中的所有标题,然后通过dd标签给每个标题添加描述信息

  • 格式:

<dl>
  <dt>北京</dt>
  <dd>中国的首都</dd>
  <dt>上海</dt>
  <dd>中国的经济中心</dd>
</dl>
  • 标签含义:dtdd都是英文缩写,dt是definition title的缩写,所以dt的含义就是用来定义列表中的标题dd是definition description的缩写,所以dd的含义就是用来定义标题对应的描述

  • 应用场景:

    • 做网站尾部的相关信息
    • 图文混排
  • 注意点:

    • ul/ol一样,dldt/dd是一个整体,一般情况下不会单独出现,都是一起出现。
    • ul/ol一样,由于dldt/dd是一个组合标签,所以dl中建议只放dtdd标签。
    • 一个dt可以没有对应的dd,也可以有多个对应的dd,但是无论有多个dd或者没有dd,都不推荐使用。推荐使用一个dt对应一个dd
    • li标签一样,当需要丰富界面的时候,可以在dt/dd标签中继续添加其他标签,但是建议不要再dl标签中添加。
  • 快捷键:

    • dl>dt+dd + tab键:生成一对dl标签,在dl标签当中生成一对dtdd标签。

参考链接:

W3School官方文档:www.w3school.com.cn

❌
❌