阅读视图

发现新文章,点击刷新页面。

🚀 React初体验:从“秃头程序员”到“数据魔法师”的奇幻漂流

🌟 1. React是啥?(What)

“DOM操作 = 手工搬砖?React = 搭积木!”

为什么你需要React?

想象你是个刚入行的“前端切图崽”,老板甩给你一坨HTML/CSS代码,让你改个按钮颜色得写10行JS,删个元素得翻山越岭找DOM节点……

💡 React就是你的“乐高积木”

  • 把页面拆成多个组件(Component),每个组件就像一块积木,组合起来就是完整大楼
  • {}语法糖把JS代码直接塞进HTML,告别document.getElementById的“考古式编码”

image.png

代码彩蛋:App.jsx开箱即用

// 组件就是一个函数!返回HTML的魔法函数
function App() {
  return (
    <div>
      <h1>我是React组件!</h1>
    </div>
  )
}

🤯 对比原生JS

// 原生JS需要手动操作DOM
document.body.innerHTML = '<h1>我是原生JS!</h1>'

⚙️ 2. 项目搭建指南(How)

npm init vite = 前端界的“一键建房”

image.png

三步打造React城堡

  1. 初始化项目
    npm init vite my-app -- --template react
    # vite帮你建好“毛坯房”,不用再手写webpack配置(秃头程序员的救星!)
    
  2. 安装依赖
    cd my-app && npm install
    # node_modules就像装修公司的材料库,React组件们要靠它才能活过来
    
  3. 启动开发服务器
    npm run dev
    # 3000端口已就位,请查收你的第一个React页面!🎉
    

踩坑日记:Node版本引发的血案

某次安装时用了Node 14,vite直接报错:“你out了,请升级到16+!”
💡 建议:用nvm管理Node版本,避免“老板说需求紧急,你却卡在环境配置”的尴尬


🔁 3. 响应式数据的秘密(How)

useState = 数据世界的“橡皮擦+复制机”

image.png

为什么直接改状态会翻车?

🤯 反面教材

const todos = ['吃饭', '睡觉', '学习']
todos.push('养鱼') // ❌ 错误!React不会知道数据变了

正确姿势

const [todos, setTodos] = useState(['吃饭', '睡觉', '学习'])
setTodos([...todos, '养鱼']) // ✅ 用setTodos告诉React:“嘿,数据更新啦!”

代码彩蛋:setTimeout模拟“养鱼”任务

setTimeout(() => {
  setTodos(['吃饭', '睡觉', '学习', '养鱼'])
  setTitle('字节之星') // 状态更新触发页面重渲染
}, 5000)

image.png

->

image.png

🧠 灵魂拷问
“为什么不能直接改todos.push()?”
“React就像班主任查作业——你没用setTodos,它怎么知道要改页面?”


🧩 4. TODO列表实战(Why)

map方法 = “批量生成表格的魔法咒语”

image.png

动态渲染列表的奥义

{
  todos.map((item, index) => (
    <tr>
      <td>{index + 1}</td>
      <td>{item}</td>
    </tr>
  ))
}

🌈 对比原生JS

// 原生JS需要手动循环+拼接字符串
let html = ''
for (let i = 0; i < todos.length; i++) {
  html += `<tr><td>${i+1}</td><td>${todos[i]}</td></tr>`
}
document.body.innerHTML = html

🎯 5. 5W1H总结(Who/When/Where/What/How/Why)

问题 答案
Who 前端小白/刚接触React的新手
When 需要从零构建响应式Web应用时
Where 浏览器端(React可结合Next.js做服务端渲染)
What React是声明式UI框架,用组件+状态管理替代DOM操作
How npm init vite初始化项目 → 用组件封装业务 → 用useState管理状态变化
Why 让程序员告别DOM暴力美学,专注业务逻辑

🧠 思维导图总结(文字版)

React核心思想
├── 组件化(乐高积木)
│   ├── 函数组件
│   └── JSX语法({JS表达式})
├── 响应式数据
│   ├── useState = [数据, 修改方法]
│   └── 状态更新触发重渲染
└── 项目搭建
    ├── npm init vite(一键建房)
    └── 开发服务器(3000端口)

💡 程序员三大快乐时刻

  1. npm install成功 ✅
  2. 代码一次过 🎉
  3. 老板不加需求 😂

🚀 现在,快去你的React世界“搭积木”吧!如果觉得本文有用,请点个赞,让更多“秃头程序员”看到这份“数据魔法师”的指南!

从原生 JS 到 React:手把手带你开启 React 业务开发之旅

一、前端开发演进:从切图崽到全干工程师的成长路径

在开始React业务开发之前,让我们先了解前端工程师的成长路线:

  1. 前端切图崽:掌握HTML+CSS+基础JS,能实现静态页面
  2. 前端开发工程师:掌握Vue/React等框架,能开发复杂Web应用
  3. 全栈开发工程师:掌握Node.js+数据库,实现前后端一体化
  4. 跨平台应用开发:掌握React Native等,开发Android/iOS应用
  5. AI应用开发:整合AI能力,开发智能化应用
  6. 全干工程师:掌握全技术栈,独立完成项目全流程

React作为现代前端开发的三大框架之一,让开发者得以脱离底层 DOM 操作,聚焦于业务逻辑,是我们进阶路上的重要里程碑。

二、React 项目初始化:从 vite 模板开始

1. 核心工具:npm 与 vite 的作用

  • npm(Node Package Manager) :作为 Node.js 的包管理器,负责安装 React 等开发依赖,如通过npm install react react-dom引入核心库。
  • vite:新一代前端构建工具,相比传统 webpack,具有极速冷启动、按需编译的特点,尤其适合 React 项目的工程化搭建。

2. 快速创建项目的 4 步流程

# 1. 初始化vite项目(选择react模板语言选择js)
npm init vite@latest my-react-app -- --template react

# 2. 进入项目目录
cd my-react-app

# 3. 安装依赖
npm install

# 4. 启动开发服务器(默认端口5173)
npm run dev

执行完成后,浏览器访问http://localhost:5173即可看到 React 欢迎页面

image.png

项目结构如下:

image.png

三、React 初体验:组件化开发的核心逻辑

1. 组件:HTML+CSS+JS 的封装单元

React 组件是完成开发任务的最小单元,它将 HTML、CSS、JS 逻辑封装在一个函数中,通过 组件组合 构建完整页面。以下是一个基础的 App 组件示例,演示如何通过函数组件渲染静态数据:

import { useState } from 'react';
import './App.css';

// 函数组件:接收参数并返回 JSX 结构
function App() {
  const staticTodos = ['吃饭', '睡觉', '打豆豆']; 
  return (
    <>
      <table>
        <thead>
          <tr>
            <th>序号</th>
            <th>任务</th>
          </tr>
        </thead>
        <tbody>
          {/* 在 JSX 中通过 {} 嵌入 JS 表达式,使用 map 遍历渲染列表 */}
          {staticTodos.map((item, index) => (
            <tr key={index}> {/* 列表渲染必须指定唯一 key,此处仅为演示,实际应使用数据唯一标识 */}
              <td>{index + 1}</td>
              <td>{item}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </>
  );
}

export default App;

image.png

关键知识点:

  • JSX 语法:允许在 JS 中直接书写类 HTML 结构,{} 用于插入 JS 表达式(如变量、函数调用等)。
  • 列表渲染:通过 Array.map() 遍历数据并生成多个 JSX 元素,需为每个列表项指定唯一 key(React 用于追踪组件更新的标识)。
  • 组件结构:函数组件必须有一个根返回值(如 <> 空标签或 <div> 等容器元素)。

2. 响应式数据:useState Hook 实现数据驱动视图

当数据需要动态变更并触发视图更新时,需使用 React 的 状态(State)  机制。通过 useState Hook 声明响应式数据,数据变更时 React 会自动重新渲染组件。

示例:动态更新标题与任务列表

import { useState } from 'react';
import './App.css';

function App() {
  // 声明响应式状态:todos(任务列表)和 title(页面标题)
  const [todos, setTodos] = useState(['吃饭', '睡觉', '打豆豆']);
  const [title, setTitle] = useState('今天你吃饭了吗'); 

  // 模拟异步操作:5 秒后更新
  setTimeout(() => {
    setTitle('今天你完成任务了吗');
    setTodos([...todos, '养鱼']); 
  }, 5000);

  return (
    <div>
      <h1 className="title">{title}</h1> {/* 绑定响应式数据 title */}
      <table>
        <thead>
          <tr>
            <th>序号</th>
            <th>任务</th>
          </tr>
        </thead>
        <tbody>
          {todos.map((item, index) => (
            <tr key={index}>
              <td>{index + 1}</td>
              <td>{item}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export default App;

CSS 样式(App.css):

* {
  margin: 0;
  padding: 0;
}

.title {
  background-color: aqua;
  color: aliceblue;
  padding: 10px;
  margin-bottom: 20px;
}

image.png

五秒后更新为: image.png

核心概念解析:

  • useState 用法

    • 调用 useState(initialValue) 返回一个数组,第一项是状态值(如 todos),第二项是更新状态的函数(如 setTodos)。
    • 状态更新函数(如 setTitle)的调用会触发组件重新渲染,确保视图与数据同步。
  • 异步更新:即使在定时器、Promise 等异步场景中调用 setState,React 也能正确捕获变更并更新视图。

  • 数据不可变性:更新数组或对象状态时,需通过 [...todos] 或 {...obj} 创建新对象,避免直接修改原数据导致 React 无法检测变更。

3. 组件组合:拆解复杂页面为可复用单元

React 推荐将页面拆分为多个小组件,通过嵌套组合实现复杂功能。以下是对示例代码的扩展思路 :

// 拆分标题组件
function Title({ title }) {
  return <h1 className="title">{title}</h1>;
}

// 拆分任务列表组件
function TodoTable({ todos }) {
  return (
    <table>
      <thead>
        <tr>
          <th>序号</th>
          <th>任务</th>
        </tr>
      </thead>
      <tbody>
        {todos.map((item, index) => (
          <tr key={index}>
            <td>{index + 1}</td>
            <td>{item}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function App() {
  const [todos, setTodos] = useState(['吃饭', '睡觉', '打豆豆']);
  const [title, setTitle] = useState('今天你吃饭了吗');

  setTimeout(() => setTitle('今天你完成任务了吗'), 5000);

  return (
    <div>
      <Title title={title} /> {/* 父组件向子组件传递数据 via props */}
      <TodoTable todos={todos} /> {/* 复用列表组件,传入不同数据 */}
    </div>
  );
}

组件通信规则:

  • 父传子:通过 props 向子组件传递数据(如 Title 组件的 title 属性)。
  • 子传父:子组件通过回调函数(如 onClick={handleClick})向父组件传递事件。
  • 跨组件通信:状态提升(将共享状态提升至共同父组件)或使用 Context 实现全局状态管理。

4. 对比原生 JS:React 如何简化开发

场景 原生 JS 实现方式 React 实现方式
渲染列表 使用 createElement 或 innerHTML 拼接 DOM 字符串 通过 JSX + map 直接声明视图结构
数据更新 手动查找 DOM 节点并更新 textContent/innerHTML 调用 setState 自动触发视图更新
组件复用 复制粘贴代码或封装函数返回 DOM 节点 定义函数组件并通过 props 传递差异化数据

核心优势:React 通过声明式编程(描述 “是什么” 而非 “怎么做”)和组件化架构,让开发者聚焦业务逻辑而非底层 DOM 操作,极大提升开发效率与代码可维护性。

四、实战案例:构建响应式待办事项应用

1. 功能拆解

我们将通过实际代码实现一个包含以下功能的待办应用:

  • 输入框添加新任务(表单组件TodoForm
  • 任务列表展示(列表组件Todos
  • 数据持久化(暂存于组件状态,后续可扩展 localStorage)

2. 核心组件实现

首先我们先在src目录下创建一个components文件夹,存放各个组件

(1)表单组件TodoForm:双向绑定与提交处理

import { useState } from 'react';

function TodoForm(props) {
  // 从 props 中获取父组件传递的回调函数(用于接收新增任务)
  const onAdd = props.onAdd;
  // 声明本地状态:text 存储输入框内容,初始值为"打豆豆"
  const [text, setText] = useState('打豆豆');

  // 表单提交处理函数
  const handleSubmit = (e) => {
    // 阻止表单默认提交行为(避免页面跳转)
    e.preventDefault();
    // 调用父组件回调,传递当前输入的文本
    onAdd(text);
    console.log(e.target.value);
  };

  // 输入框内容变化处理函数
  const handleChange = (e) => {
    // 更新本地状态,实现双向绑定(输入框值与 state 同步)
    setText(e.target.value);
  };

  return (
    <form action="http://www.baidu.com" onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="请输入待办事项"
        value={text}          // 绑定 state 到输入框 value
        onChange={handleChange} // 监听输入变化并更新 state
      />
      <button type="submit">添加</button>
    </form>
  );
}

export default TodoForm;

在这个组件中,通过useState定义text状态,实现输入框的双向绑定。handleChange函数监听输入变化更新状态,handleSubmit函数阻止表单默认提交,并调用父组件传递的onAdd回调函数,将输入内容传递出去。

(2)列表组件Todos:数据渲染与 key 的重要性

function Todos(props) {
  console.log(props, '/////');
  // 解构 props 中的 todos 数据
  const todos = props.todos;

  return (
    <ul>
      {
        // 遍历 todos 数组,渲染为列表项
        todos.map((todo) => (
          // key 是 React 识别列表项的唯一标识,必须使用稳定的唯一值(如数据 ID)
          <li key={todo.id}>{todo.text}</li>
        ))
      }
    </ul>
  );
}

export default Todos;

该组件接收父组件传递的props.todos数据,使用map方法遍历数据并渲染为列表项。为每个列表项设置唯一的key属性(这里使用todo.id),这能帮助 React 高效地更新列表,避免在数据变化时出现渲染错误。

(3)核心逻辑组件TodoList:状态管理与组件组合

import { useState } from 'react';
import '../Todo.css'; // 引入组件级样式(需确保路径正确)
import TodoForm from './TodoForm'; // 引入表单组件(子组件)
import Todos from './Todos'; // 引入列表组件(子组件)

function TodoList() {
  const [hi, setHi] = useState('haha');
  const [title, setTitle] = useState('Todo List');
  const [todos, setTodos] = useState([
    {
      id: 1,          // 唯一标识(必填,用于列表渲染和更新)
      text: '吃饭',  
      completed: false 
    }
  ]);

  // 添加任务的回调函数(供子组件调用)
  const handleAdd = (text) => {
    // 更新 todos 状态:使用展开运算符保留原有数据,新增任务项
    setTodos([
      ...todos,
      {
        id: todos.length + 1, 
        text,                // 解构参数作为任务文本
        completed: false      
      }
    ]);
  };

  return (
    <div className="container">
      <h1 className="title">
        {title}{hi} {/* 组合显示标题和演示文本 */}
      </h1>
      <TodoForm onAdd={handleAdd} /> {/* 向子组件传递回调函数,实现子传父通信 */}
      <Todos todos={todos} />       {/* 向子组件传递数据,实现父传子通信 */}
    </div>
  );
}

export default TodoList;

TodoList组件作为整个待办应用的核心,通过useState定义了多个状态,如titletodoshandleAdd函数用于处理添加任务的逻辑,当调用setTodos更新任务列表状态时,React 会自动重新渲染包含Todos组件的部分,实现数据驱动视图更新。同时,通过propshandleAdd函数传递给TodoForm组件,完成子传父的通信。

(4)根组件App:整合应用

import { useState } from 'react'
import './App.css'
import TodoList from './components/TodoList'

function App() {
  return (
    <>
      <div>
      </div> 
 {/* 渲染待办事项核心组件 */}
      <TodoList />
    </>
  )
}

export default App

App组件作为应用的入口,只负责引入并渲染TodoList组件,将整个待办应用集成到项目中。

image.png

通过这个完整的待办事项应用案例,我们能更清晰地看到 React 中组件化开发、数据驱动视图、组件间通信等核心概念的实际应用。后续还可以在此基础上,添加任务删除、任务状态切换等功能,进一步深化对 React 开发的理解 。

学习 React 之路道阻且长,唯有持续深耕

从原生 JS 到 React,前端开发变得更加高效和便捷。React 的组件化开发和响应式数据管理,让开发者能够聚焦于业务逻辑,而无需过多关注底层的 DOM 操作。随着技术的不断发展,相信前端开发还会迎来更多的变革和创新。

跟着官方示例学习 @tanStack-table --- Column Ordering

🌲系列一:跟着官方示例学习 @tanStack-table --- Basic

🌲系列二:跟着官方示例学习 @tanStack-table --- Header Groups

🌲系列三:跟着官方示例学习 @tanStack-table --- Column Filters


🧱 列可见性切换

在实际项目中,我们经常需要根据用户需求隐藏不必要的列,或者让用户自由切换列的显示与否。例如:用户在查看员工信息时可能只关注姓名和职位,而不需要每次都看到访问次数或进度等辅助信息。

🧪 核心代码解析

⚙️ 1. 设置初始列定义:

const [columns] = React.useState(() => [...defaultColumns]);

通过 useState 列定义,确保列只初始化一次。

⚙️ 2. 控制列可见性的状态:

const [columnVisibility, setColumnVisibility] = React.useState({});

用于记录每个列的可见状态(key 是列 id,value 是 true 或 false)。

⚙️ 3. 在 useReactTable 中配置状态与回调:

const table = useReactTable({
  data,
  columns,
  state: { columnVisibility },
  onColumnVisibilityChange: setColumnVisibility,
  getCoreRowModel: getCoreRowModel(),
});

一旦用户勾选复选框,表格状态会同步更新并触发 UI 渲染。

⚙️ 4. UI 部分实现:

全选开关:

<input
  type="checkbox"
  checked={table.getIsAllColumnsVisible()}
  onChange={table.getToggleAllColumnsVisibilityHandler()}
/>

单列开关:

table.getAllLeafColumns().map((column) => (
  <input
    type="checkbox"
    checked={column.getIsVisible()}
    onChange={column.getToggleVisibilityHandler()}
  />
))

getAllLeafColumns API 🔗:获取所有叶子列(leaf columns),即最终渲染到表格中的“最底层的列”。

Jun-07-2025 14-54-35.gif

🔔 对官方示例代码可能存在一些删减的情况

代码地址🔗:Gitee

官方代码地址🔗: @tanStack/react-table

✨ 列排序

在某些使用场景中,我们希望用户可以 动态调整表格列的顺序,以便根据业务需求进行自定义展示。

🔧 如何实现列顺序切换?

我们先来看最核心的两行代码:

const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>([]);

table.setColumnOrder(
  faker.helpers.shuffle(table.getAllLeafColumns().map(d => d.id))
);

⚙️ 1. 步骤拆解:

初始化状态:

const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>([])

columnOrder 是一个数组,表示当前列的显示顺序(每个元素是一个列 ID)。

⚙️ 2. 配置到表格中:

const table = useReactTable({
  //...
  state: { columnOrder },
  onColumnOrderChange: setColumnOrder,
});

当列顺序发生改变时,React Table会使用你提供的onColumnOrderChange回调更新状态。

⚙️ 3. 动态修改顺序(示例是随机打乱顺序):

table.setColumnOrder(
  faker.helpers.shuffle(table.getAllLeafColumns().map(d => d.id))
);
// 返回所有叶子列(最底层列), 得到它们的 ID 列表
getAllLeafColumns().map(d => d.id)

// 用于打乱顺序
faker.helpers.shuffle()

最终将新的顺序通过 setColumnOrder 应用到表格中

Jun-07-2025 16-30-44.gif

🔔 对官方示例代码可能存在一些删减的情况

代码地址🔗:Gitee

官方代码地址🔗: @tanStack/react-table

🧀 列拖拽排序

🧲 用 DnD Kit 加持列排序交互

我们使用 @dnd-kit/core 来为表格添加拖拽能力

⚙️ 1. 每个列头变成可拖拽单元

我们创建了一个组件 <DraggableTableHeader />,它包裹住了每一个 <th>,并绑定了拖拽行为:

const DraggableTableHeader = ({ header }) => {
  const { setNodeRef, listeners, attributes, transform, isDragging } = useSortable({ id: header.column.id });

  const style = {
    transform: CSS.Translate.toString(transform),
    opacity: isDragging ? 0.8 : 1,
    //...
  }

  return (
    <th ref={setNodeRef} style={style}>
      {header.isPlaceholder
        ? null
        : flexRender(header.column.columnDef.header, header.getContext())}
      <button {...attributes} {...listeners}>🟰</button>
    </th>
  )
}

这个组件使用了 useSortable,我们传入当前列的 IDDnd Kit 会帮我们处理拖拽逻辑,transform 会让列在拖动时产生动画过渡。

⚙️ 2. 将所有列头包进 <SortableContext />

SortableContextDnD Kit 的容器,它告诉系统:这一组元素是可以排序的,排序方式是“水平排列”。

<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
  {headerGroup.headers.map(header => (
    <DraggableTableHeader key={header.id} header={header} />
  ))}
</SortableContext>

其中 items 是当前的列顺序(用 ID 表示),strategy 选择横向排序。

⚙️ 3. 配置 DndContext 管理拖拽行为

最外层我们包了一个 <DndContext>,这是 DnD Kit 的顶级组件,用于统一管理拖拽逻辑:

<DndContext
  sensors={sensors}
  collisionDetection={closestCenter}
  modifiers={[restrictToHorizontalAxis]}
  onDragEnd={handleDragEnd}
>
  {/* 表格渲染 */}
</DndContext>

我们使用 closestCenter 判断碰撞区域,使用 restrictToHorizontalAxis 限制只能左右拖动。

⚙️ 4. 拖拽完成后更新 columnOrder

当用户释放拖拽时,handleDragEnd 会被触发:

function handleDragEnd(event: DragEndEvent) {
  const { active, over } = event
  if (active && over && active.id !== over.id) {
    setColumnOrder((old) => {
      const oldIndex = old.indexOf(active.id)
      const newIndex = old.indexOf(over.id)
      return arrayMove(old, oldIndex, newIndex)
    })
  }
}

通过 arrayMove 工具函数,我们调整列的顺序数组 columnOrder,表格会自动根据新的顺序重新渲染。

⚙️ 5. 同步拖动列单元格

为了更真实的拖拽体验,不只是 <th> 头部,单元格 <td> 也要一起动。通过 <DragAlongCell /> 组件,实现列单元格的同步拖拽动画:

const DragAlongCell = ({ cell }) => {
  const { setNodeRef, transform } = useSortable({ id: cell.column.id })
  const style = {
    transform: CSS.Translate.toString(transform),
    ...
  }
  return <td ref={setNodeRef} style={style}>{...}</td>
}

并为每一行的每一格 <td> 也包进 SortableContext 中,确保拖动时位置变化一致:

<tr>
  {row.getVisibleCells().map(cell => (
    <SortableContext key={cell.id} items={columnOrder} strategy={horizontalListSortingStrategy}>
      <DragAlongCell key={cell.id} cell={cell} />
    </SortableContext>
  ))}
</tr>

Jun-07-2025 16-17-07.gif

🔔 对官方示例代码可能存在一些删减的情况

代码地址🔗:Gitee

官方代码地址🔗: @tanStack/react-table

React组件化开发实战:从"待办事项"看前端乐高搭建术

《React组件化开发:把前端变成乐高积木大师之旅》

警告:当你学会组件化思维后,看任何网站都会自动拆解成乐高积木,此症状不可逆!

一、开篇:从“一锅炖”到“分餐制”的进化史

还记得你第一次写网页的样子吗?一个HTML文件塞满1000行代码,CSS和JS在文件里打架——这就像把披萨、冰淇淋、螺蛳粉全倒进一个碗里搅拌(别试,会后悔的)。

而现代React开发如同米其林大厨备餐:

  • Vite 是智能厨房(自动处理火候/刀工)
  • 组件 是预制菜包(每个独立封装)
  • 数据流 是传菜机器人

就像我的TodoList项目,被拆解成三个精致“料理包”:

// 料理包1:TodoForm.jsx(食材输入机)
const handleSubmit = (e) => {
  e.preventDefault(); // 拦截百度外卖订单!
  onAdd(text)         // 呼叫主厨加菜
}

// 料理包2:TodoList.jsx(中央厨房)
const [todos, setTodos] = useState([ { id: 1, text: '吃饭' } ])
const handleAdd = (text) => {
  setTodos([...todos, { id: todos.length+1, text }]) 
}

// 料理包3:Todos.jsx(菜品展示台)
todos.map(todo => <li key={todo.id}>{todo.text}</li>)

二、组件化:前端的乐高革命

1. 为什么说DOM操作像用镊子搭积木?

传统开发如同用镊子组装微观乐高:

// 远古时代的痛苦记忆
document.querySelector('ul').innerHTML = 
  todos.map(todo => `<li>${todo.text}</li>`).join('')

每当数据变化就要:

  1. 找积木盒(querySelector)
  2. 拆旧积木(innerHTML = '')
  3. 拼新积木(拼接字符串)
  4. 手抖拼错全塌(页面崩溃)

2. React组件是智能积木块

想象有会自我更新的魔法积木:

// 积木块1:TodoForm
<Form魔法盒 onAdd={加菜方法} />

// 积木块2:TodoList
<中央厨房 
  菜单={todos} 
  加菜方法={handleAdd} 
/>

// 积木块3:Todos
<展示柜 菜单={todos} />

魔法原理:当你往中央厨房(TodoList)的菜单数组里塞新菜时:

setTodos([...todos, 新菜])

所有关联积木自动重组!就像乐高城市突然长出新建筑。

三、深度解密组件通信:组件间的摩斯密码

1. Props:父子组件的“悄悄话管道”

TodoList.jsx中:

<TodoForm onAdd={handleAdd} /> {/* 塞给儿子一部对讲机 */}
<Todos todos={todos} />        {/* 递给女儿一张菜单 */}

子组件接收时如同拆快递:

// TodoForm.jsx
function TodoForm(props) {
  props.onAdd(text) // 用对讲机呼叫爸爸
}

// Todos.jsx
function Todos(props) {
  props.todos.map(...) // 查看爸爸给的菜单
}

2. 数据流:单向快递系统

React的数据流像严格的快递网络:

中央仓库(setTodos) → 卡车(props) → 子组件签收

禁止反向运输!  子组件不能直接修改props(就像不能篡改快递单)

四、useState:给组件装上“记忆芯片”

1. 变量失忆症治疗指南

普通变量刷新就失忆:

let todos = [] // 刷新页面?失忆了

useState给变量装上记忆芯片:

const [todos, setTodos] = useState(() => {
  // 首次加载从缓存读取记忆
  return JSON.parse(localStorage.getItem('todos')) || []
})

2. 数据驱动UI:魔法镜子原理

想象todos数组是现实世界,UI是它的魔法镜像:

// 现实世界改变...
setTodos([...todos, { text: "写React博客", completed: false }])

// 魔法镜像自动同步更新!
<ul>
  <li>吃饭</li>
  <li>睡觉</li>
  <li>写React博客</li> {/* 自动出现! */}
</ul>

这就是为什么叫“数据驱动” ——数据是提线木偶师,UI是听话的木偶。

五、实战黑科技:动态TodoList诞生记

1. 表单拦截术:从百度嘴里抢回数据

TodoForm.jsx中上演谍战大戏:

<form action="http://www.baidu.com" onSubmit={handleSubmit}>
  {/* 表面伪装成百度搜索... */}
  <input type="text" placeholder="伪装成搜索框" />
  
  {/* 实际暗度陈仓 */}
  {handleSubmit(e) => {
    e.preventDefault() // 截获百度快递车!
    onAdd(text)        // 把数据偷运给自家仓库
  }}
</form>

2. 数组更新绝技:三明治堆叠法

添加新任务像做三明治:

setTodos([
  ...todos, // 1. 铺下面包(原数组)
  {         // 2. 放新食材(新对象)
    id: todos.length + 1, 
    text: "学习React魔法",
    completed: false 
  }          // 3. 自动封装!(新数组)
])

3. Key的重要性:给积木贴防撞条

渲染列表时:

todos.map(todo => (
  <li key={todo.id}>{todo.text}</li>
))

没有key的后果:React会哭喊着:“这些积木长得都一样!我分不清谁是谁!” 然后胡乱重新排列,导致性能崩溃。

六、组件化哲学:从码农到乐高大师

1. 组件设计黄金法则

  • 单一职责原则:像瑞士军刀,但每个工具独立

    我的TodoForm只管输入,Todos只管展示,绝不越界

  • 可配置性:预留插槽如乐高凸点

    // 高度可配置的标题
    <TodoList title="今日待办" theme="dark" />
    
  • 无状态优先:尽量做“傻白甜”组件

    // 最佳实践:只负责展示的傻组件
    const Display = ({ value }) => <div>{value}</div>
    

2. 数据流架构图

deepseek_mermaid_20250607_57184a.png

七、血泪教训:新手村避坑指南

  1. Props命名惨案
// 父组件传参
<Todos todoList={todos} />

// 子组件拆包
function Todos(props) {
  props.todos.map(...) // 报错!实际叫todoList
}

急救方案:解构时直接重命名
const { todoList: todos } = props

  1. 直接修改状态灾难
// 错误示范(引发静默失效)
todos.push(newTodo) 
setTodos(todos) // React:这俩不是同一个数组?不理你!

// 正确姿势(创建新数组)
setTodos([...todos, newTodo])
  1. Key的重复危机
// 用索引当key?删除第二项时...
[<li key=0>A</li>, <li key=1>B</li>, <li key=2>C</li>]
// 删除B后:
[<li key=0>A</li>, <li key=1>C</li>] // React以为B→C变了!

黄金准则:用唯一ID(如数据库id/crypto.randomUUID())

八、终极思考:为什么说React是界面编程的范式革命?

回顾传统方式添加一个待办事项:

sequenceDiagram
   程序员->>DOM: 找到ul元素
   程序员->>DOM: 创建li元素
   程序员->>DOM: 设置li内容
   程序员->>DOM: 插入ul末尾

React范式下:

sequenceDiagram
   程序员->>数据: setTodos(更新数组)
   数据->>React: 通知状态变更
   React->>虚拟DOM: 计算差异
   虚拟DOM->>真实DOM: 精准更新

本质区别:从“指挥DOM做每个动作”变成“声明数据状态”,如同从驾驶马车变成设置GPS导航。

结语:你的乐高帝国正在崛起

当你掌握组件化思维后:

  • 看到按钮 → “这是个Button组件”
  • 看到导航栏 → “NavBar+MenuItem组合”
  • 看到整个页面 → “Header, Content, Footer三大模块”

现在尝试给你的TodoList添加新功能:

  1. TodoItem组件中添加删除按钮
  2. 通过父组件传递onDelete回调
  3. 使用filter更新状态:
// TodoList.jsx
const handleDelete = (id) => {
  setTodos(todos.filter(todo => todo.id !== id))
}

挑战:如何让待办事项支持完成状态切换?提示:map+条件样式

记住React哲学的核心口诀: “UI是数据的函数” 。当你下次对着页面发呆时,不妨想想——眼前的一切,不过是数据的精致舞衣罢了。

学习React的一些知识

目前前端大部分公司应该都在使用React。这里记录一些学习React所需要的资料,方便自己查阅,也方便别人省去找资料的时间

  1. React官网,提供学习的最权威资料。不管是入门还是深入学习都很有用

react.dev/

  1. React中文官网

zh-hans.react.dev/

  1. CSS 样式和选择器学习

developer.mozilla.org/zh-CN/docs/…

  1. html的所有标签,介绍和学习

developer.mozilla.org/zh-CN/docs/…

  1. less,增强的css配置

less.bootcss.com/#%E6%A6%82%…

  1. redux状态管理

cn.redux.js.org/

  1. redux-toolkit 在redux基础上封装的,更易于使用的框架

redux-toolkit.js.org/

  1. react技术解密,带你了解react的一些实现原理

react.iamkasong.com/

Zustand 第二章(状态处理)

状态处理

在上一章我们讲了,Zustand会合并第一层的state,但是如果深层次应该如何处理呢

来吧演示

首先创建一个葫芦娃,葫芦娃有七个娃,每个娃都有自己的状态,我们可以通过updateGourd来更新葫芦娃的状态,这样就实现了一个深层次的demo

import { create } from 'zustand'

interface User {
    gourd: {
        oneChild: string,
        twoChild: string,
        threeChild: string,
        fourChild: string,
        fiveChild: string,
        sixChild: string,
        sevenChild: string,
    },
    updateGourd: () => void
}
const useUserStore = create<User>(((set) => ({
    //创建葫芦娃
    gourd: {
        oneChild: '大娃',
        twoChild: '二娃',
        threeChild: '三娃',
        fourChild: '四娃',
        fiveChild: '五娃',
        sixChild: '六娃',
        sevenChild: '七娃',
    },
    updateGourd: () => set((state) => ({
        gourd: {
            //...state.gourd, 先不进行状态合并  // [!code highlight] 
            oneChild: '大娃-超进化',
        }
    }))
})))

export default useUserStore;

我们会发现如果不进行状态合并,其他的状态是会丢失的,所以深层次的状态处理需要进行状态合并,但是如果代码过多,每次都需要合并状态也挺烦的,所以我们可以通过immer中间件处理这个问题

state.gif

使用immer中间件

安装

npm install immer

原始immer的用法

需要导出produce,然后它的第一个参数是原始值,第二个参数是一个回调函数,回调函数中的参数是draft,也就是原始值的拷贝,然后我们就可以直接修改draft了,最后返回新的值

import { produce } from 'immer'

const data = {
  user: {
    name: '张三',
    age: 18
  }
}

const newData = produce(data, draft => {
  draft.user.age = 20
})

console.log(newData,data) 
//{ user: { name: '张三', age: 20 } } 
//{ user: { name: '张三', age: 18 } }

immerZustand中的使用方法

引入注意是从zustand/middleware/immer引入,而不是immer

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
  1. 首先从zustand中间件引入immer
  2. 然后注意结构create()(immer())这里是两个括号而不是放在create里面了
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface User {
    gourd: {
        oneChild: string,
        twoChild: string,
        threeChild: string,
        fourChild: string,
        fiveChild: string,
        sixChild: string,
        sevenChild: string,
    },
    updateGourd: () => void
}
//注意结构发生了变化!!!
const useUserStore = create<User>()(immer(((set) => ({
    //创建葫芦娃
    gourd: {
        oneChild: '大娃',
        twoChild: '二娃',
        threeChild: '三娃',
        fourChild: '四娃',
        fiveChild: '五娃',
        sixChild: '六娃',
        sevenChild: '七娃',
    },
    updateGourd: () => set((state) => {
        state.gourd.oneChild = '大娃-超进化' //这儿不需要状态合并了需要修改什么值直接改就行了
        state.gourd.twoChild = '二娃-谁来了'
        state.gourd.threeChild = '三娃-我来了'
    })
}))))

export default useUserStore;

immer原理剖析

immer.js 通过 Proxy 代理对象的所有操作,实现不可变数据的更新。当对数据进行修改时,immer 会创建一个被修改对象的副本,并在副本上进行修改,最后返回修改后的新对象,而原始对象保持不变。这种机制确保了数据的不可变性,同时提供了直观的修改方式。

immer 的核心原理基于以下两个概念:

  1. 写时复制 (Copy-on-Write)
  • 无修改时:直接返回原对象
  • 有修改时:创建新对象
  1. 惰性代理 (Lazy Proxy)
  • 按需创建代理
  • 通过 Proxy 拦截操作
  • 延迟代理创建

工作流程

graph TD
A[调用 produce] --> 
B[创建代理 draft] --> 
C[执行 recipe 修改 draft]  --> 
D[是否有修改!] 
D-- 是 --> E[创建新对象...base + ...modified]   
D-- 否 --> F[直接返回 base]

简化实现

type Draft<T> = {
  -readonly [P in keyof T]: T[P];
};

function produce<T>(base: T, recipe: (draft: Draft<T>) => void): T {
  // 用于存储修改过的对象
  const modified: Record<string, any> = {};
  
  const handler = {
    get(target: any, prop: string) {
      // 如果这个对象已经被修改过,返回修改后的对象
      if (prop in modified) {
        return modified[prop];
      }
      
      // 如果访问的是对象,则递归创建代理
      if (typeof target[prop] === 'object' && target[prop] !== null) {
        return new Proxy(target[prop], handler);
      }
      return target[prop];
    },
    set(target: any, prop: string, value: any) {
      // 记录修改
      modified[prop] = value;
      return true;
    }
  };

  // 创建代理对象
  const proxy = new Proxy(base, handler);
  
  // 执行修改函数
  recipe(proxy);
  
  // 如果没有修改,直接返回原对象
  if (Object.keys(modified).length === 0) {
    return base;
  }
  
  // 创建新对象,只复制修改过的属性
  return JSON.parse(JSON.stringify(proxy))
}

// 使用示例
const state = {
  user: {
    name: '张三',
    age: 25
  }
};

const newState = produce(state, draft => {
  draft.user.name = '李四';
  draft.user.age = 26;
});

console.log(state);     // { user: { name: '张三', age: 25 } }
console.log(newState);  // { user: { name: '李四', age: 26 } }

这儿只是简单实现,没有考虑数组的情况和深层次的代理,只实现了其核心思想

前端响应式网站编写套路

不知道你有没有和我有一样的疑问,像那种响应式的网站是怎么开发?用到了什么技术?这是一篇带你进入响应式开发的套路,看完基本上就能知道响应式网站开发套路,加上现在以组件化开发更是如虎添翼,废话不多说开始我们的开发之旅

环境

  • 前端:Next
  • Css框架:tailwindcss
  • UI框架:antd(其实也不用,在这个用到的很少,几乎没用)
  • 语言:TypeScript

前言

这次我们要开发的是个响应式官网首页只有首页部分,我们此次要模仿的是影视飓风首页主要包括头部、底部、中间部分三大块,这里只对头部和底部做讲解,首页全部的代码我已经全部上传 源码地址

在这也提醒各位不要频繁去刷别人的官网!!!!!!!!!

tailwindcss

这里简单的介绍下 【Next,antd,TS就不做介绍】,如果你用过像Bootstrap这类的UI框架应该了解

尺寸

响应式尺寸的前缀有 md lg xl等等,这些都代表对应的尺寸

前缀 尺寸
sm 640px及以上
md 768px及以上
lg 1024px及以上
xl 1280px及以上
2xl 15360px及以上

如果没有你要的尺寸或与你需要对应的尺寸有出入你也可以自己设置,tailwindcss 给开发者提供的修改的地方

import type { Config } from 'tailwindcss';

export default {
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      screens: {
        xs: '349px', // 设置你需要的尺寸
      },
      colors: {
        background: 'var(--background)',
        foreground: 'var(--foreground)',
      },
    },
  },
  plugins: [],
} satisfies Config;

常用的class名称

  • text- fontSize
  • bg- background
  • mx- my- m- mb- ml- mr- margin
  • px- py- p- pl- pr- padding
  • w- h- width height
  • 还有其他类名在实战中再做介绍,上面一些用法也在实战中做介绍,还有一些组合用法也会在实战做介绍

tailwind CSS 规则 非常重要!!!!!

  • 非响应式类(如 hidden, flex)在所有屏幕尺寸下生效。
  • 响应式类(如 lg:flex, md:hidden)只在指定尺寸及以上生效。响应式类是以断点前缀: + 属性类
  • 优先级规则:当非响应式类与响应式类冲突时,响应式类会在其生效的屏幕尺寸范围内覆盖非响应式类

分析网页

欲先善其事,必先利其器,我先分析下页面

250607 141330.gif 通过上面的gif图我们看到网页便没有随着宽度变化而发生变化,在切换到移动模式后需要再次刷新页面才会发生变化,我们第一步需要将其改造为随着页面变化自动变化,底部导航也是一样这就不截图了

gif录制工具

头部编码与分析

image.png 头部导航主要分为三块左右布局,左边我又将其分为两块,为后面响应式做好准备,现在开始编码与分析,下面展示的都是部分代码,但不影响布局分析

<div
  className="fixed w-dvw overflow-hidden z-50 bg-white md:px-4  xs:px-2   sm:px-1.5  lg:px-20   flex justify-between items-center h-[70px]
">
 
    {/*左侧开始*/}
  <div className="flex ">
    <img className="w-[7.5rem] h-[2.25rem] mr-[70px]" src="/images/banner-login1.png" alt=""/>
    <YSJFNav isTrue={isTrue}/>
  </div>
    {/*左侧结束*/}

  {/*右侧开始*/}
  <div className=" text-gray-300">
    <svg onClick={() => setIsShow(!isShow)} className="lg:flex hidden size-6 rounded-full"
         xmlns="http://www.w3.org/2000/svg"
         fill="none" viewBox="0 0 24 24"
         strokeWidth={1.5} stroke="currentColor"
         style={{color: isTrue ? 'gray' : 'black', background: isTrue ? '#cecece' : '#f4f6f7',}}>
      <path strokeLinecap="round" strokeLinejoin="round"
            d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
    </svg>

    <svg onClick={() => setIsNav(!isNav)} className="lg:hidden size-6" xmlns="http://www.w3.org/2000/svg" fill="none"
         viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round"
            d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
    </svg>
  </div>
  {/*右侧结束*/}
  
</div>
<ul className="lg:flex hidden  items-center  text-[12px] transition text-[#c9c9c9]">
  <li
    className="w-[70px] mr-[60px] cursor-pointer hover:scale-110 transition hover:tracking-[2px]">影视飓风
  </li>
  <li
    className="w-[70px] mr-[60px] cursor-pointer hover:scale-110 transition hover:tracking-[2px]>飓风素材库
  </li>
  <li
    className="w-[70px] mr-[60px] cursor-pointer hover:scale-110 transition hover:tracking-[2px]>闪电分镜
  </li>
  <li
    className="w-[70px] mr-[60px] cursor-pointer hover:scale-110 transition hover:tracking-[2px]>太空之眼
  </li>
  <li
    className={isTrue ? "w-[70px]  cursor-pointer  hover:scale-110 transition hover:tracking-[2px]" : "w-[70px]  cursor-pointer  hover:scale-110 transition hover:tracking-[2px] hover:text-black"}>加入我们
  </li>
</ul>

最外层分析

  • 在最外层我们设置了fixed属性让他在页面滚动的时候一直固定在头部,设置宽度w-dvw= width: 100dvw让它宽度一直都是和设备宽度一样,同时我们加上 overflow-hidden 让超出部分直接隐藏
    • 在头部X轴我们加了响应式内边距,分别在md xs lg 对应的尺寸设置不同内边距
    • 头部导航高度我们这就直接写死一个高度h-[70px],这就是tailwindcss一个好处,不仅可以使用内置已有属性,还可以自己写需要的单位
    • flex justify-between items-center 两边居中排列

头部导航右侧分析

  • 这里用到两个图表,一个是PC像登录按钮,一个是移动端点击呼出菜单的按钮
  • 这里是需要设置响应式,在不同尺寸下显示不同的图标和事件
    • lg:felx hidde在屏幕大于等1024px显示,小于就不显示
    • lg:hidde在屏幕大于等1024px隐藏,小于显示

头部导航左侧分析

  • 使用felx布局只为了让他们在一行
  • 我们给图片添加了w-[7.5rem]``w-[2.25rem]直接将宽高写死,如果想做成响应式可以自行添加响应式
  • 右侧的导航YSJFNav.tsx
    • 这里也同样需要添加响应式,我们给ul添加响应式:lg:flex hidden在屏幕大于等1024px显示,小于就不显示,因为是在PC下显示我们就将字体大小写死,text-[#c9c9c9] 字体颜色
    • liw-[70px]就不作解释,cursor-pointer hover:scale-110 transition hover:tracking-[2px]鼠标显示成手,在移上去的时候文字放大字间距变宽
    • 到这头部布局和用到的样式就解释完毕

登录界面变化与分析

如果你看过原来官网会发现也是没有进行响应式设置,我们也是要对其进行改造,我们先看下效果

250607 160712.gif

<div className='fixed top-0 left-0 overflow-hidden w-full h-full bg-[#000000b3] text-white'>
  <div className='md:w-[50rem]  xs:w-11/12 absolute top-2/4 left-2/4 -translate-x-2/4 -translate-y-2/4 bg-white text-black '>
    <div className='p-1 flex justify-end cursor-pointer ' onClick={() => closeModal()}>
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className=" size-6">
        <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
      </svg>
    </div>

    <div className='flex'>

      <img className='w-96 md:flex hidden' src="images/login.jpg" />

      <div className='pt-[70px] pr-5 ml-5 w-full'>
        {/*tab*/}
        <ul className='flex text-gray-300 text-[12px] mb-3 lg:w-full relative'>
          <li className={flag ? 'mr-4 cursor-pointer' : "mr-4 cursor-pointer text-black"} onClick={() => loginOrRegister()}>密码登录</li>
          <li className={flag ? 'mr-4 cursor-pointer text-black' : "mr-4 cursor-pointer"} onClick={() => loginOrRegister()}>验证码登录</li>
        </ul>

        {/*登录注册*/}
        {flag ? <Register /> : <Login toRegister={() => loginOrRegister()} />}

        <Divider><span className='text-[12px] text-gray-400'>或</span></Divider>

        {/*微信登录*/}
        <div className='mb-5 flex justify-center  bg-white text-black rounded-md p-[10px] border-[#eee] border text-[12px] text-center'>
          <WechatFilled style={{ fontSize: 16, color: 'green' }} />
          <span className='ml-2'>微信登录</span>
        </div>

        {/*条款*/}
        <Clause />
      </div>
    </div>
  </div>
</div>

最外层分析

在底部我们加了一层遮罩同时为了脱离文档流我们用到了定位,fixed top-0 left-0 overflow-hidden w-full h-full bg-[#000000b3] text-white,固定定位 上左都是0 高度宽度100%,bg-[#000000b3]一种背景色,文字白色

表单区域分析

  • absolute top-2/4 left-2/4 -translate-x-2/4 -translate-y-2/4 居中布局
  • top-2/4 left-2/4 = top:50% left:50%
  • -translate-x-2/4 -translate-y-2/4 = translate-y:-50% translate-x:-50%
  • 如果把-translate-x-2/4前面的负号去掉translate-x-2/4 = translate-x:50%`,同理我们可以推出类似的类,如果要取负数只需要在类名前面添加负号就行
  • 同理这里也需要用到响应式:md:w-[50rem] xs:w-11/12,md是框架自带的xs是我自定义的在前面tailwindcss尺寸哪有介绍
  • 内容区域两块左右结构,在图片部分我们需要做响应式在指定尺寸下隐藏
    • md:flex hidden这里就不解释了

移动端菜单显示

好在现在的前端是以组件化进行开发,我们只需要将移动端单独编写就行了,通过点击事件显示就好了,这里主要还是用到了antdDrawer

 <Drawer width='85%' placement="right" closable={false} open={true} bodyStyle={{ padding: 0 }} >
  <div className='flex justify-between mb-4 p-[24px]'>
    <svg onClick={() => onClose('close')} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
      <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12H12m-8.25 5.25h16.5" />
    </svg>
    <div onClick={() => onClose('login')} className='bg-[#f4f6f7] rounded-[5px] font-semibold px-[11px] py-[6px] text-[#24252c] text-[12px]'>登录</div>
  </div>
  <ul className=' text-base '>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>影视飓风</li>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>飓风素材库</li>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>闪电风景</li>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>太空之眼</li>
    <li className='px-[24px] font-semibold py-[24px] active:bg-[#F4F6F7]'>加入我们</li>
  </ul>
</Drawer>

在编写头部的时候,我们在右侧留下可以点击的部分

<div className=" text-gray-300">

    {/*PC登录按钮 */} 
  <svg onClick={() => setIsShow(!isShow)} className="lg:flex hidden size-6 rounded-full"
       xmlns="http://www.w3.org/2000/svg"
       fill="none" viewBox="0 0 24 24"
       strokeWidth={1.5} stroke="currentColor"
       style={{color: isTrue ? 'gray' : 'black', background: isTrue ? '#cecece' : '#f4f6f7',}}>
    <path strokeLinecap="round" strokeLinejoin="round"
          d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
  </svg>
  
  {/*移动端呼出菜单按钮 */} 
  <svg onClick={() => setIsNav(!isNav)} className="lg:hidden size-6" xmlns="http://www.w3.org/2000/svg" fill="none"
       viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round"
          d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
  </svg>
</div>

底部编码与粗略分析

我们直接看修改后的响应式效果

250607 162749.gif

 <div className='w-full lg:flex justify-around hidden'>

  {listArray.length && listArray.map((item, index) => {
    return <div key={item.text} className='flex justify-center flex-col items-center mt-[40px] ' onMouseLeave={() => setIsShow(index)} onMouseEnter={() => setIsShow(index)}>
      <img className={`${item.isShow ? styles.entrance : styles.field}  w-[88px] h-[66px]`} src={item.isShow ? item.activeImg : item.img} alt="" />
      <p className={`${item.isShow ? styles['text-entrance'] : styles['text-field']} mt-[40px]`}>{item.text}</p>

      <div className={`{isShow && ${item.isShow ? styles['slogan-entrance'] : styles['slogan-field']} flex flex-col  items-center`}>
        <p className={`text-[12px]`}>{item.slogan}</p>
        <div className='flex'>
          {item.icon.map((img, index) => {
            return <img key={index} className='w-10 h-10  hover:scale-150 transition' src={img} alt="" />
          })}
        </div>
      </div>
    </div>
  })}
</div >
<div className="lg:hidden mt-12 ">

  <div className='px-4 mb-4'>
    <p className='text-sm text-[#646464 ] font-[600]'>ACCOUNT</p>
    <p className="text-[#24252C] font-[600] text-2xl">官方账号</p>
  </div>

  <ul className='px-4'>
    {
      listArray.map((item, index) => {
        return <div key={index} className='mb-6'>
          <li className=" bg-[#F4F6F7] p-4">
            <div className="flex justify-between items-center">
              <div className="flex items-center">
                <img className="w-16 h-12 mr-2" src={item.activeImg} alt=""/>
                <div>
                  <p className="text-[0.85rem] text-[#24252C] font-[600]">{item.text}</p>
                  <p className="text-[0.75rem] text-[#646464] font-[300]">{item.slogan}</p>
                </div>
              </div>
              {!item.isShow &&
                <svg onClick={() => isShow(index)} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
                     strokeWidth={1.5}
                     stroke="currentColor" className="size-6">
                  <path strokeLinecap="round" strokeLinejoin="round"
                        d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0-3.75-3.75M17.25 21 21 17.25"/>
                </svg>}


              {item.isShow &&
                <svg onClick={() => isShow(index)} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
                     strokeWidth={1.5}
                     stroke="currentColor" className="size-6">
                  <path strokeLinecap="round" strokeLinejoin="round"
                        d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12"/>
                </svg>}

            </div>
          </li>

          {
            item.isShow &&
            <div className="bg-[#F4F6F7]  p-4 justify-between"
                 style={{display: 'grid', gridTemplateColumns: 'repeat(4, auto)', gap: '20px'}}>
              {item.icon.map((pItem, index) => {
                return <img key={index} src={pItem} alt=""
                            className="w-10 h-10 hover:scale-110 transition hover:tracking-[2px]"/>
              })}
            </div>
          }

        </div>
      })
    }
  </ul>
</div>

这次我们将底部分为PC和移动端两个组件编写,在两个组件之间进行,只需要在两个组件最外层的上加上lg:flexlg:hidden就能实现在不同尺寸上的显示,是不是很简单

文章只对头部和底部进行的分析,看到这你也就应该知道的响应式的开发套路,对于中间部分你应该也有思路怎么去开发

总结

  • 现在做下总结,响应式的网站就是通过给在不同尺寸下显示不同的内容和布局,但在移动下一些问题还需要特殊处理
  • 如果PC和移动端有较大出路,可以选择分为两个组件进行维护,还可以将一些相同的方法抽成一个HOOK
  • 源码地址

记住这张表

前缀 尺寸
sm 640px及以上
md 768px及以上
lg 1024px及以上
xl 1280px及以上
2xl 15360px及以上

重点记住规则 非常重要!!!!!

  • 非响应式类(如 hidden, flex)在所有屏幕尺寸下生效。
  • 响应式类(如 lg:flex, md:hidden)只在指定尺寸及以上生效。响应式类是以断点前缀: + 属性类
  • 优先级规则:当非响应式类与响应式类冲突时,响应式类会在其生效的屏幕尺寸范围内覆盖非响应式类

模式验证库——zod

引言💭

在学习react过程中接触了一下zod,这篇文章来深入了解一下什么是zod。


一、Zod 是什么?

Zod 是一个 TypeScript 优先的模式验证库,用于在运行时验证和解析数据结构,确保数据类型安全。

这是官网中给的例子:

import { z } from "zod/v4";

// 定义 Schema(模式)
// 这里定义了一个名为 User 的 Schema,它表示一个对象,
// 其中必须包含一个 name 属性,且 name 必须是字符串类型(z.string())。
const User = z.object({
  name: z.string(),
});
 
// 待验证的数据
// input 是一个未知的、可能不受信任的数据(比如来自 API 响应、用户输入等),
// 需要验证是否符合 User Schema。
const input = { /* stuff */ };
 
// 解析和验证数据
// User.parse(input) 会检查 input 是否符合 User Schema:
// 如果符合,返回解析后的数据(类型安全,TypeScript 会推断 data 的类型为 { name: string })。
// 如果不符合,Zod 会抛出一个错误(ZodError)。
const data = User.parse(input);
 
// 由于 data 已经通过 Zod 验证,可以放心使用 data.name,因为它一定是 string 类型。
console.log(data.name);

Zod 的核心思想是:

定义模式(schema) → 推导类型 → 验证数据

特征

  • 零外部依赖
  • 适用于 Node.js 和所有现代浏览器
  • 微小:2kb 核心包(gzip 压缩)
  • 不可变 API:方法返回一个新实例
  • 简洁的界面
  • 适用于 TypeScript 和纯 JS
  • 内置 JSON Schema 转换
  • 广泛的生态系统

基本用法

建议查阅官方文档,这里不作过多赘述。


看到这里是不是有种熟悉的感觉,这不是老朋友interface吗?

二、Zod Schema和Interface

Zod Schema 和 TypeScript 的 interface确实在结构上很相似,它们都用来定义对象的“形状”和“属性”。但它们的用途、时机和能力有明显区别:


🧩 1. 编译时 vs 运行时

比较项 interface Zod Schema
类型检查时机 编译时(开发时) 运行时
作用 提供类型提示、静态检查 对传入数据进行实际的运行时校验
能否在运行时使用 会被擦除 运行时可判断是否合法

🔍 2. 示例对比

interface 示例(仅开发时检查

interface User {
  name: string;
  age: number;
}

function greet(user: User) {
  console.log(`Hello, ${user.name}`);
}

// 编译时能检查,但运行时不会验证结构

Zod 示例(运行时校验)

import { z } from "zod";

const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});

function greet(user: unknown) {
  const parsed = UserSchema.parse(user); // 若不合法会抛错
  console.log(`Hello, ${parsed.name}`);
}

🧠 3. 用途对比

用途 interface Zod
类型提示 ❌(需通过 .infer 获取类型)
运行时输入验证(API、表单)
提供 IDE 自动补全 ❌(Zod Schema 可反推出类型)
数据转换(默认值、类型转换) ✅(Zod 提供 .transform()

🔁 4. 配合使用

最佳实践Zod + TypeScript 联用

const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});

type User = z.infer<typeof UserSchema>; // 自动提取类型

这样既能进行运行时校验,又能享受类型推导带来的开发体验。


总结一句话

interface 是开发阶段的类型说明,Zod 是运行阶段的数据验证器。它们可以配合使用,各司其职。

三、在 React 项目中的使用

1. 验证表单数据

Zod 可以用来验证用户在表单中输入的数据,搭配 react-hook-form 特别方便。

示例:表单验证

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {z} from "zod";

// 定义 Zod 验证 schema(运行时验证规则)
const schema = z.object({
  name: z.string().min(2, "名字至少2个字符"),
  age: z.number().min(18, "必须年满18岁"),
});

// 使用 z.infer 推导出 schema 对应的 TypeScript 类型
type FormData = z.infer<typeof schema>;

export default function MyForm() {
  // 初始化表单,使用 useForm
  const {
    register, // 注册表单字段,连接 input 元素和 React Hook Form
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(schema), // 指定使用 zodResolver(schema),让 Zod 参与验证
  });

  // 提交处理函数,只有通过验证时才会调用
  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
  // handleSubmit(onSubmit)会拦截提交事件:
  // 验证失败 → 不提交;验证成功 → 调用 onSubmit
    <form onSubmit={handleSubmit(onSubmit)}>
    
      <input {...register("name")} placeholder="名字" />
      <!--- 如果errors.name存在则显示message:"名字至少2个字符",不存在则不显示-->
      {errors.name && <p>{errors.name.message}</p>}

      <input type="number" {...register("age", { valueAsNumber: true })} placeholder="年龄" />
      <!--如果errors.age存在则显示message:"必须年满18岁",不存在则不显示-->
      {errors.age && <p>{errors.age.message}</p>}

      <button type="submit">提交</button>
      
    </form>
  );
}

2. 校验 API 返回的数据

例如,fetch 后对返回结果进行类型和结构的校验:

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

fetch("/api/user")
  .then(res => res.json())
  .then(data => {
    // 使用 Zod 的 safeParse 进行安全校验
    // 返回的是一个包含 success 和 data/error 的对象
    const result = userSchema.safeParse(data);
    if (result.success) {
      // 校验成功,result.data 是类型安全的对象
      console.log(result.data);
    } else {
      // 校验失败,result.error 是详细的验证错误信息
      console.error("数据结构不符合预期", result.error);
    }
  });

3. 组件 props 验证(开发阶段)

虽然 TypeScript 已经能检查 props 类型,但你可以使用 Zod 在运行时对 props 进行额外校验(比如防止外部调用者传错结构)。

import React from "react";
import {z} from "zod";

// 定义 props 的 schema
const userCardPropsSchema = z.object({
  name: z.string(),
  age: z.number().int().min(0),
});

type UserCardProps = z.infer<typeof userCardPropsSchema>;

// 定义函数组件,并使用推导出来的 props 类型
export const UserCard: React.FC<UserCardProps> = (props) => {
  // 使用 Zod 的 safeParse 方法在运行时校验 props 的合法性
  const result = userCardPropsSchema.safeParse(props);

  if (!result.success) {
    // 校验失败,打印错误信息,避免渲染非法数据
    console.error("UserCard props 验证失败:", result.error);
    return <div>传入的 props 不合法</div>;
  }

  // 解构通过验证的数据,类型安全
  const { name, age } = result.data;

  // 正常渲染组件内容
  return (
    <div>
      <h2>{name}</h2>
      <p>年龄: {age}</p>
    </div>
  );
};

四、Zod 的优势(特别适用于 React)

优点 描述
类型推导 使用 z.infer 可以自动获得类型定义
TypeScript 友好 完美支持 TS,无需重复定义类型
表单库兼容性 react-hook-form 等库配合默契
可组合性强 支持嵌套 schema、交叉验证、联合类型等
运行时验证 提供真实的运行时类型检查(TS 在编译时)

五、与其他库的比较(Yup, Joi)

特性 Zod Yup Joi
TypeScript 支持 ✅ 极好 ⚠️ 一般 ❌ 较差
学习曲线 简单 稍复杂 较复杂
表单支持 ✅ react-hook-form 优先支持
Schema 推导 ✅ 自动推导 ❌ 需手动 ❌ 需手动

结语✒️

知识点+1✨✨✨

猫抓爱心.gif

使用js方法实现阻止按钮的默认点击事件&触发默认事件

功能需求

  1.谷歌浏览器插件需要实现在用户提交表单的时候触发阻止默认行为
  2.对表单数据进行分析,提交给ai分析
  3.ai对用户表单进行分析并返回结果
  4.有问题回显对应文案
  5.没问题则提交表单的默认点击事件

实现思路

测试表单

<!doctype html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite + React + TS + chrome</title>
  <link rel="stylesheet" href="/form.css">
  <!-- <script src="//g.alicdn.com/chatui/icons/2.6.2/index.js"></script> -->
</head>

<body>
  <form onsubmit="return false" action="#">
    <!-- <input name="name" id="name" type="text" class="feedback-input" placeholder="Name" />
    <input name="email" id="email" type="text" class="feedback-input" placeholder="Email" /> -->
    <textarea name="text" id="text" class="feedback-input" placeholder="Comment"></textarea>
    <input type="submit" id="submitBtn" value="SUBMIT" />
  </form>
  <div id="root"></div>
  <script>
    window.onload = function () {
      const submitBtn = document.getElementById('submitBtn')
      submitBtn.addEventListener('click', onsubmit)
      function onsubmit(e) {
        const text = document.getElementById('text').value
       alert(text)
      }
    }

  </script>
  <script type="module" src="/src/main.tsx"></script>
</body>

</html>

插件代码

  useEffect(() => {
    const submitBtn = document.getElementById('submitBtn')! as HTMLButtonElement
    const overlayId = uuid()
    // 创建覆盖层
    const createOverlay = () => {
      const overlay = document.createElement('div')
      overlay.id = overlayId
      // 获取按钮位置和大小
      const updateOverlayPosition = () => {
        const rect = submitBtn.getBoundingClientRect()
        overlay.style.position = 'fixed'
        overlay.style.top = `${rect.top}px`
        overlay.style.left = `${rect.left}px`
        overlay.style.width = `${rect.width}px`
        overlay.style.height = `${rect.height}px`
        overlay.style.backgroundColor = 'transparent'
        overlay.style.zIndex = '100'
        overlay.style.cursor = 'pointer'
        overlay.style.pointerEvents = 'auto'
      }
      // 初始定位
      updateOverlayPosition()
      // 添加到DOM
      document.body.appendChild(overlay)
      // 监听窗口变化,更新位置
      window.addEventListener('resize', updateOverlayPosition)
      window.addEventListener('scroll', updateOverlayPosition)
      // 返回清理函数
      return () => {
        window.removeEventListener('resize', updateOverlayPosition)
        window.removeEventListener('scroll', updateOverlayPosition)
        if (overlay.parentNode) overlay.parentNode.removeChild(overlay)
      }
    }
    // 创建覆盖层并获取清理函数
    const removeOverlay = createOverlay()

    // 为覆盖层添加点击事件
    const overlay = document.getElementById(overlayId)! as HTMLDivElement
    overlay.addEventListener('click', handleClick)
    function handleClick() {
      const fn = () => {
        const name = (document.getElementById('name')! as HTMLInputElement)?.value
        const email = (document.getElementById('email')! as HTMLInputElement)?.value
        const text = (document.getElementById('text')! as HTMLInputElement)?.value
        return {
          name,
          email,
          text,
        }
      }
      if (isThinkingtRef.current) return
      setIsThinking(true)
      const { name, email, text } = fn()
      console.log(`text ==>`, text)
      if (text.trim() !== '') {
        onSend('text', `用户输入:\n${text}\n帮我分析一下输入是否通顺`)
      }
    }

    // 添加清理函数
    return () => {
      console.log(`清理函数==>`)
      overlay.removeEventListener('click', handleClick)
      removeOverlay()
    }
  }, [isThinkingtRef.current]) // 添加activeButton为依赖项


实现思路

创建一个div,将它覆盖到原有的按钮上,并添加点击事件,触发原本方法则使用获取dom,触发点击事件
ok!

没想到干前端2年了还能用上高中物理运动学知识

背景

最近做一个活动需求,其中有一块是类似老虎机样式的积分抽奖的功能,抽奖的结果由后端返回,产品要求动画流畅,先慢慢变快后逐渐变慢直至目标奖项。

难点

拿到这个需求第一反应就是界面不是问题,难点是抽奖动画的逻辑。

  1. 需要实现三阶段动画效果:加速→匀速→减速
  2. 减速后需要准确不突兀的停在目标上
  3. 高频动画的性能的优化
  4. 通用性封装,适用不同奖品个数,指定速率变化等

寻求解法

最开始我想的是其他项目组记得是有这个功能的,于是找到负责人要到了项目仓库权限,打开一看有点蒙。7年前的类组件写法+amis-core低代码库等各种辅助工具,且逻辑是根据写死角度deg来判定,有点过于复杂,并不是通用的。

后面开始在稀土掘金找有没有大佬实现了分享出来的,发现并没有使用ReactHook实现的,且其他大部分都是一个非常简单的匀速转动+中奖概率的Demo,不符合我的需求。

于是我就想自己封装一个高级通用可调配参数的hook。

过程

首先向DeepSeek提问:

使用react帮我实现一个九宫格抽奖逻辑,要求如下

  1. 九宫格中心是开始按钮,8个奖品分别为1-8
  2. 抽奖动画先加速再匀速再减速,直到抽中后端返回的特定的结果
  3. 使用requestAnimationFrame优化动画
  4. 逻辑清晰,结构简单,代码备注

虽然DeepSeek给出的代码运行不起来,但起码给了我最初的思路,其中核心是通过设定指针停留在奖项上的停留时间长短的变化随时间的改变而改变(所谓的加速度,每毫秒减少x停留时间),并且记录了所转的圈数,然后使用时差固定每16ms更新一次,等速度降到最低时找到目标值再停下。

 // 关键参数
 currentIndex: 当前高亮的奖品序号
 currentSpeed: 150 // 当前速度,值越小,速度越大
 minSpeed: 50, // 最快速度,每个奖品停留的最长时间
 maxSpeed: 150, // 最慢速度,每个奖品停留的最短时间
 acceleration: 0.5, // 加速度(每帧速度增加的量)
 deceleration: 0.3, // 减速度(每帧速度减少的量)
 minRounds: 3, // 最小旋转圈数
 targetIndex: 0, // 目标位置

看起来这种方式确实可以实现,但是自己实现时发现有些地方的逻辑还是比较混乱和复杂的!

首先就是指针变化的时机不好掌控,因为一个奖品停留的时间是随着时间的变化而变化的,我们需要具体算出一个奖品的停留时间就需要依靠微积分的知识了

其次比如既然我有了最大速度,是不是可以不再依赖固定的时间,而是根据实际旋转圈数和速度变化来控制动画,因为设备刷新率的不同

转换成物理模型

image.png

在我打着草稿时我突然觉得,这个场景怎么跟高中某个物理题似曾相识呢 😂

于是我大腿一拍!我可以将它转换成环形跑道的问题呀!利用高中物理的加速度公式和位移公式,把跑道等分编号,给出时间求出总路程求余得出对应编号,可行,开干!!👏👏👏

✨✨✨✨✨✨✨✨

V=V0+atV = V₀ + at
S=V0t+(1/2)at2S = V₀t + (1/2)at²

✨✨✨✨✨✨✨✨

最终,我将抽奖动画问题抽象为以下物理模型:

  • 一段环形公路长n米(n为整数且n>=1),平均分成n段标号1~n,每段长度为1m
  • 物体从速度0开始绕环运动,先经过t1秒的加速运动到最大速度
  • 再经过t2秒的匀速运动,匀速时每段公路经过的时间为s1
  • 再经过t3秒的匀减速运动至一定速度后持续做匀速运动,匀速时每段公路经过的时间为s2
  • 给出总的运动时间t,求物体在哪一段路上的公式F(t)

其中s1,s2为最长/最短停留奖项的时间,这样我就可以随着时间推移实时算出当前高亮的奖品序号。

通过AI得出表达式:

formula.jpg

高级通用抽奖动画模型的Hook封装

这里奉上我最终封装的代码(全网唯一),欢迎前端同学指出代码中的问题以及提出更好的方案哦~

import {useEffect, useRef, useState} from 'react'

// 时间单位均为秒
interface LotteryAnimationProps {
  n?: number // 奖品数量
  t1?: number // 加速时间
  t2?: number // 最大匀速时间
  t3?: number // 减速时间
  s1?: number // 最快时的停留时间
  s2?: number // 最慢时的停留时间
}

// 默认参数配置
const defaultConfig = {
  n: 8,
  t1: 1.5,
  t2: 1.5,
  t3: 2,
  s1: 0.04,
  s2: 0.2,
}

export const useLotteryAnimation = ({
  n,
  t1,
  t2,
  t3,
  s1,
  s2,
}: LotteryAnimationProps = defaultConfig) => {
  // 状态管理
  const [currentIndex, setCurrentIndex] = useState<number>(null) // 当前标号转到处于 1~n
  const [isRunning, setIsRunning] = useState<boolean>(false) // 是否正在抽奖

  // 动画相关引用
  const animationRef = useRef<number>(null) // 动画帧
  const startTimeRef = useRef<number>(0) // 抽奖动画开始时间
  const resolveRef = useRef<(value: unknown) => void>(null) // 抽奖动画开始时间
  const resultRef = useRef<number>(null) // 抽奖结果 1~n

  // 开始抽奖
  function run(target: number) {
    return new Promise((resolve) => {
      if (isRunning) throw new Error('正在抽奖')
      if (target < 1) throw new Error('中奖目标参数错误')

      setIsRunning(true)
      resultRef.current = target
      resolveRef.current = resolve

      // 初始化时间戳
      startTimeRef.current = performance.now()

      // 开始动画
      animate()
    })
  }

  // 动画函数(使用状态机管理动画阶段)
  function animate() {
    const nowTime = performance.now()
    // 耗时
    const t = (nowTime - startTimeRef.current) / 1000

    if (t < 0) return

    // 公式计算
    if (t >= 0 && t <= t1) {
      // 加速阶段
      setCurrentIndex(Math.floor((t ** 2 / (2 * s1 * t1)) % n) + 1)
    } else if (t1 < t && t <= t1 + t2) {
      // 最大匀速阶段
      setCurrentIndex((Math.floor((t - 0.5 * t1) / s1) % n) + 1)
    } else if (t1 + t2 < t && t <= t1 + t2 + t3) {
      // 减速阶段
      const formula1 = (t - 0.5 * t1) / s1
      const formula2 = ((1 / s2 - 1 / s1) * (t - t1 - t2) ** 2) / (2 * t3)
      setCurrentIndex(Math.floor((formula1 + formula2) % n) + 1)
    } else {
      // 低速寻址阶段
      const formula1 = (0.5 * t1 + t2 + 0.5 * t3) / s1
      const formula2 = (0.5 * t3) / s2
      const formula3 = (t - t1 - t2 - t3) / s2
      const cur = Math.floor(((formula1 + formula2 + formula3) % n) + 1)
      setCurrentIndex(cur)
      // 到达目标终点
      if (cur === resultRef.current) {
        cancelAnimationFrame(animationRef.current)
        setIsRunning(false)
        resolveRef.current(cur)
        return
      }
    }

    // 继续动画
    animationRef.current = requestAnimationFrame(animate)
  }

  // 清理动画帧
  useEffect(() => {
    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current)
      }
    }
  }, [])

  return {
    currentIndex,
    isRunning,
    run,
  }
}

前端组件:pc端通用新手引导组件最佳实践(React)

GuideOverlay 用户引导组件技术解析

组件功能背景

在现代Web应用中,随着产品功能的不断丰富和复杂化,新用户往往需要一定的学习成本才能熟练使用产品。特别是在B端产品中,复杂的操作流程和多样的功能模块容易让用户感到困惑。为了提升用户体验,降低学习成本,我们需要一个通用的用户引导组件来帮助用户快速了解产品功能。

用户引导组件的核心价值在于:

  • 降低学习成本:通过分步骤的引导,帮助用户快速上手
  • 提升用户体验:减少用户的困惑和挫败感
  • 增加功能发现率:主动展示重要功能,避免功能被埋没
  • 减少客服成本:减少因不会使用而产生的咨询

组件功能介绍

GuideOverlay是一个通用的用户引导组件,具备以下核心功能:

核心特性

  1. 步骤式引导:支持多步骤的引导流程,用户可以按步骤学习
  2. 元素高亮:通过遮罩层高亮目标元素,聚焦用户注意力
  3. 灵活定位:支持上下左右四个方向的弹窗定位
  4. 响应式适配:自动适配不同屏幕尺寸,处理边界情况
  5. 状态记录:基于localStorage记录用户是否已完成引导
  6. 自定义配置:支持自定义引导内容、位置、样式等

技术特点

  • React Hooks:使用现代React技术栈,基于函数组件和Hooks
  • TypeScript友好:提供完整的类型定义和JSDoc注释
  • CSS模块化:使用SCSS和CSS模块化,避免样式污染
  • 性能优化:合理使用useEffect和事件监听器,避免内存泄漏
  • 用户体验优化:防止页面滚动,智能箭头定位,平滑过渡动画

效果预览

1749194615843.jpg

设计思路

1. 架构设计

GuideOverlay
├── 遮罩层 (guide-mask)
├── 高亮区域 (guide-highlight)
└── 引导弹窗 (guide-popup)
    ├── 关闭按钮
    ├── 内容区域
    │   ├── 标题
    │   ├── 描述
    │   └── 底部操作区
    └── 箭头指示器

2. 核心算法

位置计算算法

  • 使用getBoundingClientRect()获取目标元素的精确位置
  • 根据配置的position参数计算弹窗位置
  • 智能处理边界情况,避免弹窗超出屏幕范围

高亮效果实现

  • 使用box-shadow的扩散阴影创建遮罩效果
  • 目标元素保持透明,形成"镂空"效果
  • 相比传统的多div遮罩方案,性能更优

箭头定位算法

  • 根据弹窗和目标元素的相对位置动态计算箭头位置
  • 支持左右偏移配置,适应不同的UI布局需求

3. 状态管理

使用React Hooks管理组件状态:

  • currentStep:当前引导步骤
  • showGuide:是否显示引导
  • position:目标元素位置信息
  • arrowPosition:箭头位置
  • arrowOffset:箭头偏移

使用方法

基本使用

import React from 'react';
import GuideOverlay from '@/components/common/GuideOverlay';

const MyComponent = () => {
  const guideSteps = [
    {
      title: '欢迎使用',
      desc: '这是您的第一步操作引导',
      targetSelector: '.target-element-1',
      position: 'bottom',
      arrowPosition: 'top'
    },
    {
      title: '功能介绍',
      desc: '这里是重要的功能按钮',
      targetSelector: '.target-element-2',
      position: 'right',
      arrowPosition: 'left'
    }
  ];

  const handleGuideFinish = () => {
    console.log('引导完成');
  };

  return (
    <div>
      {/* 你的页面内容 */}
      <button className="target-element-1">按钮1</button>
      <button className="target-element-2">按钮2</button>
      
      {/* 引导组件 */}
      <GuideOverlay
        steps={guideSteps}
        onFinish={handleGuideFinish}
        storageKey="myAppGuide"
      />
    </div>
  );
};

参数配置

参数 类型 默认值 描述
steps Array [] 引导步骤配置数组
onFinish Function - 引导完成回调
storageKey String 'creditMerchantGuide' 本地存储键名

步骤配置说明

每个步骤对象包含以下属性:

{
  title: '步骤标题',           // 必填,引导标题
  desc: '步骤描述',            // 必填,引导描述文本或函数形式html
  targetSelector: '.target',   // 必填,目标元素选择器
  position: 'bottom',          // 可选,弹窗位置:top/bottom/left/right
  arrowPosition: 'top'         // 可选,箭头位置,支持topLeft/topRight等
}

使用案例

案例1:新用户引导

const newUserGuide = [
  {
    title: '欢迎来到信贷商户平台',
    desc: '让我们开始您的第一次体验之旅',
    targetSelector: '.header-logo',
    position: 'bottom'
  },
  {
    title: '申请信贷产品',
    desc: '点击这里可以申请各种信贷产品',
    targetSelector: '.apply-credit-btn',
    position: 'bottom',
    arrowPosition: 'topLeft'
  },
  {
    title: '查看申请进度',
    desc: '在这里可以实时查看您的申请进度',
    targetSelector: '.progress-tab',
    position: 'right'
  }
];

<GuideOverlay
  steps={newUserGuide}
  onFinish={() => {
    // 可以在这里发送埋点数据
    analytics.track('new_user_guide_completed');
  }}
  storageKey="newUserGuide"
/>

案例2:功能更新引导

const featureUpdateGuide = [
  {
    title: '新功能上线',
    desc: () => (
      <div>
        <p>我们为您带来了全新的批量操作功能</p>
        <p>现在可以同时处理多个申请</p>
      </div>
    ),
    targetSelector: '.batch-operation-btn',
    position: 'left'
  }
];

技术要点分析

1. 高亮效果的实现

传统方案通常使用四个div拼接成遮罩,但这种方案存在性能和复杂度问题。我们采用了更优雅的box-shadow方案:

.guide-highlight {
  position: absolute;
  background: transparent;
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
  border-radius: 4px;
}

优势

  • 只需一个元素即可实现遮罩效果
  • 性能更好,减少DOM操作
  • 代码更简洁,易于维护

2. 动态位置计算

const updateTargetPosition = () => {
  const targetElement = document.querySelector(currentStepConfig.targetSelector);
  if (targetElement) {
    const rect = targetElement.getBoundingClientRect();
    const position = {
      top: rect.top,
      left: rect.left,
      width: rect.width,
      height: rect.height
    };
    setPosition(position);
  }
};

关键技术

  • 使用getBoundingClientRect()获取元素的精确位置
  • 监听窗口大小变化,实时更新位置
  • 处理滚动和动态内容变化

3. 边界处理

// 处理弹窗位置,避免超出屏幕边界
if (popupStyle.left < 20) {
  popupStyle.left = 20;
} else if (popupStyle.left + 320 > window.innerWidth - 20) {
  popupStyle.left = window.innerWidth - 320 - 20;
}

确保引导弹窗始终在可视区域内,提升用户体验。

4. 内存泄漏防护

useEffect(() => {
  const handleResize = () => {
    if (showGuide) {
      updateTargetPosition();
    }
  };

  window.addEventListener('resize', handleResize);
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, [showGuide, currentStep]);

正确地添加和清理事件监听器,避免内存泄漏。

最佳实践

1. 引导内容设计

  • 简洁明了:每步引导文案要简洁,避免冗长描述
  • 突出重点:重要操作用粗体或颜色强调
  • 循序渐进:按照用户使用流程设计引导顺序

2. 性能优化

  • 懒加载:只在需要时渲染引导组件
  • 防抖处理:对窗口resize事件进行防抖
  • 及时清理:组件卸载时清理定时器和事件监听

3. 用户体验

  • 可跳过:始终提供跳过或关闭选项
  • 状态保存:记录用户完成状态,避免重复显示
  • 响应式:适配不同设备和屏幕尺寸

扩展功能

可配置主题

.guide-popup {
  --primary-color: #1677FF;
  --text-color: #FFFFFF;
  --bg-color: var(--primary-color);
  
  background: var(--bg-color);
  color: var(--text-color);
}

动画效果增强

.guide-popup {
  animation: slideIn 0.3s ease-out;
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

总结

GuideOverlay组件技术实现价值:

  1. 技术实现优雅:使用现代React技术栈,代码结构清晰
  2. 用户体验良好:支持多种定位方式,智能边界处理
  3. 扩展性强:配置灵活,易于定制和扩展
  4. 性能优化:合理的状态管理和事件处理

这个组件可以应用于pc项目用户引导场景,有效提升产品的易用性和用户满意度。

完整代码

index.jsx

import React, { useState, useEffect } from 'react';
import { buildStatic } from '@/public/util.js';
import './index.scss';

/**
 * Tab按钮引导组件
 * @param {Object} props 组件属性
 * @param {Array} props.steps 引导步骤配置数组,每个步骤包含title, desc, targetSelector, position等属性
 * @param {Function} props.onFinish 引导结束后的回调函数
 * @param {String} props.storageKey 本地存储的键名,用于记录用户是否已看过引导
 * @returns {JSX.Element|null} 引导组件或null
 */
const GuideOverlay = ({ steps = [], onFinish, storageKey = 'creditMerchantGuide' }) => {
  const [currentStep, setCurrentStep] = useState(0);
  const [showGuide, setShowGuide] = useState(false);
  const [position, setPosition] = useState({ top: 0, left: 0, width: 0, height: 0 });
  const [arrowPosition, setArrowPosition] = useState('top');
  const [arrowOffset, setArrowOffset] = useState(null);

  // 检查是否需要显示引导
  useEffect(() => {
    const hasSeenGuide = localStorage.getItem(storageKey);
    if (!hasSeenGuide && steps.length > 0) {
      setShowGuide(true);
      updateTargetPosition();
      // 展示引导关闭页面滚动
      document.body.style.overflow = 'hidden';
      document.documentElement.style.overflow = 'hidden';
    }
  }, [storageKey, steps]);

  // 更新当前步骤目标元素的位置
  useEffect(() => {
    if (showGuide && steps.length > 0) {
      setTimeout(() => {
        updateTargetPosition();
      }, 100);
    }
  }, [currentStep, showGuide]);

  // 计算目标元素位置
  const updateTargetPosition = () => {
    const currentStepConfig = steps[currentStep];
    if (!currentStepConfig || !currentStepConfig.targetSelector) return;

    const targetElement = document.querySelector(currentStepConfig.targetSelector);
    if (targetElement) {
      const rect = targetElement.getBoundingClientRect();
      const position = {
        top: rect.top,
        left: rect.left,
        width: rect.width,
        height: rect.height
      };
      setPosition(position);
      
      // 处理箭头位置和偏移
      const arrowPos = currentStepConfig.arrowPosition || 'top';
      setArrowPosition(arrowPos.replace(/Left|Right/g, ''));
      
      // 设置箭头偏移
      if (arrowPos.includes('Left') || arrowPos.includes('Right')) {
        setArrowOffset(arrowPos.includes('Left') ? 'left' : 'right');
      } else {
        setArrowOffset(null);
      }
    }
  };

  // 下一步
  const handleNext = () => {
    if (currentStep < steps.length - 1) {
      setCurrentStep(currentStep + 1);
    } else {
      handleFinish();
    }
  };

  // 完成引导
  const handleFinish = () => {
    // 打开页面滚动
    document.body.style.overflow = 'auto';
    document.documentElement.style.overflow = 'auto';
    setShowGuide(false);
    localStorage.setItem(storageKey, 'true');
    if (onFinish && typeof onFinish === 'function') {
      onFinish();
    }
  };

  // 当窗口大小变化时更新位置
  useEffect(() => {
    const handleResize = () => {
      if (showGuide) {
        updateTargetPosition();
      }
    };

    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, [showGuide, currentStep]);

  if (!showGuide || steps.length === 0) return null;

  const currentStepConfig = steps[currentStep];
  
  // 计算弹窗位置
  let popupStyle = {};
  switch (currentStepConfig.position) {
    case 'bottom':
      popupStyle = {
        top: position.top + position.height + 12,
        left: position.left - 184 + position.width / 2
      };
      break;
    case 'top':
      popupStyle = {
        bottom: window.innerHeight - position.top + 12,
        left: position.left - 184 + position.width / 2
      };
      break;
    case 'left':
      popupStyle = {
        top: position.top + position.height / 2 - 75,
        right: window.innerWidth - position.left + 12
      };
      break;
    case 'right':
      popupStyle = {
        top: position.top + position.height / 2 - 75,
        left: position.left + position.width + 12
      };
      break;
    default:
      popupStyle = {
        top: position.top + position.height + 12,
        left: position.left - 184 + position.width / 2
      };
  }

  // 处理弹窗位置,避免超出屏幕边界
  if (popupStyle.left < 20) {
    popupStyle.left = 20;
  } else if (popupStyle.left + 320 > window.innerWidth - 20) {
    popupStyle.left = window.innerWidth - 320 - 20;
  }

  // 获取箭头样式
  const getArrowStyle = () => {
    const arrowStyle = {};
    
    if (arrowOffset === 'left') {
      // 箭头在左侧,距离左边框20px
      if (arrowPosition === 'top' || arrowPosition === 'bottom') {
        arrowStyle.left = '20px';
        arrowStyle.marginLeft = '0';
      }
    } else if (arrowOffset === 'right') {
      // 箭头在右侧
      if (arrowPosition === 'top' || arrowPosition === 'bottom') {
        arrowStyle.left = 'auto';
        arrowStyle.right = '20px';
        arrowStyle.marginLeft = '0';
      }
    }
    
    // 确保箭头指向目标元素中间
    if (arrowOffset && (arrowPosition === 'top' || arrowPosition === 'bottom')) {
      // 计算箭头相对于弹窗的位置
      const popupLeft = popupStyle.left;
      const targetCenter = position.left + position.width / 2;
      const arrowOffset = 8; // 箭头手动偏移量

      // 箭头指向目标中心的位置
      arrowStyle.left = targetCenter - popupLeft - arrowOffset;
      
      // 限制箭头不超出弹窗边界
      if (arrowStyle.left < 20) {
        arrowStyle.left = '20px';
      } else if (arrowStyle.left > 300) {
        arrowStyle.left = '300px';
      } else {
        arrowStyle.left = `${arrowStyle.left}px`;
      }
      
      arrowStyle.marginLeft = '0';
    }
    
    return arrowStyle;
  };

  return (
    <div className="guide-overlay">
      {/* 遮罩层 */}
      <div className="guide-mask"></div>
      
      {/* 目标元素高亮区域 */}
      <div 
        className="guide-highlight" 
        style={{
          top: position.top,
          left: position.left,
          width: position.width,
          height: position.height,
        }}
      ></div>
      
      {/* 引导弹窗 */}
      <div 
        className={`guide-popup guide-popup-${currentStepConfig.position || 'bottom'}`}
        style={popupStyle}
      >
        <div className="guide-close" onClick={handleFinish}>
          <img 
            src={buildStatic("/shuidi/images/archives/products/close-white-icon.png")}
            alt="关闭"
            width="16"
            height="16"
          />
        </div>
        
        <div className="guide-content">
          <div className="guide-title">{currentStepConfig.title}</div>
          <div className="guide-desc">
            {typeof currentStepConfig.desc === 'function' 
              ? currentStepConfig.desc() 
              : currentStepConfig.desc}
          </div>
          
          <div className="guide-footer">
            <div className="guide-step-indicator">
              ({currentStep + 1}/{steps.length})
            </div>
            <button 
              className="guide-next-button"
              onClick={handleNext}
            >
              {currentStep === steps.length - 1 ? '我知道了' : '下一步'}
            </button>
          </div>
        </div>
        
        {/* 箭头 */}
        <div 
          className={`guide-arrow guide-arrow-${arrowPosition}`}
          style={getArrowStyle()}
        ></div>
      </div>
    </div>
  );
};

export default GuideOverlay;

index.scss

.guide-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 9999;
  pointer-events: none;
  
  // 遮罩层
  .guide-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
  }
  
  // 高亮区域
  .guide-highlight {
    position: absolute;
    background: transparent;
    box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
    z-index: 1;
    border-radius: 4px;
    pointer-events: auto;
    transition: all 0.3s ease;
  }
  
  // 引导弹窗
  .guide-popup {
    position: absolute;
    width: 320px;
    padding: 24px;
    background: #1677FF;
    color: #FFFFFF;
    border-radius: 8px;
    pointer-events: auto;
    z-index: 2;
    transition: all 0.3s ease;
    
    // 关闭按钮
    .guide-close {
      position: absolute;
      top: 12px;
      right: 12px;
      width: 24px;
      height: 24px;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      border-radius: 50%;
      
      &:hover {
        background: rgba(255, 255, 255, 0.15);
      }
    }
    
    // 内容区域
    .guide-content {
      .guide-title {
        font-size: 24px;
        font-weight: bold;
        margin-bottom: 8px;
      }
      
      .guide-desc {
        font-size: 16px;
        margin-bottom: 24px;
        line-height: 24px;
      }
      
      .guide-footer {
        display: flex;
        justify-content: space-between;
        align-items: center;
        
        .guide-step-indicator {
          font-size: 14px;
          color: rgba(255, 255, 255, 0.8);
        }
        
        .guide-next-button {
          background: #FFFFFF;
          color: #1677FF;
          border: none;
          padding: 8px 16px;
          font-size: 14px;
          font-weight: 500;
          border-radius: 4px;
          cursor: pointer;
          transition: all 0.2s;
          
          &:hover {
            background: rgba(255, 255, 255, 0.9);
          }
        }
      }
    }
    
    // 箭头
    .guide-arrow {
      position: absolute;
      width: 16px;
      height: 16px;
      background: #1677FF;
      transform: rotate(45deg);
      
      &-top {
        top: -8px;
        left: 50%;
        margin-left: -8px;
      }
      
      &-bottom {
        bottom: -8px;
        left: 50%;
        margin-left: -8px;
      }
      
      &-left {
        left: -8px;
        top: 50%;
        margin-top: -8px;
      }
      
      &-right {
        right: -8px;
        top: 50%;
        margin-top: -8px;
      }
    }
    
    // 不同位置的弹窗样式
    &-top {
      .guide-arrow {
        top: auto;
        bottom: -8px;
      }
    }
    
    &-bottom {
      .guide-arrow {
        bottom: auto;
        top: -8px;
      }
    }
    
    &-left {
      .guide-arrow {
        left: auto;
        right: -8px;
      }
    }
    
    &-right {
      .guide-arrow {
        right: auto;
        left: -8px;
      }
    }
  }
}

Mendix,在开发组件之前,需要了解的部分知识

环境安装

  • node 环境,建议安装v18以上版本
    • nodejs官网下载安装包,或通过nvm安装
    • node -v
  • 全局安装yo
    • npm install -g yo
  • 全局安装 @mendix/generator-widget
    • npm install -g @mendix/generator-widget

使用

  • 初始化项目
    • yo @mendix/widget myWidget
  • 进入组件目录并打包,将组件更新到mendix本地工程中
    • cd myWidget && npm run build

组件目录说明

- dist
- node_modules
- src
    components
      HelloWorldSample.tsx   // 默认生成的子组件
    ui
      xxx.css   // 可修改为 scss文件,
    package.xml
    xxx.editorConfig.ts   // xml配置信息调整,比如某个xml字段的显示隐藏等操作
    xxx.editorPreview.tsx // mendix预览模式下的展示内容
    xxx.tsx  // 组件渲染的内容文件
    xxx.xml  // 配置组件需要用到的参数信息
- typings
    xxx.d.ts  // xml文件配置的字段类型,自动生成的

xml配置项说明

<?xml version="1.0" encoding="utf-8" ?>
<widget
    id="mendix.xxx.xxx"
    pluginWidget="true"
    needsEntityContext="true"
    offlineCapable="true"
    supportedPlatform="Web"
    xmlns="http://www.mendix.com/widget/1.0/"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.mendix.com/widget/1.0/ ../node_modules/mendix/custom_widget.xsd"
>
    <name>xxx</name>
    <description>My widget description</description>
    <icon />
    <properties>
        <propertyGroup caption="General">
            <!-- Data source分组 -->
            <propertyGroup caption="Data source">
                <property key="myDatasource" type="datasource" isList="true">
                    <caption>Data Source</caption>
                    <description>关联数据源</description>
                </property>
                <property key="mode" type="enumeration" defaultValue="undefined">
                    <caption>Mode</caption>
                    <description>枚举类型</description>
                    <enumerationValues>
                        <enumerationValue key="undefined">Single</enumerationValue>
                        <enumerationValue key="multiple">Multiple</enumerationValue>
                        <enumerationValue key="tags">Tags</enumerationValue>
                    </enumerationValues>
                </property>
                <property key="showCode" type="boolean" defaultValue="false">
                    <caption>Show Prefix</caption>
                    <description>布尔值</description>
                </property>
                <property key="code" type="attribute" dataSource="myDatasource">
                    <caption>Option Prefix</caption>
                    <description>从myDatasource中选取某个字段,需要指定类型</description>
                    <attributeTypes>
                        <!-- 根据字段类型显示字段 -->  
                        <attributeType name="String" />  
                        <attributeType name="AutoNumber" />  
                        <attributeType name="Boolean" />  
                        <attributeType name="DateTime" />  
                        <attributeType name="Decimal" />  
                        <attributeType name="Enum" />  
                        <attributeType name="Integer" />  
                        <attributeType name="Long" />
                    </attributeTypes>
                </property>
                <property key="labelName" type="attribute" dataSource="myDatasource">
                    <caption>Label Name</caption>
                    <description>从myDatasource中选取某个字段,需要指定类型</description>
                    <attributeTypes>
                        <attributeType name="String" />
                    </attributeTypes>
                </property>
                <property key="placeholder" type="string" defaultValue="placeholder">
                    <caption>PlaceHolder</caption>
                    <description>mendix配置的字符串</description>
                </property>
                <property key="isRequired" type="expression" required="true" defaultValue="false">
                    <caption>Is Required</caption>
                    <description>条件表达式,定义返回数据类型</description>
                    <returnType type="Boolean" />
                </property>
            </propertyGroup>
            <!-- Events分组 -->
            <propertyGroup caption="Events">
                <property key="onChangeAction" type="action" required="false">
                    <caption>Value Change</caption>
                    <description>事件</description>
                </property>
            </propertyGroup>
        </propertyGroup>
    </properties>
</widget>

更多文档内容,可参考Mendix组件文档

xml配置项的显示/隐藏方式

  • 修改 xxx.editorConfig.ts 文件
    import { xxxProps } from "../typings/xxx";
    import { hidePropertyIn } from "@mendix/pluggable-widgets-tools"; // 新增依赖    
    // ...
    // ...
    // ...    
    export function getProperties(_values: xxxProps, defaultProperties: Properties): Properties {
        // 主要代码
        if (!_values.showCode) {
            hidePropertyIn(defaultProperties, _values, "code");
        }
        if (!_values.isRequired) {
            hidePropertyIn(defaultProperties, _values, "errorText");
        }
        return defaultProperties;
    }
    
    

组件参数说明

  • 组件默认接收一个对象,默认参数在mendix中可配置且,包含有:
    • name
    • class
    • style:非必传
    • tabIndex:非必传
  • 用法
    import { ReactElement, createElement } from "react";
    import classNames from "classnames";
    import { xxxProps } from "../typings/xxxProps";
    import "./ui/xxx.scss";
    
    export function xxx(props: xxxProps): ReactElement {
        const { name, class: cls, style, tabIndex } = props;
    
        return (
            <div className={classNames(cls, "v-test-wrap")} style={style}>
                <p>Container</p>
            </div>
        );
    }
    

如需要用到mendix挂载在window上的mx方法,可在 typings 目录下添加 client.d.ts

declare namespace mx {
    namespace ui {
        type OpenForm2Function = ((
            page: string,
            dh: DisposeCallback,
            title: string,
            currentForm: any,
            option: Option,
            numberOfPagesToClose: number
        ) => Promise<any>) & { ["_tabRouter"]: boolean };
        interface DisposeCallback {
            [key: string]: { unsubscribe: () => void };
        }
        interface Option {
            location: "content" | "popup" | "node";
            domNode?: Element;
        }
        let openForm2: OpenForm2Function;

        let getContentForm: any;
    }
    namespace data {
        type saveDocument = (
            guid: string,
            name: string,
            params: any,
            blob: File,
            callback: () => void,
            errorCallback: (error: any) => void
        ) => Promise<void>;

        type remove = (params: RemoveType) => Promise<void>;

        interface RemoveType {
            guid: string;
            callback: () => void;
            errorCallback: (error: any) => void;
        }

        type action = (
            actionname: any,
            applyto: any,
            guids: any[],
            params: any,
            callback: (result: any) => void,
            errorCallback: (error: any) => void
        ) => Promise<void>;

        type callNanoflow = (
            nanoflow: any,
            context: mx.lib.MxContext,
            origin: mx.lib.form._FormBase,
            callback: (result: any) => void,
            errorCallback: (error: any) => void
        ) => Promise<void>;

        type create = (
            entity: string,
            callback: (guid: string) => void,
            errorCallback: (error: any) => void
        ) => Promise<void>;

        const action = action;
        const callNanoflow = callNanoflow;
        const saveDocument = saveDocument;
        const remove = remove;
        const create = create;
    }
}
declare namespace mendix {
    namespace lib {
        class MxContext {
            setTrackObject(obj: any): void;
        }
    }
    interface Lang {
        getUniqueId(): string;
    }
    let lang: Lang;
}

declare namespace dijit {
    function getUniqueId(id: string): string;
}

declare namespace window {
    namespace vRequestManager {
        function request(url: string, options: any, requestId: string): Promise<any>;
        function vCreateObjFn(obj: any): Promise<any>;
        function cancelRequest(id: string): Promise<void>;
    }
}

mendix工程,默认是不会生成 index.html 文件的,需要手动生成,方式如下

  • mendix 菜单栏 App -> Show App Directory in Explorer
  • xxx/theme/web 目录下,就是项目启动的入口文件所在位置
  • index.html 文件内容
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
        <meta http-equiv="Pragma" content="no-cache" />
        <meta http-equiv="Expires" content="0" />
        <meta name="referrer" content="no-referrer" />
        <title>Mendix</title>
        <script type="text/javascript">
          // 定义全局 hash
          globalThis.hash = Math.floor(Math.random() * 0xffffff).toString(16);
          try {
            eval("async () => {}");
          } catch (error) {
            var homeUrl = window.location.origin + window.location.pathname;
            var appUrl = homeUrl.slice(0, homeUrl.lastIndexOf("/") + 1);
            window.location.replace(appUrl + "unsupported-browser.html");
          }
        </script>
      </head>
    
      <body>
        <noscript>To use this application, please enable JavaScript.</noscript>
        <div id="content"></div>
      </body>
    
      <script type="text/javascript">
        dojoConfig = {
          isDebug: false,
          useCustomLogger: true,
          async: true,
          baseUrl: "mxclientsystem/dojo/",
          cacheBust: globalThis.hash,
          rtlRedirect: "index-rtl.html",
        };
        // 需要添加script标签的地方
        const jsSrcList = [ "mxclientsystem/mxui/mxui.js" ];
        for (const src of jsSrcList) {
          const scriptEl = document.createElement("script");
          scriptEl.src = `${src}?s=${globalThis.hash}`;
          document.body.appendChild(scriptEl);
        }
        // 加载 link 标签
        const iconList = [
          // manifest
          {
            rel: "manifest",
            href: "manifest.webmanifest",
            crossorigin: "use-credentials",
          },
          // main.scss 编译之后文件
          { rel: "stylesheet", href: "theme.compiled.css" },
          // icon
          {
            rel: "apple-touch-icon",
            href: "apple-touch-icon.png",
            sizes: "180x180",
          },
          { rel: "icon", href: "icon-32.png", sizes: "32x32" },
          { rel: "icon", href: "icon-16.png", sizes: "16x16" },
          // apple-touch-startup-image
          {
            custorm: true,
            width: 1024,
            height: 1366,
            devicePixelRatio: 2,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 1366,
            height: 1024,
            devicePixelRatio: 2,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 834,
            height: 1194,
            devicePixelRatio: 2,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 1194,
            height: 834,
            devicePixelRatio: 2,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 834,
            height: 1112,
            devicePixelRatio: 2,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 1112,
            height: 834,
            devicePixelRatio: 2,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 810,
            height: 1080,
            devicePixelRatio: 2,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 1080,
            height: 810,
            devicePixelRatio: 2,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 768,
            height: 1024,
            devicePixelRatio: 2,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 1024,
            height: 768,
            devicePixelRatio: 2,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 428,
            height: 926,
            devicePixelRatio: 3,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 926,
            height: 428,
            devicePixelRatio: 3,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 390,
            height: 844,
            devicePixelRatio: 3,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 844,
            height: 390,
            devicePixelRatio: 3,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 360,
            height: 780,
            devicePixelRatio: 3,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 780,
            height: 360,
            devicePixelRatio: 3,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 414,
            height: 896,
            devicePixelRatio: 3,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 896,
            height: 414,
            devicePixelRatio: 3,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 414,
            height: 896,
            devicePixelRatio: 2,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 896,
            height: 414,
            devicePixelRatio: 2,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 375,
            height: 812,
            devicePixelRatio: 3,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 812,
            height: 375,
            devicePixelRatio: 3,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 375,
            height: 667,
            devicePixelRatio: 2,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 667,
            height: 375,
            devicePixelRatio: 2,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 414,
            height: 736,
            devicePixelRatio: 3,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 736,
            height: 414,
            devicePixelRatio: 3,
            orientation: "landscape",
          },
          {
            custorm: true,
            width: 320,
            height: 568,
            devicePixelRatio: 2,
            orientation: "portrait",
          },
          {
            custorm: true,
            width: 568,
            height: 320,
            devicePixelRatio: 2,
            orientation: "landscape",
          },
        ];
        for (const items of iconList) {
          const link = document.createElement("link");
          if (items.custorm) {
            link.rel = "apple-touch-startup-image";
            link.href = `img/startup-image-${
              items.width * items.devicePixelRatio
            }x${items.height * items.devicePixelRatio}.png?${globalThis.hash}`;
            link.media = `screen and (device-width: ${items.width}px) and (device-height: ${items.height}px) and (-webkit-device-pixel-ratio: ${items.devicePixelRatio}) and (orientation: ${items.orientation})`;
          } else {
            link.rel = items.rel;
            if (items.sizes) {
              link.sizes = items.sizes;
            }
            if (items.crossorigin) {
              link.crossorigin = items.crossorigin;
            }
            link.href = `${items.href}?l=${globalThis.hash}`;
          }
          document.head.appendChild(link);
        }
    
        // 处理登录页面的 cookie
        if (!document.cookie || !document.cookie.match(/(^|;) *originURI=/gi)) {
          const url = new URL(window.location.href);
          const subPath = url.pathname.substring(0, url.pathname.lastIndexOf("/"));
          document.cookie = `originURI=${subPath}/login.html${
            window.location.protocol === "https:" ? ";SameSite=None;Secure" : ""
          }`;
        }
      </script>
    </html>
    

React 19 新特性:用 use 实现服务端和客户端组件的数据无缝协作

在 React 19 中,use 是一个新的 Hook,用于在客户端组件中直接消费由父组件(通常是 Server Component)传递下来的 Promise,同时与 Suspense 和错误边界集成。这种模式可以让你在客户端组件中安全地处理异步数据,同时保持数据获取的早期发起。

场景解析

你的问题描述的场景是:

  1. 父组件是 Server Component:负责发起数据请求(如获取轮播图图片列表),生成一个 Promise。
  2. 子组件 Content 是 Client Component:因为需要交互(如轮播图滑动),不能使用 async/await
  3. 目标:将父组件的 Promise 传递给子组件,子组件用 use 解析它。

具体实现步骤

1. 父组件(Server Component):发起请求并传递 Promise

// 父组件(Server Component)
async function ParentComponent() {
  // 在 Server Component 中发起请求,生成 Promise
  const carouselPromise = fetchCarouselData();

  return (
    <Suspense fallback={<div>加载轮播图中...</div>}>
      {/* 将 Promise 作为 prop 传递给子组件 */}
      <Content carouselPromise={carouselPromise} />
    </Suspense>
  );
}

2. 子组件(Client Component):用 use 解析 Promise

// 子组件(Client Component)
'use client'; // 标记为客户端组件

import { use } from 'react';

function Content({ carouselPromise }) {
  // 用 use 解析父组件传递的 Promise
  const carouselData = use(carouselPromise);

  // 渲染轮播图(需要客户端交互)
  return (
    <Carousel>
      {carouselData.map((image) => (
        <img key={image.id} src={image.url} />
      ))}
    </Carousel>
  );
}

关键点解释

  • use 的作用
    类似于 await,但专为 React 组件设计。它会暂停组件渲染,直到 Promise 解决,并自动与最近的 Suspense 配合显示加载状态。
  • 为什么在父组件发起请求
    Server Component 可以在服务端提前发起请求,客户端组件只需消费已发起的 Promise,减少客户端等待时间。
  • 为什么不用 useEffect
    useEffect 在客户端运行时才发起请求,会导致延迟。而父组件提前发起请求,子组件用 use 直接消费,数据获取更早开始。
  • async/await 的区别
    客户端组件不能使用 async/await,但 use 允许在客户端组件中以同步语法处理异步数据,同时保持组件逻辑清晰。

对比传统方案(useEffect

传统方式(不推荐)

// 子组件(Client Component)
'use client';

import { useState, useEffect } from 'react';

function Content() {
  const [carouselData, setCarouselData] = useState(null);

  useEffect(() => {
    // 在客户端发起请求,导致延迟
    fetchCarouselData().then(setCarouselData);
  }, []);

  if (!carouselData) return <div>加载中...</div>;

  return <Carousel>{/* ... */}</Carousel>;
}

缺点:数据获取从客户端开始,加载时间更长,无法利用服务端提前发起的优势。

use 方案(推荐)

// 子组件(Client Component)
'use client';

import { use } from 'react';

function Content({ carouselPromise }) {
  const carouselData = use(carouselPromise); // 直接消费服务端发起的 Promise
  return <Carousel>{/* ... */}</Carousel>;
}

优势:数据请求在服务端提前发起,客户端直接消费结果,加载更快,代码更简洁。


总结

  • use 的定位:专为在客户端组件中消费服务端发起的 Promise 设计,替代 useEffect + 手动状态管理。
  • 适用场景:需要客户端交互的组件(如轮播图、表单),但数据获取需在服务端提前完成。
  • 核心价值:统一服务端与客户端的异步数据流,提升性能,简化代码。

React 19 亮点:让异步请求和数据变更也能用 Transition 管理!

React 团队意识到,如果能将“Transition”机制(原本用于管理本地 CPU 密集型渲染任务)扩展到网络请求场景,不仅能大幅提升其应用价值,还能解决 React 长期以来在数据变更(mutations)处理上的核心痛点。

React 19 最大的变化之一,就是将 transition(过渡)机制从仅支持本地(CPU 渲染)任务,扩展到了异步网络请求和数据变更(mutations)
具体表现为:

  • useTransition 支持异步函数:在 React 18,startTransition 只能包裹同步函数,主要用于本地渲染优先级的调度;到了 React 19,你可以直接把异步函数(如网络请求、表单提交等)包裹进 transition,React 会自动管理 pending/loading 状态、错误、乐观更新等。
  • 引入 useActionState 等新 Hook:React 19 新增了如 useActionState 这样的 Hook,专门用来管理异步 action 的状态,自动处理 pending、错误、乐观更新等逻辑,极大简化了与后端交互和数据变更的代码。
  • 与表单、服务端组件深度集成:React 19 支持在 <form> 的 action 属性直接传入异步函数,并自动在 transition 中处理,进一步统一了本地和网络状态的管理。

核心背景与痛点

  1. 传统数据变更(Mutations)的问题
    在 React 18 之前,处理数据变更(如提交表单、保存数据到后端)时,开发者需要手动管理加载状态、错误处理、竞态条件等,代码复杂且容易出错。例如:

    • 用户提交表单后,界面可能长时间无反馈(等待网络响应)。
    • 多个并发请求可能导致状态不一致(如后发请求覆盖先发结果)。
  2. Transition 的原始用途
    React 18 的 Transition 机制(如 startTransition)最初设计用于区分 UI 更新的优先级,将非紧急的渲染任务(如大数据列表过滤)标记为低优先级,避免阻塞用户交互。


扩展 Transition 到网络请求的意义

  1. 统一处理本地与远程状态
    将网络请求(如 API 调用)也纳入 Transition 的管理范畴,让 React 能自动处理以下问题:

    • 竞态条件:自动丢弃过时的网络响应,避免状态混乱。
    • 乐观更新:在等待网络响应时,先乐观更新本地 UI,若请求失败则回滚。
    • 加载状态:自动管理 isPending 状态,无需手动维护 loading 变量。
  2. 解决数据变更的核心痛点

    • 无缝衔接异步操作:通过 Transition 包裹数据变更逻辑,React 能智能调度网络请求与 UI 更新,保持界面响应。
    • 错误恢复与重试:在 Transition 中,React 可自动处理错误重试或回退,简化开发者代码。
    • 与 Suspense 深度集成:结合 Suspense,可实现“渐进式加载”,先显示部分内容,再逐步补充。

示例:传统 vs. Transition 扩展后的数据变更

传统方式(痛点明显)

jsx
function SubmitForm() {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async () => {
    setIsLoading(true);
    try {
      const response = await fetch('/api/submit', { method: 'POST' });
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <button onClick={handleSubmit} disabled={isLoading}>
        {isLoading ? '提交中...' : '提交'}
      </button>
      {error && <div>错误:{error.message}</div>}
      {data && <div>结果:{data}</div>}
    </div>
  );
}

问题:需手动管理 isLoadingerror,易遗漏竞态处理,代码冗余。

Transition 扩展后(理想方案)

function SubmitForm() {
  const [data, setData] = useState(null);
  const [isPending, startTransition] = useTransition();

  const handleSubmit = () => {
    startTransition(async () => {
      const response = await fetch('/api/submit', { method: 'POST' });
      const result = await response.json();
      setData(result);
    });
  };

  return (
    <div>
      <button onClick={handleSubmit} disabled={isPending}>
        {isPending ? '提交中...' : '提交'}
      </button>
      {data && <div>结果:{data}</div>}
    </div>
  );
}

优势

  • 自动管理 isPending 状态。
  • 内置竞态处理(如中断过时请求)。
  • 可结合 Suspense 实现更流畅的加载体验。

总结

React 团队正是从 React 19 开始,把 transition 能力扩展到“跨网络”的异步数据变更场景,让 UI 状态与网络请求的管理变得自动化、声明式,也解决了 React 长期以来在 mutations(数据变更)上的痛点。

  • 扩展 Transition 到网络:让 React 能统一管理本地状态更新和远程数据变更,解决异步操作中的竞态、加载、错误处理等复杂问题。
  • 核心价值:通过声明式 API 简化开发者代码,同时提升应用响应性和健壮性,这是 React 在数据流管理领域的一次重大突破。

fiber 节点与 FiberRootNode - HostRootFiber

react 代码

import { createRoot } from "react-dom/client";

console.log("main");

const element = (
  <h1>
    hello <span>test</span> children
  </h1>
);

console.log(element);

// 调用 createRoot 函数生成 root
const root = createRoot(document.getElementById("root"));

root.render(element);

FiberRootNode 与 HostRootFiber

FiberRootNode 指向 HostRootFiber,HostRootFiber 也指向 FiberRootNode。

FiberRootNode

截屏2025-06-05 15.42.35.png

根据 createRoot 函数的调用链,整理出 FiberRootNode 的实现。 代码中删除了无关逻辑。

// 入口文件,导出 createRoot 函数
export { createRoot } from "./src/ReactDOMRoot";
// 删减无关代码
import { createContainer } from "react-reconciler/src/ReactFiberReconciler";

// ReactDOMRoot 构造函数
function ReactDOMRoot(internalRoot) {
  this._internalRoot = internalRoot;
}
export function createRoot(container) {
  // 创建 root
  const root = createContainer(container);
  // 返回 ReactDOMRoot 实例
  return new ReactDOMRoot(root);
}
// 调用 createFiberRoot 方法
import { createFiberRoot } from "./ReactFiberRoot";
export function createContainer(containerInfo) {
  return createFiberRoot(containerInfo);
}
// FiberRootNode 构造函数
function FiberRootNode(containerInfo) {
  this.containerInfo = containerInfo;
}
// 返回 FiberRootNode 实例
export function createFiberRoot(containerInfo) {
  const root = new FiberRootNode(containerInfo);
  return root;
}

最终发现,root 是一个 ReactDOMRoot 实例。数据结构如下。

截屏2025-06-05 16.49.45.png

可以看到,在 ReactDOMRoot 实例中,ReactDOMRoot._internalRoot = FiberRootNode。

HostRootFiber

HostRootFiber 按这个命名来说,HostRootFiber 是一个 fiber 节点,且是根 fiber 节点。根据上图 HostRootFiber 和 FiberRootNode 的关系可以知道,应该在创建 FiberRootNode 时也初始化 HostRootFiber。

export function createFiberRoot(containerInfo) {
  const root = new FiberRootNode(containerInfo);
  // 创建根fiber
  const uninitializedFiber = createHostRootFiber(root);
  // root.current 指当前 fiber 树
  root.current = uninitializedFiber;
  // 对于 DOM 元素, stateNode 为 fiber 节点对应的真实 DOM 节点。
  // 对于函数式组件, stateNode 为 fiber 节点对应的函数返回值。
  // 对于类组件, stateNode 为 fiber 节点对应的组件实例。
  uninitializedFiber.stateNode = root;
  return root;
}
import { HostRoot } from "./ReactWorkTags";
import { NoFlags } from "./ReactFiberFlags";
/**
 *  Fiber 节点
 * @param {*} tag fiber 类型: 函数组件 类组件 原生标签 根
 * @param {*} pendingProps 等待渲染的属性
 * @param {*} key 唯一标识
 */
export function FiberNode(tag, pendingProps, key) {
  this.tag = tag;
  this.key = key;
  this.type = null; // fiber 对应虚拟DOM 节点的类型 例如:h1 span
  this.stateNode = null; // 对应真实 DOM 节点

  this.return = null; // 指向父节点
  this.child = null; // 指向子节点
  this.sibling = null; // 指向兄弟节点

  this.pendingProps = pendingProps; // 等待生效的属性
  this.memoizedProps = null; // 已经生效的属性

  this.memoizedState = null; // 状态
  this.updateQueue = null; // 更新队列

  this.alternate = null; // fiber 轮替节点

  // 副作用标识指操作类型, 增删改,react 内部使用位运算计算副作用类型
  this.flags = NoFlags; // fiber 本身对应副作用标识
  this.subtreeFlags = NoFlags; // 子树对应副作用标识
}

export function createFiber(tag, pendingProps, key) {
  return new FiberNode(tag, pendingProps, key);
}

export function createHostRootFiber() {
  return createFiber(HostRoot, null, null);
}

FiberRootNode 与 HostRootFiber 的关系就实现了。

这里有一个点,在实现根 fiber 的时候,没有用到虚拟DOM。正常理解,虚拟DOM、fiber 和真实DOM的关系是先有虚拟DOM,再根据虚拟DOM实现 fiber 树,在根据 fiber 树渲染真实DOM。但是这里直接就渲染出了根 fiber ,这是因为根节点的真实DOM是直接给的,对应的是 index.html 中的 div。

updateQueue

先实现一个简单的 updateQueue。

export function initialUpdateQueue(fiber) {
  const queue = {
    shared: {
      pending: null, // 循环链接,指向链表中最后一个 update
    },
  };
  fiber.updateQueue = queue;
}

结束

这篇文章主要梳理 createRoot 函数执行逻辑。

通过 require.context 动态加载SVG图标

0880806010ffe685a102.jpg

(3月份和同事爬山看日出,图片拍摄于日出之时)

前言

Snipaste_2025-06-04_16-46-44.png 需求就是有一个表格,表格里的数据就是一些文件,在文件名称列,需要根据文件的后缀渲染不同的文件图标。 图标就是放在代码静态资源中的一些svg图。我们需要根据不同的文件后缀渲染不同的svg图。

Snipaste_2025-06-04_16-50-20.png

代码实现

Snipaste_2025-06-04_16-54-26.png

我们可以选择像这样将文件一个一个引入进来。但是这样很麻烦,所有有没有能一次性导入的方法呢?

代码实现

  const importAll = (requireContext: any) => {
    const list = requireContext.keys().map((key: any) => {
      const name = key.replace(/\.\/(.*)\.\w+$/, "$1");
      return { name, value: requireContext(key) };
    });
    return list;
  };

  const [svgIconList, setSvgIconList] = useState([]);

  useEffect(() => {
    setSvgIconList(
      importAll(require.context("@/app/icons/file-icons", true, /\.svg$/)),
    );
  }, []);

输入如下对象

Snipaste_2025-06-04_17-40-20.png 这样我们就可以通过不同的name渲染不同的module

我们还需要一个函数根据不同的文件后缀得到不同的svg的name

/**
 * 将文件文件名转为文件类型后缀
 * @param {string} fileName mime type
 * @returns svg icon name
 */
export function parseMimeTypeToIconName(suffix:string) {
  if (!suffix) {
    return 'file-type-unknown'
  }
  const ext = suffix
  if (!ext) {
    return 'file-type-unknown'
  }
  if (['png', 'jpg', 'jpeg', 'ico', 'gif', 'bmp', 'webp'].includes(ext)) {
    return 'file-type-img'
  }
  if (['markdown', 'md', 'txt'].includes(ext)) {
    return 'file-type-txt'
  }
  if (['docx', 'doc', 'docm', 'dot', 'dotx'].includes(ext)) {
    return 'file-type-office'
  }
  if (['csv', 'xls', 'xlsb', 'xlsm', 'xlsx', 'xltx'].includes(ext)) {
    return 'file-type-excel'
  }
  if (ext === 'pdf') {
    return 'file-type-pdf'
  }
  if (['pptx', 'ppt', 'pptm'].includes(ext)) {
    return 'file-type-ppt'
  }
  if (['zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'tar.gz', 'tar.xz'].includes(ext)) {
    return 'file-type-zip'
  }
  if (['mp4', 'avi', 'wmv', 'rmvb', '3gp', 'mov', 'm4v', 'flv', 'mkv'].includes(ext)) {
    return 'file-type-video'
  }
  if (['mp3', 'wav'].includes(ext)) {
    return 'file-type-music'
  }
  if (
    ['vue', 'js', 'go', 'java', 'ts', 'css', 'html', 'php', 'c', 'cpp', 'swift', 'kt'].includes(ext)
  ) {
    return 'file-type-code'
  }
  return 'file-type-unknown'
}

渲染svg图

Snipaste_2025-06-04_17-43-58.png

文末

088080601081bcf7ac02.jpg

从零开始学 React:构建现代 Web 应用的完整指南

一、前端开发的演进之路

在前端开发的历史中,我们经历了多个阶段的演变:

  • 初级阶段:前端切图崽(HTML + CSS + 简单 JS)
  • 进阶阶段:前端工程师(掌握 Vue / React 等框架)
  • 全栈阶段:Node.js + 数据库,实现前后端一体化
  • 移动开发阶段:React Native 构建 Android/iOS 应用
  • AI 阶段:AIGC 工具辅助开发,AI 赋能应用
  • 终极目标:JS 全干工程师 —— 前端、后端、移动端、AI 应用全能

随着技术的进步,开发者越来越需要关注业务本身,而非底层 API 的复杂操作。而 React 正是帮助我们实现这一目标的强大工具。


二、什么是 React?

React 是由 Facebook 开源的一个用于构建用户界面的 JavaScript 库。它以组件化为核心思想,允许我们将 UI 拆分为独立、可复用的部分。React 并不强制使用某种架构模式,但它非常适合与现代前端开发流程结合使用,如模块化、响应式数据、状态管理等。


三、项目创建全流程详解

1. npm 是什么?

npm(Node Package Manager)是 Node.js 自带的包管理器,用于安装和管理 JavaScript 包。它是前端生态的基础工具之一。

npm init vite

该命令会引导你使用 Vite 创建一个基于模板的新项目。Vite 是新一代前端构建工具,具备快速冷启动、即时热更新等优点。

2. 初始化 React 项目

在初始化过程中,你需要选择以下配置项:

  • 项目名称
  • 框架类型:React
  • 是否使用 TypeScript:选择 JS(本文以 JS 为例)

执行完毕后,Vite 将为你生成一个标准的 React 项目结构。

3. 安装依赖 & 启动项目

进入项目目录并安装依赖:

cd your-project-name
npm install

这一步会下载 node_modules 中所需的依赖包。

启动开发服务器:

npm run dev

默认情况下,项目将在 http://localhost:5173 运行。


四、React 初体验:组件驱动开发

1. 组件是开发的基本单元

React 的核心理念是组件化开发。每个组件都是一个函数或类,负责渲染页面的一部分。

function App() {
  return (
    <div>
      <h1>这里是一个组件</h1>
    </div>
  );
}

组件可以组合使用,形成完整的页面结构。

2. JSX 语法简介

JSX 是一种类似 HTML 的语法扩展,可以直接在 JavaScript 中书写 HTML 标签,提高代码可读性和开发效率。

const element = <h1>欢迎来到 React 世界</h1>;

3. 使用 JavaScript 表达式 { }

在 JSX 中,可以通过 {} 插入任意合法的 JavaScript 表达式:

const name = "张三";
return <p>你好,{name}</p>;

无需手动调用 DOM API,React 会自动处理视图更新。


五、响应式数据与状态管理

1. 状态的概念

在 React 中,数据的状态决定了组件的行为和显示内容。当状态发生变化时,React 会自动更新对应的 UI。

2. 使用 useState 管理响应式数据

React 提供了 Hook 函数 useState 来定义响应式状态:

import React, { useState } from 'react';

function TodoApp() {
  const [todos, setTodos] = useState(['吃饭', '睡觉', '学习']);

  return (
    <ul>
      {todos.map((item, index) => (
        <li key={index}>{item}</li>
      ))}
    </ul>
  );
}

其中:

  • todos 是当前的状态值
  • setTodos 是用来更新状态的方法

3. 动态更新状态

你可以通过事件绑定来修改状态,例如添加新任务:

function AddTodo({ onAdd }) {
  const [text, setText] = useState('');

  setTimeout(()=>{
      setText=['添加了']    
  },3000)

  return (
    <div>
      <h1>{text}</h1>
    </div>
  );
}

六、React 与实际业务场景结合:TODOS 应用示例

1. 业务需求分析

我们的目标是开发一个简单的 TODOS 应用,支持添加新任务、删除任务以及显示任务列表。这个应用将帮助我们理解 React 的核心概念,如状态管理、事件处理等。

2. 数据结构设计

为了简单起见,我们将使用一个字符串数组来存储待办事项:

const initialTodos = ['吃饭', '睡觉', '学习'];

每个元素代表一个待办事项。随着用户交互(添加或删除),这个数组会动态变化。

3. 功能实现详解

完整代码

function TodoApp() {
  const [todos, setTodos] = useState(['吃饭', '睡觉', '学习']);
  const [inputValue, setInputValue] = useState('');

  const addTodo = () => {
    if (inputValue.trim()) {
      setTodos([...todos, inputValue]);
      setInputValue('');
    }
  };

  const deleteTodo = (index) => {
    const newTodos = todos.filter((_, i) => i !== index);
    setTodos(newTodos);
  };

  return (
    <div>
      <h1>我的待办事项</h1>
      <input
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
      />
      <button onClick={addTodo}>添加</button>
      <ul>
        {todos.map((todo, index) => (
          <li key={index}>
            {todo}
            <button onClick={() => deleteTodo(index)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

详细解释

  • 状态初始化

    const [todos, setTodos] = useState(['吃饭', '睡觉', '学习']);
    const [inputValue, setInputValue] = useState('');
    

    使用 useState 钩子初始化两个状态变量:

    • todos:用于存储当前的待办事项列表。
    • inputValue:用于存储输入框中的文本值。
  • 添加新任务

    const addTodo = () => {
      if (inputValue.trim()) {
        setTodos([...todos, inputValue]);
        setInputValue('');
      }
    };
    

    当点击“添加”按钮时,addTodo 函数会被调用。它首先检查输入框的内容是否为空白字符以外的内容。如果是,则将当前输入框的内容添加到 todos 数组中,并清空输入框。

  • 删除任务

    const deleteTodo = (index) => {
      const newTodos = todos.filter((_, i) => i !== index);
      setTodos(newTodos);
    };
    

    当用户点击某个任务旁边的“删除”按钮时,deleteTodo 函数会被调用。该函数通过 filter 方法创建一个新的数组,排除掉指定索引的任务,并更新 todos 状态。

  • 渲染 UI

    return (
      <div>
        <h1>我的待办事项</h1>
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
        />
        <button onClick={addTodo}>添加</button>
        <ul>
          {todos.map((todo, index) => (
            <li key={index}>
              {todo}
              <button onClick={() => deleteTodo(index)}>删除</button>
            </li>
          ))}
        </ul>
      </div>
    );
    

    这段 JSX 代码描述了整个应用的 UI 结构:

    • 显示一个标题。
    • 提供一个输入框,允许用户输入新的待办事项。
    • 显示一个按钮,用于触发添加新任务的操作。
    • 列表 (<ul>) 中的每一项 (<li>) 对应一个待办事项,旁边有一个“删除”按钮,用于移除该项。

七、React 技术栈的优势

  • 专注业务逻辑:远离底层 DOM 操作,专注于功能实现
  • 组件化开发:高内聚、低耦合,易于维护和复用
  • 响应式数据流:通过状态管理实现高效的数据驱动
  • 跨平台能力:React 可用于 Web、React Native(移动端)、甚至桌面端(Electron)

八、总结:React 在现代开发中的定位

React 不仅是一个前端框架,更是一种开发思维方式的变革。它帮助我们构建响应式、可维护、可扩展的应用程序,从而让我们把精力集中在真正重要的事情上——业务逻辑与用户体验

基于 react-use 的 useIdle:业务场景下的用户空闲检测解决方案

背景

在Web应用开发中,检测用户空闲状态是常见的业务需求场景:

  1. 安全场景:银行/医疗系统在用户无操作15分钟后自动锁定界面
  2. 资源优化:视频网站暂停播放器控制栏的强制显示以节约计算资源
  3. 体验增强:在线文档工具在用户停止输入后自动保存草稿
  4. 精准推荐:电商平台根据用户停留时长优化推荐策略

传统实现需要手动绑定mousemove/keydown等事件,并通过定时器管理状态,而react-use提供的useIdle Hook将这些逻辑封装为可复用的解决方案。

设计思路

该Hook的核心思路通过四个步骤实现智能检测:

  1. 事件监听:默认监听17种用户交互事件(点击、滚动、触摸等)
  2. 定时轮询:通过requestAnimationFrame实现高性能时间检测
  3. 状态切换:在活动事件触发时重置空闲计时
  4. 自动清理:组件卸载时自动移除事件监听避免内存泄漏

功能简介

const isIdle = useIdle(initialState?, options?);
  • 参数

    • initialState:初始空闲状态(默认false)
    • options:配置对象包含超时时间、检测间隔等
  • 返回

    • isIdle:当前是否处于空闲状态
    • reset:手动重置空闲计时的方法

API详解

配置参数(Options)

参数 类型 默认值 说明
timeout number 60_000 判定空闲的毫秒阈值(推荐业务设置为5-15分钟)
throttle number 100 检测间隔的节流时间,平衡精度与性能
events string[] [预设17种] 需要监听的活动事件类型(可根据业务扩展)
initialState boolean false 初始化时是否立即视为空闲
autoRun boolean true 是否自动开始检测(特殊场景可关闭)
lead boolean true 是否在初始化时立即执行首次检测

返回值

属性 类型 说明
isIdle boolean 当前是否处于空闲状态
reset () => void 强制重置空闲计时的方法(如弹窗后重新计时)

实现原理

核心代码结构

function useIdle(ms: number = 60e3, initialState = false) {
  const [state, setState] = useState(initialState);
  const timer = useRef<number>();
  const mounted = useRef(true);

  const onEvent = useCallback(() => {
    if (!state) {
      resetTimer();
    }
    setState(false);
  }, [state]);

  const resetTimer = () => {
    clearTimeout(timer.current);
    timer.current = window.setTimeout(() => {
      if (mounted.current) setState(true);
    }, ms);
  };

  useEffect(() => {
    const events = ['mousemove''keydown', ...];
    events.forEach(e => window.addEventListener(e, onEvent));
    
    resetTimer();
    return () => {
      mounted.current = false;
      events.forEach(e => window.removeEventListener(e, onEvent));
    };
  }, [onEvent]);

  return state;
}

关键实现点

  1. 性能优化:采用requestAnimationFrame替代常规定时器
  2. 事件覆盖:默认监听包括触摸事件在内的移动端操作
  3. 状态安全:通过mounted标记避免组件卸载后的状态更新
  4. 内存管理:自动清理事件监听和定时器

业务场景Demo

案例一:安全锁屏机制

function SecurityLock() {
  const isIdle = useIdle(15 * 60 * 1000); // 15分钟
  const navigate = useNavigate();

  useEffect(() => {
    if (isIdle) {
      showModal('您已长时间未操作,即将退出登录');
      logout();
      navigate('/login');
    }
  }, [isIdle]);

  return <MainApp />;
}

案例二:视频播放器控制栏优化

function VideoPlayer() {
  const [showControls, setControls] = useState(true);
  const isIdle = useIdle(3000, { events: ['mousemove'] });

  useLayoutEffect(() => {
    setControls(!isIdle);
  }, [isIdle]);

  return (
    <div className="player">
      <video src="..." />
      {showControls && <ControlBar />}
    </div>
  );
}

案例三:表单草稿自动保存

function OrderForm() {
  const [formData, setData] = useState();
  const isIdle = useIdle(5 * 60 * 1000, {
    events: ['mousedown', 'keypress', 'scroll']
  });

  useEffect(() => {
    if (isIdle && formData) {
      autoSaveDraft(formData);
    }
  }, [isIdle]);

  return <Form fields={...} />;
}

总结与建议

优势对比

方案 代码量 维护成本 跨设备支持 性能影响
原生实现 需要适配
useIdle 自动覆盖 优化

最佳实践

  1. 超时设置:金融类建议10-15分钟,内容类可放宽至30分钟
  2. 事件选择:移动端应用需保留touch相关事件监听
  3. 状态组合:推荐与useLocalStorage结合实现草稿持久化
  4. 异常处理:在SSR场景下需要做服务端兼容

扩展建议

对于复杂场景可考虑以下增强:

// 多阶段空闲检测
const [phase, setPhase] = useState(0);
useIdle(300_000, { 
  onChange: idle => setPhase(p => idle ? Math.min(p+1,3) : 0)
});

// 与页面可见性结合
const isVisible = usePageVisibility();
const trueIdle = isIdle && !isVisible;

通过合理使用useIdle,开发者可以快速实现符合业务需求的用户行为感知系统,在安全性与用户体验之间找到最佳平衡点。

面试必备 | React项目的一些优化方案(持续更新......)

写在前面

本专栏分享一些面试时可能遇到的React相关的问题。包括性能优化,底层原理等知识。

我会用是什么、为什么、怎么做的方式为大家讲解,便于大家逻辑清晰的知道我在讲什么。

让我们开始

一、下载依赖时的 -D与-S如何选择

1.下面我言简意赅的介绍一下 -D与-S
  • -S(--save的简写) 或 --save:将依赖添加到 dependencies(生产环境依赖)

  • -D(--save-dev的简写) 或 --save-dev:将依赖添加到 devDependencies(开发环境依赖)

如下图

image.png


2.那么什么是生产环境、什么是开发环境?
  • 开发环境:程序员写代码、调试的阶段,需要各种辅助工具 (如测试库、构建工具等),这些依赖用 -D 安装。
  • 生产环境:项目实际运行时的阶段 (比如用户访问的网站/APP),只需要核心运行依赖(如React/Vue等),这些用 -S 安装。

3.那么我们为什么要区分-S和-D(开发环境与生产环境)呢?有什么实际的意义?

我总结三个实际意义,不过多解释,相信大家可以自行理解

  1. 减少生产环境体积:避免将测试工具、构建脚本等无用代码打包进线上产品,加快用户加载速度
  2. 避免安全隐患:开发工具(如调试模块)可能包含漏洞,不打包到生产环境能降低攻击风险
  3. 清晰依赖管理:团队能快速区分「项目运行需要什么」和「开发需要什么」,便于维护

4.进一步,我们讨论何时-D何时-S(用图片上的例子分析)

这里我挑几个有代表性的依赖来讲解

-S(生产依赖,项目运行必需)

(1)react & react-dom

项目运行时核心库,用户界面渲染直接依赖。

(2)axios

网络请求工具,线上环境需调用API。

(3)react-router-dom

路由管理,直接影响用户访问的页面跳转逻辑。

判断标准:这些库会被打包到最终产品代码中,用户访问网站时实际加载。

-D(开发依赖,仅开发阶段需要)

(1)eslint & @eslint/js

代码规范检查工具,只在开发时提示错误,不影响运行。

(2)@types/react

TypeScript类型定义,编译后会被移除,不参与线上运行。

(3)vite & @vitejs/plugin-react

构建工具,仅用于开发调试和打包,成品中不存在。

判断标准:这些工具只在写代码、测试或构建时使用,不会出现在用户浏览器中。

如果看到这里还是不理解-D -S,也没有关系,在之后的项目打包阶段的讲解,大家会明白

二、代码风格(React组件风格)

良好的代码风格可以让面试官眼前一亮,我们每个人都应该固定下来自己的代码风格,下面推荐一种良好的React组件风格

1.在大家的views或者pages(或者其他名字)一般放置着页面组件,那么我们写页面组件风格应该怎样呢?

请看下图

image.png

一般来讲,我们需要每一个页面组件一个文件夹文件夹中应有相应的index.jsx\index.css或者style.moduie.less,如果有页面组件有子组件的话,也可以在页面文件夹里面再建一个子组件的文件夹,文件夹里面的内容相似,值得注意的是组件文件夹的名字要首字母大写

请看下图

image.png

2.那么,这样的风格写组件有什么好处呢?

(1)首先,我们在引入组件的时候可以如下引用

image.png

我们并 不用刻意的去引用 比如说Index中的index.jsx和index.css

(2)我们在写代码的时候也不用刻意的去将About组件文件夹下的方法写成about.jsx,这样的结构更整洁,减少决策成本

(3)我们的组件文件夹首字母大写,可以和其他非组件文件夹做区分,方便开发和维护

三、循环中的key

在React 的jsx语法中,我们经常可以看到key={...}的身影,比如

<Router>
        <Routes>
          {routes.map(route=><Route key={route.path} path={route.path} element={<route.component/>}/>)}
        </Routes>
        <Button theme='primary'>按钮</Button>
</Router>

那么为什么要有这个key呢?

在 React 中,key 是动态列表渲染的关键优化手段,它的核心作用可以总结为三点

  1. 精准更新
    React 通过 key 识别列表项的唯一性,在数据变化时只更新变动的部分,而非重新渲染整个列表。
  2. 避免渲染错乱
    如果省略 key,React 默认使用数组索引(index)作为标识。当列表顺序变化时,可能导致组件状态错乱(如删除第一项后,第二项错误继承第一项的状态)。
  3. 性能提升
    稳定的 key(如路由路径 route.path)能让 React 复用已有 DOM 节点,减少不必要的销毁和重建。

万一面试官问,“你如何理解key的底层实现?” 不要慌,我十分贴心的总结了一套话术,如下

"在React中,`key`是用于优化虚拟DOM对比(Diff算法)的特殊属性,它帮助React更高效地识别列表元素的变化。

从底层实现来看,React通过虚拟DOM来最小化真实DOM操作。当组件状态变化时,React会对比新旧虚拟DOM树,这个过程称为reconciliation(协调)。对于列表渲染,如果没有key,React会默认使用数组索引(index)作为对比依据,这在列表顺序变化时会导致性能问题和状态错乱。

具体来说,key的作用体现在React Fiber架构的reconcileChildren阶段:

1.  React会为每个列表项创建Fiber节点,并记录其key
2.  在Diff过程中,React会优先匹配相同key的节点
3.  如果key匹配且组件类型相同,React会复用现有Fiber节点(仅更新props)
4.  如果key不匹配,React会销毁旧Fiber并创建新Fiber

在实际项目中,良好的key选择(如数据库ID、路由path等唯一标识)能带来两大优势:

1.  性能优化:减少不必要的DOM操作
2.  状态保持:避免列表顺序变化导致的组件状态错乱

相反,如果使用index作为key,在列表中间插入或删除项时,会导致后续所有项的key变化,引发不必要的重新渲染。而使用随机数作为key则会使Diff算法完全失效,因为每次渲染key都不同。

从源码层面看,React内部通过map结构存储key与Fiber节点的对应关系,这使得key的查找和匹配非常高效。这也是为什么稳定的key能显著提升列表渲染性能。"

四、项目打包时的优化

项目的打包可是大有讲究,我先来讲解一下react项目的打包

下图是我的项目目录

image.png

下图打包后的目录

image.png

有些离谱吗,为什么我的项目那么多的目录文件,打包过后就三个文件(js、css、html),我的依赖关系呢,我辛辛苦苦写的代码呢?我们可以随便打开一个文件看一看,如下图

image.png

有点意思,对吧? 下面我拿几个最关键的点来讲解

1.为何编译后的代码挤在了一起,甚至空格都没有?

主要是为了 减少文件体积 和 提升加载性能:通过移除所有非必要字符(如空格、注释)、缩短变量名(如 functionA → a),并启用 Gzip 压缩,最终使浏览器能更快下载和解析代码,同时节省服务器带宽成本。

空格、换行、注释等只是给程序员看的,浏览器并不需要

2.为何我们写的.jsx等其他文件都没了,打包后的文件只有html、css、js文件

打包工具(如 Webpack/Vite)会将所有 .jsx.css、图片等资源 编译合并 为浏览器可直接运行的 .js.css 和 index.html,目的是 减少HTTP请求次数 并 优化加载性能(如合并多个组件为单一JS文件、内联小资源),同时隐藏源码细节,最终输出轻量、安全的静态文件供生产环境使用

可以优化性能,一个js文件可以减少http的并发数

3.js就这一个文件,如何处理的我们原先项目的依赖关系?

打包工具(如 Webpack)通过 依赖分析 将项目中所有模块(包括第三方库和本地组件)的依赖关系 递归解析,最终按照正确的执行顺序合并到一个或多个 JS 文件中,同时通过 作用域隔离 和 模块标识符映射 来确保代码间的依赖关系在打包后依然能正确执行,既保持了模块化逻辑又优化了运行性能

比如一个最简单的例子,被依赖的放在上面,依赖的放在下面


那么回归正题,我们如何去优化项目的打包?我们把注意力放在打包的“大小”上

现在,随便打开一个之前写过的项目,里面有没有我们不需要的依赖被import引入了?这便是我们要优化的点

下面举个例子,我打包一个没有优化的项目

image.png

下面是打优化后的效果

image.png

可以观察到index-DyW19ZCt.css不论是文件大小还是传输大小都有大幅度缩减,那么是如何实现的呢

我们需要一个插件vite-plugin-style-import,用于按需自动导入组件库的样式文件,避免全量引入 CSS 导致的性能浪费。

只需三步

1.下载依赖

image.png

2.配置文件 只需在vite.config.js文件中进行以下配置

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import {createStyleImportPlugin} from 'vite-plugin-style-import'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    createStyleImportPlugin({
      libs: [
        {
          libraryName: 'zarm',
          esModule: true,
          resolveStyle: (name) => {
            return `zarm/es/${name}/style/css`;
          },
        },
      ]
    })],
})

3.修改代码

这里举个例子(zarm)

//import 'zarm/dist/zarm.css' 

只需把导入的组件UI库样式文件注释就可以了,vite-plugin-style-import 自动导入css,而且会自动压缩css

五、移动端适配

移动端的适配是面试的热门话题,那么我们该如何适配移动端,这里给出rem的方案

1.什么是 rem 单位

rem(root em)是一个相对单位,它相对于根元素(即 元素)的字体大小。例如,如果 元素的字体大小设置为 16px,那么 1rem 就等于 16px;如果 元素的字体大小变为 20px,那么 1rem 就等于 20px。这种相对性使得 rem 单位在实现响应式设计时非常有用,因为我们可以通过动态改变根元素的字体大小来调整整个页面的布局

2.为什么选择 rem 单位进行移动端响应式设计

在移动端开发中,我们通常会避免使用固定的像素单位(px),因为不同设备的屏幕尺寸和分辨率差异很大。使用 px 单位可能会导致页面在某些设备上显示过大或过小。而 rem 单位可以根据根元素的字体大小进行自适应调整,从而确保页面在不同设备上都能保持一致的布局和视觉效果

3.设计稿与 rem 的换算关系

在实际开发中,我们通常会拿到一份设计稿,假设设计稿的宽度为 750px。为了方便换算,**我们可以将 10rem 对应设计稿的 750px,那么 1rem 就等于 75px。**也就是说,当 元素的字体大小设置为 75px 时,我们可以直接根据设计稿上的尺寸进行 rem 换算。

例如,设计稿上一个元素的宽度为 150px,那么在代码中我们可以将其宽度设置为 2rem(150px / 75px = 2rem)。

下面是代码的实现

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>使用 rem 实现响应式页面</title>
    <script>
        // 立即执行函数
        (function(){
            function calc(){
                // 获取设备屏幕的宽度
                const w = document.documentElement.clientWidth;
                console.log(w);
                // 750 是设计稿的宽度
                document.documentElement.style.fontSize = 75 * (w / 750) + 'px';
            }
            // 首次加载时计算并设置根元素字体大小
            calc();
            // 当窗口大小改变时重新计算并设置根元素字体大小
            window.onresize = function(){
                calc();
            }
        })();
    </script>
    <style>
        *{
            margin: 0;
            padding: 0;
        }
    </style>
</head>
<body>
    <!-- 使用 rem 单位设置元素的宽度和高度 -->
    <div style="width: 10rem;height: 2rem;background-color: red;"></div>
    <!-- 换行符为 0 -->
    <div style="font-size: 0;">
        <div style="width: 5rem;height: 2rem;background-color: green;display: inline-block;font-size: 20px;color: white;">111</div>
        <div style="width: 5rem;height: 2rem;background-color: blue;display: inline-block">222</div>
    </div>
</body>
</html>

其实这样的写也并不难,但是到这里还不能拿到office(儿化音),我们有更好的方法来处理rem——引入依赖

npm i lib-flexible

lib-flexible 是一个移动端自适应解决方案,通过动态计算根元素的 font-size 并配合 rem 单位实现页面元素的等比缩放,适配不同屏幕尺寸。——它可以自动完成之前我们的rem配置

这就够了吗?还不够!如果仅仅是这样的话,我们在设置宽度等大小的时候还得手动换算比如100px=XXrem,这还不够,还有一个插件——postcss-px2rem

npm i postcss-px2rem

它可以完成自动换算px->rem

代码如下

.index{
    width:150px;
    span{
        color:red;
    }
}

页面的渲染如下

image.png

六、跨域解决方案

这部分下一篇再写吧,玩一会儿星露谷去,嘿嘿

结语

通过本专栏,我们从 依赖管理代码规范性能优化 到 移动端适配,系统梳理了React面试的核心知识点。掌握这些内容,能让你在开发中更高效地 减少生产包体积提升渲染性能,并轻松实现 多端适配

保持技术好奇心,持续探索React生态的深度。下期我们将深入 跨域解决方案等内容,帮你彻底打通前后端联调的任督二脉!

❌