普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月14日首页

JS打造“九宫格抽奖”

2025年9月14日 09:04

在如今的营销活动中,抽奖功能已经成为提升用户活跃度的标配。尤其是“九宫格抽奖”这种形式,因其视觉冲击力强、交互简单、适配性好,被广泛应用于电商、社交、内容平台等各类场景。本文将带你从零开始,使用 原生 JavaScript 实现一个可配置、可扩展、动画流畅的九宫格抽奖组件,支持运营人员通过 JSON 配置奖品信息,前端无需上线即可更新抽奖内容。

JS打造“九宫格抽奖”.gif

一、总体思路

1. 数据结构:奖品配置 JSON 化

为了支持运营实时配置奖品,我们将奖品信息抽象为 JSON 格式:

[
  {"id":1,"name":"iPhone","img":"//cdn/1.png"},
  {"id":2,"name":"现金50元","img":"//cdn/2.png"},
  {"id":3,"name":"HUAWEI","img":"//cdn/3.png"},
  {"id":4,"name":"现金10元","img":"//cdn/4.png"},
  {"id":5,"name":"谢谢参与","img":"//cdn/5.png"},
  {"id":6,"name":"手机优惠券","img":"//cdn/6.png"},
  {"id":7,"name":"电脑优惠券","img":"//cdn/7.png"},
  {"id":8,"name":"U盘","img":"//cdn/8.png"}
]

优点:前端只负责展示和动画,中奖逻辑由后端控制,避免暴露算法,提升安全性。

2.核心思路

我们将整个抽奖流程抽象为两个核心步骤:

  • 绘制奖品视图:无论使用 flex 、 grid 还是 absolute ,只要按顺序渲染奖品 DOM,顺序即代表“跑道”顺序。
  • 动画高亮奖品:通过setInterval控制高亮项的切换,只操作索引,不依赖具体坐标或行列。

这种设计的好处是:

  • 布局可随意更换(九宫格、圆环、横向跑道);
  • 动画逻辑不变,复用性极高;
  • 支持动态增减奖品数量,只需更新 JSON 和 CSS。

二、html骨架

<div class="main">
        <div class="content-container">
            <div class="prize-list">
                <img src="./img/prize_1.png" alt="">
                <span>IphoneX</span>
            </div>
            <div class="prize-list">
                <img src="./img/prize_2.png" alt="">
                <span>现金50元</span>
            </div>
            <div class="prize-list">
                <img src="./img/prize_3.png" alt="">
                <span>HUAWEI</span>
            </div>
            <div class="prize-list">
                <img src="./img/prize_4.png" alt="">
                <span>现金10元</span>
            </div>
            <div class="prize-list">
                <img src="./img/prize_5.png" alt="">
                <span>谢谢参与</span>
            </div>
            <div class="prize-list">
                <img src="./img/prize_6.png" alt="">
                <span>手机优惠券</span>
            </div>
            <div class="prize-list">
                <img src="./img/prize_7.png" alt="">
                <span>电脑优惠券</span>
            </div>
            <div class="prize-list">
                <img src="./img/prize_8.png" alt="">
                <span>U盘</span>
            </div>
            <!-- 中心内容部分 -->
            <div class="handler-container">
                <div class="inner-container">
                    <img class="handler-left" src="./img/center_1.png" alt="">
                    <div class="handler-container-middle">
                        还可以抽奖 <span class="prize-number">0</span></div>
                    <div class="handler-container-btn"></div>
                </div>
            </div>
        </div>

        <div class="dialog-container">
            <div class="dialog-main">
                <div class="head">
                    <span class="title">温馨提示</span>
                    <span class="close">&times;</span>
                </div>
                <div class="content">
                    每次抽奖将消耗 8000 积分
                </div>
                <div class="dialog-main-footer">
                    <div class="button">再来一次</div>
                </div>
            </div>
        </div>
    </div>

三、js核心动画

  var runGame = function () {
    var random = Math.floor(Math.random() * 6000 + 3000)
    timer = setInterval(function () {
      random -= 200
      if (random < 200) {
        clearInterval(timer)
        timer = null
        openDialog()
        return
      }
      currentIndex = ++index % prizeList.length
      prizeList.forEach(function (node) {
        node.classList.remove('active')
      })
      prizeList[currentIndex].classList.add('active')
    }, 50)
  }

技术亮点:

  • 索引循环: ++index % prizeList.length 保证无限循环;
  • 时间递减: random -= 200 模拟减速刹车效果;
  • 动画与结果分离:动画结束后调用 openDialog() 显示结果,逻辑清晰。

四、业务层

为了防止刷奖和前端篡改,我们将“中奖逻辑”放在后端: 流程如下:

  1. 前端点击“抽奖”按钮;
  2. 请求后端接口,后端根据权重随机计算出中奖奖品;
  3. 后端返回中奖奖品的 id 和对应索引 stopIndex ;
  4. 前端根据 stopIndex 播放动画,最终高亮到对应奖品;
  5. 动画结束后弹出结果提示。

✅ 优点:

  • 前端不暴露中奖逻辑,安全性高;
  • 动画与业务解耦,可复用性强;
  • 支持运营实时调整奖品和中奖概率,无需前端上线。

后续优化建议

  1. 动画性能优化:使用 Web Animations API 或 CSS Animation 替代 setInterval ;
  2. 防抖节流:防止用户快速点击触发多次抽奖;
  3. 状态管理:引入轻量状态机(如 xstate)管理抽奖流程;
  4. 音效与震动:增强用户互动体验。

Vue3 的 ref 和 reactive 到底用哪个?90% 的开发者都选错了

作者 刘大华
2025年9月14日 00:09

前言

refreactiveVue3Composition API里最常用的两个核心成员,专门用来创建响应式数据的。 啥叫响应式呢?就是数据一变,页面自动跟着更新,不用你手动刷新。 其实它们干的是一件事就是:让Vue能监听到你的数据变化

但它们用起来不一样,适合的场景也不一样。很多人在用的时候都是凭感觉选,到底该用哪个?一直都觉得很模糊。

先简单认识一下:

ref:可以包装任何类型的值,包括基本类型(数字、字符串等)和对象类型。使用时需要通过.value来访问和修改值。 reactive:只能包装对象类型(包括数组)。使用时直接访问属性即可,不需要.value

b0ca79428871de1434b721295ba9daef.jpg

下面咱们通过实际例子来看一下。

一、区别

// ref 啥都能包
const count = ref(0)          // 数字
const name = ref('小明')       // 字符串  
const isActive = ref(false)   // 布尔值
const user = ref({ age: 18 }) // 对象
const list = ref([1, 2, 3])   // 数组

// reactive 只接对象/数组
const user = reactive({ age: 18 }) // 对象
const list = reactive([1, 2, 3])   // 数组
const count = reactive(0)         // 报错

二、优缺点

ref

优点:
  1. 什么都能包:基本类型、对象、数组,来者不拒
  2. 赋值简单:直接 xxx.value = newValue
  3. 解构安全:不用怕响应式丢失
  4. 类型推导好TypeScript支持完美
缺点:
  1. 总要写 .value:有点烦人
  2. 模板中也要 .value:不过 Vue 会自动解包

reactive

优点:
  1. 不用写 .value:直接访问属性
  2. 相关联数据组织性好:类似Vue2data
缺点:
  1. 解构大坑:直接解构容易丢失响应式
  2. 赋值限制:不能整个重新赋值
  3. 类型推导有时抽风:TS环境下偶尔会出现问题

三、watch监听中的差异

这里是最容易踩坑的地方!

监听 ref

const count = ref(0)
const user = ref({ name: '小明', age: 18 })

// 监听基本类型 ref
watch(count, (newVal, oldVal) => {
  console.log('count变化:', newVal, oldVal)
})

// 监听对象 ref - 需要深度监听
watch(user, (newVal, oldVal) => {
  console.log('user变化:', newVal, oldVal)
}, { deep: true }) // 必须加 deep!

// 监听对象 ref 的特定属性
watch(() => user.value.name, (newVal, oldVal) => {
  console.log('name变化:', newVal, oldVal)
})

监听 reactive

const state = reactive({
  count: 0,
  user: { name: '小明', age: 18 }
})

// 自动深度监听,不需要 deep: true
watch(state, (newVal, oldVal) => {
  console.log('state变化:', newVal, oldVal)
}) 

// 推荐:监听特定属性
watch(() => state.count, (newVal) => {
  console.log('count变了', newVal)
})

// 监听嵌套属性
watch(() => state.user.name, (newVal) => {
  console.log('名字变了', newVal)
})

// 监听多个属性
watch([() => state.count, () => state.user.name], ([newCount, newName]) => {
  console.log('count或name变了', newCount, newName)
})

watch 的重要区别

1.深度监听:

  • ref对象需要手动{ deep: true }
  • reactive自动深度监听

2.旧值获取:

  • reactive的旧值和新值相同(Proxy特性)
  • ref的旧值正常

3.性能影响:

  • reactive自动深度监听,可能影响性能
  • ref可以精确控制监听深度

四、案例

案例1:表单处理 - 推荐 reactive

// 相关联的表单数据,用 reactive 更合适
const form = reactive({
  username: '',
  password: '',
  remember: false,
  errors: {}
})

// 验证函数
const validateForm = () => {
  form.errors = {}
  if (!form.username) {
    form.errors.username = '用户名不能为空'
  }
  // ...其他验证
}

案例2:API 数据加载 - 推荐 ref

// API 返回的数据,经常需要重新赋值,用 ref
const data = ref(null)
const loading = ref(false)
const error = ref(null)

const fetchData = async () => {
  loading.value = true
  try {
    const response = await fetch('/api/data')
    data.value = await response.json() // 直接赋值,美滋滋
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

案例3:组件状态管理 - 看情况

// 方案1:用 reactive(状态相关联)
const modal = reactive({
  isOpen: false,
  title: '',
  content: '',
  loading: false
})

// 方案2:用多个 ref(状态相对独立)
const isModalOpen = ref(false)
const modalTitle = ref('')
const modalContent = ref('')
const modalLoading = ref(false)

案例4:列表操作 - 强烈推荐 ref

const list = ref([])

// 添加项目
const addItem = (item) => {
  list.value = [...list.value, item] // 重新赋值,安全
}

// 删除项目
const removeItem = (id) => {
  list.value = list.value.filter(item => item.id !== id)
}

// 清空列表
const clearList = () => {
  list.value = [] // 直接赋值,不会丢失响应式
}

如果用reactive来做列表:

const list = reactive([])

// 添加项目 - 只能用 push
const addItem = (item) => {
  list.push(item) // 可以,但不够直观
}

// 删除项目 - 需要找到索引
const removeItem = (id) => {
  const index = list.findIndex(item => item.id === id)
  if (index !== -1) {
    list.splice(index, 1) // 有点麻烦
  }
}

// 清空列表 - 只能修改长度
const clearList = () => {
  list.length = 0 // 能工作,但有点 hack
}

五、组合式函数中的选择

这是决定用哪个的关键场景!

返回多个 ref:灵活好用

function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const double = computed(() => count.value * 2)
  const increment = () => count.value++

  return { count, double, increment }
}

// 使用时:
const { count, double, increment } = useCounter()

返回 reactive:结构固定

function useUser() {
  const state = reactive({
    user: null,
    loading: false,
    error: null
  })

  const fetchUser = async (id) => {
    state.loading = true
    try {
      state.user = await fetchUserById(id)
    } catch (err) {
      state.error = err.message
    } finally {
      state.loading = false
    }
  }

  return { ...toRefs(state), fetchUser } // 必须用 toRefs!
}

// 使用时:
const { user, loading, error, fetchUser } = useUser()

明显看到,返回多个ref更简单直接。

六、混合使用模式(推荐)

// 基本类型和需要重新赋值的用 ref
const loading = ref(false)
const error = ref(null)
const data = ref(null)

// 相关联的数据用 reactive
const form = reactive({
  username: '',
  password: '',
  remember: false
})

// 这样写,既清晰又安全

七、性能

其实在大多数情况下,性能差异可以忽略不计,推荐如下:

  1. 大量基本类型:用ref,因为reactive需要创建Proxy对象
  2. 大对象但只关心部分属性:用多个ref,避免不必要的深度监听
  3. 频繁重新赋值:用refreactive不能直接重新赋值

总结

优先使用ref的场景: -基本类型(数字、字符串、布尔值) -DOM 引用和组件引用 -需要频繁重新赋值的变量 -API 返回的数据 -组合式函数的返回值 -列表数据

考虑使用reactive的场景: -相关联的表单数据 -复杂的组件状态 -配置对象 -不需要重新赋值的对象

希望这篇分析对你有帮助。如果有不同意见,欢迎在评论区理性讨论!

公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》

《Java 订单超时未支付,如何自动关闭?掌握这 3 种方案,轻松拿 offer!》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

最近一年的感悟

作者 雾恋
2025年9月14日 00:04

感悟

从去年8月离职到现在差不多也有一年多了,从去年开始工作到现在马上也快一年了,在这一年中我发现好像没有那么累了,没有那么精神紧张了;每天按时上下班、下班以后也没有工作的事情打扰,感觉生活回归了平常 但又闲的没事干了。

这篇文章没有技术的讲解,只有最近一年工作的一些体会以及一些想法,如果不喜欢这一类的文章就不要往下看了,你喜欢技术类文章的话。

工作

我来这公司上班也快一年了,我就说说我对此类公司的一些看法和与原来公司的对比。

优点

  • 工作安排比较合理:你如果在突击技术问题或者协助他人解决技术问题,他们会给你充足的时间,如果遇到技术难题得不到解决可以进行上报,技术主管或者技术总监会给你一些思路然后再给你时间进行研究(上报也不麻烦就找到技术管理说说就行,不会说走什么钉钉流程什么的)。
  • 加班:基本上没有加过班,我来这公司将近一年的时间周六加过班的时间最多4/5天;工作日加班的时间最多10/20天(最晚下班就8点多,哈哈哈哈)还不错吧~。
  • 手机不用24小时:下班以后不会有任何领导跟你打电话修改需求或者去加班(唯一一次还是我打电话问问题,然后他们没有看到,我到家以后跟我回电话)。
  • 同事关系:这儿的同事关系很简单,没有那么多内斗(原来公司经常背锅)
  • 公积金社保:这一块的话在成都而言(其他的地方不是很清楚)的话相对来说还是不错的,很多公司都是按照最低标准来的;而这是一个月1000块钱(我工作5年才3千多公积金,来这儿一年现在都1万多了),可能我原来遇到的公司都比较差吧。
  • 补贴:每天上班有餐补20块钱。

缺点

  • 出差报销慢:根据一些老同事消息说,出差报销极慢很多人半年都没有报销下来,虽然我目前还没有出过差。
  • 节假日没有礼物:老同事说原来节假日都是几百块钱的购物卡,现在为啥没有了不知道,反正端午啊这些没有东西。

说明

该公司是内网开发的,然后是自己的产品,所有功能都是提前开发好了的,然后卖给客户(客户也可以定制开发一些东西),客户定制开发的功能就由项目人员去对接开发(也就是外派);说的是平台部和项目部,其实差别不大;那边忙不过来,就需要去那边进行帮忙,只不过前端很少出差,不过也有很大一部分人员在客户哪儿。

  • 平台部门:简单来说就是项目的研发、组件的封装等。
  • 项目部门:将平台打包项目去客户哪儿部署以及根据封装好的api进行客户的定制开发。

我在入职一段时间以后发现我有一个朋友也入职了类似的公司(他从毕业到现在,应该有6/7年了吧!一直没有换公司),沟通以后发现其他的基本情况差不多,但是我们共识的点就是:1.相对比较稳定不容易倒闭;2.AI对此类公司冲击相对较小。

总结

如果一直是这样的情况下,那我也就这样过了吧!到时候找一个相对比较近的地方弄个房子(现在太远了,通勤差不多一个小时了);明年计划买一个小车车,然后就这样安安稳稳的慢慢过活吧,也不去想创业的想法了;如果能遇到或者有幸遇到一个不错的人在考虑结婚吧;我感觉目前的收入对于我一个人而言还是绰绰有余的,潇潇洒洒。

副业收入

  • 最近在看AI炒股,有好的开源项目的朋友可以推荐一下,我今年还是挣了3k多,加上去年10月开始到现在的话差不多有8k多了。
  • 偶尔帮朋友做做项目:目前收入差不多2W(一个小时150)。
  • uniapp开发插件可以有广告收入,我开发了一个(目前可以忽略不计)。
  • 最近两天在看智能体方面的,还没有收益;但是看网上说可以挣钱,先研究哈。

如果你看到了这儿我表示很感谢,我就是在网上发发牢骚;聊聊自己,欢迎评论留言讨论,如果有好的副业收入 我也想学习哈;主要是目前时间相对比较多~

「Ant Design 组件库探索」四:Input组件

作者 李仲轩
2025年9月13日 23:10

好久不见,最近真的是太忙了,终于是有时间来进行博客撰写了;继续Ant Design系列,这一次是Input组件,无需多言,直接开始

组件架构概览

Ant Design 的 Input 组件采用了复合组件模式,这是一个非常巧妙的设计决策。让我们先来看组件的整体结构:

import Group from './Group';
import InternalInput from './Input';
import OTP from './OTP';
import Password from './Password';
import Search from './Search';
import TextArea from './TextArea';

type CompoundedComponent = typeof InternalInput & {
  Group: typeof Group;
  Search: typeof Search;
  TextArea: typeof TextArea;
  Password: typeof Password;
  OTP: typeof OTP;
};

const Input = InternalInput as CompoundedComponent;

Input.Group = Group;
Input.Search = Search;
Input.TextArea = TextArea;
Input.Password = Password;
Input.OTP = OTP;

这种设计允许开发者通过 Input.TextAreaInput.Search 等方式使用不同的输入类型,保持了 API 的一致性和易用性。

核心实现解析

1. 基于 rc-input 的封装

Ant Design Input 组件并不是从零开始构建的,而是基于 rc-input 进行封装。这种设计模式有几个显著优势:

  • 关注点分离rc-input 处理核心的输入逻辑,Ant Design 专注于样式和用户体验
  • 可维护性:底层逻辑的更新不会影响上层 API
  • 一致性:确保所有输入组件具有相同的行为模式
import type { InputRef, InputProps as RcInputProps } from 'rc-input';
import RcInput from 'rc-input';

2. 上下文集成系统

Input 组件深度集入了 Ant Design 的上下文系统,这是其强大功能的基础:

// 配置上下文
const { getPrefixCls, direction, allowClear: contextAllowClear } = useComponentConfig('input');

// 禁用状态上下文
const disabled = React.useContext(DisabledContext);

// 表单状态上下文
const { status: contextStatus, hasFeedback, feedbackIcon } = useContext(FormItemInputContext);

// 紧凑布局上下文
const { compactSize, compactItemClassnames } = useCompactItemContext(prefixCls, direction);

这种上下文集成使得 Input 组件能够:

  • 自动继承父组件的配置(如尺寸、禁用状态)
  • 响应表单验证状态
  • 适应不同的布局环境

3. 状态合并策略

组件采用了智能的状态合并策略,优先级从高到低为:

  1. 组件自身的 props
  2. 上下文配置
  3. 默认值
const mergedSize = useSize((ctx) => customSize ?? compactSize ?? ctx);
const mergedDisabled = customDisabled ?? disabled;
const mergedStatus = getMergedStatus(contextStatus, customStatus);

样式系统设计

Ant Design Input 的样式系统是其设计精髓所在:

CSS 变量支持

const rootCls = useCSSVarCls(prefixCls);
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls, rootClassName);
const [wrapCSSVar] = useStyle(prefixCls, rootCls);

这种设计使得主题定制变得非常简单,开发者可以通过 CSS 变量覆盖默认样式。

动态类名生成

classNames({
  [`${prefixCls}-sm`]: mergedSize === 'small',
  [`${prefixCls}-lg`]: mergedSize === 'large',
  [`${prefixCls}-rtl`]: direction === 'rtl',
}, classes?.input, contextClassNames.input, hashId)

这种模式确保了样式的一致性和可扩展性。

高级功能实现

4. 焦点管理优化

Input 组件对焦点管理进行了深度优化,特别是在动态添加/删除前后缀时的处理:

const inputHasPrefixSuffix = hasPrefixSuffix(props) || !!hasFeedback;
const prevHasPrefixSuffix = useRef<boolean>(inputHasPrefixSuffix);

useEffect(() => {
  if (inputHasPrefixSuffix && !prevHasPrefixSuffix.current) {
    warning(
      document.activeElement === inputRef.current?.input,
      'usage',
      `When Input is focused, dynamic add or remove prefix / suffix will make it lose focus...`
    );
  }
  prevHasPrefixSuffix.current = inputHasPrefixSuffix;
}, [inputHasPrefixSuffix]);

这个机制防止了在用户输入时动态修改组件结构导致的焦点丢失问题。

5. 密码输入安全处理

密码输入框有特殊的安全考虑,组件实现了自动清除机制:

const removePasswordTimeout = useRemovePasswordTimeout(inputRef, true);

const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
  removePasswordTimeout();
  onBlur?.(e);
};

这个 Hook 确保在适当的时候清除密码值,增强安全性。

设计模式分析

复合组件模式 (Compound Components)

Ant Design Input 采用了经典的复合组件模式:

// 使用方式
<Input placeholder="Basic input" />
<Input.TextArea placeholder="Textarea" />
<Input.Search placeholder="Search input" />
<Input.Password placeholder="Password input" />

这种模式的优势:

  • 一致的 API:所有输入类型使用相同的导入方式
  • 可发现性:开发者很容易发现可用的输入类型
  • 类型安全:TypeScript 提供完整的类型提示

配置继承模式

组件支持多层配置继承:

const {
  getPrefixCls,
  direction,
  allowClear: contextAllowClear,
  autoComplete: contextAutoComplete,
  // ... 更多配置
} = useComponentConfig('input');

这种设计使得:

  • 全局配置可以影响所有 Input 组件
  • 局部配置可以覆盖全局设置
  • 默认值提供了合理的回退

样式系统深度解析

CSS 变量架构

Ant Design 的样式系统基于 CSS 变量构建,提供了极强的定制能力:

const rootCls = useCSSVarCls(prefixCls);
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls, rootClassName);

这种架构允许:

  • 动态主题切换
  • 细粒度的样式覆盖
  • 运行时样式修改

响应式样式处理

组件根据不同的状态动态生成类名:

variant: classNames({
  [`${prefixCls}-${variant}`]: enableVariantCls,
}, getStatusClassNames(prefixCls, mergedStatus)),

这种模式确保了样式与状态的完美同步。

特殊输入类型实现分析

Password 组件的精妙设计

Password 组件展示了如何通过组合和扩展来创建特殊输入类型:

const Password = React.forwardRef<InputRef, PasswordProps>((props, ref) => {
  const [visible, setVisible] = useState(() =>
    visibilityControlled ? visibilityToggle.visible! : false
  );

  const onVisibleChange = () => {
    if (mergedDisabled) {
      return;
    }
    if (visible) {
      removePasswordTimeout();
    }
    // ... 切换可见性逻辑
  };
});

关键设计特点:

  1. 受控与非受控模式:支持完全受控和部分受控两种模式
  2. 安全性处理:使用 useRemovePasswordTimeout 确保密码安全
  3. 无障碍支持:完整的键盘和鼠标交互支持

图标渲染系统

Password 组件实现了灵活的图标渲染机制:

const defaultIconRender = (visible: boolean): React.ReactNode =>
  visible ? <EyeOutlined /> : <EyeInvisibleOutlined />;

const getIcon = (prefixCls: string) => {
  const iconTrigger = actionMap[action] || '';
  const icon = iconRender(visible);
  // ... 事件处理逻辑
};

这种设计允许开发者完全自定义图标和交互行为。

搜索输入框的实现

让我们看看 Search 组件的实现:

Search 组件的智能设计

Search 组件展示了如何通过组合和事件处理来创建功能丰富的搜索输入框:

事件处理系统

const onSearch = (e: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLInputElement>) => {
  if (customOnSearch) {
    customOnSearch(inputRef.current?.input?.value!, e, {
      source: 'input',
    });
  }
};

const onPressEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (composedRef.current || loading) {
    return;
  }
  onSearch(e);
};

关键特性:

  1. 多触发方式:支持点击按钮、按回车键触发搜索
  2. 输入法组合处理:正确处理中文输入法状态
  3. 加载状态集成:与 loading 状态完美集成

按钮渲染逻辑

Search 组件实现了灵活的按钮渲染机制:

let button: React.ReactNode;
const enterButtonAsElement = (enterButton || {}) as React.ReactElement;
const isAntdButton = enterButtonAsElement.type && 
  (enterButtonAsElement.type as typeof Button).__ANT_BUTTON === true;

if (isAntdButton || enterButtonAsElement.type === 'button') {
  button = cloneElement(enterButtonAsElement, {
    onMouseDown,
    onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
      enterButtonAsElement?.props?.onClick?.(e);
      onSearch(e);
    },
    key: 'enterButton',
  });
} else {
  button = (
    <Button
      className={btnClassName}
      type={enterButton ? 'primary' : undefined}
      size={size}
      disabled={disabled}
      key="enterButton"
      onMouseDown={onMouseDown}
      onClick={onSearch}
      loading={loading}
      icon={searchIcon}
    >
      {enterButton}
    </Button>
  );
}

这种设计支持:

  • 自定义按钮组件
  • Ant Design Button 组件
  • 简单的布尔值配置

TextArea 组件的实现

让我们看看 TextArea 如何处理多行文本输入:

TextArea 组件的复杂功能实现

TextArea 组件展示了如何处理复杂的多行文本输入场景:

尺寸调整处理

const [isMouseDown, setIsMouseDown] = React.useState(false);
const [resizeDirty, setResizeDirty] = React.useState(false);

const onInternalMouseDown: typeof onMouseDown = (e) => {
  setIsMouseDown(true);
  onMouseDown?.(e);
  
  const onMouseUp = () => {
    setIsMouseDown(false);
    document.removeEventListener('mouseup', onMouseUp);
  };
  
  document.addEventListener('mouseup', onMouseUp);
};

这种机制确保了:

  • 正确的鼠标按下/抬起状态跟踪
  • 尺寸调整过程中的样式处理
  • 无障碍交互支持

引用暴露模式

TextArea 使用了 React.imperativeHandle 来暴露特定的 API:

React.useImperativeHandle(ref, () => ({
  resizableTextArea: innerRef.current?.resizableTextArea,
  focus: (option?: InputFocusOptions) => {
    triggerFocus(innerRef.current?.resizableTextArea?.textArea, option);
  },
  blur: () => innerRef.current?.blur(),
}));

这种模式提供了:

  • 类型安全的 API 暴露
  • 对底层 DOM 元素的封装
  • 一致的编程接口

设计模式与最佳实践总结

1. 复合组件模式 (Compound Components)

Ant Design Input 组件是复合组件模式的典范:

优势:

  • 一致性:所有输入类型共享相同的导入方式
  • 可发现性:开发者容易发现可用功能
  • 类型安全:完整的 TypeScript 支持

实现方式:

const Input = InternalInput as CompoundedComponent;
Input.Group = Group;
Input.Search = Search;
Input.TextArea = TextArea;

2. 配置继承系统

组件实现了多层配置继承机制:

优先级顺序:

  1. 组件 props(最高优先级)
  2. 上下文配置
  3. 默认值(最低优先级)
const mergedSize = useSize((ctx) => customSize ?? compactSize ?? ctx);
const mergedDisabled = customDisabled ?? disabled;

3. 样式系统架构

基于 CSS 变量的现代化样式系统:

核心特性:

  • 动态主题切换
  • 运行时样式修改
  • 细粒度的样式覆盖
const rootCls = useCSSVarCls(prefixCls);
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls, rootClassName);

4. 无障碍访问支持

所有组件都内置了完整的无障碍支持:

  • 键盘导航
  • 屏幕阅读器支持
  • 焦点管理
  • 语义化 HTML 结构

5. 类型安全设计

完整的 TypeScript 支持确保了开发体验:

export interface InputProps
  extends Omit<RcInputProps, 'wrapperClassName' | 'groupClassName' | 'inputClassName'> {
  rootClassName?: string;
  size?: SizeType;
  status?: InputStatus;
}

组件构建分析

基于对 Ant Design Input 组件的分析,我认为以下是构建类似组件的系统的关键步骤:

第一步:基础架构设计

  1. 确定组件边界:明确核心组件和扩展组件的关系
  2. 设计复合模式:规划如何组织不同的输入类型
  3. 定义配置系统:设计上下文继承机制

第二步:核心实现

  1. 基于现有库封装:如使用 rc-input 作为基础
  2. 实现状态管理:设计受控/非受控模式
  3. 集成样式系统:实现 CSS 变量支持

第三步:扩展功能

  1. 添加特殊输入类型:Password、Search、TextArea
  2. 实现无障碍支持:键盘导航、焦点管理
  3. 优化性能:避免不必要的重渲染

第四步:质量保障

  1. 完整的类型定义:TypeScript 支持
  2. 单元测试覆盖:确保功能正确性
  3. 文档编写:清晰的 API 文档和使用示例

结语

OK,又到了总结的时候;

说实话,这个组件设计得非常好,通过复合组件模式、配置继承系统、样式系统架构等设计,提供了一个既强大又易用的输入解决方案。

从设计理念到实现细节,这个组件都体现了以下几个核心原则:

  1. 一致性:所有输入类型提供统一的 API 和体验
  2. 可扩展性:易于添加新的输入类型和功能
  3. 可定制性:支持深度的样式和行为定制
  4. 无障碍性:内置完整的无障碍访问支持
  5. 类型安全:完整的 TypeScript 支持

通过学习和理解这个组件的设计,咱们可以将其中的模式和最佳实践应用到自己的项目中,构建出更加健壮和易用的组件系统。

OK,我是李仲轩,下一篇再见吧!👋

pnpm 的 monorepo架构多包管理

作者 gnip
2025年9月13日 21:08

概述

可以使用 pnpm 的 monorepo 架构来共享公共组件、方法和第三方依赖,且效果非常好!
相比传统的 npmyarnpnpm 在 monorepo 场景下提供了更好的包管理性能和依赖去重,特别适合微前端架构。

方案概述

pnpm monorepo 主要依赖 workspace 机制,把多个子应用和公共包统一管理在一个单一的仓库中。
主应用和子应用可以直接共享公共组件库、工具方法、第三方包,避免重复安装和版本冲突。

实现

1. 创建 pnpm monorepo 项目


mkdir microfrontend-monorepo && cd microfrontend-monorepo
pnpm init

package.json 里启用 workspaces


{
  "name": "microfrontend-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}
  • packages/ 存放共享的公共组件库、工具库
  • apps/ 存放主应用和子应用

2. 添加共享的 packages

创建 shared-ui (共享组件库)


cd packages/shared-ui
pnpm init

安装 React 组件依赖(如果是 Vue 则用 vue


pnpm add react react-dom

创建 index.tsx


// packages/shared-ui/src/Button.tsx
import React from "react";

export const Button = ({ text }: { text: string }) => {
  return <button style={{ padding: "10px 20px", background: "blue", color: "white" }}>{text}</button>;
};

导出组件


// packages/shared-ui/index.ts
export * from "./src/Button";

添加 package.json 配置


{
  "name": "@micro/shared-ui",
  "version": "1.0.0",
  "main": "index.ts",
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

创建 shared-utils (共享工具库)


cd ../shared-utils
pnpm init

添加工具函数


// packages/shared-utils/index.ts
export const formatDate = (date: string) => new Date(date).toLocaleString();

配置 package.json


{
  "name": "@micro/shared-utils",
  "version": "1.0.0",
  "main": "index.ts"
}

3. 创建 apps 目录(主应用和子应用)

 
mkdir -p apps/main-app apps/sub-app

配置主应用

 
cd apps/main-app
pnpm init
pnpm add react react-dom @micro/shared-ui @micro/shared-utils

App.tsx 里使用共享组件和方法


import React from "react";
import { Button } from "@micro/shared-ui";
import { formatDate } from "@micro/shared-utils";

const App = () => {
  return (
    <div>
      <h1>主应用</h1>
      <Button text="点击我" />
      <p>当前时间:{formatDate(Date.now().toString())}</p>
    </div>
  );
};

export default App;

配置子应用

 
cd ../sub-app
pnpm init
pnpm add react react-dom @micro/shared-ui @micro/shared-utils

子应用 App.tsx


import React from "react";
import { Button } from "@micro/shared-ui";

const SubApp = () => {
  return (
    <div>
      <h2>子应用</h2>
      <Button text="我是子应用的按钮" />
    </div>
  );
};

export default SubApp;

4. 在 pnpm monorepo 里安装依赖

回到项目根目录,运行:

 
pnpm install

pnpm 会自动去重依赖,所有 packagesapps 共享相同的 node_modules,提升构建速度。

启动主应用和子应用

方式 1:独立运行(适合非微前端模式)

 
cd apps/main-app && pnpm start
cd apps/sub-app && pnpm start

这样主应用和子应用可以各自独立运行。

方式 2:微前端整合

使用 qiankun 微前端框架

主应用( main-app


import { registerMicroApps, start } from "qiankun";

registerMicroApps([
  {
    name: "sub-app",
    entry: "//localhost:3001",
    container: "#subapp-container",
    activeRule: "/subapp",
  },
]);

start();

子应用( sub-app


import { render } from "react-dom";
import SubApp from "./App";

export async function bootstrap() {
  console.log("子应用 bootstrap");
}

export async function mount(props) {
  console.log("子应用 mount", props);
  render(<SubApp />, document.getElementById("root"));
}

export async function unmount() {
  console.log("子应用 unmount");
}

优势

  • 共享组件库 & 工具库:所有子应用共用 @micro/shared-ui@micro/shared-utils,代码复用率高。
  • 自动依赖去重pnpm 采用硬链接,不会重复安装 React、Ant Design 等第三方包。
  • 独立开发 & 统一管理:子应用可以独立开发,但也能享受 monorepo 的依赖管理
  • 微前端兼容性强:可以配合 qiankun Module Federation Web Components 等方式实现微前端。

适合的场景

  • 多子应用共享组件库、工具库
  • 每个子应用可以独立运行,但也能合并成微前端
  • 减少重复安装 React、Vue、Ant Design 等依赖

很久以前写的排序算法动画

作者 驳是
2025年9月13日 19:05

排序动画

这是一段尘封了有十好几年的代码,那时候写前端的都还在被 IE6 折磨,没有 ES6 的新语法。

经过了这么多年的沉寂,这段古旧的代码依然还能够运行,着实算是意料之中 😎。放这里算做个纪念吧。

我当时写了四种排序算法的动画,每个算法都用相同的 5 组不同性质的数据(已排序、少量乱序、逆序、乱序、大量重复)。直接看效果:

说明一下:

  1. 红色小三角:当前正在处理的元素指示(快速排序有两个)
  2. 灰色线条:未挪动过位置的元素
  3. 黑色线条:挪动过位置的元素
  4. 双击某个图形可以重新开始对应的动画

从效果上,可以很明显地看出这四种排序算法的速度:快速排序 > 选择排序 > 插入排序 > 冒泡排序。

代码实现

写过排序的算法的一般都知道,排序其实就是一顿循环,循环无非就是 forwhile 这些,在没有 async-await 之前,只能是一个同步的过程。

根据视觉停留的时间原理,而要让排序以动画的形式能够展现,就需要让每个循环的消耗一定的时间,50~150ms 左右。

要做到这件事情,所能想到的就是把同步循环转成异步,当年的做法就是利用 setTimeout + 递归的方法(当时还写过一篇博客,但已经不可考了)。

以下所有代码,我都基本保留之前的原汁原味,不做过多的更新,所以没有用到新的 JS 特性,而且,当时我还不会 TS,因此这里所有的代码都是纯 ES5 JS。

公用逻辑

var COMPARE_TIME = 5,
    MOVE_TIME = 50;

/**
 * Used by all sorting algorithms to swap two entries in the array.
 * @param {Array} arr
 * @param {Number} i
 * @param {Number} j
 */
function _swap(arr, i, j) {
  var tmp = arr[i];
  arr[i] = arr[j];
  arr[j] = tmp;
}

/**
 * Used to do setTimeout.
 * @param {Function} fn
 * @param {Number} ms
 */
function _delay(fn, ms) {
  if (ms > 0) {
    setTimeout(fn, ms);
  } else {
    fn();
  }
}

快速排序

function quickSort(arr) {
  function quick(l, r) { // l for left, r for right
    if (l >= r) {
      return;
    }
    
    var pivot = arr[l], // the first of the sub-list as the pivot
        i = l + 1,
        j = r;
    
    while (true) {
      while (i < r && arr[i] <= pivot) {
        i++;
      }
      
      while (j > l && arr[j] >= pivot) {
        j--;
      }
      
      if (i < j) {
        _swap(arr, i, j);
      } else {
        _swap(arr, l, j);
        break;
      }
    }
    
    quick(l, j - 1);
    quick(j + 1, r);
  }
  
  quick(0, arr.length - 1);
}

快速排序(递归版)

function quickSortRecursive(arr) {
  var recursiveStack = [],
      n = arr.length - 1,
      pivot, i, j, l, r, needCmp;
  
  function loopJ() {
    needCmp = j > l;
    
    if (needCmp && arr[j] >= pivot) {
      j--;
      _delay(loopJ, COMPARE_TIME);
    } else {
      if (i < j) {
        _swap(arr, i, j);
        _delay(loop, needCmp ? COMPARE_TIME + MOVE_TIME : MOVE_TIME);
      } else {
        _swap(arr, l, j);
        
        recursiveStack.push({
          l: j + 1,
          r: r
        });
        _delay(function() {
          quick(l, j - 1);
        }, needCmp ? COMPARE_TIME + MOVE_TIME : MOVE_TIME);
      }
    }
  }
  
  function loopI() {
    needCmp = i < r;
    if (needCmp && arr[i] <= pivot) {
      i++;
      _delay(loopI, COMPARE_TIME);
    } else {
      _delay(loopJ, needCmp ? COMPARE_TIME : 0);
    }
  }
  
  function loop() {
    loopI();
  }
  
  function quick(_l, _r) {// l for left, r for right
    l = _l;
    r = _r;
    
    if (l >= r) {
      var o = recursiveStack.pop();
      if (o) {
        quick(o.l, o.r);
      } // else - end of loop
      
      return;
    }
    
    pivot = arr[l];// the first of the sub-list as the pivot
    i = l + 1;
    j = r;
    
    loop();
  }
  
  quick(0, n);
}

选择排序

function selectionSort(arr) {
  var n = arr.length,
      i, j, min, minIdx;
  
  for (i = 0; i < n - 1; i++) {
    min = arr[i];
    minIdx = i;
    for (j = i + 1; j < n; j++) {
      if (arr[j] < min) {
        min = arr[j];
        minIdx = j;
      }
    }
    
    _swap(arr, i, minIdx);
  }
  
  console.info(arr);
}

选择排序(递归版)

function selectionSortRecursive(arr) {
  var n = arr.length,
      i = 0, j, min, minIdx;
  
  function loopInner() {
    if (j < n) {
      if (arr[j] < min) {// compare
        min = arr[j];
        minIdx = j;
      }
      j++;
      _delay(loopInner, COMPARE_TIME);
    } else {
      _swap(arr, i, minIdx);// move
      i++;
      _delay(loop, MOVE_TIME);
    }
  }
  
  function loop() {
    if (i < n - 1) {
      min = arr[i];
      minIdx = i;
      j = i + 1;
      loopInner();
    } // else - end of sort
  }
  
  loop();
}

插入排序

function insertionSort(arr) {
  var n = arr.length,
      i, j;
  
  for (i = 1; i < n; i++) {
    for (j = i; j >= 0 && arr[j] < arr[j - 1]; j--) {
      _swap(arr, j - 1, j);
    }
  }
}

插入排序(递归版)

function insertionSort_noloop(arr) {
  var n = arr.length,
      i = 1, j;
  
  function loopInner() {
    if (j >= 0) {
      if (arr[j] < arr[j - 1]) {// compare
        _swap(arr, j - 1, j);
        j--;
        _delay(loopInner, COMPARE_TIME + MOVE_TIME);
      } else {
        i++;
        _delay(loop, COMPARE_TIME);
      }
    } else {
      i++;
      loop();
    }
  }
  
  function loop() {
    if (i < n) {
      j = i;
      loopInner();
    } // else - end of loop
  }
  
  loop();
}

冒泡排序

function bubbleSort(arr) {
  var n = arr.length,
      i, j;
  
  for (i = n - 1; i >= 0; i--) {
    for (j = 0; j < i; j++) {
      if (arr[j] > arr[j + 1]) {
        _swap(arr, j, j + 1);
      }
    }
  }
}

冒泡排序(递归版)

function bubbleSortRecursive(arr) {
  var n = arr.length,
      i = n - 1, j;
  
  function loopInner() {
    if (j < i) {
      var needMove = arr[j] > arr[j + 1];// compare
      if (needMove) {
        _swap(arr, j, j + 1);// move
      }
      j++;
      _delay(loopInner, needMove ? COMPARE_TIME + MOVE_TIME : COMPARE_TIME);
    } else {
      i--;
      loop();
    }
  }
  
  function loop() {
    if (i >= 0) {
      j = 0;
      loopInner();
    } // else - end of sort
  }
  
  loop();
}

重新思考

如果现在去写,会怎么写?

是的,这些年前端的基础已经发生了天翻地覆的变化,个人在编程风格和技巧上,也有了一些变化。

如果现在去写的话,除了会用 TS 之外,我会考虑以下点:

  1. 用 await 代替迭代,或者考虑用 Generator(我会更倾向于后者)
  2. 增加更多输出,比如耗时、总循环数、比较次数、交换次数等
  3. 增加更多配置,比如速度、数组大小等

有时间的话..

全排列-遇到的深浅拷贝问题

作者 力Mer
2025年9月13日 17:35

题目描述:

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

解答代码:

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function(nums) {
    let res = [];
    let used = [];

    function dfs(path){
        if(path.length === nums.length){
            res.push(path.slice());
            return;
        }
        for(let i = 0;i<nums.length;i++){
            if(used[nums[i]])continue;
            path.push(nums[i]);
            used[nums[i]] = true;
            dfs(path);
            path.pop();
            used[nums[i]] = false;
        }
    }
    dfs([]);
    return res;
};

遇到的问题描述:

  为什么res.push(path.slice())这里要拷贝一个新的数组,而不能直接用res.push(path):这句代码的作用是保存了当前排列的副本,可以避免后续递归修改path时影响之前res中保存好的结果。如果直接 res.push(path),后续 path 的变化会影响 res 中的内容,导致结果错误。

  但是我印象中了解到的问题是JS中slice()方法是一个浅拷贝,既然是浅拷贝,而且数组本身不是一个基础数据类型,所以它依然是复制的引用,内部数据应该是共享的,后续改变path依然会出现问题,那这里为什么没出现问题呢?

解释

浅拷贝对于复制到的第一层属性不会互相影响,类似于深拷贝。如果数组中只有基本数据类型,浅拷贝和深拷贝的效果是一样的;只有数组中有对象、数组等引用类型时,浅拷贝和深拷贝才有区别,同理对象也是。

引用赋值
let path = [{a:1}, {b:2}];
let copy = path;
copy.push({ c: 3 });
console.log(path);  //[ { a: 1 }, { b: 2 }, { c: 3 } ]
console.log(copy);  //[ { a: 1 }, { b: 2 }, { c: 3 } ]
path[0].a = 100;
console.log(path);  //[ { a: 100 }, { b: 2 }, { c: 3 } ]
console.log(copy);   //[ { a: 100 }, { b: 2 }, { c: 3 } ]

可以看出来引用赋值是完全会互相影响。

浅拷贝

重点:浅拷贝只会完全复制对象的第一层属性(和引用赋值的区别)。

//数组中是基本数据类型
let path = [1,2];
let arr = path.slice()
path.push(3);   
console.log(path);  //[1,2,3]
console.log(arr);  //[1,2]
let path = [{a:1}, {b:2}];
let copy = path.slice();
copy.push({ c: 3 });
console.log(path);  //[ { a: 1 }, { b: 2 } ]
console.log(copy);  //[ { a: 1 }, { b: 2 }, { c: 3 } ]
path[0].a = 100;
console.log(path);  //[ { a: 100 }, { b: 2 } ]
console.log(copy);   //[ { a: 100 }, { b: 2 }, { c: 3 } ]

这里还可以看出即使是浅拷贝,两个数组也是不同的数组对象,执行的push操作不会影响另一个,但是复制后,对于已复制的引用数据类型(也就是第二层属性)的改变还是会互相影响的。

let path = {a:1, b:2};
let copy = Object.assign({},path);
copy.c = 3;
console.log(path);  //{ a: 1, b: 2}
console.log(copy);  //{ a: 1, b: 2, c: 3 }
path.a = 100;
console.log(path);  //{ a: 100, b: 2 }
console.log(copy);   //{ a: 1, b: 2, c: 3 }

上面这段代码同样说明浅拷贝对于第一层属性的变化互相之间不会有影响,所以可以得出结论:
❗️当对象只有一级属性为深拷贝;
❗️当对象中有多级属性时,二级属性后就是浅拷贝;

浅拷贝的一些方法:

  • 对象:let cppy = Object.assign({}, original); 数组:let cppy = Object.assign([], original);
  • 对象:let copy = { ...original }; 数组:let copy = [ ...original ];
  • let copyy = original.slice();仅适用于数组
深拷贝
let path = [{a:1}, {b:2}];
let copy = JSON.parse(JSON.stringify(path));
copy.push({ c: 3 });
console.log(path);  //[ { a: 1 }, { b: 2 } ]
console.log(copy);  //[ { a: 1 }, { b: 2 }, { c: 3 } ]
path[0].a = 100;
console.log(path);  //[ { a: 100 }, { b: 2 } ]
console.log(copy);   //[ { a: 1 }, { b: 2 }, { c: 3 } ]

深拷贝互相之间完全不影响。

p5.js 绘制 3D 椭球体 ellipsoid

2025年9月13日 16:08

点赞 + 关注 + 收藏 = 学会了

ellipsoid() 是 p5.js 中用于绘制 3D 椭球体的函数,就像 3D 版本的椭圆。它可以创建各种形状的球体和椭球体,是 3D 绘图中非常基础且常用的函数。

基本语法

ellipsoid(w, h, d, [detailX], [detailY])
  • w:椭球体在 X 轴方向的宽度
  • h:椭球体在 Y 轴方向的高度
  • d:椭球体在 Z 轴方向的深度
  • detailX(可选):X 方向的细节级别,值越大表面越光滑,默认 12
  • detailY(可选):Y 方向的细节级别,默认 12

动手试试

先绘制一个简单的椭球体,在setup()中设置 3D 画布,并在draw()中设置相机视角。

01.gif

function setup() {
  // 创建3D画布,需要指定WEBGL参数
  createCanvas(600, 400, WEBGL);
}

function draw() {
  background(220);
  
  // 添加简单光照,让3D效果更明显
  ambientLight(150);
  pointLight(255, 255, 255, 100, 100, 200);
  
  // 旋转椭球体,让我们能看到3D效果
  rotateX(frameCount * 0.01);
  rotateY(frameCount * 0.01);
  
  // 绘制一个球体(当w=h=d时就是球体)
  fill(100, 150, 255);
  ellipsoid(80, 150, 100);
}

这段代码很简单,我逐一讲讲关键方法

  • createCanvas(600, 400, WEBGL):创建 3D 画布,必须添加WEBGL参数才能使用 3D 函数
  • ambientLight()pointLight():添加光照让 3D 物体有立体感
  • rotateX()rotateY():让物体随时间旋转,方便观察 3D 效果
  • ellipsoid(80, 150, 100):绘制一个椭球体的三维参数。而当三个维度数值相等时,绘制的就是一个标准球体

不同参数的效果

一起看看改变参数会产生什么效果:

02.png

function setup() {
  createCanvas(800, 600, WEBGL);
}

function draw() {
  background(20);
  ambientLight(100);
  directionalLight(255, 255, 255, 0, 0, -1);
  
  // 旋转整个场景
  orbitControl(); // 允许用户通过鼠标旋转视角
  
  // 左侧:橄榄球形状(X轴拉长)
  push(); // 保存当前坐标系状态
  translate(-200, 0, 0); // 向左移动
  fill(255, 100, 100);
  ellipsoid(100, 50, 50); // X轴更长
  pop(); // 恢复坐标系状态
  
  // 中间:正常球体
  push();
  fill(100, 255, 100);
  ellipsoid(80, 80, 80); // 所有轴相等
  pop();
  
  // 右侧:扁平形状(Y轴压缩)
  push();
  translate(200, 0, 0); // 向右移动
  fill(100, 100, 255);
  ellipsoid(80, 40, 80, 24, 24); // 更高细节级别
  pop();
}

搞点猛的

做一个动态气泡效果。

03.gif

let bubbles = [];
let numBubbles = 15;

function setup() {
  createCanvas(800, 600, WEBGL);
  
  // 创建多个气泡
  for (let i = 0; i < numBubbles; i++) {
    bubbles.push({
      x: random(-width/2, width/2),
      y: random(-height/2, height/2),
      z: random(-500, 500),
      size: random(20, 80),
      speed: random(0.5, 3),
      rotX: random(0.01),
      rotY: random(0.01),
      hue: random(180, 240) // 蓝色系气泡
    });
  }
  
  // 启用鼠标控制视角
  orbitControl();
}

function draw() {
  background(10);
  
  // 添加环境光和方向光
  ambientLight(30);
  directionalLight(255, 255, 255, 0, 1, -1);
  
  // 绘制所有气泡
  for (let b of bubbles) {
    push();
    
    // 移动到气泡位置
    translate(b.x, b.y, b.z);
    
    // 旋转气泡
    rotateX(frameCount * b.rotX);
    rotateY(frameCount * b.rotY);
    
    // 设置气泡颜色和透明度
    noStroke();
    fill(b.hue, 200, 255, 200);
    
    // 绘制椭球体作为气泡
    let w = b.size * random(0.8, 1.2); // 轻微变形,更自然
    let h = b.size * random(0.8, 1.2);
    let d = b.size * random(0.8, 1.2);
    ellipsoid(w, h, d, 20, 20);
    
    pop();
    
    // 移动气泡(Z轴方向)
    b.z += b.speed;
    
    // 当气泡移出视野,重置位置
    if (b.z > 600) {
      b.z = -600;
      b.x = random(-width/2, width/2);
      b.y = random(-height/2, height/2);
    }
  }
}

这个案例创建了一个气泡数组,每个气泡都有自己的位置、大小、速度等属性。使用随机值让每个气泡的大小略有不同,看起来更自然。通过translate()将每个气泡放置在不同位置,每个气泡有独立的旋转速度,增加动态效果。使用半透明颜色和光照,营造出气泡的通透感,当气泡移出视野时,会在另一侧重新出现,形成循环效果。


以上就是本文的全部内容了,想了解更多P5.js玩法的工友欢迎关注《P5.js中文教程》

点赞 + 关注 + 收藏 = 学会了

《前端笔试必备:JavaScript ACM输入输出模板》

作者 Enddme
2025年9月13日 16:02

引言:最近秋招黄金期,陆陆续续收到了一些笔试邀请,但很多都是ACM模式而不是力扣上的核心代码模式。所以代码题做起来无从下手,从网上找资料也很少有Javascript版本的ACM输入输出模板,并且也不够详细。于是自己搜集了许多资料,准备把常见的模板整理出来,希望可以帮到大家。

一、基础模板

我们先来看一个基础模板

const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 直接输出需要的字符串,不需要处理输入
    console.log("Hello Nowcoder!");
}()

我们接下来逐行解析下每行代码的作用

1. 引入 readline 模块并创建接口
const rl = require("readline").createInterface({ input: process.stdin });
  • require("readline"):引入Node.js内置的readline模块,这个模块用于从命令行(标准输入)读取一行一行的输入。
  • createInterface({ input: process.stdin }):创建一个输入接口,指定输入源为process.stdin(标准输入,也就是用户在控制台输入的内容)。
  • 变量rl就是这个输入接口的实例,后续通过它来控制输入的读取。
2. 创建异步迭代器
var iter = rl[Symbol.asyncIterator]();
  • Symbol.asyncIterator是Javascript的一个内置符号,用于定义对象的异步迭代器
  • 这里通过rl[Symbol.asyncIterator]()获取rl接口的异步迭代器,赋值给iter
  • 异步迭代器的作用是:可以通过next()方法异步地获取下一行输入(因为输入是用户手动输入的,属于异步操作)。
3. 定义读取一行输入的函数
const readline = async () => (await iter.next()).value;
  • 这是一个异步函数(async标记),作用是读取一行输入。
  • 调用iter.next()会返回一个Promise,await会等待这个Promise完成,获取下一行输入的结果。
  • 结果的value属性就是读取到的一行字符串(如果没有更多输入,value会是undefined)。
  • 简单说:调用readline()就可以得到一行输入的内容(字符串类型)
4. 立即执行的异步函数(核心逻辑区)
void async function () {

      // Write your code here 👉 你的核心代码写在这里

      // 直接输出需要的字符串,不需要处理输入 console.log("Hello Nowcoder!"); 
}()

这是整个代码的执行入口,也就是你需要编写核心逻辑的地方,我们拆解一下:

  • void async function (){...}():这是一个立即执行的异步函数表达式(IIFE)。
    • async标记:允许函数内部使用await关键字(因为读取输入输出是异步操作)。
    • void:避免函数执行后返回值可能导致的语法问题,单纯让函数执行。
    • 最后的():表示定义后立即执行这个函数。

核心代码写在哪里?

答案是:写在void async function () { ... }这个函数内部(也就是注释// Write your code here的位置)。根据题目的输入格式不同,你需要修改这个区域的代码。具体常见的输入格式见我第二部分详细讲解。

总结

这个模板的作用是标准化输入读取流程:

  1. 准备好读取输入的工具(rl接口,iter迭代器,readline函数)。
  2. 在立即执行的异步函数中,通过await readline()获取输入。
  3. 在函数内部编写你的核心逻辑(处理输入、计算、输出结果)。

二、常见出题形式

1.单组A+B

描述

给定两个整数ab,请你求出a + b的值。

输入描述:

第一行有两个整数ab

输出描述:

输入一个整数,代表a + b的值。

示例
输入:
1 2
输出:
3
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // Write your code here
    while(line = await readline()){
        let tokens = line.split(' ');
        let a = parseInt(tokens[0]);
        let b = parseInt(tokens[1]);
        console.log(a + b);
    }
}()
核心逻辑解析

函数内部的while循环:

while(line = await readline()) { ... }
  • 作用:持续读取每一行输入,直到没有更多输入(readline()返回undefined,循环终止)。
  • line = await readline():先调用readline()读取每一行输入,赋值给line
  • 当没有输入时,readline()先返回undefined,循环条件为false,退出循环。

循环内部的代码

let tokens = line.split(' '); // 将一行输入按空格分割成数组(比如输入"1 2",得到["1", "2"]) 
let a = parseInt(tokens[0]); // 将第一个元素转为整数 
let b = parseInt(tokens[1]); // 将第二个元素转为整数 console.log(a + b); // 输出结果

2.多组_A+B_EOF形式

描述

给定若干组测试数据,读取至文件末尾为止,每组数据有两个整数a和b,请你求出a + b的值。

输入描述

每行有两个整数a和b,读取至文件末尾为止

输出描述

输出若干行,每行一个整数,代表a + b的值。

示例
输入:
1 2
114 514
2024 727
输出:
3
628
2751
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 循环读取每一行输入,直到没有更多输入(EOF)
    while(line = await readline()){
        // 将一行输入按空格分割成数组(例如"1 2"分割为["1", "2"])
        let tokens = line.split(' ');
        // 将分割后的字符串转为整数
        let a = parseInt(tokens[0]);
        let b = parseInt(tokens[1]);
        // 输出两数之和
        console.log(a + b);
    }
}()

3.多组_A+B_T组形式

描述

给定t组测试数据。每组数据有两个整数a和b,请你求出a + b的值。

输入描述

第一行有一个整数t,每行有两个整数a和b

输出描述

输出t行,每行一个整数,代表a + b的值。

示例
输入:
3
1 2
114 514
2024 727
输出:
3
628
2751
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 第一步:读取第一行,获取测试用例数量T
    let T = parseInt(await readline());
    
    // 第二步:循环T次,处理每组数据
    for (let i = 0; i < T; i++) {
        // 读取一行输入
        let line = await readline();
        // 分割成两个数字
        let tokens = line.split(' ');
        let a = parseInt(tokens[0]);
        let b = parseInt(tokens[1]);
        // 输出结果
        console.log(a + b);
    }
}()

4.多组_A+B_零尾模式

描述

给定若干组测试数据,最后一组数据为0 0,作为输入的结尾。每组数据有两个整数a和b,请你求出a + b的值。

输入描述

每行有两个整数a和b,最后一组数据为0 0,作为输入的结尾。

输出描述

输出若干行,每行一个整数,代表a + b的值。

示例
输入:
1 2
114 514
2024 727 
0 0
输出:
3
628 
2751
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 循环读取每一行输入
    while(line = await readline()){
        // 分割并转换为数字
        let tokens = line.split(' ');
        let a = parseInt(tokens[0]);
        let b = parseInt(tokens[1]);
        
        // 关键:判断是否为0 0,是则终止循环
        if (a === 0 && b === 0) {
            break; // 退出循环,不再处理后续输入
        }
        
        // 不是终止条件则输出结果
        console.log(a + b);
    }
}()

5.单组_一维数组

示例
输入:
3
1 4 7
输出:
12
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 第一步:读取第一行,获取数字的个数n
    let n = parseInt(await readline());
    
    // 第二步:读取第二行,获取包含n个数字的字符串
    let line = await readline();
    
    // 第三步:对字符串进行处理,转化为数字数组
    let nums = line.split(" ").filter(x => x).map(Number); // 用空格分割,过滤空值
    
    // 第四步:计算数组中所有数字的总和
    let sum = nums.reduce((acc, curr) => acc + curr, 0);
    
    // 第五步:输出总和
    console.log(sum);
}()

6.多组_一维数组_T组形式

示例
输入:
3
3
1 4 7
1
1000
2
1 2
输出:
12
1000
3
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void (async function () {
    // 读取测试用例总数T
    const T = parseInt(await readline());

    // 循环处理每组数据
    for (let i = 0; i < T; i++) {
        // 读取当前组的元素个数n
        const n = parseInt(await readline());

        // 读取当前组的数组元素行
        const arrayLine = await readline();

        // 将字符串分割为数字数组
        const numbers = arrayLine.split(" ").filter(x => x).map(Number);

        // 计算数组总和(使用reduce累加,初始值为0)
        const sum = numbers.reduce((acc, current) => acc + current, 0);

        // 输出当前组的总和
        console.log(sum);
    }
})();

7.单组_二维数组

示例
输入:
3 4
1 2 3 4
5 6 7 8
9 10 11 12
输出:
78
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 1. 读取第一行,获取二维数组的行数m和列数n
    let firstLine = await readline();
    let [m, n] = firstLine.split(' ').map(Number); // m=3, n=4(对应示例输入)
    
    let totalSum = 0; // 存储总和
    
    // 2. 循环读取m行数据(二维数组的每一行)
    for (let i = 0; i < m; i++) {
        let row = await readline(); // 读取一行数据(如"1 2 3 4")
        let nums = row.split(' ').filter(x => x).map(Number); // 转为数字数组(如[1,2,3,4])
        
        // 3. 累加当前行的所有元素到总和
        let rowSum = nums.reduce((acc, curr) => acc + curr, 0);
        totalSum += rowSum;
    }
    
    // 4. 输出二维数组所有元素的总和
    console.log(totalSum);
}()

8.多组_二维数组_T组形式

示例
输入:
3
3 4
1 2 3 4
5 6 7 8
9 10 11 12
1 1
2024
3 2
1 1
4 5
1 4
输出:
78
2024
16
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void (async function () {
    // 1. 读取测试用例总数T
    const T = parseInt(await readline());

    // 2. 循环处理每组二维数组
    for (let t = 0; t < T; t++) {
        // 2.1 读取当前组的行数m和列数n
        const [m, n] = (await readline()).split(" ").filter(x => x).map(Number);

        let totalSum = 0; // 存储当前组的总和

        // 2.2 读取m行数据(二维数组的每一行)
        for (let i = 0; i < m; i++) {
            const row = (await readline()).split(" ").filter(x => x).map(Number);
            // 累加当前行的所有元素
            const rowSum = row.reduce((acc, curr) => acc + curr, 0);
            totalSum += rowSum;
        }

        // 2.3 输出当前组的总和
        console.log(totalSum);
    }
})();

9.单组_字符串

描述

给定一个长度为n的字符串s,请你将其倒置,然后输出。

输入描述

第一行有一个整数n,第二行有一个字符串s,仅包含小写英文字符。

输出描述

输出一个字符串,代表倒置后的字符串s

示例
输入:
5
abcde
输出:
edcba
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 1. 读取第一行:字符串的长度n(本题中可忽略具体值,仅用于匹配输入格式)
    const n = parseInt(await readline());
    
    // 2. 读取第二行:需要反转的字符串
    const str = await readline();
    
    // 3. 反转字符串:
    //    - split('') 将字符串转为字符数组(如"abcde" → ['a','b','c','d','e'])
    //    - reverse() 反转数组(→ ['e','d','c','b','a'])
    //    - join('') 将数组转回字符串(→ "edcba")
    const reversedStr = str.split('').reverse().join('');
    
    // 4. 输出反转后的字符串
    console.log(reversedStr);
}()

10.多组_字符串_T组形式

描述

给定t组询问,每次只给出一个长度为n的字符串s,请你将其倒置,然后输出。

输入描述

第一行有一个整数t,随后t组数据。每组的第一行有一个整数n,每组的第二行有一个字符串s,仅包含小写英文字符。

输出描述

输出t行,每行一个字符串,代表倒置后的字符串s

示例
输入:
3
5
abcde
8
redocwon
9
tfarcenim
输出:
edcba
nowcoder
minecraft
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void (async function () {
    // 1. 读取测试用例总数T
    const T = parseInt(await readline());

    // 2. 循环处理每组字符串
    for (let t = 0; t < T; t++) {
        // 2.1 读取当前组的字符串长度n(仅用于匹配输入格式,反转逻辑不依赖此值)
        const n = parseInt(await readline());

        // 2.2 读取当前组需要反转的字符串
        const str = await readline();

        // 2.3 反转字符串:拆分为字符数组 → 反转数组 → 拼接为字符串
        const reversedStr = str.split("").reverse().join("");

        // 2.4 输出反转后的字符串
        console.log(reversedStr);
    }
})();

11.单组_二维字符数组

输入描述

第一行有两个整数nm,随后n行,每行有m个字符,仅包含小写英文字符。

输出描述

输出一个二维字符数组。

示例
输入:
3 4
abcd
efgh
ijkl
输出:
lkji
hgfe
dcba
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void (async function () {
    // 1. 读取第一行,获取二维数组的行数m和列数n
    const [m, n] = (await readline()).split(" ").filter(x => x).map(Number);

    // 2. 读取m行字符串,存储到数组中
    const rows = [];
    for (let i = 0; i < m; i++) {
        rows.push(await readline());
    }

    // 3. 处理逻辑:
    //    a. 先将每行字符串反转(如"abcd" → "dcba")
    //    b. 再将所有行的顺序反转(如[行1, 行2, 行3] → [行3, 行2, 行1])
    const reversedRows = rows
        .map((row) => row.split("").reverse().join("")) // 每行字符反转
        .reverse(); // 行顺序反转

    // 4. 逐行输出处理后的结果
    reversedRows.forEach((row) => console.log(row));
})();

12.多组_带空格的字符串_T组形式

描述

给定t组询问,每次给出一个长度为n的带空格的字符串s,请你去掉空格之后,将其倒置,然后输出。

输入描述

第一行有一个整数t,随后有t组数据。每组的第一行有一个整数n,每组的第二行有一个字符串s,仅包含小写英文字符和空格,保证字符串首尾都不是空格。

输出描述

输出t行,每行一个字符串,代表倒置后的字符串s

示例
输入:
3
9
one space
11
two  spaces
14
three   spaces
输出:
ecapseno
secapsowt
secapseerht
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void (async function () {
    // 1. 读取测试用例总数T
    const T = parseInt(await readline());

    // 2. 循环处理每组字符串
    for (let t = 0; t < T; t++) {
        // 2.1 读取当前组的字符串总长度n(用于匹配输入格式)
        const n = parseInt(await readline());

        // 2.2 读取带空格的字符串
        const str = await readline();

        // 2.3 处理逻辑:
        //    a. 先将字符串所有字符(包括空格)反转
        //    b. 再去除反转后字符串中的所有空格
        const processed = str
            .split("") // 拆分为字符数组(含空格)
            .reverse() // 反转所有字符(包括空格)
            .join("") // 拼接回字符串
            .replace(/\s+/g, ""); // 去除所有空格(\s+匹配任意空白字符)

        // 2.4 输出处理结果
        console.log(processed);
    }
})();

13.单组_保留小数位数

描述

给定一个小数 n ,请你保留 3 位小数后输出。
如果原来的小数位数少于 3 ,需要补充 0 。
如果原来的小数位数多于 3 ,需要四舍五入到 3 位。

输出描述

输出一个小数,保留 3 位。

示例
输入:
1.23
输出:
1.230

输入:
114.514
输出:
114.514

输入:
123
输出:
123.000
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 1. 读取输入的小数(单组输入,只需读一次)
    const numStr = await readline();
    
    // 2. 将字符串转换为浮点数
    const num = parseFloat(numStr);
    
    // 3. 保留3位小数:toFixed(3)会自动补零,确保结果是3位小数
    const result = num.toFixed(3);
    
    // 4. 输出格式化后的结果
    console.log(result);
}()

14.单组_补充前导零

描述

给定一个正整数 n ,请你保留 9 个数位,然后输出。
如果数位少于 9 个,那么需要补充前导零。

输出描述

输出一个小数,保留 3 位。

示例
输入:
123
输出:
000000123

输入:
123456789
输出:
123456789
const rl = require("readline").createInterface({ input: process.stdin });
var iter = rl[Symbol.asyncIterator]();
const readline = async () => (await iter.next()).value;

void async function () {
    // 1. 读取输入的数字(单组输入,读取一行即可)
    const numStr = await readline();
    
    // 2. 补充前导零至9位:
    //    - padStart(9, '0') 表示如果字符串长度不足9位,在前面补'0'直到长度为9
    const result = numStr.padStart(9, '0');
    
    // 3. 输出处理后的结果
    console.log(result);
}()

三、总结

以上便是我们在笔试中经常会遇到的ACM输入输出题型,我们只需要特别牢记以下几点就可以很好得应对。

  • line = await readline():要注意await readline()获取的是一段字符串,后面我们还要自己将它分割或者转化为其他数据类型。
  • let tokens = line.split(' '):这段代码作用是,将一行输入按空格分割成数组(例如"1 2"分割为["1", "2"])。
  • let a = parseInt(tokens[0]):这段代码的作用是,将分割的字符转化为数字。

掌握以上这些,再配合whilefor语句就可以应对各种题型了。

Vue移动端开发的适配方案与性能优化技巧

作者 鹏多多
2025年9月12日 09:08

1. 移动端适配方案

1.1. 视口适配

在Vue项目中设置viewport的最佳实践:

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

通过插件自动生成viewport配置:

// vue.config.js
module.exports = {
  chainWebpack: config => {
    config.plugin('html').tap(args => {
      args[0].meta = {
        viewport: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
      }
      return args
    })
  }
}

1.2. 基于rem/em的适配方案

使用postcss-pxtorem自动转换px为rem:

// postcss.config.js
module.exports = {
  plugins: {
    'postcss-pxtorem': {
      rootValue: 37.5, // 设计稿宽度的1/10
      propList: ['*'],
      selectorBlackList: ['.ignore', '.hairlines']
    }
  }
}

1.3. vw/vh视口单位适配

结合postcss-px-to-viewport实现px自动转换:

// postcss.config.js
module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      unitToConvert: 'px',
      viewportWidth: 375,
      unitPrecision: 5,
      propList: ['*'],
      viewportUnit: 'vw',
      fontViewportUnit: 'vw',
      selectorBlackList: [],
      minPixelValue: 1,
      mediaQuery: false,
      replace: true,
      exclude: undefined,
      include: undefined,
      landscape: false,
      landscapeUnit: 'vw',
      landscapeWidth: 568
    }
  }
}

1.4. 移动端UI组件库适配

推荐使用适配移动端的Vue组件库:

在项目中集成Vant组件库:

npm i vant -S

按需引入组件:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { Button, Cell, CellGroup } from 'vant';
import 'vant/lib/index.css';

const app = createApp(App);

app.use(Button)
   .use(Cell)
   .use(CellGroup);

app.mount('#app')

2. 移动端性能优化技巧

2.1. 虚拟列表实现长列表优化

可以使用vue-virtual-scroller实现高性能列表:

npm install vue-virtual-scroller --save
<template>
  <RecycleScroller
    class="items-container"
    :items="items"
    :item-size="32"
    key-field="id"
  >
    <template #item="{ item }">
      <div class="item">{{ item.text }}</div>
    </template>
  </RecycleScroller>
</template>

<script>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

export default {
  components: {
    RecycleScroller
  },
  data() {
    return {
      items: Array.from({ length: 10000 }).map((_, i) => ({
        id: i,
        text: `Item ${i}`
      }))
    }
  }
}
</script>

2.2. 图片懒加载与优化

使用vueuse的useIntersectionObserver实现图片懒加载:

npm i @vueuse/core
<template>
  <img 
    v-for="item in imageList" 
    :key="item.id"
    :src="item.loaded ? item.src : placeholder"
    @load="handleImageLoad(item)"
    class="lazy-image"
  >
</template>

<script>
import { ref, onMounted } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'

export default {
  setup() {
    const imageList = ref([
      { id: 1, src: 'https://example.com/image1.jpg', loaded: false },
      { id: 2, src: 'https://example.com/image2.jpg', loaded: false },
      // 更多图片...
    ])
    const placeholder = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
    
    const handleImageLoad = (item) => {
      item.loaded = true
    }
    
    onMounted(() => {
      imageList.value.forEach(item => {
        const el = ref(null)
        const { stop } = useIntersectionObserver(
          el,
          ([{ isIntersecting }]) => {
            if (isIntersecting) {
              item.loaded = true
              stop()
            }
          }
        )
      })
    })
    
    return {
      imageList,
      placeholder,
      handleImageLoad
    }
  }
}
</script>

2.3. 减少首屏加载时间

使用Vue的异步组件和路由懒加载:

// 路由配置
const routes = [
  {
    path: '/home',
    name: 'Home',
    component: () => import(/* webpackChunkName: "home" */ '../views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

使用CDN加载外部资源:

<!-- index.html -->
<head>
  <!-- 加载Vue -->
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
</head>

2.4. 事件节流与防抖

使用lodash的throttle和debounce函数:

npm install lodash --save
<template>
  <div>
    <input v-model="searchText" @input="debouncedSearch" placeholder="搜索...">
  </div>
</template>

<script>
import { debounce } from 'lodash'

export default {
  data() {
    return {
      searchText: '',
      debouncedSearch: null
    }
  },
  created() {
    this.debouncedSearch = debounce(this.handleSearch, 300)
  },
  methods: {
    handleSearch() {
      // 执行搜索操作
      console.log('Searching with:', this.searchText)
    }
  }
}
</script>

3. 移动端常见问题解决方案

3.1. 移动端300ms点击延迟问题

使用fastclick库解决:

npm install fastclick --save
// main.js
import FastClick from 'fastclick'

FastClick.attach(document.body)

3.2. 滚动卡顿问题

优化滚动性能:

.scroll-container {
  -webkit-overflow-scrolling: touch; /* 开启硬件加速 */
  overflow-y: auto;
}

3.3. 移动端适配iOS安全区域

/* 适配iOS安全区域 */
body {
  padding-top: constant(safe-area-inset-top);
  padding-top: env(safe-area-inset-top);
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
}

3.4. 解决1px边框问题

/* 0.5px边框 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
  .border-bottom {
    border-bottom: 0.5px solid #e5e5e5;
  }
}

4. 性能监控与分析

使用Lighthouse进行性能评估:

npm install -g lighthouse
lighthouse https://your-vue-app.com --view

使用Vue DevTools进行性能分析:

  1. 在Chrome浏览器中安装Vue DevTools扩展
  2. 在Vue项目中启用性能模式:
// main.js
const app = createApp(App)

if (process.env.NODE_ENV !== 'production') {
  app.config.performance = true
}

app.mount('#app')

5. 实战案例:开发响应式移动端应用

5.1. 项目初始化

npm init vite@latest my-mobile-app -- --template vue-ts
cd my-mobile-app
npm install

5.2. 配置适配方案

集成postcss-px-to-viewport:

npm install postcss-px-to-viewport --save-dev

配置postcss.config.js:

module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      unitToConvert: 'px',
      viewportWidth: 375,
      unitPrecision: 5,
      propList: ['*'],
      viewportUnit: 'vw',
      fontViewportUnit: 'vw',
      selectorBlackList: [],
      minPixelValue: 1,
      mediaQuery: false,
      replace: true,
      exclude: /node_modules/i
    }
  }
}

5.3. 集成Vant组件库

npm install vant --save

配置按需引入:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [VantResolver()],
    }),
  ],
})

5.4. 实现响应式布局

<template>
  <div class="container">
    <van-nav-bar title="我的应用" left-arrow @click-left="onClickLeft" />
    
    <van-swipe class="banner" :autoplay="3000" indicator-color="white">
      <van-swipe-item v-for="(item, index) in banners" :key="index">
        <img :src="item" alt="Banner" />
      </van-swipe-item>
    </van-swipe>
    
    <van-grid :columns-num="4">
      <van-grid-item v-for="(item, index) in gridItems" :key="index" :text="item.text" :icon="item.icon" />
    </van-grid>
    
    <van-list
      v-model:loading="loading"
      :finished="finished"
      finished-text="没有更多了"
      @load="onLoad"
    >
      <van-cell v-for="(item, index) in list" :key="index" :title="item.title" :value="item.value" is-link />
    </van-list>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue'

export default defineComponent({
  setup() {
    const banners = ref([
      'https://picsum.photos/600/200?random=1',
      'https://picsum.photos/600/200?random=2',
      'https://picsum.photos/600/200?random=3'
    ])
    
    const gridItems = ref([
      { icon: 'photo-o', text: '图片' },
      { icon: 'video-o', text: '视频' },
      { icon: 'music-o', text: '音乐' },
      { icon: 'friends-o', text: '社交' }
    ])
    
    const list = ref([])
    const loading = ref(false)
    const finished = ref(false)
    
    const onLoad = () => {
      // 模拟加载数据
      setTimeout(() => {
        for (let i = 0; i < 10; i++) {
          list.value.push({
            title: `标题 ${list.value.length + 1}`,
            value: '内容'
          })
        }
        
        // 加载状态结束
        loading.value = false
        
        // 数据全部加载完成
        if (list.value.length >= 50) {
          finished.value = true
        }
      }, 1000)
    }
    
    onMounted(() => {
      onLoad()
    })
    
    const onClickLeft = () => {
      console.log('返回')
    }
    
    return {
      banners,
      gridItems,
      list,
      loading,
      finished,
      onLoad,
      onClickLeft
    }
  }
})
</script>

<style scoped>
.banner img {
  width: 100%;
  height: 180px;
  object-fit: cover;
}
</style>

通过以上步骤,我们可以开发出一个适配移动端的Vue应用。


本次分享就到这儿啦,我是鹏多多,如果看了觉得有帮助的,欢迎 点赞 关注 评论,在此谢过道友;

往期文章

JS 打造仿腾讯影视轮播导航

2025年9月12日 08:37

首页 Banner 不是简单的「几张图轮播」,而是「大图 + 侧边导航 + 自动播放 + 手动介入」的复合组件。本文用 JS原生代码实现一条无依赖、可复用、可扩展的影视轮播链路,涵盖数据驱动、事件委托、状态管理三大核心技能。

效果预览

JS 打造仿腾讯影视轮播导航.gif

一、数据约定

把后端返回的列表抽象成统一结构:

// data.js
const data = [
  {
    title: "三十而已",
    desc: "话题爽剧!姐姐飒气挑战",
    img: "https://puui.qpic.cn/media_img/lena/PICgthm4a_580_1680/0",
    bg: "rgb(25,117,180)"
  },
  // ... 更多对象
]
  • title:导航主标题
  • desc:副标题,用于 hover 提示
  • img:大图地址
  • bg:背景色,与大图同步切换

二、html骨架

<div class="content">
  <div class="top-nav"></div>
  <div class="imgs" id="imgs">
  </div>
  <div class="side-bar" id="side-bar">
    <a href="#" class="cnhz"> <img src="./img/all.png"> 猜你会追</a>
    <a href="#" class="zbtj"> <img src="./img/tj.png"> 重磅推荐</a>
  </div>
</div>

三、js链式渲染三步走

1.初始化:生成大图与导航

const imgs = document.getElementById('imgs')
const sideBar = document.getElementById('side-bar')
let activeImg = null
let activeNav = null

for (let i = 0; i < data.length; i++) {
  const item = data[i]

  // 大图 a 标签
  const tagA = document.createElement('a')
  tagA.href = '#'
  tagA.style.backgroundColor = item.bg
  tagA.style.backgroundImage = `url(${item.img})`
  imgs.appendChild(tagA)

  // 导航 a 标签
  const tagNav = document.createElement('a')
  tagNav.href = '#'
  tagNav.className = 'nav'
  tagNav.title = `${item.title}: ${item.desc}`
  tagNav.innerHTML = `<span>${item.title}</span> ${item.desc}`
  sideBar.appendChild(tagNav)

  // 第一个元素默认激活
  if (i === 0) {
    tagA.className = 'active'
    tagNav.className = 'active'
    activeImg = tagA
    activeNav = tagNav
  }
}

一次循环同时生成两张「a」:左侧大图与右侧导航,减少遍历次数。

2.鼠标介入:hover 即切换

tagNav.onmouseenter = function () {
  clearInterval(t) // 暂停自动播放

  // 取消旧活跃
  activeNav.className = 'nav'
  activeImg.className = ''

  // 激活当前
  this.className = 'active'
  tagA.className = 'active'

  // 更新指针
  activeNav = this
  activeImg = tagA
}

使用 onmouseenter 而非 onmouseover,避免子元素冒泡导致频繁触发。

3.自动播放:定时器 + 索引循环

function move() {
  // 取消旧活跃
  activeNav.className = 'nav'
  activeImg.className = ''

  // 找到下一个
  const index = Array.from(imgs.children).indexOf(activeImg)
  const nextIndex = (index + 1) % data.length

  // 激活下一个
  activeImg = imgs.children[nextIndex]
  activeNav = sideBar.children[nextIndex + 2] // 跳过两个标题
  activeImg.className = 'active'
  activeNav.className = 'active'
}

let t = setInterval(move, 3000)

nextIndex = (index + 1) % data.length 实现首尾循环;nextIndex + 2 跳过「猜你会追」和「重磅推荐」两个标题节点。

四、边界与优化细节

1.鼠标离开继续自动播放

tagNav.onmouseleave = () => {
  t = setInterval(move, 3000)
}

2.背景色同步切换

大图 abackgroundColor 直接读取 item.bg,避免额外计算。

3.长文字截断

CSS 设置 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;,hover 时通过 title 属性展示完整标题。

4.无闪烁切换

display: none ↔ block 瞬间完成,背景图预加载,肉眼无闪屏。

昨天 — 2025年9月13日首页

前端文件下载的三种方式:a标签、Blob、ArrayBuffer

作者 CassieHuu
2025年9月13日 13:43

引言

在现代 Web 应用中,前端文件下载是常见的需求。根据不同的业务场景和技术要求,我们可以选择多种下载策略,主要包括直接触发下载、通过 Blob 对象下载以及通过 arrayBuffer 对象下载。理解它们的差异和适用场景对于构建高效、灵活的文件下载功能至关重要。

一. 直接触发下载

工作原理:

这是最简单直接的下载方式。前端通过创建 <a> 标签并设置其 href 属性为文件下载链接,然后模拟点击该链接来触发浏览器下载。

<a href="http://example.com/api/download/file.pdf" download="document.pdf">下载文件</a>

或使用 JavaScript:

function directDownload(url, filename) {
    const link = document.createElement('a');
    link.href = url;
    link.download = filename || 'download'; // filename 是可选的,如果服务器没有提供
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
}

// 示例
// directDownload('http://example.com/api/download/file.pdf', 'myDocument.pdf');

适用场景:

  • 简单文件下载: 当下载的文件不需要前端进行任何处理,且服务器能够直接提供可访问的下载链接时。
  • 公共文件: 下载的文件不涉及用户认证或授权,或者认证信息可以通过 URL 参数或 Cookie 自动携带。
  • 后端直接控制文件名: 服务器可以直接通过 Content-Disposition 响应头指定下载的文件名。

使用差异:

  • 优点: 实现简单,浏览器处理机制成熟,通常由浏览器完成进度管理和错误处理。
  • 缺点:
    • 无法处理认证: 对于需要认证才能下载的文件,如果认证信息不能通过 URL 或 Cookie 传递,直接下载会比较困难(例如,需要携带请求头 Authorization 的情况)。
    • 无法获取下载进度: 前端无法直接监控下载进度。
    • 文件名控制受限: 如果后端不设置 Content-Disposition,文件名可能不可控,或者浏览器会使用 URL 的最后一部分作为文件名。
    • 跨域限制: 如果下载链接与当前页面不同源,可能存在跨域问题。
    • 无法对文件内容进行前端处理: 在下载前或下载后无法对文件内容进行修改、预览等操作。

二. 通过 Blob 对象下载

工作原理:

Blob(Binary Large Object)对象表示一个不可变的、原始数据的类文件对象。通过 XMLHttpRequestFetch APIresponseType: 'blob' 请求二进制数据,获取到 Blob 对象后,可以利用 URL.createObjectURL() 方法创建一个临时的 URL,然后通过 <a> 标签触发下载。

async function downloadWithBlob(url, filename) {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const blob = await response.blob(); // 获取 Blob 对象

        const urlObject = window.URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = urlObject;
        link.download = filename; // 由前端指定文件名,也可以从响应头中获取
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        window.URL.revokeObjectURL(urlObject); // 释放 URL 对象

    } catch (error) {
        console.error('Download failed:', error);
    }
}

// 示例
// downloadWithBlob('http://example.com/api/getFileData', 'report.xlsx');

适用场景:

  • 需要认证的下载: 当下载文件需要自定义请求头(如 Authorization token)进行身份认证时,fetchXMLHttpRequest 可以很好地支持。
  • 前端处理文件内容: 在下载前需要对文件内容进行预览、压缩、解密、格式转换等操作时,Blob 是非常方便的中间数据格式。
  • 生成文件: 前端动态生成文件内容(例如 CSV、图片、PDF),然后将其打包成 Blob 进行下载。
  • 获取下载进度: fetchXMLHttpRequest 可以监听下载进度事件。
  • 后端只返回二进制数据,前端控制文件名: 当后端只返回纯粹的二进制数据,文件类型和文件名需要前端来判断或指定时。

使用差异:

  • 优点:
    • 支持认证和自定义请求头: 能够处理复杂的认证场景。
    • 前端可控性高: 可以在下载前对文件内容进行处理,支持动态生成文件。
    • 可获取下载进度: 通过 fetchXMLHttpRequest 可以监听下载进度。
    • 前端可定义文件名和文件类型: 可以灵活指定下载文件的名称和 MIME 类型。
  • 缺点:
    • 内存占用: 对于非常大的文件,将整个文件内容加载到内存中可能会占用较多内存。
    • 兼容性: 较老的浏览器可能不支持 Blob 或相关 API。
    • 需要手动释放 URL: URL.createObjectURL() 创建的 URL 需要通过 URL.revokeObjectURL() 手动释放,否则可能导致内存泄漏。

三. 通过 ArrayBuffer 对象下载

工作原理:

ArrayBuffer 对象用于表示通用的、固定长度的原始二进制数据缓冲区。与 Blob 类似,通过 XMLHttpRequestFetch APIresponseType: 'arraybuffer' 请求二进制数据,获取到 ArrayBuffer 后,通常会将其转换为 Blob,然后再通过 URL.createObjectURL() 触发下载。

async function downloadWithArrayBuffer(url, filename, mimeType = 'application/octet-stream') {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const arrayBuffer = await response.arrayBuffer(); // 获取 ArrayBuffer 对象

        // 将 ArrayBuffer 转换为 Blob
        const blob = new Blob([arrayBuffer], { type: mimeType });

        const urlObject = window.URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = urlObject;
        link.download = filename;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        window.URL.revokeObjectURL(urlObject);

    } catch (error) {
        console.error('Download failed:', error);
    }
}

// 示例
// downloadWithArrayBuffer('http://example.com/api/getBinaryData', 'image.png', 'image/png');

适用场景:

  • 需要对二进制数据进行底层操作: 当需要直接访问和操作文件的原始字节数据时,ArrayBuffer 是理想的选择。例如,加密/解密文件内容、解析文件头信息、进行位操作等。
  • 与其他二进制 API 交互: ArrayBufferTypedArray(如 Uint8ArrayInt32Array 等)和 DataView 紧密结合,方便进行二进制数据的读写。
  • 需要认证的下载:Blob 类似,fetchXMLHttpRequest 支持认证请求。
  • 获取下载进度: 同样可以通过 fetchXMLHttpRequest 监听下载进度。
  • 后端返回的文件格式和文件名称是后端定义的: arrayBuffer 同样可以接收这种响应,然后由前端根据响应头或业务逻辑确定文件名和MIME类型。

使用差异:

  • 优点:
    • 底层数据访问: 能够直接操作原始二进制数据,适用于更底层的处理需求。
    • 支持认证和自定义请求头:Blob
    • 可获取下载进度:Blob
  • 缺点:
    • 需要转换为 Blob 才能下载: ArrayBuffer 本身不能直接创建 URL 进行下载,通常需要先转换为 Blob
    • 内存占用:Blob
    • 需要手动释放 URL:Blob

总结对比

特性 直接触发下载 Blob 对象下载 ArrayBuffer 对象下载
认证/请求头 困难(依赖 Cookie/URL) 容易(通过 Fetch/XHR) 容易(通过 Fetch/XHR)
前端处理 无法处理 方便(预览、压缩、生成等) 方便(底层二进制操作,需转 Blob 下载)
下载进度 无法获取 可获取 可获取
文件名控制 依赖后端 Content-Disposition 或 URL 前端可定义,或从响应头获取 前端可定义,或从响应头获取
内存占用 浏览器管理,前端感知较少 较高(整个文件加载到内存) 较高(整个文件加载到内存)
实现复杂度 中等 中等偏高(通常需转 Blob)
适用场景 简单文件、公共文件 需认证、前端处理、动态生成文件、前端控制文件名 需认证、底层二进制操作、前端控制文件名
跨域支持 浏览器处理(可能受 CORS 影响) 完全支持(通过 CORS) 完全支持(通过 CORS)

Node.js 打包踩坑?NCC+PKG 从单文件到多平台可执行文件,解决 axios 缺失等 80% 问题

作者 李李记
2025年9月13日 11:37

Node.js 打包踩坑?NCC+PKG 从单文件到多平台可执行文件,解决 axios 缺失等 80% 问题

你是否遇到过这些 Node.js 打包难题?想把项目做成可执行文件分发,却卡在工具选型;用 PKG 打包后一运行就闪退,报错 “找不到 axios 模块”;NCC 打包完静态资源全丢了…… 别慌!本文不仅带你吃透 NCC(轻量单文件打包)和 PKG(多平台可执行文件生成)两大工具,还会针对 “axios 缺失”“macOS 无法运行” 等高频坑点,给出能直接落地的解决方案,让你从打包小白到高效分发,一步到位。

一、先避坑!搞懂 NCC 和 PKG 怎么选(选错多走 3 小时弯路)

很多人打包前没搞清楚工具差异,盲目用 PKG 导致反复踩坑。先看这张对比表,30 秒选对工具:

特性 NCC PKG
核心功能 将项目打包为单个 JavaScript 文件 直接生成多平台可执行文件
输出产物 单文件 .js(需 Node 环境运行) 无需 Node 环境的独立可执行文件
支持平台 跨平台(依赖宿主 Node 环境) Windows/macOS/Linux
打包体积 较小(仅包含代码和依赖) 较大(内置 Node 运行时)
适用场景 服务器部署、轻量脚本分发(如 CLI 工具) 桌面应用、无 Node 环境的场景(如给客户的离线工具)

简单说:想快速部署到有 Node 环境的服务器,选 NCC;想做无需安装 Node 就能跑的桌面软件,选 PKG—— 但遇到依赖缺失问题,记得看后文的 “NCC+PKG 组合方案”!

二、NCC 实战:10 分钟打包轻量脚本(连 axios 都能一起整合)

NCC 是 Vercel 出品的 “零配置神器”,尤其适合打包 CLI 工具或服务器脚本,连依赖带代码整合成一个文件,部署时再也不用传整个 node_modules。

2.1 5 秒安装 NCC(推荐局部安装,避免版本冲突)

# 局部安装(项目内使用,不影响其他项目)
npm install @vercel/ncc --save-dev
# 想跨项目用?全局安装也可以
npm install @vercel/ncc -g

2.2 以 axios 项目为例,3 步打包

假设你有个项目,入口文件是index.js,还引入了 axios 做接口请求,项目结构如下:

my-node-project/
├── index.js       # 入口文件(比如:用axios请求接口并打印结果)
├── package.json   # 已声明axios依赖
└── node_modules/  # 依赖包

打包就 3 步,复制命令就行:

  1. 执行打包命令(指定入口和输出目录):
# 局部安装用npx执行,全局安装直接输ncc build...
npx ncc build index.js -o dist
  1. 看结果:dist 目录里会生成index.js(已整合 axios!)、package.json和assets(静态资源在这)。
  1. 运行验证:进入 dist 目录,node index.js,跟没打包前一样正常跑,还不用依赖 node_modules!

2.3 别踩!NCC 的 3 个高频配置(解决静态资源丢失、调试难题)

  • 想调试打包后的代码?加--source-map生成映射文件:
ncc build index.js -o dist --source-map
  • 项目是 ES 模块(用 import/export)?加--esm:
ncc build index.js -o dist --esm
  • 静态资源丢了?用--asset指定资源目录(比如 images 文件夹):
ncc build index.js -o dist --asset images/**/*

代码里引用资源时,记得用path模块写绝对路径,比如:

const path = require('path');
// 正确:用__dirname拼接路径
const imgPath = path.join(__dirname, 'images', 'logo.png');
// 错误:相对路径可能找不到
// const imgPath = './images/logo.png';

三、PKG 实战:生成多平台可执行文件(Windows/macOS/Linux 通用)

用 PKG 能把项目做成.exe(Windows)、.app(macOS)这类独立文件,发给别人双击就能用。但它坑点多,尤其依赖处理,跟着步骤来少踩雷。

3.1 安装 PKG(同样推荐局部安装)

npm install pkg --save-dev

3.2 先配置 package.json!避免入口文件找不到

很多人直接跑pkg .报错,就是没配置入口。打开package.json,加这几行:

{
  "name": "my-node-app",
  "version": "1.0.0",
  "bin": "index.js",  // 关键!指定入口文件
  "dependencies": {
    "axios": "^1.6.0"  // 确保依赖已声明
  },
  "scripts": {
    "pkg": "pkg ."  // 后续直接npm run pkg就行
  }
}

3.3 打包命令(想要哪个平台就生成哪个)

  • 只打 Windows 64 位:
npx pkg . --targets win-x64
  • 同时打 Windows、macOS、Linux:
npx pkg . --targets win-x64,macos-x64,linux-x64
  • 自定义输出路径(比如放 dist/pkg 里):
npx pkg . --output dist/pkg/my-app

打包完一看,根目录多了my-app.exe(Windows)、my-app-macos(macOS),双击试试?—— 如果闪退,别急,看第四节的解决方案!

3.4 静态资源和动态依赖怎么处理?

  • 静态资源(如 config.json、图片):在package.json里加pkg.assets:
{
  "pkg": {
    "assets": [
      "config/**/*",  // 包含config下所有文件
      "images/**/*"   // 包含images下所有文件
    ]
  }
}
  • 动态依赖(比如用require(变量)加载的模块):加pkg.dependencies强制打包,比如:
{
  "pkg": {
    "dependencies": ["axios"]  // 强制打包axios,防止动态引用找不到
  }
}

四、救命!解决 PKG 最坑的 4 个问题(axios 缺失、macOS 打不开…)

这部分是重点!收集了开发者最常遇到的 4 个问题,每个都给具体解决方案,照着做就能解决。

4.1 最常见:PKG 打包后闪退,报错 “找不到 axios 模块”

问题现象

双击my-app.exe没反应,打开 CMD 运行,看到这样的报错:

pkg/prelude/bootstrap.js:1872
      throw error;
      ^
Error: Cannot find module 'C:\snapshot\my-node-project\node_modules\axios\dist\node\axios.cjs'
原因

PKG 对 ESM 模块(比如 axios)的解析有 bug,没把依赖正确嵌入可执行文件。

万能解决方案:NCC+PKG 组合打包(亲测有效)

先让 NCC 把项目 + 依赖整合成单文件,再用 PKG 打包这个单文件,彻底解决依赖缺失:

  1. 第一步:用 NCC 打包成单文件(参考第二节):
npx ncc build index.js -o dist
  1. 第二步:进入 dist 目录,用 PKG 打包这个单文件:
cd dist
# 打包成Windows可执行文件
npx pkg index.js --targets win-x64 --output my-app.exe
  1. 再运行my-app.exe,再也不报错了!

4.2 macOS 用户:双击应用提示 “来自身份不明的开发者”

解决方案

进入 “系统设置 → 隐私与安全性”,拉到最下面,会看到 “my-app-macos 已被阻止”,点击 “仍要打开”,再点一次 “打开” 就可以了。

如果提示 “文件损坏”,打开终端,输入这个命令(把路径换成你的文件路径):

xattr -cr /Applications/my-app-macos

4.3 运行提示 “Permission denied”(权限不够)

解决方案

终端里给文件加执行权限:

# macOS/Linux通用
chmod +x ./my-app-macos

再运行./my-app-macos就可以了。

4.4 打包文件太大?3 步缩小体积

PKG 打包后文件大,是因为内置了 Node 运行时,试试这 3 招:

  1. 先用 NCC 打包,剔除冗余依赖;
  1. 加--no-bytecode选项(减少 10% 体积,注意:会降低一点运行速度):
npx pkg . --targets win-x64 --no-bytecode
  1. 清理package.json里没用的依赖(用npm uninstall 无用依赖名删除)。

五、总结:3 句话记住核心用法

  1. 轻量脚本 / 服务器部署:用 NCC,命令记ncc build 入口.js -o dist,静态资源加--asset;
  1. 独立可执行文件 / 桌面应用:用 PKG,先配置package.json的bin字段,遇到依赖缺失就用 “NCC+PKG” 组合;
  1. 遇到问题:先看第四节的 4 个解决方案,尤其 axios 缺失问题,组合打包是万能招。

按照本文步骤来,你不仅能搞定打包,还能避开 80% 的坑。如果还有其他问题,比如 Electron 项目怎么结合 PKG 打包,或者想了解更多配置选项,随时告诉我,再给你补充细节!

Kotlin学习第 6 课:Kotlin 集合框架:操作数据的核心工具

作者 叽哥
2025年9月13日 11:15

在日常开发中,我们经常需要处理 “一组数据”—— 比如学生列表、商品信息、用户配置等。Kotlin 的集合框架就是专门用来管理和操作这些 “数据组” 的核心工具,它提供了一套简洁、高效的 API,涵盖了数据的存储、查询、过滤、排序等常见需求。本节课我们将从集合的分类开始,逐步深入讲解 List、Set、Map 三大核心集合,以及能大幅简化代码的 “高阶函数操作”。

一、集合的分类与初始化:先搞懂 “可变” 与 “不可变”

Kotlin 集合框架的核心特点是区分 “不可变集合” 和 “可变集合” —— 这是为了满足 “数据安全性” 需求(比如避免误修改全局数据)。两者的本质区别是:不可变集合创建后无法修改(不能添加、删除元素),可变集合支持动态修改

1. 三大集合类型的分类

Kotlin 主要提供三种核心集合类型,每种类型都对应 “不可变” 和 “可变” 两个版本:

集合类型 不可变版本(只读) 可变版本(可读写) 核心特性
列表 List MutableList 有序、可重复元素
集合 Set MutableSet 无序、不可重复元素
映射 Map MutableMap 键值对(key-value)、键唯一

2. 集合的创建方式:一行代码搞定

Kotlin 提供了专门的 “工厂函数” 来创建集合,无需手动 new,语法简洁。

(1)List 的创建

  • 不可变 List:用 listOf() 函数,创建后无法修改;
  • 可变 List:用 mutableListOf() 函数,支持 add/remove 等操作;
  • 若需指定初始容量(优化性能):用 mutableListOf(capacity)(比如 mutableListOf(10) 表示初始容量为 10)。

示例:

// 1. 不可变 List:元素按插入顺序保存,可重复
val fruitList: List<String> = listOf("苹果", "香蕉", "苹果", "橙子")
// fruitList.add("葡萄") // 报错:不可变集合没有 add 方法

// 2. 可变 List:可动态修改
val mutableFruitList: MutableList<String> = mutableListOf("苹果", "香蕉")
mutableFruitList.add("葡萄") // 添加元素
mutableFruitList.remove("香蕉") // 删除元素
println(mutableFruitList) // 输出:[苹果, 葡萄]

// 3. 创建空 List(不可变空集合用 emptyList(),可变空集合用 mutableListOf())
val emptyImmutableList: List<Int> = emptyList()
val emptyMutableList: MutableList<Int> = mutableListOf()

(2)Set 的创建

  • 不可变 Set:用 setOf() 函数,自动去重、无序;
  • 可变 Set:用 mutableSetOf() 函数,支持修改;
  • 若需 “有序 Set”(按插入顺序保存):用 LinkedHashSet(创建方式 linkedSetOf())。

示例:

// 1. 不可变 Set:重复元素被自动忽略,无序(输出顺序可能和插入顺序不同)
val colorSet: Set<String> = setOf("红色", "绿色", "红色", "蓝色")
println(colorSet) // 输出:[红色, 绿色, 蓝色](去重)

// 2. 可变 Set:支持添加/删除
val mutableColorSet: MutableSet<String> = mutableSetOf("红色", "绿色")
mutableColorSet.add("黄色")
mutableColorSet.remove("红色")
println(mutableColorSet) // 输出:[绿色, 黄色](无序)

// 3. 有序 Set(LinkedHashSet):按插入顺序保存,仍去重
val linkedColorSet: MutableSet<String> = linkedSetOf("红色", "绿色", "红色")
println(linkedColorSet) // 输出:[红色, 绿色](有序+去重)

(3)Map 的创建

Map 是 “键值对” 集合,每个元素包含 key(键)和 value(值),核心规则是 “键唯一,值可重复”。

  • 不可变 Map:用 mapOf() 函数,键值对用 to 关键字连接(如 key to value);
  • 可变 Map:用 mutableMapOf() 函数,支持 put/remove 等操作;
  • 有序 Map(按插入顺序):用 linkedMapOf() 函数。

示例:

// 1. 不可变 Map:键唯一,重复键会覆盖(后面的覆盖前面的)
val userMap: Map<String, Any> = mapOf(
    "name" to "张三",
    "age" to 20,
    "gender" to "男",
    "age" to 21 // 重复键,覆盖前面的 20
)
println(userMap) // 输出:{name=张三, age=21, gender=男}

// 2. 可变 Map:支持添加/修改/删除
val mutableUserMap: MutableMap<String, Any> = mutableMapOf(
    "name" to "李四",
    "age" to 18
)
mutableUserMap.put("gender", "女") // 添加键值对
mutableUserMap["age"] = 19 // 简化写法:修改值(等价于 put)
mutableUserMap.remove("gender") // 删除键值对
println(mutableUserMap) // 输出:{name=李四, age=19}

// 3. 有序 Map(LinkedHashMap):按插入顺序保存键值对
val linkedUserMap: MutableMap<String, Any> = linkedMapOf(
    "name" to "王五",
    "age" to 22
)
println(linkedUserMap) // 输出:{name=王五, age=22}(顺序与插入一致)


二、List 集合:有序、可重复的 “数据队列”

List 是最常用的集合类型,核心特性是元素有序(有固定索引,从 0 开始)、可重复,适合存储 “需要按顺序访问” 的数据(比如排行榜、待办清单)。

1. List 的核心特性回顾

  • 有序:每个元素有唯一索引,可通过索引快速获取元素;
  • 可重复:同一元素可多次添加,索引不同则视为不同位置的元素;
  • 不可变 List(List):仅支持查询,无修改方法;
  • 可变 List(MutableList):支持添加、删除、修改元素。

2. List 的常用操作(附代码示例)

(1)获取元素:通过索引

不可变和可变 List 都支持,用 get(index) 方法或简化的 [index] 运算符(推荐后者,更简洁)。

val fruitList = listOf("苹果", "香蕉", "橙子")
// 方式1:get(index)
val firstFruit = fruitList.get(0)
// 方式2:[index](简化写法,推荐)
val secondFruit = fruitList[1]
println("第一个水果:$firstFruit,第二个水果:$secondFruit") 
// 输出:第一个水果:苹果,第二个水果:香蕉

// 注意:索引越界会报错(比如访问 index=3,集合长度为 3,索引最大为 2)
// fruitList[3] // 抛出 IndexOutOfBoundsException

(2)添加元素:仅可变 List 支持

用 add(element) 追加到末尾,或 add(index, element) 插入到指定索引位置。

val mutableFruitList = mutableListOf("苹果", "香蕉")
// 1. 追加到末尾
mutableFruitList.add("葡萄") 
// 2. 插入到索引 1 的位置(原索引 1 及之后的元素后移)
mutableFruitList.add(1, "橙子") 
println(mutableFruitList) // 输出:[苹果, 橙子, 香蕉, 葡萄]

(3)删除元素:仅可变 List 支持

  • remove(element):根据元素值删除(删除第一个匹配的元素);
  • removeAt(index):根据索引删除(更高效,直接定位);
  • clear():清空所有元素。
val mutableFruitList = mutableListOf("苹果", "橙子", "香蕉", "葡萄")
// 1. 根据元素值删除
mutableFruitList.remove("橙子") 
// 2. 根据索引删除(删除第三个元素,索引 2)
mutableFruitList.removeAt(2) 
println(mutableFruitList) // 输出:[苹果, 香蕉]

// 3. 清空集合
mutableFruitList.clear()
println(mutableFruitList) // 输出:[]

(4)遍历元素:三种常用方式

  • 普通 for 循环:通过索引遍历;
  • for-in 循环:直接遍历元素(推荐,简洁);
  • forEach 高阶函数:函数式遍历(更灵活,支持 lambda 逻辑)。
val fruitList = listOf("苹果", "香蕉", "橙子")

// 1. 普通 for 循环(通过索引)
for (i in 0 until fruitList.size) {
    println("索引 $i${fruitList[i]}")
}

// 2. for-in 循环(直接遍历元素)
for (fruit in fruitList) {
    println("水果:$fruit")
}

// 3. forEach 高阶函数(lambda 表达式)
fruitList.forEach { fruit ->
    println("forEach 遍历:$fruit")
}
// 简化:若 lambda 参数仅用一次,可用 it 代替
fruitList.forEach {
    println("简化 forEach:$it")
}

(5)排序:sorted 系列方法

Kotlin 提供了丰富的排序 API,无需手动实现排序逻辑:

  • sorted():默认升序排序(适用于数值、字符串等可比较类型);
  • sortedDescending():降序排序;
  • sortedBy(selector):按指定属性排序(比如按对象的年龄、价格排序);
  • sortedByDescending(selector):按指定属性降序排序。

示例(含普通数据和对象数据):

// 1. 数值 List 排序
val numList = listOf(3, 1, 4, 2)
val sortedAsc = numList.sorted() // 升序:[1, 2, 3, 4]
val sortedDesc = numList.sortedDescending() // 降序:[4, 3, 2, 1]
println("升序:$sortedAsc,降序:$sortedDesc")

// 2. 对象 List 排序(按属性)
data class Student(val name: String, val score: Int)
val studentList = listOf(
    Student("张三", 85),
    Student("李四", 92),
    Student("王五", 78)
)
// 按分数升序排序
val sortedByScoreAsc = studentList.sortedBy { it.score }
// 按分数降序排序
val sortedByScoreDesc = studentList.sortedByDescending { it.score }
println("按分数升序:$sortedByScoreAsc")
// 输出:[Student(name=王五, score=78), Student(name=张三, score=85), Student(name=李四, score=92)]

(6)过滤:filter 方法

filter(predicate) 用于 “筛选符合条件的元素”,参数是一个 lambda 表达式(返回布尔值,true 保留元素,false 过滤掉)。

示例:

// 1. 筛选偶数
val numList = listOf(1, 2, 3, 4, 5, 6)
val evenList = numList.filter { it % 2 == 0 }
println("偶数列表:$evenList") // 输出:[2, 4, 6]

// 2. 筛选长度大于 2 的字符串
val strList = listOf("a", "ab", "abc", "abcd")
val longStrList = strList.filter { it.length > 2 }
println("长度>2的字符串:$longStrList") // 输出:[abc, abcd]

// 3. 筛选分数>=80的学生
data class Student(val name: String, val score: Int)
val studentList = listOf(
    Student("张三", 85),
    Student("李四", 75),
    Student("王五", 90)
)
val goodStudents = studentList.filter { it.score >= 80 }
println("分数>=80的学生:$goodStudents")
// 输出:[Student(name=张三, score=85), Student(name=王五, score=90)]


三、Set 集合:无序、不可重复的 “数据集合”

Set 适合存储 “需要去重” 的数据(比如用户 ID、标签列表),核心特性是无序(无索引,不能通过索引访问)、不可重复(添加重复元素会自动忽略)

1. Set 的核心特性回顾

  • 无序:元素没有固定位置,遍历顺序可能与插入顺序不同(普通 HashSet);
  • 不可重复:基于 equals() 和 hashCode() 判断元素是否重复(若自定义对象,需重写这两个方法);
  • 不可变 Set(Set):仅支持查询,无修改方法;
  • 可变 Set(MutableSet):支持添加、删除元素。

2. Set 的常用操作(附代码示例)

(1)添加元素:仅可变 Set 支持

用 add(element) 方法,重复元素会自动忽略(无报错,返回 false 表示添加失败)。

val mutableTagSet = mutableSetOf("Kotlin", "Android", "Java")
// 1. 添加新元素(成功,返回 true)
val addSuccess = mutableTagSet.add("Jetpack")
// 2. 添加重复元素(失败,返回 false,集合无变化)
val addFail = mutableTagSet.add("Kotlin")

println(mutableTagSet) // 输出:[Kotlin, Android, Java, Jetpack](无序)
println("添加新元素成功?$addSuccess,添加重复元素成功?$addFail") 
// 输出:添加新元素成功?true,添加重复元素成功?false

(2)判断元素是否存在:contains 方法

Set 的 contains(element) 方法效率极高(基于哈希表,时间复杂度 O (1)),适合频繁判断 “元素是否存在” 的场景。

val tagSet = setOf("Kotlin", "Android", "Java")
// 判断是否包含 "Android"
val hasAndroid = tagSet.contains("Android")
// 判断是否包含 "iOS"
val hasIos = tagSet.contains("iOS")

println("包含 Android?$hasAndroid,包含 iOS?$hasIos") 
// 输出:包含 Android?true,包含 iOS?false

// 简化写法:用 in 关键字(等价于 contains)
val hasKotlin = "Kotlin" in tagSet
println("包含 Kotlin?$hasKotlin") // 输出:true

(3)集合运算:交集、并集、差集

Set 天然支持 “数学集合运算”,Kotlin 直接提供了对应的 API,无需手动实现:

  • 交集(intersect):两个集合的 “共有元素”;
  • 并集(union):两个集合的 “所有元素(去重)”;
  • 差集(subtract):当前集合 “减去另一个集合的元素后剩余的元素”。

示例:

val setA = setOf(1, 2, 3, 4)
val setB = setOf(3, 4, 5, 6)

// 1. 交集:共有的元素(3,4)
val intersection = setA.intersect(setB)
// 2. 并集:所有元素去重(1,2,3,4,5,6)
val union = setA.union(setB)
// 3. 差集:setA 减去 setB 的元素(1,2)
val subtract = setA.subtract(setB)

println("交集:$intersection,并集:$union,差集:$subtract")
// 输出:交集:[3, 4],并集:[1, 2, 3, 4, 5, 6],差集:[1, 2]

(4)遍历元素:仅支持 for-in 和 forEach

由于 Set 无序,没有索引,不能用 “普通 for 循环(索引遍历)”,只能直接遍历元素:

val colorSet = setOf("红色", "绿色", "蓝色")

// 1. for-in 循环
for (color in colorSet) {
    println("for-in 遍历:$color")
}

// 2. forEach 高阶函数
colorSet.forEach {
    println("forEach 遍历:$it")
}


四、Map 集合:键值对、键唯一的 “数据字典”

Map 适合存储 “键值对应” 的数据(比如用户信息:id -> 用户对象、配置项:key -> 配置值),核心特性是键值对存储、键唯一(重复键会覆盖旧值)、值可重复

1. Map 的核心特性回顾

  • 键值对(key-value):每个元素由 “键” 和 “值” 组成,通过键获取值;
  • 键唯一:同一 Map 中不能有重复键,重复 put 会覆盖旧值;
  • 值可重复:不同键可以对应相同的值;
  • 不可变 Map(Map):仅支持查询,无修改方法;
  • 可变 Map(MutableMap):支持添加、修改、删除键值对。

2. Map 的常用操作(附代码示例)

(1)获取值:get 方法与 [] 运算符

  • get(key):根据键获取值,若键不存在,返回 null
  • [key]:简化写法(等价于 get(key));
  • getOrDefault(key, defaultValue):键不存在时返回默认值(避免 null)。

示例:

val userMap = mapOf(
    "name" to "张三",
    "age" to 20,
    "gender" to "男"
)

// 1. get(key) 方法
val name1 = userMap.get("name")
// 2. [] 简化写法(推荐)
val age1 = userMap["age"]
// 3. 键不存在,返回 null
val address1 = userMap["address"]
// 4. getOrDefault:键不存在时返回默认值
val address2 = userMap.getOrDefault("address", "未知地址")

println("姓名:$name1,年龄:$age1,地址1:$address1,地址2:$address2")
// 输出:姓名:张三,年龄:20,地址1:null,地址2:未知地址

(2)添加 / 修改键值对:仅可变 Map 支持

  • put(key, value):添加或修改键值对(键存在则修改,不存在则添加);
  • [key] = value:简化写法(等价于 put);
  • putAll(fromMap):批量添加另一个 Map 的键值对。

示例:

val mutableUserMap = mutableMapOf(
    "name" to "李四",
    "age" to 18
)

// 1. 添加新键值对(address 不存在,添加)
mutableUserMap.put("address", "北京市")
// 2. 修改已有键值对(age 存在,修改为 19)
mutableUserMap["age"] = 19
// 3. 批量添加
val extraMap = mapOf("phone" to "13800138000", "email" to "lisi@example.com")
mutableUserMap.putAll(extraMap)

println(mutableUserMap)
// 输出:{name=李四, age=19, address=北京市, phone=13800138000, email=lisi@example.com}

(3)遍历元素:三种常用方式

Map 的遍历分为 “遍历键”“遍历值”“遍历键值对”,按需选择:

val userMap = mapOf(
    "name" to "张三",
    "age" to 20,
    "gender" to "男"
)

// 1. 遍历键(通过 keys 属性)
println("遍历键:")
for (key in userMap.keys) {
    println("键:$key")
}

// 2. 遍历值(通过 values 属性)
println("遍历值:")
for (value in userMap.values) {
    println("值:$value")
}

// 3. 遍历键值对(最常用)
println("遍历键值对:")
// 方式1:for-in 循环(解构赋值:将 key-value 拆分为两个变量)
for ((key, value) in userMap) {
    println("$key: $value")
}
// 方式2:forEach 高阶函数(lambda 参数为 (key, value))
userMap.forEach { (key, value) ->
    println("$key -> $value")
}

(4)判断键 / 值是否存在

  • containsKey(key):判断键是否存在;
  • containsValue(value):判断值是否存在(效率低于 containsKey,因为需要遍历所有值)。

示例:

val userMap = mapOf(
    "name" to "张三",
    "age" to 20,
    "gender" to "男"
)

// 判断键是否存在
val hasNameKey = userMap.containsKey("name")
val hasAddressKey = userMap.containsKey("address")

// 判断值是否存在
val hasAge20 = userMap.containsValue(20)
val hasAge30 = userMap.containsValue(30)

println("有 name 键?$hasNameKey,有 address 键?$hasAddressKey")
// 输出:有 name 键?true,有 address 键?false
println("有值 20?$hasAge20,有值 30?$hasAge30")
// 输出:有值 20?true,有值 30?false


五、集合的高阶函数操作:简化代码的 “神器”

Kotlin 集合框架的一大亮点是内置了大量高阶函数,可以用 “函数式编程” 的方式处理集合,大幅减少模板代码(比如无需手动写循环、判断)。本节课重点讲解最常用的 6 类高阶函数:转换、过滤、排序、聚合、分组、关联。

1. 转换:map 方法(将元素 “变形”)

map(transform) 用于 “将集合中的每个元素按规则转换为新元素”,返回一个新集合(元素类型可与原集合不同)。

示例(多种转换场景):

// 1. 数值转换:每个元素乘 2
val numList = listOf(1, 2, 3, 4)
val doubledList = numList.map { it * 2 }
println("数值乘 2:$doubledList") // 输出:[2, 4, 6, 8]

// 2. 字符串转换:每个字符串添加前缀
val strList = listOf("苹果", "香蕉", "橙子")
val prefixedList = strList.map { "水果:$it" }
println("添加前缀:$prefixedList") // 输出:[水果:苹果, 水果:香蕉, 水果:橙子]

// 3. 对象转换:提取对象的某个属性(常用!)
data class Student(val name: String, val score: Int)
val studentList = listOf(
    Student("张三", 85),
    Student("李四", 92),
    Student("王五", 78)
)
// 提取所有学生的姓名,生成姓名列表
val nameList = studentList.map { it.name }
// 提取所有学生的分数,生成分数列表
val scoreList = studentList.map { it.score }
println("学生姓名列表:$nameList") // 输出:[张三, 李四, 王五]
println("学生分数列表:$scoreList") // 输出:[85, 92, 78]

2. 过滤:filter 系列方法(筛选元素)

除了前面讲过的 filter(基础筛选),Kotlin 还提供了更细分的过滤方法,满足不同场景:

  • filterNot(predicate):反向筛选(保留 false 的元素);
  • filterIsInstance<T>():按类型筛选(保留指定类型的元素);
  • filterNotNull():筛选非 null 元素(适合可空类型集合)。

示例:

// 1. filterNot:反向筛选(保留偶数以外的元素)
val numList = listOf(1, 2, 3, 4, 5)
val oddList = numList.filterNot { it % 2 == 0 }
println("非偶数列表:$oddList") // 输出:[1, 3, 5]

// 2. filterIsInstance:按类型筛选(保留字符串元素)
val mixedList = listOf(1, "苹果", 3.14, "香蕉", true)
val stringList = mixedList.filterIsInstance<String>()
println("字符串列表:$stringList") // 输出:[苹果, 香蕉]

// 3. filterNotNull:筛选非 null 元素
val nullableList = listOf("张三", null, "李四", null, "王五")
val nonNullList = nullableList.filterNotNull()
println("非 null 列表:$nonNullList") // 输出:[张三, 李四, 王五]

3. 排序:sorted 系列方法(进阶)

除了前面讲过的基础排序,还有两个常用进阶排序方法:

  • sortedWith(comparator):自定义比较器排序(适合复杂排序逻辑);
  • reversed():反转集合顺序(基于当前顺序反转)。

示例:

// 1. sortedWith:自定义比较器(按字符串长度排序)
val strList = listOf("apple", "banana", "cherry", "date")
// 按字符串长度升序排序(短的在前)
val sortedByLength = strList.sortedWith(compareBy { it.length })
println("按长度排序:$sortedByLength") // 输出:[date, apple, banana, cherry]

// 2. reversed:反转顺序
val numList = listOf(1, 2, 3, 4)
val reversedList = numList.reversed()
println("反转后:$reversedList") // 输出:[4, 3, 2, 1]

4. 聚合:count、sum、max、min(统计数据)

聚合函数用于 “对集合数据进行统计计算”,返回单个结果值,常用的有:

  • count(predicate?):统计元素个数(可带条件,无条件则统计总个数);
  • sumOf(selector):计算数值总和(推荐,支持 Int、Long、Double 等);
  • maxOf(selector):获取最大值(按指定属性);
  • minOf(selector):获取最小值(按指定属性);
  • averageOf(selector):计算平均值(仅数值类型)。

示例:

data class Product(val name: String, val price: Double, val stock: Int)
val productList = listOf(
    Product("手机", 2999.0, 50),
    Product("平板", 1999.0, 30),
    Product("耳机", 499.0, 100),
    Product("手表", 1299.0, 20)
)

// 1. count:统计商品总数,及价格>1000的商品数
val totalCount = productList.count() // 总个数:4
val expensiveCount = productList.count { it.price > 1000 } // 价格>1000的个数:3
println("商品总数:$totalCount,高价商品数:$expensiveCount")

// 2. sum:计算总库存,及总金额(价格*库存)
val totalStock = productList.sumOf { it.stock } // 总库存:50+30+100+20=200
val totalAmount = productList.sumOf { it.price * it.stock } // 总金额
// 四舍五入(兼容写法)
val roundedAmount = Math.round(totalAmount).toLong()
println("总库存:$totalStock,总金额:$roundedAmount") 

// 3. max/min:获取最高/最低价格(兼容旧版本)
val maxPrice = productList.maxOf { it.price } // 最高价格:2999.0
val minPrice = productList.minOf { it.price } // 最低价格:499.0
println("最高价格:$maxPrice,最低价格:$minPrice")

// 4. average:计算平均价格(兼容旧版本)
val avgPrice = productList.map { it.price }.average() // 先提取价格列表,再求平均
val roundedAvgPrice = avgPrice.toInt() // 简化:直接转为Int(自动截断小数)
println("平均价格:$roundedAvgPrice")

5. 分组:groupBy 方法(按条件分组)

groupBy(selector) 用于 “将集合按指定条件分组”,返回一个 Map

  • Map 的 key:分组条件(selector lambda 的返回值);
  • Map 的 value:该组的元素集合(与原集合元素类型相同)。

示例(常见分组场景):

// 1. 按性别分组(用户列表)
data class User(val name: String, val gender: String, val age: Int)
val userList = listOf(
    User("张三", "男", 20),
    User("李四", "女", 18),
    User("王五", "男", 22),
    User("赵六", "女", 19)
)
val groupedByGender = userList.groupBy { it.gender }
println("按性别分组:$groupedByGender")
// 输出:{男=[User(张三,男,20), User(王五,男,22)], 女=[User(李四,女,18), User(赵六,女,19)]}

// 2. 按分数段分组(学生列表)
data class Student(val name: String, val score: Int)
val studentList = listOf(
    Student("张三", 85),
    Student("李四", 92),
    Student("王五", 78),
    Student("赵六", 65),
    Student("钱七", 59)
)
// 按分数段分组:"优秀"(>=90)、"良好"(80-89)、"及格"(60-79)、"不及格"(<60)
val groupedByScore = studentList.groupBy {
    when {
        it.score >= 90 -> "优秀"
        it.score >= 80 -> "良好"
        it.score >= 60 -> "及格"
        else -> "不及格"
    }
}
println("按分数段分组:$groupedByScore")
// 输出:{良好=[Student(张三,85)], 优秀=[Student(李四,92)], 及格=[Student(王五,78), Student(赵六,65)], 不及格=[Student(钱七,59)]}

6. 关联:associate 系列方法(生成 Map)

关联函数用于 “将集合转换为 Map”,常用的有三个:

  • associate(transform):自定义键值对(transform 返回 Pair<Key, Value>);
  • associateBy(selector):键为 selector 结果,值为原元素;
  • associateWith(selector):键为原元素,值为 selector 结果。

示例:

data class Student(val id: Int, val name: String, val score: Int)
val studentList = listOf(
    Student(1, "张三", 85),
    Student(2, "李四", 92),
    Student(3, "王五", 78)
)

// 1. associate:自定义键值对(id -> name)
val idToNameMap = studentList.associate { it.id to it.name }
println("id->name:$idToNameMap") // 输出:{1=张三, 2=李四, 3=王五}

// 2. associateBy:键为 id,值为 Student 对象(常用!通过 id 快速查对象)
val idToStudentMap = studentList.associateBy { it.id }
// 通过 id 获取 Student 对象
val student = idToStudentMap[2]
println("id=2 的学生:$student") // 输出:Student(id=2, name=李四, score=92)

// 3. associateWith:键为 Student 对象,值为 score
val studentToScoreMap = studentList.associateWith { it.score }
println("student->score:$studentToScoreMap")
// 输出:{Student(1,张三,85)=85, Student(2,李四,92)=92, Student(3,王五,78)=78}


六、集合的空安全与互操作性

1. 空安全:避免空指针

Kotlin 集合默认支持空安全,需注意:

  • 不可变空集合:用 emptyList()/emptySet()/emptyMap()(返回非空集合,只是元素为空);
  • 可空元素集合:声明时需指定类型为 List<T?>(比如 List<String?> 表示元素可空);
  • 过滤空元素:用 filterNotNull() 快速去除空元素(前面已讲)。

示例:

// 可空元素集合
val nullableStrList: List<String?> = listOf("苹果", null, "香蕉", null, "橙子")
// 过滤空元素,得到非空集合
val nonNullStrList: List<String> = nullableStrList.filterNotNull()
println(nonNullStrList) // 输出:[苹果, 香蕉, 橙子]

2. 与 Java 集合的互操作性

Kotlin 集合可以直接与 Java 集合互操作(无需手动转换):

  • Kotlin 集合转 Java 集合:用 toJavaList()/toJavaSet()/toJavaMap()(需导入 kotlin.collections);
  • Java 集合转 Kotlin 集合:直接赋值(Kotlin 会自动识别为 Mutable 集合)。

示例:

// Java 代码:返回一个 Java List
public class JavaCollectionUtils {
    public static List<String> getJavaList() {
        List<String> list = new ArrayList<>();
        list.add("Java-苹果");
        list.add("Java-香蕉");
        return list;
    }
}
// Kotlin 代码:使用 Java 集合
import com.example.JavaCollectionUtils

fun main() {
    // Java List 转 Kotlin 集合(自动转为 MutableList)
    val kotlinList: MutableList<String> = JavaCollectionUtils.getJavaList()
    kotlinList.add("Kotlin-橙子")
    println(kotlinList) // 输出:[Java-苹果, Java-香蕉, Kotlin-橙子]

    // Kotlin 集合转 Java 集合
    val kotlinSet = setOf("Kotlin-1", "Kotlin-2")
    val javaSet: java.util.Set<String> = kotlinSet.toJavaSet()
}

React工程实践面试题深度分析2025

作者 NeverSettle_
2025年9月13日 11:07

React工程实践面试题深度分析

1. 项目架构设计

现代化React + TypeScript项目结构

基于TypeScript的模块化架构示例:

src/
├── components/           # 通用组件
│   ├── ui/              # 基础UI组件
│   ├── forms/           # 表单组件
│   └── layout/          # 布局组件
├── hooks/               # 自定义Hooks
├── utils/               # 工具函数
├── services/            # API服务层
├── stores/              # 状态管理
├── types/               # TypeScript类型定义
│   ├── api.ts           # API响应类型
│   ├── user.ts          # 用户相关类型
│   ├── common.ts        # 通用类型
│   └── index.ts         # 类型导出
├── pages/               # 页面组件
├── routes/              # 路由配置
├── styles/              # 样式文件
├── assets/              # 静态资源
└── constants/           # 常量定义

TypeScript配置最佳实践

企业级tsconfig.json配置:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["DOM", "DOM.Iterable", "ES6"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "declaration": true,
    "declarationMap": true,
    "incremental": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "baseUrl": "src",
    "paths": {
      "@/*": ["*"],
      "@/components/*": ["components/*"],
      "@/hooks/*": ["hooks/*"],
      "@/types/*": ["types/*"],
      "@/utils/*": ["utils/*"]
    }
  },
  "include": [
    "src",
    "src/**/*",
    "src/**/*.ts",
    "src/**/*.tsx"
  ],
  "exclude": [
    "node_modules",
    "build",
    "dist"
  ]
}

类型定义体系设计

完整的TypeScript类型定义:

// src/types/common.ts - 通用类型定义
export interface ApiResponse<T = any> {
  code: number
  message: string
  data: T
  timestamp: number
}

export interface PaginationParams {
  page: number
  pageSize: number
  total?: number
}

export interface PaginatedResponse<T> {
  list: T[]
  pagination: PaginationParams
}

export type RequestStatus = 'idle' | 'loading' | 'success' | 'error'

export interface AsyncState<T> {
  data: T | null
  status: RequestStatus
  error: string | null
}

// 工具类型
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
export type RequiredNonNull<T> = { [P in keyof T]-?: NonNullable<T[P]> }
export type DeepPartial<T> = { [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P] }

// src/types/user.ts - 用户相关类型
export interface User {
  id: string
  name: string
  email: string
  phone: string
  avatar?: string
  bio?: string
  lastLogin: string
  createdAt: string
  updatedAt: string
  preferences: UserPreferences
  roles: UserRole[]
}

export interface UserPreferences {
  theme: 'light' | 'dark' | 'auto'
  language: 'zh-CN' | 'en-US'
  notifications: boolean
  timezone: string
}

export interface UserRole {
  id: string
  name: string
  permissions: string[]
}

export interface CreateUserRequest {
  name: string
  email: string
  phone: string
  password: string
  avatar?: string
  bio?: string
}

export interface UpdateUserRequest extends Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>> {}

export interface LoginCredentials {
  email: string
  password: string
  remember?: boolean
}

export interface AuthResponse {
  user: User
  token: string
  refreshToken: string
  expiresIn: number
}

// src/types/post.ts - 文章相关类型
export interface Post {
  id: string
  title: string
  content: string
  excerpt: string
  coverImage?: string
  authorId: string
  author: Pick<User, 'id' | 'name' | 'avatar'>
  tags: Tag[]
  status: PostStatus
  viewCount: number
  likeCount: number
  commentCount: number
  createdAt: string
  updatedAt: string
  publishedAt?: string
}

export type PostStatus = 'draft' | 'published' | 'archived'

export interface Tag {
  id: string
  name: string
  color: string
}

export interface CreatePostRequest {
  title: string
  content: string
  excerpt?: string
  coverImage?: string
  tags: string[]
  status: PostStatus
}

export interface PostListParams extends PaginationParams {
  authorId?: string
  status?: PostStatus
  tags?: string[]
  search?: string
  sortBy?: 'createdAt' | 'updatedAt' | 'viewCount' | 'likeCount'
  sortOrder?: 'asc' | 'desc'
}

组件设计原则深度实践

现代React + TypeScript组件设计的核心理念:

在2025年的React生态中,组件设计已经从简单的UI拆分进化为一套完整的软件工程实践。优秀的组件设计不仅仅是代码的重用,更是系统架构稳定性和可维护性的基石。基于SOLID原则和React最佳实践,现代组件设计需要遵循以下核心理念:

单一职责原则(SRP)在React + TypeScript组件中的深度应用

单一职责的具体含义: 一个React组件应该只有一个变化的理由。这意味着组件只应该关注一个特定的业务功能或UI职责,而不是试图处理多个不相关的关注点。

反面案例 - 违反单一职责原则:

// ❌ 错误设计:一个组件承担过多职责
import React, { useState, useEffect } from 'react'
import { User, Post, Notification, Analytics } from '@/types'
import { fetchUser, fetchUserPosts, fetchNotifications, fetchAnalytics, notificationService } from '@/services'

interface UserDashboardProps {
  userId: string
}

function UserDashboard({ userId }: UserDashboardProps) {
  const [user, setUser] = useState<User | null>(null)
  const [posts, setPosts] = useState<Post[]>([])
  const [notifications, setNotifications] = useState<Notification[]>([])
  const [analytics, setAnalytics] = useState<Analytics | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  
  useEffect(() => {
    const loadData = async () => {
      try {
        // 职责1:用户数据获取
        const userData = await fetchUser(userId)
        setUser(userData)
        
        // 职责2:用户文章获取
        const postsData = await fetchUserPosts(userId)
        setPosts(postsData)
        
        // 职责3:通知数据获取
        const notificationsData = await fetchNotifications(userId)
        setNotifications(notificationsData)
        
        // 职责4:分析数据获取
        const analyticsData = await fetchAnalytics(userId)
        setAnalytics(analyticsData)
        
        setLoading(false)
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error')
        setLoading(false)
      }
    }
    
    loadData()
  }, [userId])
  
  // 职责5:数据验证
  const validateUserData = (data: User): boolean => {
    return !!(data.email && data.name && data.phone)
  }
  
  // 职责6:数据格式化
  const formatDate = (date: string): string => {
    return new Intl.DateTimeFormat('zh-CN').format(new Date(date))
  }
  
  // 职责7:通知发送
  const sendNotification = async (message: string): Promise<void> => {
    await notificationService.send(userId, message)
  }
  
  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>
  if (!user) return <div>用户不存在</div>
  
  return (
    <div className="dashboard">
      {/* 职责8:复杂的UI渲染逻辑 */}
      <header>
        <h1>{user.name}的仪表板</h1>
        <span>最后登录:{formatDate(user.lastLogin)}</span>
      </header>
      
      <div className="content">
        <section className="user-info">
          <h2>用户信息</h2>
          {/* 用户信息展示逻辑 */}
        </section>
        
        <section className="posts">
          <h2>最新文章</h2>
          {/* 文章列表展示逻辑 */}
        </section>
        
        <section className="notifications">
          <h2>通知</h2>
          {/* 通知列表展示逻辑 */}
        </section>
        
        <section className="analytics">
          <h2>数据分析</h2>
          {/* 分析图表展示逻辑 */}
        </section>
      </div>
    </div>
  )
}

这种设计的问题分析:

  1. 维护困难:任何功能的修改都可能影响整个组件
  2. 测试复杂:需要模拟所有依赖才能测试单一功能
  3. 重用性差:无法单独复用其中的某个功能
  4. 职责混乱:数据获取、验证、格式化、UI渲染都混在一起
  5. 性能问题:任何状态变化都会导致整个组件重新渲染
  6. 类型安全性差:复杂的状态管理容易出现类型错误

正确设计 - 遵循单一职责原则:

// ✅ 职责分离:数据获取逻辑 - 自定义Hook
import { useState, useEffect } from 'react'
import { User, AsyncState } from '@/types'
import { fetchUser } from '@/services'

interface UseUserDataReturn extends AsyncState<User> {
  refetch: () => Promise<void>
}

export function useUserData(userId: string): UseUserDataReturn {
  const [data, setData] = useState<User | null>(null)
  const [status, setStatus] = useState<RequestStatus>('idle')
  const [error, setError] = useState<string | null>(null)
  
  const fetchUserData = async (): Promise<void> => {
    if (!userId) return
    
    setStatus('loading')
    setError(null)
    
    try {
      const userData = await fetchUser(userId)
      setData(userData)
      setStatus('success')
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'Failed to fetch user'
      setError(errorMessage)
      setStatus('error')
    }
  }
  
  useEffect(() => {
    fetchUserData()
  }, [userId])
  
  return {
    data,
    status,
    error,
    refetch: fetchUserData,
  }
}

// ✅ 职责分离:用户文章数据
import { Post, PaginatedResponse, PostListParams } from '@/types'
import { fetchUserPosts } from '@/services'

interface UseUserPostsReturn extends AsyncState<Post[]> {
  refetch: () => Promise<void>
  loadMore: () => Promise<void>
  hasMore: boolean
}

export function useUserPosts(userId: string, params?: Partial<PostListParams>): UseUserPostsReturn {
  const [data, setData] = useState<Post[]>([])
  const [status, setStatus] = useState<RequestStatus>('idle')
  const [error, setError] = useState<string | null>(null)
  const [hasMore, setHasMore] = useState(true)
  const [currentPage, setCurrentPage] = useState(1)
  
  const fetchPosts = async (page: number = 1, append: boolean = false): Promise<void> => {
    if (!userId) return
    
    setStatus('loading')
    setError(null)
    
    try {
      const response: PaginatedResponse<Post> = await fetchUserPosts(userId, {
        page,
        pageSize: 10,
        ...params,
      })
      
      setData(prev => append ? [...prev, ...response.list] : response.list)
      setHasMore(response.list.length === 10) // 假设pageSize为10
      setCurrentPage(page)
      setStatus('success')
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'Failed to fetch posts'
      setError(errorMessage)
      setStatus('error')
    }
  }
  
  useEffect(() => {
    fetchPosts(1, false)
  }, [userId, params])
  
  const refetch = (): Promise<void> => fetchPosts(1, false)
  const loadMore = (): Promise<void> => fetchPosts(currentPage + 1, true)
  
  return {
    data,
    status,
    error,
    refetch,
    loadMore,
    hasMore,
  }
}

// ✅ 职责分离:数据验证工具
import { User } from '@/types'

type ValidationResult = {
  isValid: boolean
  errors: string[]
}

export const userValidators = {
  email: (email: string): boolean => {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
  },
  
  phone: (phone: string): boolean => {
    return /^1[3-9]\d{9}$/.test(phone)
  },
  
  name: (name: string): boolean => {
    return name && name.length >= 2 && name.length <= 50
  },
  
  validateUser: (user: Partial<User>): ValidationResult => {
    const errors: string[] = []
    
    if (user.email && !userValidators.email(user.email)) {
      errors.push('邮箱格式不正确')
    }
    if (user.phone && !userValidators.phone(user.phone)) {
      errors.push('手机号格式不正确')
    }
    if (user.name && !userValidators.name(user.name)) {
      errors.push('姓名长度必须在2-50字符之间')
    }
    
    return { isValid: errors.length === 0, errors }
  }
} as const
// ✅ 职责分离:日期格式化工具
export const dateFormatters = {
  toLocalDate: (date: string): string => {
    return new Intl.DateTimeFormat('zh-CN', {
      year: 'numeric',
      month: '2-digit',
      day: '2-digit'
    }).format(new Date(date))
  },
  
  toRelativeTime: (date: string): string => {
    const now = new Date()
    const target = new Date(date)
    const diffInSeconds = Math.floor((now.getTime() - target.getTime()) / 1000)
    
    if (diffInSeconds < 60) return '刚刚'
    if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}分钟前`
    if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}小时前`
    if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)}天前`
    
    return dateFormatters.toLocalDate(date)
  }
} as const

// ✅ 职责分离:组件定义 - TypeScript版本
import React, { memo } from 'react'
import { User } from '@/types'

interface UserProfileCardProps {
  user: User | null
  loading?: boolean
}

const UserProfileCard: React.FC<UserProfileCardProps> = memo(({ user, loading = false }) => {
  if (loading) return <div className="profile-skeleton">加载中...</div>
  if (!user) return <div className="profile-empty">用户不存在</div>
  
  const { isValid, errors } = userValidators.validateUser(user)
  
  return (
    <div className="user-profile-card">
      <div className="avatar-section">
        <img 
          src={user.avatar || '/default-avatar.png'} 
          alt={user.name}
          className="avatar"
          loading="lazy"
        />
        <div className="user-basic">
          <h3>{user.name}</h3>
          <p className="email">{user.email}</p>
          {!isValid && (
            <div className="validation-errors">
              {errors.map((error, index) => (
                <span key={index} className="error-message">{error}</span>
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  )
})

UserProfileCard.displayName = 'UserProfileCard'

interface UserPostsListProps {
  userId: string
}

const UserPostsList: React.FC<UserPostsListProps> = memo(({ userId }) => {
  const { data: posts, status, error, refetch } = useUserPosts(userId)
  
  if (status === 'loading') return <div>加载中...</div>
  if (status === 'error') return <div>错误: {error}</div>
  if (posts?.length === 0) return <div>暂无文章</div>
  
  return (
    <div className="posts-list">
      <div className="list-header">
        <h3>最新文章 ({posts?.length || 0})</h3>
        <button onClick={refetch} disabled={status === 'loading'}>
          刷新
        </button>
      </div>
      
      <div className="posts-grid">
        {posts?.map(post => (
          <div key={post.id}>{post.title}</div>
        ))}
      </div>
    </div>
  )
})

UserPostsList.displayName = 'UserPostsList'      
      <div className="dashboard-content">
        <aside className="sidebar">
          <UserProfileCard user={user} />
          <QuickActions userId={userId} />
        </aside>
        
        <main className="main-content">
          <div className="content-grid">
            <section className="posts-section">
              <UserPostsList userId={userId} />
            </section>
            
            <section className="notifications-section">
              <UserNotifications userId={userId} />
            </section>
            
            <section className="analytics-section">
              <UserAnalytics userId={userId} />
            </section>
          </div>
        </main>
      </div>
    </div>
  )
}

重构后的优势分析:

  1. 职责清晰:每个组件和Hook都有明确的单一职责

    • useUserData: 专门处理用户数据获取
    • useUserPosts: 专门处理文章数据
    • userValidators: 专门处理数据验证
    • dateFormatters: 专门处理日期格式化
    • UserProfileCard: 专门展示用户信息
    • UserPostsList: 专门展示文章列表
    • UserDashboard: 专门处理布局协调
  2. 可测试性:每个单元都可以独立测试

// 测试数据验证逻辑
describe('userValidators', () => {
  test('should validate email correctly', () => {
    expect(userValidators.email('test@example.com')).toBe(true)
    expect(userValidators.email('invalid-email')).toBe(false)
  })
  
  test('should validate user object', () => {
    const validUser = {
      email: 'test@example.com',
      phone: '13800138000',
      name: 'John Doe'
    }
    const { isValid } = userValidators.validateUser(validUser)
    expect(isValid).toBe(true)
  })
})

// 测试用户数据Hook
describe('useUserData', () => {
  test('should fetch user data', async () => {
    const { result } = renderHook(() => useUserData('123'))
    
    expect(result.current.loading).toBe(true)
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false)
      expect(result.current.user).toBeDefined()
    })
  })
})
  1. 可重用性:组件和工具函数可以在不同场景下复用
// 在其他页面重用用户卡片
function AdminUserList() {
  return (
    <div>
      {users.map(user => (
        <UserProfileCard key={user.id} user={user} />
      ))}
    </div>
  )
}

// 重用日期格式化工具
function CommentList({ comments }) {
  return (
    <div>
      {comments.map(comment => (
        <div key={comment.id}>
          <p>{comment.content}</p>
          <span>{dateFormatters.toRelativeTime(comment.createdAt)}</span>
        </div>
      ))}
    </div>
  )
}
  1. 维护性:修改某个功能不会影响其他功能

    • 修改日期格式只需要修改dateFormatters
    • 修改验证逻辑只需要修改userValidators
    • 修改数据获取逻辑只需要修改对应的Hook
  2. 性能优化:每个组件只在相关数据变化时重新渲染

// UserProfileCard只在user变化时重新渲染
const MemoizedUserProfileCard = memo(UserProfileCard)

// UserPostsList只在userId变化时重新获取数据
function UserPostsList({ userId }) {
  const { posts, loading, error } = useUserPosts(userId) // 只依赖userId
  // ...
}

实际项目中的应用指导:

  1. 识别违反SRP的信号:

    • 组件名称中包含"and"、"or"等连接词
    • 组件代码超过200行
    • 组件依赖过多的props(超过8个)
    • 组件内部有多个useState和useEffect
    • 修改一个功能需要同时修改多个地方
  2. 重构策略:

    • 按功能领域拆分(用户管理、文章管理、通知管理)
    • 按技术层级拆分(数据层、业务层、展示层)
SOLID原则在React组件设计中的综合应用

开闭原则(OCP)实践: 通过策略模式和组件组合实现可扩展的组件设计。组件应该能够通过配置、插槽、或策略注入的方式扩展功能,而不需要修改核心代码。

// ✅ 可扩展的表单验证组件
function FormField({ type, validators = [], ...props }) {
  const [value, setValue] = useState('')
  const [errors, setErrors] = useState([])
  
  const validate = (inputValue) => {
    // 对扩展开放:可以传入任意验证器
    const validationErrors = validators
      .map(validator => validator(inputValue))
      .filter(Boolean)
    setErrors(validationErrors)
  }
  
  return (
    <div className="form-field">
      <input 
        type={type}
        value={value}
        onChange={(e) => {
          setValue(e.target.value)
          validate(e.target.value)
        }}
        {...props}
      />
      {errors.map((error, index) => (
        <span key={index} className="error">{error}</span>
      ))}
    </div>
  )
}

// 验证器策略(可扩展)
const validators = {
  required: (value) => !value ? '此字段为必填项' : null,
  email: (value) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '邮箱格式不正确' : null,
  minLength: (min) => (value) => value.length < min ? `最少需要${min}个字符` : null,
}

// 使用时可以灵活组合验证规则
<FormField 
  type="email"
  validators={[validators.required, validators.email]}
  placeholder="请输入邮箱"
/>

里氏替换原则(LSP)实践: 确保所有实现同一接口的组件能够安全替换,不破坏系统行为。

// ✅ 一致的按钮接口
const BaseButton = ({ children, onClick, disabled, ...props }) => (
  <button 
    onClick={disabled ? undefined : onClick}
    disabled={disabled}
    {...props}
  >
    {children}
  </button>
)

const PrimaryButton = (props) => (
  <BaseButton {...props} className="btn-primary" />
)

const ConfirmButton = ({ onConfirm, needsConfirm, ...props }) => {
  const [confirmed, setConfirmed] = useState(false)
  
  const handleClick = () => {
    if (needsConfirm && !confirmed) {
      setConfirmed(true)
      return
    }
    // 保持一致的行为:总是调用onClick
    props.onClick?.()
    onConfirm?.()
  }
  
  return (
    <BaseButton 
      {...props} 
      onClick={handleClick}
      className="btn-confirm"
    >
      {needsConfirm && !confirmed ? '点击确认' : props.children}
    </BaseButton>
  )
}

接口隔离原则(ISP)实践: 组件不应该依赖它不需要的props,应该将大型接口拆分为小的、专门的接口。

// ❌ 违反ISP:组件被迫接受不需要的props
function BlogPost({ 
  title, content, author, publishDate,
  canEdit, canDelete, canShare,
  onEdit, onDelete, onShare,
  showComments, commentCount,
  tags, category,
  seoTitle, seoDescription 
}) {
  // 组件需要处理太多不相关的props
}

// ✅ 遵循ISP:拆分为专门的组件
function BlogPostContent({ title, content, author, publishDate }) {
  return (
    <article>
      <h1>{title}</h1>
      <div className="meta">
        作者:{author} | 发布时间:{publishDate}
      </div>
      <div className="content">{content}</div>
    </article>
  )
}

function BlogPostActions({ canEdit, canDelete, onEdit, onDelete }) {
  if (!canEdit && !canDelete) return null
  
  return (
    <div className="actions">
      {canEdit && <button onClick={onEdit}>编辑</button>}
      {canDelete && <button onClick={onDelete}>删除</button>}
    </div>
  )
}

function BlogPostComments({ showComments, commentCount, postId }) {
  if (!showComments) return null
  return <CommentSection postId={postId} count={commentCount} />
}

// 主组件组合使用
function BlogPost(props) {
  return (
    <div className="blog-post">
      <BlogPostContent {...pick(props, ['title', 'content', 'author', 'publishDate'])} />
      <BlogPostActions {...pick(props, ['canEdit', 'canDelete', 'onEdit', 'onDelete'])} />
      <BlogPostComments {...pick(props, ['showComments', 'commentCount', 'postId'])} />
    </div>
  )
}

依赖倒置原则(DIP)实践: 组件应该依赖抽象而不是具体实现,通过依赖注入提供灵活性。

// ✅ 通过依赖注入实现可测试和可替换的组件
function UserList({ userService, notificationService }) {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    // 依赖抽象接口,而不是具体实现
    userService.getUsers()
      .then(setUsers)
      .catch(error => {
        notificationService.showError('获取用户列表失败')
      })
      .finally(() => setLoading(false))
  }, [userService, notificationService])
  
  const handleDeleteUser = async (userId) => {
    try {
      await userService.deleteUser(userId)
      setUsers(users.filter(user => user.id !== userId))
      notificationService.showSuccess('删除成功')
    } catch (error) {
      notificationService.showError('删除失败')
    }
  }
  
  if (loading) return <div>加载中...</div>
  
  return (
    <div className="user-list">
      {users.map(user => (
        <UserCard 
          key={user.id} 
          user={user} 
          onDelete={() => handleDeleteUser(user.id)}
        />
      ))}
    </div>
  )
}

// 在应用层注入具体实现
function App() {
  const userService = new ApiUserService()
  const notificationService = new ToastNotificationService()
  
  return (
    <UserList 
      userService={userService}
      notificationService={notificationService}
    />
  )
}

// 测试时可以注入mock实现
function UserList.test() {
  const mockUserService = {
    getUsers: jest.fn().mockResolvedValue([{ id: 1, name: 'Test User' }]),
    deleteUser: jest.fn().mockResolvedValue(undefined)
  }
  const mockNotificationService = {
    showError: jest.fn(),
    showSuccess: jest.fn()
  }
  
  render(
    <UserList 
      userService={mockUserService}
      notificationService={mockNotificationService}
    />
  )
}

组件设计最佳实践总结:

  1. 职责分离:每个组件只关注一个核心功能
  2. 接口一致:同类型组件保持相同的props接口
  3. 可扩展性:通过配置、策略模式支持功能扩展
  4. 依赖注入:通过props注入依赖,提高可测试性
  5. 组合优于继承:优先使用组件组合而非类继承
  6. 最小接口:组件只接受必要的props,避免接口膨胀

这些原则的应用帮助我们构建更加健壮、可维护和可扩展的React应用程序。

1.3 现代化构建工具 - Vite深度实践

Vite相对于Webpack的技术优势分析

2025年构建工具选型的关键考量:

Vite作为下一代前端构建工具,在2025年已成为React项目的首选构建方案。相比传统的Webpack,Vite在多个维度都展现出显著优势:

开发环境性能对比:

# 冷启动时间对比
Webpack (Create React App): 15-30秒
Vite: 1-3秒 (提升90%+)

# 热更新(HMR)速度对比  
Webpack: 1-5秒
Vite: 50-200ms (提升95%+)

核心技术原理差异:

  • Webpack: 打包所有模块后启动开发服务器
  • Vite: 基于ES模块的按需编译,原生ESM + esbuild预构建

实际项目中的体验差异示例:

// vite.config.ts - 完整的TypeScript配置
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { resolve } from 'path'
import type { PluginOption } from 'vite'

interface ViteConfigOptions {
  mode: string
  command: 'build' | 'serve'
}

export default defineConfig(({ mode, command }: ViteConfigOptions) => {
  const isDev = command === 'serve'
  const isProd = mode === 'production'
  
  return {
    plugins: [
      // 使用SWC替代Babel,编译速度提升10倍
      react({
        // Fast Refresh配置
        fastRefresh: true,
        // SWC配置选项
        swcOptions: {
          jsc: {
            parser: {
              syntax: 'typescript',
              tsx: true,
              decorators: false,
            },
            transform: {
              react: {
                runtime: 'automatic',
                development: isDev,
                refresh: isDev,
              },
            },
          },
        },
      }),
    ] as PluginOption[],
    
    // TypeScript路径映射
    resolve: {
      alias: {
        '@': resolve(__dirname, 'src'),
        '@/components': resolve(__dirname, 'src/components'),
        '@/hooks': resolve(__dirname, 'src/hooks'),
        '@/types': resolve(__dirname, 'src/types'),
        '@/utils': resolve(__dirname, 'src/utils'),
        '@/services': resolve(__dirname, 'src/services'),
        '@/stores': resolve(__dirname, 'src/stores'),
      },
    },
    
    // 开发服务器配置
    server: {
      port: 3000,
      open: true,
      // 热更新配置
      hmr: {
        overlay: true,
        port: 3001,
      },
      // TypeScript类型检查代理
      proxy: {
        '/api': {
          target: 'http://localhost:8080',
          changeOrigin: true,
          rewrite: (path: string) => path.replace(/^\/api/, ''),
        },
      },
    },
    
    // 构建配置
    build: {
      target: 'es2020',
      outDir: 'dist',
      sourcemap: isDev,
      minify: isProd ? 'esbuild' : false,
      
      // TypeScript构建优化
      rollupOptions: {
        output: {
          manualChunks: {
            react: ['react', 'react-dom'],
            router: ['react-router-dom'],
            ui: ['@mui/material', 'antd'],
            utils: ['lodash-es', 'dayjs'],
          },
        },
      },
    },
    
    // TypeScript编译选项
    esbuild: {
      target: 'es2020',
      jsx: 'automatic',
      jsxImportSource: 'react',
    },
    
    // 依赖预构建
    optimizeDeps: {
      include: [
        'react',
        'react-dom',
        'react-router-dom',
        '@mui/material',
        'lodash-es',
      ],
      esbuildOptions: {
        target: 'es2020',
      },
    },
  }
})
    },
  },
})
Vite项目初始化最佳实践

现代化React项目脚手架:

# 2025年推荐的项目初始化方式
npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install

# 安装常用开发依赖
npm install -D @types/node @vitejs/plugin-react-swc
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
npm install -D prettier eslint-config-prettier eslint-plugin-prettier

完整的vite.config.ts配置示例:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { resolve } from 'path'

export default defineConfig({
  plugins: [react()],
  
  // 路径别名配置
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@components': resolve(__dirname, 'src/components'),
      '@utils': resolve(__dirname, 'src/utils'),
      '@hooks': resolve(__dirname, 'src/hooks'),
      '@stores': resolve(__dirname, 'src/stores'),
      '@types': resolve(__dirname, 'src/types'),
    },
  },
  
  // 环境变量前缀
  envPrefix: ['VITE_', 'REACT_APP_'],
  
  // 开发服务器配置
  server: {
    port: 3000,
    host: true, // 支持局域网访问
    open: true,
    cors: true,
    // 代理配置
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
  
  // 构建配置
  build: {
    // 输出目录
    outDir: 'dist',
    // 生成源码映射
    sourcemap: true,
    // 代码分割策略
    rollupOptions: {
      output: {
        manualChunks: {
          // 将React相关库单独打包
          react: ['react', 'react-dom'],
          // 将路由相关库单独打包  
          router: ['react-router-dom'],
          // 将状态管理库单独打包
          store: ['zustand', '@reduxjs/toolkit'],
          // 将UI组件库单独打包
          ui: ['@mui/material', 'antd'],
        },
      },
    },
    // 压缩配置
    minify: 'esbuild',
    // 资源内联阈值
    assetsInlineLimit: 4096,
  },
  
  // 依赖预构建配置
  optimizeDeps: {
    include: [
      'react',
      'react-dom',
      'react-router-dom',
      'zustand',
      '@reduxjs/toolkit',
    ],
    exclude: ['@vite/client', '@vite/env'],
  },
})
Vite环境变量管理策略

多环境配置文件管理:

# 项目根目录环境变量文件结构
.env                # 所有环境的默认配置
.env.local          # 本地环境配置(被git忽略)
.env.development    # 开发环境配置
.env.production     # 生产环境配置
.env.staging        # 预发环境配置
// .env.development
VITE_API_BASE_URL=http://localhost:8080/api
VITE_APP_TITLE=React App (Development)
VITE_ENABLE_DEVTOOLS=true
VITE_LOG_LEVEL=debug

// .env.production  
VITE_API_BASE_URL=https://api.production.com
VITE_APP_TITLE=React App
VITE_ENABLE_DEVTOOLS=false
VITE_LOG_LEVEL=error

// 环境变量类型定义
// src/types/env.d.ts
interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_APP_TITLE: string
  readonly VITE_ENABLE_DEVTOOLS: string
  readonly VITE_LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

环境变量使用示例:

// src/config/index.ts
const config = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  appTitle: import.meta.env.VITE_APP_TITLE,
  isDev: import.meta.env.DEV,
  isProd: import.meta.env.PROD,
  enableDevtools: import.meta.env.VITE_ENABLE_DEVTOOLS === 'true',
  logLevel: import.meta.env.VITE_LOG_LEVEL,
}

export default config

// 使用示例
import config from '@/config'

const ApiService = {
  baseURL: config.apiBaseUrl,
  timeout: config.isDev ? 10000 : 5000,
}
Vite插件生态系统应用

常用插件配置集合:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { resolve } from 'path'

// 性能分析插件
import { visualizer } from 'rollup-plugin-visualizer'
// PWA插件
import { VitePWA } from 'vite-plugin-pwa'
// 自动导入插件
import AutoImport from 'unplugin-auto-import/vite'
// ESLint插件
import eslint from 'vite-plugin-eslint'
// 模拟数据插件
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig({
  plugins: [
    react(),
    
    // ESLint集成
    eslint({
      include: ['src/**/*.ts', 'src/**/*.tsx'],
      exclude: ['node_modules', 'dist'],
      cache: false,
    }),
    
    // 自动导入React和常用Hooks
    AutoImport({
      imports: [
        'react',
        'react-router-dom',
        {
          'zustand': ['create'],
          '@reduxjs/toolkit': ['createSlice', 'configureStore'],
        },
      ],
      dts: true, // 生成类型声明文件
      include: [/\.[tj]sx?$/],
    }),
    
    // PWA配置
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\./,
            handler: 'StaleWhileRevalidate',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 24 * 60 * 60, // 24小时
              },
            },
          },
        ],
      },
    }),
    
    // 开发环境Mock服务
    viteMockServe({
      mockPath: 'mock',
      localEnabled: true,
      prodEnabled: false,
      logger: true,
    }),
    
    // 构建分析
    visualizer({
      filename: 'dist/stats.html',
      open: true,
      gzipSize: true,
      brotliSize: true,
    }),
  ],
})
Vite构建性能优化策略

代码分割和懒加载优化:

// 路由级别的代码分割
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import LoadingSpinner from '@/components/LoadingSpinner'

// 懒加载页面组件
const Home = lazy(() => import('@/pages/Home'))
const Dashboard = lazy(() => import('@/pages/Dashboard'))
const Settings = lazy(() => import('@/pages/Settings'))

// 组件级别的懒加载
const HeavyChart = lazy(() => import('@/components/HeavyChart'))

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  )
}

// 条件懒加载示例
function DashboardPage() {
  const [showChart, setShowChart] = useState(false)
  
  return (
    <div>
      <h1>Dashboard</h1>
      <button onClick={() => setShowChart(true)}>显示图表</button>
      {showChart && (
        <Suspense fallback={<div>加载图表中...</div>}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  )
}

依赖预构建优化:

// vite.config.ts - 依赖优化配置
export default defineConfig({
  optimizeDeps: {
    // 强制预构建的依赖
    include: [
      'react',
      'react-dom',
      'react-router-dom',
      'zustand',
      'axios',
      'lodash-es',
      '@mui/material',
    ],
    // 排除预构建的依赖
    exclude: [
      '@vite/client',
      '@vite/env',
    ],
    // 自定义esbuild配置
    esbuildOptions: {
      define: {
        global: 'globalThis',
      },
    },
  },
  
  // 构建时的依赖外部化
  build: {
    rollupOptions: {
      external: [
        // 对于库模式,外部化React
        // 'react',
        // 'react-dom',
      ],
      output: {
        // 全局变量映射
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
        // 自定义chunk分割策略
        manualChunks(id) {
          // node_modules中的依赖单独打包
          if (id.includes('node_modules')) {
            if (id.includes('react')) {
              return 'react-vendor'
            }
            if (id.includes('antd') || id.includes('@mui')) {
              return 'ui-vendor'
            }
            if (id.includes('lodash') || id.includes('ramda')) {
              return 'utils-vendor'
            }
            return 'vendor'
          }
        },
      },
    },
  },
})
Vite开发体验增强配置

热模块替换(HMR)优化:

// 自定义HMR处理
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(<App />)

// HMR处理
if (import.meta.hot) {
  import.meta.hot.accept('./App', (newApp) => {
    if (newApp) {
      root.render(<newApp.default />)
    }
  })
  
  // 状态保持配置
  import.meta.hot.accept(['./stores/userStore'], () => {
    console.log('Store updated, state preserved')
  })
}

开发工具集成:

// vite.config.ts - 开发工具配置
export default defineConfig({
  server: {
    // 开发服务器配置
    host: '0.0.0.0', // 允许外部访问
    port: 3000,
    strictPort: true, // 端口被占用时报错而不是自动换端口
    
    // HTTPS配置
    https: {
      key: './certs/localhost-key.pem',
      cert: './certs/localhost.pem',
    },
    
    // 中间件配置
    middlewareMode: false,
    
    // 开发时的错误覆盖层
    hmr: {
      overlay: true,
      clientPort: 3000,
    },
  },
  
  // CSS预处理器配置
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "@/styles/variables.scss";`,
      },
      less: {
        modifyVars: {
          '@primary-color': '#1890ff',
        },
        javascriptEnabled: true,
      },
    },
    modules: {
      // CSS模块化配置
      localsConvention: 'camelCase',
      generateScopedName: '[name]__[local]___[hash:base64:5]',
    },
  },
})

2. 状态管理策略

状态管理方案选择与2025年最佳实践

基于项目复杂度和团队规模的选择策略:

现代React应用的状态管理已经形成了明确的技术趋势。根据2025年最新的技术调研,状态管理方案选择应该基于以下维度:

技术栈选择矩阵:

# 小型项目 (< 10个组件)
技术栈: React Context API + useReducer
适用场景: 原型项目、个人项目、简单的管理后台
团队规模: 1-2人

# 中型项目 (10-50个组件) 
技术栈: Zustand (2025年首选)
适用场景: 中小企业应用、SaaS产品、移动应用
团队规模: 2-8人

# 大型项目 (50+个组件)
技术栈: Redux Toolkit + RTK Query
适用场景: 企业级应用、复杂业务系统、高并发应用
团队规模: 8+人

React 18/19 并发特性在状态管理中的应用

useDeferredValue和useTransition的实际应用:

import React, { useState, useDeferredValue, useTransition, useMemo } from 'react'
import { useStore } from './store'

interface DataItem {
  id: string
  name: string
  category: string
  createdAt: string
}

interface DataListProps {
  initialData?: DataItem[]
}

// 大数据列表的性能优化
const DataList: React.FC<DataListProps> = ({ initialData = [] }) => {
  const [query, setQuery] = useState<string>('')
  const [isPending, startTransition] = useTransition()
  
  // 延迟更新搜索查询,避免阻塞UI
  const deferredQuery = useDeferredValue(query)
  const { data, searchData } = useStore<DataItem[]>()
  
  // 使用并发特性优化搜索
  const filteredData = useMemo((): DataItem[] => {
    const currentData = data || initialData
    return currentData.filter((item: DataItem) => 
      item.name.toLowerCase().includes(deferredQuery.toLowerCase())
    )
  }, [data, initialData, deferredQuery])
  
  const handleSearch = (newQuery: string): void => {
    setQuery(newQuery) // 立即更新输入框
    
    // 将搜索操作标记为非紧急
    startTransition(() => {
      searchData(newQuery)
    })
  }
  
  return (
    <div className="data-list">
      <input 
        value={query}
        onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleSearch(e.target.value)}
        placeholder="搜索..."
        className="search-input"
      />
      {isPending && <div className="loading">搜索中...</div>}
      
      <div className="results">
        {filteredData.map((item: DataItem) => (
          <div key={item.id} className="item">
            <h3>{item.name}</h3>
            <span className="category">{item.category}</span>
          </div>
        ))}
      </div>
    </div>
  )
}

export default DataList

React 19的新特性集成:

// React 19的useOptimistic Hook应用
import React, { useOptimistic, useState, useCallback } from 'react'
import { useStore } from './store'
import type { Todo, CreateTodoRequest, UpdateTodoRequest } from '@/types'

interface TodoListProps {
  userId: string
}

const TodoList: React.FC<TodoListProps> = ({ userId }) => {
  const { todos, addTodo, updateTodo, deleteTodo } = useStore()
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
  
  // 乐观更新:在网络请求完成前就更新UI
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos: Todo[], optimisticTodo: Todo): Todo[] => {
      return [...currentTodos, optimisticTodo]
    }
  )
  
  const [optimisticUpdates, updateOptimistic] = useOptimistic(
    optimisticTodos,
    (currentTodos: Todo[], update: { id: string; updates: Partial<Todo> }): Todo[] => {
      return currentTodos.map(todo => 
        todo.id === update.id 
          ? { ...todo, ...update.updates }
          : todo
      )
    }
  )
  
  const handleAddTodo = useCallback(async (text: string): Promise<void> => {
    if (!text.trim()) return
    
    const optimisticTodo: Todo = {
      id: `temp-${Date.now()}`,
      text: text.trim(),
      completed: false,
      userId,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    }
    
    // 立即更新UI
    addOptimisticTodo(optimisticTodo)
    setIsSubmitting(true)
    
    try {
      const newTodo = await addTodo({
        text: text.trim(),
        userId,
      } as CreateTodoRequest)
      
      // 网络请求成功,不需要额外操作
      // 乐观更新会自动被真实数据替换
    } catch (error) {
      // 网络请求失败,撤销乐观更新
      console.error('Failed to add todo:', error)
      // 乐观更新会自动撤销
    } finally {
      setIsSubmitting(false)
    }
  }, [addTodo, addOptimisticTodo, userId])
  
  const handleToggleTodo = useCallback(async (todoId: string): Promise<void> => {
    const todo = optimisticUpdates.find(t => t.id === todoId)
    if (!todo) return
    
    // 立即更新UI
    updateOptimistic({
      id: todoId,
      updates: { completed: !todo.completed }
    })
    
    try {
      await updateTodo(todoId, {
        completed: !todo.completed
      } as UpdateTodoRequest)
    } catch (error) {
      console.error('Failed to update todo:', error)
      // 乐观更新会自动撤销
    }
  }, [optimisticUpdates, updateOptimistic, updateTodo])
  
  const handleDeleteTodo = useCallback(async (todoId: string): Promise<void> => {
    try {
      await deleteTodo(todoId)
      // 删除成功后,状态管理器会自动更新数据
    } catch (error) {
      console.error('Failed to delete todo:', error)
    }
  }, [deleteTodo])
  
  return (
    <div className="todo-list">
      <div className="todo-input">
        <input 
          type="text"
          placeholder="添加新任务..."
          disabled={isSubmitting}
          onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) => {
            if (e.key === 'Enter') {
              const target = e.target as HTMLInputElement
              handleAddTodo(target.value)
              target.value = ''
            }
          }}
        />
      </div>
      
      <div className="todo-items">
        {optimisticUpdates.map((todo: Todo) => (
          <div 
            key={todo.id} 
            className={`todo-item ${todo.completed ? 'completed' : ''}`}
          >
            <input 
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggleTodo(todo.id)}
            />
            <span className="todo-text">{todo.text}</span>
            <button 
              onClick={() => handleDeleteTodo(todo.id)}
              className="delete-btn"
            >
              删除
            </button>
          </div>
        ))}
      </div>
      
      {isSubmitting && (
        <div className="loading-overlay">
          正在保存...
        </div>
      )}
    </div>
  )
}

export default TodoList
  
  // 乐观更新:立即显示UI变化,后台同步数据
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo) => [...state, { ...newTodo, id: Date.now(), pending: true }]
  )
  
  const handleAddTodo = async (text) => {
    // 乐观更新UI
    addOptimisticTodo({ text, completed: false })
    
    try {
      // 后台真实提交
      await addTodo(text)
    } catch (error) {
      // 如果失败,状态会自动回滚
      console.error('添加失败:', error)
    }
  }
  
  return (
    <div>
      {optimisticTodos.map(todo => (
        <div 
          key={todo.id} 
          className={todo.pending ? 'pending' : ''}
        >
          {todo.text}
        </div>
      ))}
    </div>
  )
}

小型项目 - Context API:

// 创建Context
const AppContext = createContext();

// Provider组件
function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  
  const value = useMemo(() => ({
    user,
    setUser,
    theme,
    setTheme,
    login: async (credentials) => {
      const userData = await authService.login(credentials);
      setUser(userData);
    },
    logout: () => {
      authService.logout();
      setUser(null);
    }
  }), [user, theme]);
  
  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

// 使用Hook
function useApp() {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error('useApp must be used within AppProvider');
  }
  return context;
}

中型项目 - Zustand企业级实践:

Zustand在2025年已成为中小型项目的首选状态管理方案。相比Redux,它提供了更简洁的API和更小的包体积,同时保持了强大的功能性。

import { create } from 'zustand'
import { subscribeWithSelector, devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
import type { User, UserPreferences, LoginCredentials } from '@/types'
import { authService, userService } from '@/services'

// 复杂状态管理的企业级实践
interface UserState {
  // 状态定义
  user: User | null
  preferences: UserPreferences
  permissions: string[]
  loading: boolean
  error: string | null
  
  // 同步操作
  setUser: (user: User) => void
  updatePreferences: (preferences: Partial<UserPreferences>) => void
  clearError: () => void
  
  // 异步操作
  login: (credentials: LoginCredentials) => Promise<void>
  logout: () => Promise<void>
  fetchUserProfile: () => Promise<void>
  updateProfile: (updates: Partial<User>) => Promise<void>
  
  // 计算属性
  isAuthenticated: () => boolean
  hasPermission: (permission: string) => boolean
  getDisplayName: () => string
}

// 使用多个中间件组合
const useUserStore = create<UserState>()
  devtools( // 开发工具集成
    persist( // 数据持久化
      immer( // 不可变状态更新
        subscribeWithSelector( // 选择性订阅
          (set, get) => ({
            // 初始状态
            user: null,
            preferences: {
              theme: 'light' as const,
              language: 'zh-CN' as const,
              notifications: true,
            },
            permissions: [],
            loading: false,
            error: null,
            
            // 同步操作
            setUser: (user: User) => set((state) => {
              state.user = user
              state.permissions = user.roles?.flatMap(role => role.permissions) || []
              state.error = null
            }),
            
            updatePreferences: (newPreferences: Partial<UserPreferences>) => set((state) => {
              Object.assign(state.preferences, newPreferences)
            }),
            
            clearError: () => set((state) => {
              state.error = null
            }),
            
            // 异步操作实现
            login: async (credentials: LoginCredentials): Promise<void> => {
              set((state) => {
                state.loading = true
                state.error = null
              })
              
              try {
                const response = await authService.login(credentials)
                const { user, token } = response
                
                // 存储token
                localStorage.setItem('token', token)
                
                set((state) => {
                  state.user = user
                  state.permissions = user.roles?.flatMap(role => role.permissions) || []
                  state.loading = false
                })
                
                // 获取用户详细信息
                await get().fetchUserProfile()
                
              } catch (error) {
                const errorMessage = error instanceof Error ? error.message : '登录失败'
                set((state) => {
                  state.loading = false
                  state.error = errorMessage
                })
                throw error
              }
            },
            
            logout: async (): Promise<void> => {
              try {
                await authService.logout()
              } catch (error) {
                console.error('退出登录失败:', error)
              } finally {
                localStorage.removeItem('token')
                set((state) => {
                  state.user = null
                  state.permissions = []
                  state.error = null
                })
              }
            },
            
            fetchUserProfile: async (): Promise<void> => {
              const state = get()
              if (!state.user?.id) return
              
              try {
                const profile = await userService.getProfile(state.user.id)
                set((state) => {
                  if (state.user) {
                    state.user = { ...state.user, ...profile }
                  }
                })
              } catch (error) {
                set((state) => {
                  state.error = '获取用户信息失败'
                })
              }
            },
            
            updateProfile: async (updates: Partial<User>): Promise<void> => {
              const state = get()
              if (!state.user?.id) return
              
              set((state) => {
                state.loading = true
                state.error = null
              })
              
              try {
                const updatedUser = await userService.updateProfile(
                  state.user.id, 
                  updates
                )
                
                set((state) => {
                  state.user = updatedUser
                  state.loading = false
                })
                
              } catch (error) {
                set((state) => {
                  state.loading = false
                  state.error = '更新用户信息失败'
                })
                throw error
              }
            },
            
            // 计算属性
            isAuthenticated: (): boolean => {
              const state = get()
              return !!state.user && !!localStorage.getItem('token')
            },
            
            hasPermission: (permission: string): boolean => {
              const state = get()
              return state.permissions.includes(permission)
            },
            
            getDisplayName: (): string => {
              const state = get()
              return state.user?.name || state.user?.email || '未知用户'
            },
            
            getDisplayName: () => {
              const state = get()
              return state.user?.displayName || 
                     state.user?.email || 
                     '未知用户'
            },
          })
        )
      ),
      {
        name: 'user-store', // 存储键名
        // 部分状态持久化
        partialize: (state) => ({
          user: state.user,
          preferences: state.preferences,
        }),
        // 版本管理
        version: 1,
        migrate: (persistedState, version) => {
          // 数据迁移逻辑
          return persistedState
        },
      }
    )
  )
)

// Store的使用示例
function UserProfile() {
  const { 
    user, 
    loading, 
    error, 
    updateProfile, 
    getDisplayName,
    clearError 
  } = useUserStore()
  
  const [formData, setFormData] = useState({
    displayName: user?.displayName || '',
    email: user?.email || '',
  })
  
  const handleSubmit = async (e) => {
    e.preventDefault()
    try {
      await updateProfile(formData)
      toast.success('更新成功')
    } catch (error) {
      // 错误由store处理
    }
  }
  
  useEffect(() => {
    if (error) {
      toast.error(error)
      clearError()
    }
  }, [error, clearError])
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>用户资料 - {getDisplayName()}</h2>
      
      {error && (
        <div className="error">{error}</div>
      )}
      
      <input 
        value={formData.displayName}
        onChange={(e) => setFormData(prev => ({
          ...prev, 
          displayName: e.target.value
        }))}
        placeholder="显示名称"
        disabled={loading}
      />
      
      <input 
        value={formData.email}
        onChange={(e) => setFormData(prev => ({
          ...prev, 
          email: e.target.value
        }))}
        placeholder="邮箱"
        disabled={loading}
      />
      
      <button type="submit" disabled={loading}>
        {loading ? '更新中...' : '更新'}
      </button>
    </form>
  )
}

// 订阅机制示例
function useUserSubscription() {
  useEffect(() => {
    // 监听用户状态变化
    const unsubscribe = useUserStore.subscribe(
      (state) => state.user,
      (user, prevUser) => {
        if (user && !prevUser) {
          // 用户登录
          analytics.track('用户登录', { userId: user.id })
        } else if (!user && prevUser) {
          // 用户退出
          analytics.track('用户退出', { userId: prevUser.id })
        }
      }
    )
    
    return unsubscribe
  }, [])
}

// 多个store的组合使用
const useAuthenticatedUser = () => {
  const user = useUserStore((state) => state.user)
  const isAuthenticated = useUserStore((state) => state.isAuthenticated())
  const hasPermission = useUserStore((state) => state.hasPermission)
  
  return { user, isAuthenticated, hasPermission }
}

Zustand性能优化策略:

// 选择器优化策略
const useUserBasicInfo = () => {
  // 只订阅需要的字段,减少重渲染
  return useUserStore(useCallback(
    (state) => ({
      id: state.user?.id,
      name: state.user?.displayName || state.user?.email,
      avatar: state.user?.avatar,
    }),
    []
  ))
}

// 浅比较优化
const useUserPermissions = () => {
  return useUserStore(
    (state) => state.permissions,
    (a, b) => JSON.stringify(a) === JSON.stringify(b) // 自定义比较
  )
}

// 状态分片策略
const createSlice = (name, initialState, actions) => {
  return create((set, get) => ({
    ...initialState,
    ...Object.keys(actions).reduce((acc, key) => {
      acc[key] = (...args) => {
        const result = actions[key](set, get, ...args)
        // 记录状态变化
        if (process.env.NODE_ENV === 'development') {
          console.log(`[${name}] ${key}`, ...args)
        }
        return result
      }
      return acc
    }, {}),
  }))
}

// 使用分片
const useThemeStore = createSlice('theme', 
  {
    theme: 'light',
    colors: {},
  },
  {
    setTheme: (set, get, theme) => {
      set({ theme })
      document.documentElement.setAttribute('data-theme', theme)
    },
    toggleTheme: (set, get) => {
      const newTheme = get().theme === 'light' ? 'dark' : 'light'
      get().setTheme(newTheme)
    },
  }
)

大型项目 - Redux Toolkit:

// store/slices/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId, { rejectWithValue }) => {
    try {
      const response = await userApi.getUser(userId);
      return response.data;
    } catch (error) {
      return rejectWithValue(error.response.data);
    }
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState: {
    data: null,
    loading: false,
    error: null
  },
  reducers: {
    clearUser: (state) => {
      state.data = null;
      state.error = null;
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      });
  }
});

export const { clearUser } = userSlice.actions;
export default userSlice.reducer;

3. 路由管理实践

路由权限控制

基于角色的路由守卫:

import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './hooks/useAuth';

// 路由守卫组件
function ProtectedRoute({ children, requiredRoles = [] }) {
  const { user, loading } = useAuth();
  
  if (loading) {
    return <div>Loading...</div>;
  }
  
  if (!user) {
    return <Navigate to="/login" replace />;
  }
  
  if (requiredRoles.length > 0 && 
      !requiredRoles.some(role => user.roles.includes(role))) {
    return <Navigate to="/unauthorized" replace />;
  }
  
  return children;
}

// 路由配置
function AppRoutes() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route path="/unauthorized" element={<UnauthorizedPage />} />
      
      <Route path="/admin/*" element={
        <ProtectedRoute requiredRoles={['admin']}>
          <AdminLayout />
        </ProtectedRoute>
      } />
      
      <Route path="/user/*" element={
        <ProtectedRoute requiredRoles={['user', 'admin']}>
          <UserLayout />
        </ProtectedRoute>
      } />
      
      <Route path="/public" element={<PublicPage />} />
      
      <Route path="/" element={
        <ProtectedRoute>
          <Dashboard />
        </ProtectedRoute>
      } />
    </Routes>
  );
}

动态路由加载

基于权限的动态路由:

function useRoutes() {
  const { user } = useAuth();
  
  const routes = useMemo(() => {
    const baseRoutes = [
      { path: '/', element: <Home /> },
      { path: '/profile', element: <Profile /> }
    ];
    
    if (user?.roles.includes('admin')) {
      baseRoutes.push(
        { path: '/admin', element: <AdminDashboard /> },
        { path: '/admin/users', element: <UserManagement /> }
      );
    }
    
    if (user?.roles.includes('editor')) {
      baseRoutes.push(
        { path: '/editor', element: <EditorDashboard /> },
        { path: '/editor/posts', element: <PostManagement /> }
      );
    }
    
    return baseRoutes;
  }, [user]);
  
  return routes;
}

function App() {
  const routes = useRoutes();
  
  return (
    <Routes>
      {routes.map(route => (
        <Route key={route.path} {...route} />
      ))}
    </Routes>
  );
}

4. 测试策略与实践

7.1 Vitest测试框架集成

为什么选择Vitest而非Jest:

2025年,Vitest已成为Vite项目的默认测试解决方案。相比Jest,它提供了更快的执行速度和更好的TypeScript支持。

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react-swc'
import { resolve } from 'path'

export default defineConfig({
  plugins: [react()],
  test: {
    // 测试环境配置
    environment: 'jsdom',
    
    // 全局设置文件
    setupFiles: ['./src/tests/setup.ts'],
    
    // 测试文件匹配
    include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
    exclude: ['node_modules', 'dist', 'build'],
    
    // 覆盖率配置
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{js,ts,jsx,tsx}'],
      exclude: [
        'src/**/*.d.ts',
        'src/**/*.stories.{js,ts,jsx,tsx}',
        'src/tests/**/*',
        'src/main.tsx',
      ],
      thresholds: {
        global: {
          branches: 80,
          functions: 80,
          lines: 80,
          statements: 80,
        },
      },
    },
    
    // 并发测试
    threads: true,
    maxThreads: 4,
    
    // 测试超时
    testTimeout: 10000,
    
    // Mock配置
    mockReset: true,
    clearMocks: true,
    restoreMocks: true,
  },
  
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@tests': resolve(__dirname, 'src/tests'),
    },
  },
})

测试环境设置:

// src/tests/setup.ts
import { expect, afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'

// 扩展expect匹配器
expect.extend(matchers)

// 清理DOM
afterEach(() => {
  cleanup()
})

// 全局Mock配置
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(), // deprecated
    removeListener: vi.fn(), // deprecated
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
})

// IntersectionObserver Mock
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
  observe: vi.fn(),
  disconnect: vi.fn(),
  unobserve: vi.fn(),
}))

// ResizeObserver Mock
global.ResizeObserver = vi.fn().mockImplementation(() => ({
  observe: vi.fn(),
  disconnect: vi.fn(),
  unobserve: vi.fn(),
}))

// 环境变量设置
vi.mock('import.meta.env', () => ({
  VITE_API_BASE_URL: 'http://localhost:3001/api',
  VITE_APP_TITLE: 'Test App',
  DEV: true,
  PROD: false,
}))

组件测试最佳实践:

// src/components/UserProfile.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserProfile } from './UserProfile'
import { useUserStore } from '@/stores/userStore'

// Mock store
vi.mock('@/stores/userStore')
const mockUseUserStore = vi.mocked(useUserStore)

// 渲染帮助函数
const renderUserProfile = (props = {}) => {
  const defaultProps = {
    userId: '123',
    onEdit: vi.fn(),
    ...props,
  }
  
  return {
    ...render(<UserProfile {...defaultProps} />),
    props: defaultProps,
  }
}

describe('UserProfile', () => {
  beforeEach(() => {
    // 重置所有mock
    vi.clearAllMocks()
    
    // 设置默认store状态
    mockUseUserStore.mockReturnValue({
      user: {
        id: '123',
        name: 'John Doe',
        email: 'john@example.com',
        avatar: 'https://example.com/avatar.jpg',
      },
      loading: false,
      error: null,
      updateProfile: vi.fn(),
      clearError: vi.fn(),
    })
  })
  
  it('应该正确渲染用户信息', () => {
    renderUserProfile()
    
    expect(screen.getByText('John Doe')).toBeInTheDocument()
    expect(screen.getByText('john@example.com')).toBeInTheDocument()
    expect(screen.getByRole('img', { name: /john doe/i })).toHaveAttribute(
      'src', 
      'https://example.com/avatar.jpg'
    )
  })
  
  it('应该在加载时显示加载状态', () => {
    mockUseUserStore.mockReturnValue({
      ...mockUseUserStore(),
      loading: true,
    })
    
    renderUserProfile()
    
    expect(screen.getByText('加载中...')).toBeInTheDocument()
    expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
  })
  
  it('应该在错误时显示错误信息', () => {
    const errorMessage = '加载用户信息失败'
    
    mockUseUserStore.mockReturnValue({
      ...mockUseUserStore(),
      error: errorMessage,
    })
    
    renderUserProfile()
    
    expect(screen.getByText(errorMessage)).toBeInTheDocument()
    expect(screen.getByRole('button', { name: /重试/i })).toBeInTheDocument()
  })
  
  it('应该处理编辑操作', async () => {
    const user = userEvent.setup()
    const { props } = renderUserProfile()
    
    const editButton = screen.getByRole('button', { name: /编辑/i })
    await user.click(editButton)
    
    expect(props.onEdit).toHaveBeenCalledWith('123')
  })
  
  it('应该处理表单提交', async () => {
    const user = userEvent.setup()
    const mockUpdateProfile = vi.fn().mockResolvedValue(undefined)
    
    mockUseUserStore.mockReturnValue({
      ...mockUseUserStore(),
      updateProfile: mockUpdateProfile,
    })
    
    renderUserProfile()
    
    // 点击编辑按钮进入编辑模式
    await user.click(screen.getByRole('button', { name: /编辑/i }))
    
    // 修改输入框
    const nameInput = screen.getByDisplayValue('John Doe')
    await user.clear(nameInput)
    await user.type(nameInput, 'Jane Doe')
    
    // 提交表单
    await user.click(screen.getByRole('button', { name: /保存/i }))
    
    await waitFor(() => {
      expect(mockUpdateProfile).toHaveBeenCalledWith({
        name: 'Jane Doe',
      })
    })
  })
})

Hook测试示例:

// src/hooks/useDebounce.test.ts
import { describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDebounce } from './useDebounce'

describe('useDebounce', () => {
  it('应该在指定延迟后返回值', async () => {
    vi.useFakeTimers()
    
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      {
        initialProps: { value: 'initial', delay: 500 },
      }
    )
    
    // 初始值应该立即返回
    expect(result.current).toBe('initial')
    
    // 更新值
    rerender({ value: 'updated', delay: 500 })
    
    // 在延迟之前不应该更新
    expect(result.current).toBe('initial')
    
    // 快进时间
    act(() => {
      vi.advanceTimersByTime(500)
    })
    
    // 现在应该返回新值
    expect(result.current).toBe('updated')
    
    vi.useRealTimers()
  })
  
  it('应该在值快速变化时防抖动', () => {
    vi.useFakeTimers()
    
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      {
        initialProps: { value: 'initial', delay: 500 },
      }
    )
    
    // 快速连续更新
    rerender({ value: 'update1', delay: 500 })
    act(() => {
      vi.advanceTimersByTime(300)
    })
    
    rerender({ value: 'update2', delay: 500 })
    act(() => {
      vi.advanceTimersByTime(300)
    })
    
    rerender({ value: 'final', delay: 500 })
    
    // 在延迟结束前不应该更新
    expect(result.current).toBe('initial')
    
    // 完成最后一次延迟
    act(() => {
      vi.advanceTimersByTime(500)
    })
    
    // 应该返回最后一次更新的值
    expect(result.current).toBe('final')
    
    vi.useRealTimers()
  })
})

7.2 现代化E2E测试策略

Playwright集成配置:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['junit', { outputFile: 'results.xml' }],
    ['json', { outputFile: 'test-results.json' }],
  ],
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    video: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
  
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
})

E2E测试用例:

// e2e/user-flow.spec.ts
import { test, expect } from '@playwright/test'

test.describe('用户流程测试', () => {
  test.beforeEach(async ({ page }) => {
    // 设置Mock数据
    await page.route('/api/user/login', async route => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          success: true,
          data: {
            user: {
              id: '123',
              name: 'Test User',
              email: 'test@example.com',
            },
            token: 'mock-token',
          },
        }),
      })
    })
    
    await page.goto('/')
  })
  
  test('应该能够成功登录', async ({ page }) => {
    // 点击登录按钮
    await page.getByRole('button', { name: '登录' }).click()
    
    // 填写登录表单
    await page.getByLabel('邮箱').fill('test@example.com')
    await page.getByLabel('密码').fill('password123')
    
    // 提交表单
    await page.getByRole('button', { name: '登录' }).click()
    
    // 验证登录成功
    await expect(page.getByText('欢迎,Test User')).toBeVisible()
    await expect(page).toHaveURL('/dashboard')
  })
  
  test('应该处理登录错误', async ({ page }) => {
    // Mock登录失败
    await page.route('/api/user/login', async route => {
      await route.fulfill({
        status: 401,
        contentType: 'application/json',
        body: JSON.stringify({
          success: false,
          message: '邮箱或密码错误',
        }),
      })
    })
    
    await page.getByRole('button', { name: '登录' }).click()
    await page.getByLabel('邮箱').fill('test@example.com')
    await page.getByLabel('密码').fill('wrong-password')
    await page.getByRole('button', { name: '登录' }).click()
    
    // 验证错误信息
    await expect(page.getByText('邮箱或密码错误')).toBeVisible()
    await expect(page).toHaveURL('/login')
  })
  
  test('应该支持响应式设计', async ({ page }) => {
    // 测试桌面端
    await page.setViewportSize({ width: 1200, height: 800 })
    await expect(page.getByTestId('desktop-nav')).toBeVisible()
    await expect(page.getByTestId('mobile-nav')).not.toBeVisible()
    
    // 测试移动端
    await page.setViewportSize({ width: 375, height: 667 })
    await expect(page.getByTestId('mobile-nav')).toBeVisible()
    await expect(page.getByTestId('desktop-nav')).not.toBeVisible()
  })
  
  test('应该正确处理数据加载', async ({ page }) => {
    // Mock慢速API响应
    await page.route('/api/dashboard/data', async route => {
      await new Promise(resolve => setTimeout(resolve, 2000))
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          data: [
            { id: 1, name: '项目1', status: 'active' },
            { id: 2, name: '项目2', status: 'pending' },
          ],
        }),
      })
    })
    
    await page.goto('/dashboard')
    
    // 验证加载状态
    await expect(page.getByTestId('loading-spinner')).toBeVisible()
    
    // 验证数据加载完成
    await expect(page.getByText('项目1')).toBeVisible({ timeout: 5000 })
    await expect(page.getByTestId('loading-spinner')).not.toBeVisible()
  })
})

单元测试 - 工具函数:

// utils/format.test.js
import { formatDate, formatCurrency } from './format';

describe('format utils', () => {
  test('formatDate should format date correctly', () => {
    expect(formatDate('2023-01-15')).toBe('Jan 15, 2023');
  });
  
  test('formatCurrency should format number as currency', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56');
  });
});

组件测试 - React Testing Library:

// components/Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button', () => {
  test('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });
  
  test('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
  
  test('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByText('Click me')).toBeDisabled();
  });
});

集成测试 - 用户流程:

// tests/login.test.js
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from '../components/LoginForm';
import { AuthProvider } from '../contexts/AuthContext';

describe('Login flow', () => {
  test('successful login redirects to dashboard', async () => {
    render(
      <AuthProvider>
        <LoginForm />
      </AuthProvider>
    );
    
    // 填写表单
    await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
    await userEvent.type(screen.getByLabelText(/password/i), 'password123');
    
    // 提交表单
    await userEvent.click(screen.getByRole('button', { name: /login/i }));
    
    // 验证重定向
    await waitFor(() => {
      expect(window.location.pathname).toBe('/dashboard');
    });
  });
});

8. 现代化部署与CI/CD

8.1 基于Vite的Docker容器化部署

优化的Dockerfile配置:

# 多阶段构建 - Node.js 20 Alpine
FROM node:20-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat
RUN corepack enable

# 依赖安装阶段
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

# 构建阶段
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG VITE_API_BASE_URL
ARG VITE_APP_VERSION
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_APP_VERSION=$VITE_APP_VERSION
RUN pnpm build

# 生产阶段
FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

优化的nginx配置:

events {
  worker_connections 1024;
}

http {
  include /etc/nginx/mime.types;
  default_type application/octet-stream;
  
  # Gzip压缩
  gzip on;
  gzip_vary on;
  gzip_min_length 1024;
  gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
  
  # Brotli压缩(如果支持)
  # brotli on;
  # brotli_comp_level 6;
  
  server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;
    
    # 安全头
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
    
    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
      expires 1y;
      add_header Cache-Control "public, immutable";
    }
    
    # SPA路由处理
    location / {
      try_files $uri $uri/ /index.html;
    }
    
    # API代理(可选)
    location /api/ {
      proxy_pass http://backend:8080/;
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
    }
  }
}

8.2 GitHub Actions CI/CD流水线

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'
  PNPM_VERSION: '8'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          
      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: ${{ env.PNPM_VERSION }}
          
      - name: Get pnpm store directory
        shell: bash
        run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
        
      - name: Setup pnpm cache
        uses: actions/cache@v3
        with:
          path: ${{ env.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
        
      - name: Run tests
        run: pnpm test
        
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        
  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js and pnpm
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          
      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: ${{ env.PNPM_VERSION }}
          
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
        
      - name: Build application
        run: pnpm build
        env:
          VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }}
          VITE_APP_VERSION: ${{ github.sha }}
          
      - name: Build Docker image
        run: |
          docker build -t myapp:${{ github.sha }} .
          docker tag myapp:${{ github.sha }} myapp:latest
          
      - name: Deploy to production
        run: |
          echo "部署到生产环境"
          # 实际部署命令

8.3 性能监控与错误追踪

Web Vitals监控集成:

// src/utils/performance.ts
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'

const vitalsUrl = 'https://vitals.vercel-analytics.com/v1/vitals'

function getConnectionSpeed() {
  return 'connection' in navigator &&
    navigator.connection &&
    'effectiveType' in navigator.connection
    ? navigator.connection.effectiveType
    : ''
}

function sendToAnalytics(metric, options) {
  const body = {
    dsn: process.env.VITE_ANALYTICS_ID,
    id: metric.id,
    page: window.location.pathname,
    href: window.location.href,
    event_name: metric.name,
    value: metric.value.toString(),
    speed: getConnectionSpeed(),
    ...options,
  }
  
  if (navigator.sendBeacon) {
    navigator.sendBeacon(vitalsUrl, JSON.stringify(body))
  } else {
    fetch(vitalsUrl, {
      body: JSON.stringify(body),
      method: 'POST',
      keepalive: true,
    })
  }
}

// 初始化性能监控
export function initPerformanceMonitoring() {
  try {
    getCLS(sendToAnalytics)
    getFID(sendToAnalytics)
    getFCP(sendToAnalytics)
    getLCP(sendToAnalytics)
    getTTFB(sendToAnalytics)
  } catch (err) {
    console.error('Performance monitoring failed:', err)
  }
}

总结

2025年的React工程实践已经发生了显著变化:

  1. 构建工具革命: Vite替代Webpack成为主流,带来10倍+性能提升
  2. 状态管理进化: Zustand成为中小型项目首选,更轻量更灵活
  3. React新特性: 并发渲染、Suspense、useTransition成为标准实践
  4. 测试现代化: Vitest+Playwright替代Jest+Cypress
  5. 部署优化: 基于Vite的容器化部署,更快的构建速度

高级开发者需要掌握这些现代化技术栈,才能在竞争激烈的市场中保持竞争优势。

6. React 18/19 高级特性与性能优化

6.1 并发渲染特性实践

Suspense边界的企业级应用:

现代React应用中,Suspense不仅用于代码分割,更是构建响应式用户体验的核心工具。React 18/19的并发特性让Suspense变得更加强大和实用。

import { Suspense, lazy, startTransition } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

// 智能加载组件
const DataDashboard = lazy(() => {
  return Promise.all([
    import('./DataDashboard'),
    // 预加载相关资源
    import('./charts/LineChart'),
    import('./charts/BarChart'),
    // 模拟最小加载时间,避免闪烁
    new Promise(resolve => setTimeout(resolve, 500))
  ]).then(([module]) => module)
})

const ReportsPage = lazy(() => import('./ReportsPage'))
const SettingsPage = lazy(() => import('./SettingsPage'))

// 多层次Suspense架构
function App() {
  const [currentTab, setCurrentTab] = useState('dashboard')
  const [isPending, startTransition] = useTransition()
  
  const handleTabChange = (tab) => {
    // 使用transition避免阻塞UI
    startTransition(() => {
      setCurrentTab(tab)
    })
  }
  
  return (
    <div className="app">
      <nav>
        <button 
          onClick={() => handleTabChange('dashboard')}
          className={isPending ? 'loading' : ''}
        >
          仪表板
        </button>
        <button onClick={() => handleTabChange('reports')}>
          报告
        </button>
        <button onClick={() => handleTabChange('settings')}>
          设置
        </button>
      </nav>
      
      {/* 全局错误边界 */}
      <ErrorBoundary
        fallback={<ErrorFallback />}
        onError={(error, errorInfo) => {
          // 错误上报
          errorReporting.captureException(error, {
            extra: errorInfo
          })
        }}
      >
        {/* 页面级Suspense */}
        <Suspense fallback={<PageSkeleton />}>
          {currentTab === 'dashboard' && (
            // 组件级Suspense
            <Suspense fallback={<DashboardSkeleton />}>
              <DataDashboard />
            </Suspense>
          )}
          {currentTab === 'reports' && <ReportsPage />}
          {currentTab === 'settings' && <SettingsPage />}
        </Suspense>
      </ErrorBoundary>
    </div>
  )
}

// 智能骨架屏组件
function PageSkeleton() {
  return (
    <div className="page-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-content">
        <div className="skeleton-sidebar" />
        <div className="skeleton-main" />
      </div>
    </div>
  )
}

// 特定组件的骨架屏
function DashboardSkeleton() {
  return (
    <div className="dashboard-skeleton">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="chart-skeleton" />
      ))}
    </div>
  )
}

React 19的新特性集成:

// 使用React 19的useActionState
import { useActionState } from 'react'

function ContactForm() {
  const [state, formAction] = useActionState(async (prevState, formData) => {
    try {
      await submitContact({
        name: formData.get('name'),
        email: formData.get('email'),
        message: formData.get('message')
      })
      return { success: true, message: '提交成功!' }
    } catch (error) {
      return { success: false, message: error.message }
    }
  }, { success: null, message: '' })
  
  return (
    <form action={formAction}>
      {state.message && (
        <div className={`alert ${state.success ? 'success' : 'error'}`}>
          {state.message}
        </div>
      )}
      
      <input name="name" placeholder="姓名" required />
      <input name="email" type="email" placeholder="邮箱" required />
      <textarea name="message" placeholder="消息" required />
      
      <button type="submit">
        提交
      </button>
    </form>
  )
}

// React 19的use Hook用法
import { use, Suspense } from 'react'

function UserProfile({ userPromise }) {
  // use Hook可以在组件中消费Promise
  const user = use(userPromise)
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

function UserPage({ userId }) {
  // 创建用户数据Promise
  const userPromise = useMemo(() => 
    fetchUser(userId), [userId]
  )
  
  return (
    <Suspense fallback={<div>加载用户信息...</div>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

6.2 现代化性能优化策略

基于Vite的构建时优化:

// vite.config.ts - 高级性能优化配置
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { splitVendorChunkPlugin } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    react({
      // 启用React DevTools profiling
      plugins: [
        ['@swc/plugin-react-remove-properties', {
          properties: ['data-testid']
        }]
      ]
    }),
    
    // 智能vendor分包
    splitVendorChunkPlugin(),
    
    // 构建分析
    visualizer({
      filename: 'dist/stats.html',
      open: true,
      gzipSize: true,
      brotliSize: true,
    })
  ],
  
  build: {
    // 代码分割策略优化
    rollupOptions: {
      output: {
        manualChunks: {
          // React核心
          'react-core': ['react', 'react-dom'],
          // 路由相关
          'router': ['react-router-dom', 'history'],
          // 状态管理
          'state': ['zustand', '@reduxjs/toolkit'],
          // UI组件库
          'ui-vendor': ['@mui/material', '@mui/icons-material'],
          // 工具库
          'utils': ['lodash-es', 'date-fns', 'ramda'],
          // 图表库
          'charts': ['recharts', 'chart.js', 'echarts'],
        },
        // 优化chunk命名
        chunkFileNames: (chunkInfo) => {
          const facadeModuleId = chunkInfo.facadeModuleId
          if (facadeModuleId) {
            const moduleName = facadeModuleId.split('/').pop()?.replace(/\.[^.]*$/, '')
            return `js/${moduleName}-[hash].js`
          }
          return 'js/[name]-[hash].js'
        },
        assetFileNames: (assetInfo) => {
          const extType = assetInfo.name?.split('.').pop()
          if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType || '')) {
            return `images/[name]-[hash].[ext]`
          }
          if (/woff2?|eot|ttf|otf/i.test(extType || '')) {
            return `fonts/[name]-[hash].[ext]`
          }
          return `assets/[name]-[hash].[ext]`
        }
      },
    },
    
    // 压缩优化
    minify: 'esbuild',
    target: 'es2020',
    
    // CSS代码分割
    cssCodeSplit: true,
    
    // 资源内联阈值
    assetsInlineLimit: 4096,
    
    // 开启压缩
    reportCompressedSize: true,
  },
  
  // 依赖预构建优化
  optimizeDeps: {
    include: [
      'react/jsx-runtime',
      'react-dom/client',
      'react-router-dom',
      'zustand',
      '@mui/material/styles',
      '@emotion/react',
      '@emotion/styled',
    ],
    esbuildOptions: {
      target: 'es2020',
      define: {
        global: 'globalThis'
      },
    },
  },
})

运行时性能优化策略:

import { memo, useMemo, useCallback, startTransition } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'

// 虚拟滚动优化大列表
function VirtualizedList({ items, height = 400 }) {
  const parentRef = useRef()
  
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 5, // 预渲染额外行数
  })
  
  return (
    <div
      ref={parentRef}
      style={{ height, overflow: 'auto' }}
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <ListItem item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

// 智能memo组件
const ListItem = memo(({ item }) => {
  return (
    <div className="list-item">
      <h3>{item.title}</h3>
      <p>{item.description}</p>
    </div>
  )
}, (prevProps, nextProps) => {
  // 自定义比较逻辑
  return prevProps.item.id === nextProps.item.id &&
         prevProps.item.title === nextProps.item.title
})

// 复杂计算的优化
function DataVisualization({ rawData, filters, sortBy }) {
  // 使用useMemo缓存昂贵的计算
  const processedData = useMemo(() => {
    console.log('Processing data...') // 只在依赖变化时执行
    
    return rawData
      .filter(item => {
        return Object.entries(filters).every(([key, value]) => {
          if (!value) return true
          return item[key]?.toString().toLowerCase().includes(value.toLowerCase())
        })
      })
      .sort((a, b) => {
        if (sortBy.direction === 'asc') {
          return a[sortBy.field] > b[sortBy.field] ? 1 : -1
        }
        return a[sortBy.field] < b[sortBy.field] ? 1 : -1
      })
  }, [rawData, filters, sortBy])
  
  // 稳定的事件处理器
  const handleItemClick = useCallback((item) => {
    startTransition(() => {
      // 非紧急更新
      onItemSelect(item)
    })
  }, [onItemSelect])
  
  return (
    <VirtualizedList 
      items={processedData}
      onItemClick={handleItemClick}
    />
  )
}

Core Web Vitals优化实践:

// 性能监控组件
function PerformanceMonitor() {
  useEffect(() => {
    // 监控Core Web Vitals
    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
      getCLS(console.log)
      getFID(console.log)
      getFCP(console.log)
      getLCP(console.log)
      getTTFB(console.log)
    })
    
    // 监控长任务
    if ('PerformanceObserver' in window) {
      const observer = new PerformanceObserver((entryList) => {
        const entries = entryList.getEntries()
        entries.forEach((entry) => {
          if (entry.duration > 50) {
            console.warn('Long task detected:', entry)
            // 上报长任务
            analytics.track('long_task', {
              duration: entry.duration,
              startTime: entry.startTime
            })
          }
        })
      })
      
      observer.observe({ entryTypes: ['longtask'] })
      
      return () => observer.disconnect()
    }
  }, [])
  
  return null
}

// 图片懒加载优化
function OptimizedImage({ src, alt, className, ...props }) {
  const [imageSrc, setImageSrc] = useState()
  const [imageRef, inView] = useInView({
    triggerOnce: true,
    threshold: 0.1,
  })
  
  useEffect(() => {
    if (inView && src) {
      // 预加载高优先级图片
      const img = new Image()
      img.onload = () => setImageSrc(src)
      img.src = src
    }
  }, [inView, src])
  
  return (
    <div ref={imageRef} className={className}>
      {imageSrc ? (
        <img 
          src={imageSrc} 
          alt={alt} 
          loading="lazy"
          decoding="async"
          {...props}
        />
      ) : (
        <div className="image-placeholder" />
      )}
    </div>
  )
}

// 字体加载优化
function FontOptimizer() {
  useEffect(() => {
    // 预加载关键字体
    const link = document.createElement('link')
    link.rel = 'preload'
    link.href = '/fonts/inter-var.woff2'
    link.as = 'font'
    link.type = 'font/woff2'
    link.crossOrigin = 'anonymous'
    document.head.appendChild(link)
    
    // 字体加载完成后移除fallback样式
    document.fonts.ready.then(() => {
      document.documentElement.classList.add('fonts-loaded')
    })
  }, [])
  
  return null
}

7. 现代化测试策略

错误边界与监控

完整的错误处理体系:

// components/ErrorBoundary.jsx
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { 
      hasError: false, 
      error: null, 
      errorInfo: null 
    };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    this.setState({
      error: error,
      errorInfo: errorInfo
    });
    
    // 上报错误到监控系统
    errorMonitoringService.captureException(error, {
      extra: {
        componentStack: errorInfo.componentStack
      }
    });
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>Something went wrong.</h2>
          <details>
            {this.state.error && this.state.error.toString()}
            <br />
            {this.state.errorInfo.componentStack}
          </details>
          <button onClick={() => window.location.reload()}>
            Reload Page
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// 使用示例
function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<LoadingSpinner />}>
        <Router>
          <Routes>
            <Route path="/" element={<Home />} />
            {/* 其他路由 */}
          </Routes>
        </Router>
      </Suspense>
    </ErrorBoundary>
  );
}

性能监控集成

React性能监控:

// hooks/usePerformance.js
function usePerformance(componentName) {
  const mountTime = useRef(performance.now());
  const renderCount = useRef(0);
  
  useEffect(() => {
    const measure = () => {
      const now = performance.now();
      const renderTime = now - mountTime.current;
      
      renderCount.current++;
      
      // 发送性能数据
      performanceMonitor.trackRender({
        component: componentName,
        renderTime: renderTime,
        renderCount: renderCount.current,
        timestamp: now
      });
      
      mountTime.current = now;
    };
    
    measure();
  });
}

// 在组件中使用
function ExpensiveComponent() {
  usePerformance('ExpensiveComponent');
  
  // 组件逻辑
  return <div>...</div>;
}

总结

React工程实践面试题主要考察项目架构设计、状态管理策略、路由管理、测试体系、部署流程和监控系统等方面的实践经验。高级开发者需要具备完整的工程化思维,能够设计可维护、可扩展的React应用架构。

【TS 设计模式完全指南】TypeScript 装饰器模式的优雅之道

作者 烛阴
2025年9月13日 10:51

一、什么是装饰器模式?

装饰器模式(Decorator Pattern)是一种结构型设计模式,它允许你在不修改现有对象结构的情况下,动态地为对象添加新的功能。它通过将对象包装在一个“装饰器”对象中来实现,这个装饰器对象提供了与原对象相同的接口,并增加了额外的行为。

二、装饰器模式的核心组件

  1. 组件 (Component):定义了原始对象和装饰器对象的共同接口。客户端代码将通过这个接口与所有对象(无论是否被装饰)进行交互。
  2. 具体组件 (Concrete Component):这是我们要装饰的原始对象,它实现了组件接口。
  3. 基础装饰器 (Base Decorator):一个抽象类,它也实现了组件接口。它内部持有一个对另一个“组件”对象的引用(可以是具体组件,也可以是另一个装饰器)。它的主要职责是将所有请求转发给被包装的对象。
  4. 具体装饰器 (Concrete Decorator):继承自基础装饰器,负责为组件添加特定的新功能。它会在转发请求之前或之后执行自己的附加逻辑。

三、示例:打造一个多渠道通知器

3.1 定义组件接口 (Component)

这是所有通知器都必须遵守的契约。

// Component: 通知器的统一接口
interface INotifier {
    send(message: string): void;
}

3.2 创建具体组件 (Concrete Component)

这是我们的基础,最核心的通知功能。

// Concrete Component: 基础通知器,只打印到控制台
class BaseNotifier implements INotifier {
    send(message: string): void {
        console.log(`[站内信]:发送消息 - "${message}"`);
    }
}

3.3 创建基础装饰器 (Base Decorator)

这个抽象类是所有装饰器的父类,它负责“包装”和“代理”。

// Base Decorator: 装饰器基类
abstract class NotifierDecorator implements INotifier {
    protected wrappedNotifier: INotifier;

    constructor(notifier: INotifier) {
        this.wrappedNotifier = notifier;
    }

    // 将 send 请求委托给被包装的对象
    send(message: string): void {
        this.wrappedNotifier.send(message);
    }
}

3.4. 创建具体装饰器 (Concrete Decorators)

现在来制作我们的实际业务:发送短信和邮件

// Concrete Decorator 1: 短信通知装饰器
class SMSDecorator extends NotifierDecorator {
    send(message: string): void {
        super.send(message); // 首先调用原始的 send 方法
        console.log(`[短信]:发送消息 - "${message}"`);
    }
}

// Concrete Decorator 2: 邮件通知装饰器
class EmailDecorator extends NotifierDecorator {
    send(message: string): void {
        super.send(message); // 调用原始方法
        console.log(`[邮件]:发送消息 - "${message}"`);
    }
}

注意 super.send(message) 的调用,它确保了包装链上的原始功能得以执行。你也可以把自己的逻辑放在它前面,实现前置处理。

3.5 自由组合,动态增强!

现在,我们可以像搭积木一样,自由地组合这些功能。

const message = '您的订单已发货!';

// 1. 只需要一个基础通知器
console.log('--- 基础通知 ---');
const basicNotifier = new BaseNotifier();
basicNotifier.send(message);

console.log('\n' + '='.repeat(30) + '\n');

// 2. 我需要站内信 + 短信
console.log('--- 站内信 + 短信 ---');
let notifierWithSMS = new BaseNotifier();
notifierWithSMS = new SMSDecorator(notifierWithSMS);
notifierWithSMS.send(message);

console.log('\n' + '='.repeat(30) + '\n');

// 3. 我需要站内信 + 短信 + 邮件
console.log('--- 站内信 + 短信 + 邮件 ---');
let notifierWithAll = new BaseNotifier();
notifierWithAll = new SMSDecorator(notifierWithAll);
notifierWithAll = new EmailDecorator(notifierWithAll);
notifierWithAll.send(message);

console.log('\n' + '='.repeat(30) + '\n');

// 4. 还可以有更疯狂的组合:站内信 + 邮件 + 再次短信(如果业务需要)
console.log('--- 疯狂组合 ---');
let crazyNotifier: INotifier = new BaseNotifier();
crazyNotifier = new EmailDecorator(crazyNotifier);
crazyNotifier = new SMSDecorator(crazyNotifier);
crazyNotifier.send('系统即将维护,请注意!');

运行结果:

--- 基础通知 ---
[站内信]:发送消息 - "您的订单已发货!"

==============================

--- 站内信 + 短信 ---
[站内信]:发送消息 - "您的订单已发货!"
[短信]:发送消息 - "您的订单已发货!"

==============================

--- 站内信 + 短信 + 邮件 ---
[站内信]:发送消息 - "您的订单已发货!"
[短信]:发送消息 - "您的订单已发货!"
[邮件]:发送消息 - "您的订单已发货!"

==============================

--- 疯狂组合 ---
[站内信]:发送消息 - "系统即将维护,请注意!"
[邮件]:发送消息 - "系统即将维护,请注意!"
[短信]:发送消息 - "系统即将维护,请注意!"

为了方便大家学习和实践,本文的所有示例代码和完整项目结构都已整理上传至我的 GitHub 仓库。欢迎大家克隆、研究、提出 Issue,共同进步!

📂 核心代码与完整示例: GoF

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

Kotlin学习第 5 课:Kotlin 面向对象编程:类、对象与继承

作者 叽哥
2025年9月13日 10:30

在编程领域,面向对象编程(OOP)是一种主流的编程范式,它将数据和操作数据的方法封装在一起,通过类、对象、继承、多态等特性,让代码更具模块化、可复用性和可维护性。Kotlin 作为一门现代编程语言,对面向对象编程提供了非常优雅且强大的支持,今天我们就从 “类” 这个核心概念入手,逐步深入讲解 Kotlin 面向对象编程的关键知识点。

一、类的定义与实例化:OOP 的基础单元

类是面向对象编程的 “模板”,它定义了对象的属性(数据)和方法(行为);而对象则是类的 “实例”,是根据模板创建出的具体实体。在 Kotlin 中,我们通过 class 关键字来定义类,整个过程比 Java 更简洁。

1. 类的基本定义:class 关键字

Kotlin 中类的最小定义非常简单,甚至可以只有一个类名(无属性、无方法),语法格式为:
class 类名

比如定义一个空的 Person 类:

// 空类:无属性、无方法,仅作为模板
class Person

但实际开发中,类通常会包含属性(成员变量,存储数据)和方法(成员函数,处理逻辑)。比如给 Person 类添加 “姓名”“年龄” 属性和 “自我介绍” 方法:

class Person {
    // 类的属性(成员变量):var(可变)/ val(不可变)+ 名称 + 类型 + 可选默认值
    var name: String = "未知"
    var age: Int = 0

    // 类的方法(成员函数)
    fun introduce() {
        println("大家好,我叫$name,今年$age 岁")
    }
}

2. 构造函数:初始化对象的 “入口”

构造函数是创建对象时自动调用的方法,用于初始化对象的属性。Kotlin 中的构造函数分为主构造函数次构造函数,两者分工不同但可配合使用。

(1)主构造函数:类头中的 “简洁入口”

主构造函数是类的 “主要初始化入口”,直接定义在类名后面,语法格式:
class 类名(参数列表) { ... }

它的特点是:无函数体,初始化逻辑需放在 init 初始化块中;参数可直接用于初始化属性(甚至可省略属性定义,直接用 var/val 修饰参数,自动转为类属性)。

举个例子,用主构造函数简化 Person 类:

// 主构造函数:(name: String, age: Int),用var修饰参数,自动转为类属性
class Person(var name: String, var age: Int) {
    // init 初始化块:主构造函数的“函数体”,创建对象时会执行
    init {
        println("Person 初始化:name=$name, age=$age")
        // 可在这里添加初始化逻辑(如参数校验)
        require(age >= 0) { "年龄不能为负数" }
    }

    fun introduce() {
        println("大家好,我叫$name,今年$age 岁")
    }
}

(2)次构造函数:主构造函数的 “补充入口”

次构造函数是主构造函数的补充,用于支持更多的初始化场景(比如不同的参数组合)。它通过 constructor 关键字定义,且必须直接或间接调用主构造函数(用 this() 表示)。

比如给 Person 类添加一个 “只传姓名,年龄默认 18” 的次构造函数:

class Person(var name: String, var age: Int) {
    init {
        println("Person 初始化:name=$name, age=$age")
        require(age >= 0) { "年龄不能为负数" }
    }

    // 次构造函数:只传姓名,年龄默认18,通过this()调用主构造函数
    constructor(name: String) : this(name, 18) {
        println("次构造函数执行:年龄默认18")
    }

    fun introduce() {
        println("大家好,我叫$name,今年$age 岁")
    }
}

3. 创建类的实例:省略 new 关键字

在 Java 中创建对象需要 new 关键字,但 Kotlin 简化了这一步 —— 直接调用构造函数即可创建实例,语法:
val/var 对象名 = 类名(构造函数参数)

以上面的 Person 类为例,创建实例:

// 调用主构造函数:name="张三", age=20
val zhangsan = Person("张三", 20)
zhangsan.introduce() // 输出:大家好,我叫张三,今年20岁

// 调用次构造函数:只传name,age默认18
val lisi = Person("李四")
lisi.introduce() // 输出:大家好,我叫李四,今年18岁


二、类的属性详解:控制数据的访问与初始化

类的属性是存储数据的核心,Kotlin 对属性的控制非常灵活 —— 支持自定义访问器、延迟初始化、惰性初始化等特性,解决了 Java 中 “字段 + getter/setter” 的冗余问题。

1. 属性的定义:四要素缺一不可

Kotlin 中属性的完整定义格式为:
var/val 属性名: 数据类型 = 默认值

  • var:表示可变属性(可读写,会自动生成 getter 和 setter);
  • val:表示不可变属性(只读,仅自动生成 getter,初始化后不能修改);
  • 数据类型:必须显式声明(除非有默认值且能被 Kotlin 自动推导);
  • 默认值:可选,若没有默认值,必须在构造函数或初始化块中赋值。

示例:

class Student {
    // val 不可变属性:只能读,不能改
    val studentId: String = "2024001" // 有默认值,类型可推导(可省略: String)
    // var 可变属性:可读写
    var score: Double = 0.0 // 无默认值会报错,必须初始化
    // 无默认值的属性:需在init块或构造函数中赋值
    var grade: Int
    init {
        grade = 1 // 在init块中初始化grade
    }
}

2. 属性的访问器:自定义 getter 与 setter

Kotlin 会为属性自动生成默认的 getter(读取属性时调用)和 setter(修改属性时调用),但我们也可以根据需求自定义访问器,实现数据校验、格式转换等逻辑。

自定义访问器的语法:

var 属性名: 类型 = 默认值
    get() { // 自定义getter
        return 处理后的结果
    }
    set(value) { // 自定义setter,value是赋值时的参数(类型与属性一致)
        // 自定义逻辑(如参数校验)
        field = value // field 是“幕后字段”,表示属性的实际存储值
    }

示例:给 “年龄” 属性添加范围校验

class Person {
    var name: String = "未知"
    // 自定义age的setter:确保年龄在0-150之间
    var age: Int = 0
        set(value) {
            if (value < 0 || value > 150) {
                throw IllegalArgumentException("年龄必须在0-150之间")
            }
            field = value // 符合条件,赋值给幕后字段
        }
    // 自定义name的getter:
    get() {
        return field
    }
}

// 测试
val person = Person()
person.name = "zhangsan"
println(person.name) // 输出:ZHANGSAN(getter自动转大写)

person.age = 200 // 抛出异常:年龄必须在0-150之间

注意:val 属性不能自定义 setter(因为它是只读的),只能自定义 getter。

3. 延迟初始化属性:lateinit 关键字

当属性的初始化逻辑不能在声明时完成(比如依赖后续的网络请求、数据库查询),但我们能保证在使用前一定会初始化,此时可以用 lateinit 关键字标记属性,避免 “必须初始化” 的编译错误。

用法与限制:

  • 仅用于 var 属性(不可变的 val 不行,因为 val 必须在初始化时确定值);
  • 仅用于非基本数据类型(如 String、自定义类,不能用于 IntDouble 等,基本类型需用 lateinit var age: Int? = null 或 Delegates.notNull());
  • 使用前必须初始化,否则会抛出 UninitializedPropertyAccessException
  • 可通过 ::属性名.isInitialized 判断是否已初始化。

示例:

class UserManager {
    // lateinit 标记:延迟初始化,声明时不赋值
    lateinit var user: User

    // 模拟网络请求后初始化user
    fun loadUser() {
        user = User("张三", "123456") // 初始化
    }

    fun printUser() {
        // 判断是否已初始化,避免异常
        if (::user.isInitialized) {
            println(user.name)
        } else {
            println("user 未初始化")
        }
    }
}

class User(val name: String, val password: String)

// 测试
val manager = UserManager()
manager.printUser() // 输出:user 未初始化
manager.loadUser()
manager.printUser() // 输出:张三

4. 惰性初始化属性:by lazy 关键字

“惰性初始化” 指属性在首次被访问时才初始化(而非创建对象时),适用于初始化成本较高的场景(如加载大文件、初始化复杂工具类)。Kotlin 用 by lazy 实现惰性初始化,语法:
val 属性名: 类型 by lazy { 初始化逻辑 }

用法与特点:

  • 仅用于 val 属性(因为初始化后值不会变,符合惰性初始化的语义);
  • 初始化逻辑在首次访问时执行,且仅执行一次(线程安全,默认用 LazyThreadSafetyMode.SYNCHRONIZED,多线程下仅一个线程执行初始化);
  • 初始化逻辑是一个 lambda 表达式,返回值即为属性的初始值。

示例:模拟加载配置文件(初始化成本高)

class ConfigLoader {
    // 惰性初始化:首次访问config时才加载配置
    val config: Map<String, String> by lazy {
        println("开始加载配置文件...")
        // 模拟加载逻辑(如读取文件、解析JSON)
        mapOf(
            "serverUrl" to "https://api.example.com",
            "timeout" to "5000"
        )
    }
}

// 测试
val loader = ConfigLoader()
println("ConfigLoader 创建完成,未加载配置") // 此时config未初始化
val serverUrl = loader.config["serverUrl"] // 首次访问,执行初始化
println("服务器地址:$serverUrl") // 输出:服务器地址:https://api.example.com
val timeout = loader.config["timeout"] // 再次访问,不执行初始化
println("超时时间:$timeout") // 输出:超时时间:5000


三、继承与多态:实现代码复用与扩展

继承是面向对象的核心特性之一,它允许一个类(子类)继承另一个类(父类)的属性和方法,从而实现代码复用;多态则允许子类重写父类的方法,让同一行为有不同的实现。

Kotlin 为了避免 “菱形继承” 等问题,默认所有类都是不可继承的(相当于 Java 中的 final 类),若要允许继承,必须用 open 关键字标记父类。

1. 父类与子类:open 关键字与继承语法

(1)定义可继承的父类:open 关键字

要让一个类可被继承,需在 class 前加 open;若要让父类的方法可被子类重写,需在方法前加 open

示例:定义可继承的 Animal 父类

// open 关键字:允许Animal被继承
open class Animal(val name: String) {
    // open 关键字:允许eat()方法被重写
    open fun eat() {
        println("$name 在吃食物")
    }

    fun sleep() {
        println("$name 在睡觉")
    }
}

(2)定义子类:继承语法 : 父类名(构造函数参数)

子类通过 : 继承父类,且必须在子类的构造函数中调用父类的构造函数(用 父类名(参数) 表示,类似 Java 的 super())。

示例:定义 Dog 子类继承 Animal

// 子类Dog:继承Animal,主构造函数参数name传给父类
class Dog(name: String) : Animal(name) {
    // override 关键字:重写父类的open方法
    override fun eat() {
        println("$name 在吃骨头") // 子类的具体实现
    }
}

2. 方法重写:override 关键字

子类重写父类的方法时,必须满足两个条件:

  1. 父类方法必须用 open 标记;
  2. 子类方法必须用 override 标记(强制显式声明,避免误重写)。

重写后,子类对象调用方法时,会优先执行子类的实现(多态特性):

// 测试多态
val animal1: Animal = Animal("通用动物")
animal1.eat() // 输出:通用动物 在吃食物(父类实现)

val animal2: Animal = Dog("旺财") // 父类引用指向子类对象(多态的核心)
animal2.eat() // 输出:旺财 在吃骨头(子类实现)
animal2.sleep() // 输出:旺财 在睡觉(父类未重写的方法)

3. 属性重写:override 同样适用

除了方法,Kotlin 也支持属性重写 —— 子类可以重写父类的 open 属性,实现不同的取值逻辑。

属性重写的规则:

  • 父类属性必须用 open 标记;
  • 子类属性必须用 override 标记;
  • val 子类属性可以重写 val 或 var 父类属性(因为 val 是只读,扩展为 var 是允许的);
  • var 子类属性不能重写 val 父类属性(因为 val 没有 setter,无法扩展为可写)。

示例:属性重写

open class Person {
    // open 属性:允许被重写
    open val description: String
        get() = "这是一个人"
}

class Student : Person() {
    // 重写父类的description属性
    override val description: String
        get() = "这是一个学生"
}

// 测试
val person: Person = Student()
println(person.description) // 输出:这是一个学生(子类实现)

4. 抽象类与抽象方法:强制子类实现

抽象类是 “不完全的类”,它包含抽象方法(只有声明,没有实现),必须由子类实现。抽象类用 abstract 关键字标记,抽象方法也用 abstract 标记(无需 open,因为抽象方法默认就是可重写的)。

特点:

  • 抽象类不能直接实例化(必须通过子类);
  • 子类继承抽象类后,必须重写所有抽象方法(除非子类也是抽象类);
  • 抽象类中可以包含非抽象方法(带实现的方法)。

示例:定义抽象类 Shape

// 抽象类:不能实例化
abstract class Shape(val name: String) {
    // 抽象方法:只有声明,无实现,子类必须重写
    abstract fun calculateArea(): Double

    // 非抽象方法:带实现,子类可直接使用
    fun printName() {
        println("图形名称:$name")
    }
}

// 子类Circle:继承抽象类,必须重写calculateArea()
class Circle(name: String, val radius: Double) : Shape(name) {
    override fun calculateArea(): Double {
        return Math.PI * radius * radius // 圆的面积公式
    }
}

// 测试
val circle = Circle("圆形", 5.0)
circle.printName() // 输出:图形名称:圆形
println("圆的面积:${circle.calculateArea()}") // 输出:圆的面积:78.5398...


四、接口:实现多 “能力” 扩展

接口是一种 “行为契约”,它定义了一组方法(或属性)的声明,类通过 “实现接口” 来获取对应的能力。Kotlin 中的接口支持抽象方法、默认方法和属性声明,且一个类可以实现多个接口(解决了类单继承的限制)。

1. 接口的定义:interface 关键字

接口用 interface 关键字定义,语法:

interface 接口名 {
    // 1. 抽象方法:无需abstract,默认就是抽象的
    fun 方法名(参数列表): 返回值类型

    // 2. 默认方法:带实现的方法,用fun...body
    fun 默认方法名(参数列表): 返回值类型 {
        // 实现逻辑
    }

    // 3. 属性声明:只能是抽象的(无幕后字段),或提供getter(不能有setter)
    val 属性名: 类型 // 抽象属性,子类必须实现
    val 带Getter的属性名: 类型
        get() = 初始值 // 提供getter,无需子类实现
}

示例:定义 “学习” 和 “工作” 接口

// 学习接口:定义“学习”能力
interface Study {
    // 抽象方法:学习
    fun study()

    // 默认方法:复习(带实现)
    fun review() {
        println("复习所学内容")
    }

    // 抽象属性:学习时长
    val studyHours: Int

    // 带Getter的属性:学习状态
    val studyStatus: String
        get() = if (studyHours > 2) "认真学习" else "摸鱼中"
}

// 工作接口:定义“工作”能力
interface Work {
    // 抽象方法:工作
    fun work()

    // 带Getter的属性:工作类型
    val workType: String
        get() = "全职"
}

2. 类实现接口:多实现语法

类通过 : 实现接口,多个接口用逗号分隔。实现接口时,必须重写所有接口的抽象方法抽象属性;默认方法和带 Getter 的属性可直接使用,也可重写。

示例:定义 Student 类,同时实现 Study 和 Work 接口

// 实现多个接口:Study和Work
class Student(
    val name: String,
    override val studyHours: Int // 重写Study接口的抽象属性
) : Study, Work {

    // 重写Study接口的抽象方法:study()
    override fun study() {
        println("$name 在学习Kotlin")
    }

    // 可选:重写Study接口的默认方法review()
    override fun review() {
        println("$name 在复习Kotlin面向对象编程")
    }

    // 重写Work接口的抽象方法:work()
    override fun work() {
        println("$name 在做兼职开发")
    }

    // 可选:重写Work接口的带Getter属性workType
    override val workType: String
        get() = "兼职"
}

// 测试
val student = Student("李四", 3)
student.study() // 输出:李四 在学习Kotlin
student.review() // 输出:李四 在复习Kotlin面向对象编程
student.work() // 输出:李四 在做兼职开发
println("学习状态:${student.studyStatus}") // 输出:学习状态:认真学习
println("工作类型:${student.workType}") // 输出:工作类型:兼职

3. 接口与抽象类的区别

很多初学者会混淆接口和抽象类,其实两者的设计目的和用法有本质区别:

对比维度 接口(Interface) 抽象类(Abstract Class)
继承 / 实现数量 一个类可实现多个接口(多实现) 一个类只能继承一个抽象类(单继承)
属性与字段 无幕后字段,属性只能是抽象的或带 Getter 可包含普通字段(如 var age: Int),支持完整属性
构造函数 无构造函数(不能实例化) 有构造函数(但不能直接实例化)
方法实现 可包含抽象方法和默认方法 可包含抽象方法和非抽象方法
设计目的 定义 “能力契约”(has-a 关系),如 “可学习”“可工作” 定义 “is-a 关系”(父子类),如 “Dog is an Animal”

简单来说:需要复用代码、表示父子关系时用抽象类;需要扩展能力、支持多实现时用接口



五、数据类与密封类:Kotlin 的 “特色类”

除了普通类、抽象类,Kotlin 还提供了两种特殊的类 —— 数据类(data class)和密封类(sealed class),分别用于简化 “数据存储” 和 “有限子类” 场景。

1. 数据类:自动生成常用方法

数据类是专门用于存储数据的类(如实体类、DTO),Kotlin 会为数据类自动生成以下方法,无需手动编写:

  • equals():判断两个对象的属性是否相等;
  • hashCode():根据属性生成哈希值;
  • toString():返回 “类名 (属性 1 = 值 1, 属性 2 = 值 2, ...)” 格式的字符串;
  • componentN():用于解构赋值(如 val (name, age) = person);
  • copy():复制对象并修改部分属性(避免修改原对象)。

定义数据类:data class 关键字

语法:data class 类名(主构造函数参数)
注意事项:

  • 主构造函数至少包含一个参数;
  • 参数最好用 val 修饰(确保不可变,符合数据类的语义);
  • 不能是抽象类、密封类、内部类或枚举类。

示例:定义 User 数据类

// 数据类:自动生成equals、hashCode、toString、copy等方法
data class User(val id: Int, val name: String, val age: Int)

// 测试
val user1 = User(1, "张三", 20)
val user2 = User(1, "张三", 20)
val user3 = User(2, "李四", 18)

// 1. toString():自动生成格式化字符串
println(user1) // 输出:User(id=1, name=张三, age=20)

// 2. equals():判断属性是否相等
println(user1 == user2) // 输出:true(属性相同)
println(user1 == user3) // 输出:false(属性不同)

// 3. copy():复制对象并修改部分属性
val user4 = user1.copy(age = 21) // 复制user1,修改age为21
println(user4) // 输出:User(id=1, name=张三, age=21)

// 4. 解构赋值:通过componentN()获取属性
val (id, name, age) = user1
println("id=$id, name=$name, age=$age") // 输出:id=1, name=张三, age=20

2. 密封类:限制子类范围,优化 when 判断

密封类(sealed class)是一种 “有限层次结构” 的类,它的子类范围被严格限制 ——子类只能定义在密封类的内部或同一个文件中,不能在其他文件中定义子类。

密封类的核心优势是:在 when 表达式中匹配子类时,无需添加 else 分支(Kotlin 编译时能确定所有子类,避免遗漏),从而提高代码的安全性和可读性。

定义密封类:sealed class 关键字

语法:

sealed class 密封类名 {
    // 子类1:定义在密封类内部
    class 子类1(参数列表) : 密封类名()
    // 子类2:定义在密封类内部
    object 子类2 : 密封类名() // 若子类无属性,可用object(单例)
}

// 子类3:定义在同一个文件中
class 子类3(参数列表) : 密封类名()

示例:定义 “网络请求结果” 密封类 Result

// 密封类:限制子类只能是Success、Error、Loading
sealed class Result<out T> { // 泛型T:表示成功时的数据类型
    // 成功:带数据
    data class Success<out T>(val data: T) : Result<T>()
    // 失败:带错误信息
    data class Error(val message: String) : Result<Nothing>()
    // 加载中:无数据,用object(单例)
    object Loading : Result<Nothing>()
}

// 处理请求结果的函数
fun <T> handleResult(result: Result<T>) {
    // when 匹配:无需else,因为编译时知道所有子类
    when (result) {
        is Result.Success -> println("请求成功,数据:${result.data}")
        is Result.Error -> println("请求失败,原因:${result.message}")
        Result.Loading -> println("请求中,请等待...")
    }
}

// 测试
val successResult = Result.Success("Kotlin 面向对象教程")
val errorResult = Result.Error("网络连接超时")
val loadingResult = Result.Loading

handleResult(successResult) // 输出:请求成功,数据:Kotlin 面向对象教程
handleResult(errorResult) // 输出:请求失败,原因:网络连接超时
handleResult(loadingResult) // 输出:请求中,请等待...


六、对象表达式与对象声明(单例)

除了类和接口,Kotlin 还提供了 “对象” 相关的特性,用于简化匿名内部类和单例模式的实现。

1. 对象表达式:替代匿名内部类

在 Java 中,我们常用匿名内部类(如 new OnClickListener() { ... }),而 Kotlin 用 “对象表达式”(object : 父类/接口 { ... })替代,语法更简洁。

示例:模拟按钮点击监听

// 定义点击监听接口
interface OnClickListener {
    fun onClick()
}

// 模拟按钮类
class Button {
    var onClickListener: OnClickListener? = null

    fun click() {
        onClickListener?.onClick()
    }
}

// 测试:用对象表达式实现OnClickListener
val button = Button()
button.onClickListener = object : OnClickListener {
    override fun onClick() {
        println("按钮被点击了!")
    }
}

button.click() // 输出:按钮被点击了!

2. 对象声明:实现单例模式

单例模式是一种常用的设计模式,确保一个类只有一个实例。Kotlin 用 “对象声明”(object 类名 { ... })直接实现单例,无需手动处理线程安全和实例创建。

特点:

  • 对象声明是饿汉式单例(类加载时创建实例,线程安全);
  • 不能有构造函数(因为是单例,只能有一个实例);
  • 通过 类名.属性/方法 直接访问。

示例:定义单例 Singleton

// 对象声明:单例类
object Singleton {
    var count = 0

    fun increment() {
        count++
        println("当前计数:$count")
    }
}

// 测试:直接访问单例,无需实例化
Singleton.increment() // 输出:当前计数:1
Singleton.increment() // 输出:当前计数:2
Singleton.increment() // 输出:当前计数:3

🔥深度解析:Nginx目录浏览美化与功能增强实战指南

作者 子兮曰
2025年9月13日 09:51

引言

Nginx的autoindex功能虽然实用,但默认界面简洁且缺乏现代感。在许多场景下,尤其是需要公开文件目录时(如软件下载站、文档中心),一个美观、专业的界面能显著提升用户体验和品牌形象。

本文将深入探讨Nginx目录美化的技术细节,并提供生产环境的最佳实践方案,包括如何添加搜索、排序等高级功能。

原理解析:Nginx目录列表生成机制

当Nginx的autoindex on指令启用时,它会动态生成HTML页面来显示目录内容。这个页面结构简单,包含基本的文件列表和元信息(大小、修改日期)。我们的美化策略是通过CSS注入和HTML结构调整来增强这个默认输出。

完整实现方案

  1. 创建高级样式文件

创建/usr/share/nginx/html/nginx-custom.css,内容如下:

/* Nginx目录美化样式 */
:root {
    --primary-color: #3498db;
    --secondary-color: #2ecc71;
    --background-color: #f8f9fa;
    --text-color: #333;
    --border-color: #e0e0e0;
    --hover-color: #f1f8ff;
}

body {
    font-family: "Segoe UI", "Helvetica Neue", Roboto, Arial, sans-serif;
    background-color: var(--background-color);
    color: var(--text-color);
    margin: 0;
    padding: 0;
    line-height: 1.6;
}

.container {
    max-width: 1200px;
    margin: 40px auto;
    padding: 0 20px;
}

.header {
    background: white;
    padding: 25px 30px;
    border-radius: 12px 12px 0 0;
    box-shadow: 0 2px 15px rgba(0, 0, 0, 0.08);
    margin-bottom: 2px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.header h1 {
    margin: 0;
    color: var(--primary-color);
    font-size: 24px;
    font-weight: 600;
    display: flex;
    align-items: center;
    gap: 12px;
}

.header h1::before {
    content: "📂";
    font-size: 28px;
}

.search-box {
    position: relative;
}

.search-box input {
    padding: 10px 15px;
    border: 1px solid var(--border-color);
    border-radius: 6px;
    width: 250px;
    font-size: 14px;
}

.file-list {
    background: white;
    border-radius: 0 0 12px 12px;
    box-shadow: 0 2px 15px rgba(0, 0, 0, 0.08);
    overflow: hidden;
}

.list-header {
    display: grid;
    grid-template-columns: 4fr 1fr 2fr;
    padding: 15px 30px;
    background-color: #f6f8fa;
    font-weight: 600;
    border-bottom: 1px solid var(--border-color);
    cursor: pointer;
}

.list-header span:hover {
    color: var(--primary-color);
}

.file-item {
    display: grid;
    grid-template-columns: 4fr 1fr 2fr;
    padding: 15px 30px;
    text-decoration: none;
    color: inherit;
    border-bottom: 1px solid var(--border-color);
    transition: background-color 0.2s ease;
}

.file-item:last-child {
    border-bottom: none;
}

.file-item:hover {
    background-color: var(--hover-color);
}

.file-name {
    display: flex;
    align-items: center;
    gap: 10px;
    font-weight: 500;
}

.file-name::before {
    content: "📄";
}

.file-item[href$="/"] .file-name::before {
    content: "📁";
}

.file-size {
    color: #666;
}

.file-date {
    color: #666;
}

.breadcrumb {
    margin-bottom: 20px;
    font-size: 14px;
}

.breadcrumb a {
    color: var(--primary-color);
    text-decoration: none;
}

.breadcrumb a:hover {
    text-decoration: underline;
}

.footer {
    text-align: center;
    margin-top: 30px;
    color: #777;
    font-size: 14px;
}

/* 排序指示器 */
.sort-indicator {
    margin-left: 5px;
    font-size: 12px;
}

/* 响应式设计 */
@media (max-width: 768px) {
    .header {
        flex-direction: column;
        align-items: flex-start;
        gap: 15px;
    }
    
    .search-box input {
        width: 100%;
    }
    
    .list-header {
        display: none;
    }
    
    .file-item {
        grid-template-columns: 1fr;
        gap: 5px;
        padding: 15px 20px;
    }
    
    .file-size::before {
        content: "大小: ";
        font-weight: 600;
    }
    
    .file-date::before {
        content: "修改时间: ";
        font-weight: 600;
    }
}
  1. 创建JavaScript功能文件

创建/usr/share/nginx/html/nginx-custom.js,内容如下:

// Nginx目录增强功能
document.addEventListener('DOMContentLoaded', function() {
    // 文件类型图标差异化
    function setFileIcons() {
        const items = document.querySelectorAll('.file-item');
        items.forEach(item => {
            const link = item.getAttribute('href');
            const icon = item.querySelector('.file-name');
            
            if (link.endsWith('/')) {
                icon.innerHTML = '<span class="file-icon">📁</span> ' + icon.innerHTML;
            } else if (link.match(/\.(zip|rar|tar|gz|7z)$/i)) {
                icon.innerHTML = '<span class="file-icon">📦</span> ' + icon.innerHTML;
            } else if (link.match(/\.(pdf)$/i)) {
                icon.innerHTML = '<span class="file-icon">📕</span> ' + icon.innerHTML;
            } else if (link.match(/\.(doc|docx|odt)$/i)) {
                icon.innerHTML = '<span class="file-icon">📘</span> ' + icon.innerHTML;
            } else if (link.match(/\.(xls|xlsx|ods)$/i)) {
                icon.innerHTML = '<span class="file-icon">📗</span> ' + icon.innerHTML;
            } else if (link.match(/\.(mp3|wav|flac|ogg)$/i)) {
                icon.innerHTML = '<span class="file-icon">🎵</span> ' + icon.innerHTML;
            } else if (link.match(/\.(mp4|avi|mov|mkv|webm)$/i)) {
                icon.innerHTML = '<span class="file-icon">🎬</span> ' + icon.innerHTML;
            } else if (link.match(/\.(jpg|jpeg|png|gif|webp|bmp)$/i)) {
                icon.innerHTML = '<span class="file-icon">🖼️</span> ' + icon.innerHTML;
            } else {
                icon.innerHTML = '<span class="file-icon">📄</span> ' + icon.innerHTML;
            }
        });
    }

    // 文件搜索功能
    function setupSearch() {
        const searchInput = document.getElementById('fileSearch');
        if (!searchInput) return;
        
        searchInput.addEventListener('input', function() {
            const filter = this.value.toLowerCase();
            const items = document.querySelectorAll('.file-item');
            
            items.forEach(item => {
                const text = item.textContent.toLowerCase();
                item.style.display = text.includes(filter) ? 'grid' : 'none';
            });
        });
    }

    // 文件排序功能
    function setupSorting() {
        const headers = document.querySelectorAll('.list-header span');
        if (!headers.length) return;
        
        let currentSort = { column: -1, direction: 1 }; // 1=asc, -1=desc
        
        headers.forEach((header, index) => {
            header.addEventListener('click', function() {
                // 更新排序指示器
                headers.forEach(h => h.innerHTML = h.textContent);
                
                if (currentSort.column === index) {
                    currentSort.direction *= -1;
                } else {
                    currentSort.column = index;
                    currentSort.direction = 1;
                }
                
                // 添加排序指示器
                const indicator = currentSort.direction === 1 ? '↑' : '↓';
                this.innerHTML += `<span class="sort-indicator">${indicator}</span>`;
                
                // 执行排序
                sortTable(index, currentSort.direction);
            });
        });
        
        function sortTable(column, direction) {
            const container = document.querySelector('.file-list');
            const items = Array.from(document.querySelectorAll('.file-item'));
            
            items.sort((a, b) => {
                let aValue, bValue;
                
                if (column === 0) {
                    // 按文件名排序
                    aValue = a.querySelector('.file-name').textContent;
                    bValue = b.querySelector('.file-name').textContent;
                } else if (column === 1) {
                    // 按文件大小排序
                    aValue = parseSize(a.querySelector('.file-size').textContent);
                    bValue = parseSize(b.querySelector('.file-size').textContent);
                } else {
                    // 按修改日期排序
                    aValue = new Date(a.querySelector('.file-date').textContent);
                    bValue = new Date(b.querySelector('.file-date').textContent);
                }
                
                if (aValue < bValue) return -1 * direction;
                if (aValue > bValue) return 1 * direction;
                return 0;
            });
            
            // 重新排列项目
            items.forEach(item => container.appendChild(item));
        }
        
        function parseSize(sizeStr) {
            const units = { 'B': 1, 'K': 1024, 'M': 1048576, 'G': 1073741824 };
            const match = sizeStr.match(/^([\d.]+)\s*([BKMGT])?/i);
            if (!match) return 0;
            
            const value = parseFloat(match[1]);
            const unit = match[2] ? match[2].toUpperCase() : 'B';
            return value * (units[unit] || 1);
        }
    }

    // 面包屑导航生成
    function generateBreadcrumb() {
        const path = window.location.pathname;
        const paths = path.split('/').filter(Boolean);
        let breadcrumb = '<div class="breadcrumb"><a href="/">首页</a>';
        
        let currentPath = '';
        paths.forEach((segment, index) => {
            currentPath += '/' + segment;
            if (index === paths.length - 1) {
                breadcrumb += ' / ' + segment;
            } else {
                breadcrumb += ' / <a href="' + currentPath + '">' + segment + '</a>';
            }
        });
        
        breadcrumb += '</div>';
        
        const container = document.querySelector('.container');
        if (container) {
            container.insertAdjacentHTML('afterbegin', breadcrumb);
        }
    }

    // 初始化所有功能
    setFileIcons();
    setupSearch();
    setupSorting();
    generateBreadcrumb();
});
  1. 增强的Nginx配置
server {
    listen 80;
    server_name example.com;
    
    # 下载目录配置
    location /download/ {
        alias /path/to/your/download/directory/;
        autoindex on;
        autoindex_exact_size off;  # 显示易读的文件大小(KB, MB, GB)
        autoindex_localtime on;    # 使用本地时间而非UTC
        charset utf-8;
        
        # HTML结构重构
        sub_filter '</head>' '<link rel="stylesheet" href="/nginx-custom.css"><script src="/nginx-custom.js" defer></script></head>';
        sub_filter '<hr>' '';
        sub_filter '<h1>Index of ' '<div class="container"><div class="header"><h1>$1</h1><div class="search-box"><input type="text" id="fileSearch" placeholder="搜索文件..."></div></div><div class="file-list"><div class="list-header"><span>名称</span><span>大小</span><span>修改日期</span></div>';
        sub_filter '</body>' '</div></div><div class="footer">Powered by Nginx</div></body>';
        
        # 应用多次替换
        sub_filter_once off;
        
        # 设置MIME类型确保替换生效
        sub_filter_types text/html;
    }
    
    # 样式文件服务配置
    location /nginx-custom.css {
        root /usr/share/nginx/html;
        expires 1h;  # 客户端缓存1小时
        add_header Cache-Control "public";
    }
    
    # JavaScript文件服务配置
    location /nginx-custom.js {
        root /usr/share/nginx/html;
        expires 1h;
        add_header Cache-Control "public";
    }
    
    # 图标资源处理
    location ~* \.(ico|svg|png|gif|jpg|jpeg)$ {
        root /usr/share/nginx/html;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
    
    # 安全设置:禁止访问隐藏文件
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

高级优化技巧

  1. 添加文件操作按钮

在JavaScript文件中添加以下代码,为文件添加操作按钮:

// 添加操作按钮(下载、查看等)
function addActionButtons() {
    const items = document.querySelectorAll('.file-item');
    
    items.forEach(item => {
        const link = item.getAttribute('href');
        if (link.endsWith('/')) return; // 跳过目录
        
        const actionCell = document.createElement('div');
        actionCell.className = 'file-actions';
        actionCell.innerHTML = `
            <a href="${link}" download title="下载">⬇️</a>
            <a href="${link}" target="_blank" title="查看">👁️</a>
        `;
        
        // 将网格布局改为4列
        item.style.gridTemplateColumns = '3fr 1fr 2fr 1fr';
        item.appendChild(actionCell);
    });
    
    // 更新表头
    const listHeader = document.querySelector('.list-header');
    if (listHeader) {
        listHeader.style.gridTemplateColumns = '3fr 1fr 2fr 1fr';
        const actionHeader = document.createElement('span');
        actionHeader.textContent = '操作';
        listHeader.appendChild(actionHeader);
    }
}
  1. 添加目录统计信息

在页面底部添加统计信息:

// 添加目录统计信息
function addStats() {
    const items = document.querySelectorAll('.file-item');
    let fileCount = 0;
    let folderCount = 0;
    let totalSize = 0;
    
    items.forEach(item => {
        const link = item.getAttribute('href');
        if (link.endsWith('/')) {
            folderCount++;
        } else {
            fileCount++;
            const sizeText = item.querySelector('.file-size').textContent;
            totalSize += parseSize(sizeText);
        }
    });
    
    // 格式化总大小
    function formatSize(bytes) {
        const units = ['B', 'KB', 'MB', 'GB', 'TB'];
        let size = bytes;
        let unitIndex = 0;
        
        while (size >= 1024 && unitIndex < units.length - 1) {
            size /= 1024;
            unitIndex++;
        }
        
        return size.toFixed(2) + ' ' + units[unitIndex];
    }
    
    const statsHtml = `
        <div class="stats">
            总计: ${fileCount} 个文件, ${folderCount} 个文件夹, 总大小: ${formatSize(totalSize)}
        </div>
    `;
    
    const fileList = document.querySelector('.file-list');
    if (fileList) {
        fileList.insertAdjacentHTML('afterend', statsHtml);
    }
}

安全考虑与最佳实践

  1. 限制访问权限:确保只有需要公开的文件才可通过Web访问
  2. 防止目录遍历攻击:确保Nginx配置正确限制了访问范围
  3. 禁用敏感文件显示:使用autoindex_exclude指令隐藏特定文件
  4. 设置适当缓存头:平衡性能与内容更新的需求
  5. 监控与日志:记录文件访问情况以便审计
# 安全增强配置示例
location /download/ {
    # 禁止访问上级目录
    internal;
    
    # 限制某些文件类型
    location ~* \.(htaccess|htpasswd|env|config|log)$ {
        deny all;
    }
    
    # 限制某些目录
    location ~* /(private|confidential)/ {
        deny all;
    }
}

性能优化建议

  1. 启用Gzip压缩:减少传输数据量
  2. 合理配置缓存:对CSS和静态资源设置适当缓存时间
  3. 使用CDN:对大型文件考虑使用CDN加速
  4. 优化图片图标:确保图标文件经过压缩优化
# 性能优化配置
gzip on;
gzip_types text/css application/javascript;

# 缓存优化
location ~* \.(css|js)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

location ~* \.(jpg|jpeg|png|gif|ico|svg)$ {
    expires 1M;
    add_header Cache-Control "public";
}

总结

通过本文介绍的Nginx目录美化技术,您可以将功能性的文件列表转换为美观、专业的界面。这种美化不仅提升了用户体验,还增强了网站的品牌形象。更重要的是,通过合理的配置和优化,可以在保持美观的同时确保安全性和性能。

这种方法的优势在于它不需要修改Nginx源代码,只需通过配置和外部资源即可实现,保持了升级的便利性和维护的简便性。根据实际需求,您可以进一步扩展此方案,添加更多高级功能如文件预览、多语言支持或高级搜索功能。

通过本指南,您应该能够创建出一个既美观又功能强大的文件目录界面,满足大多数企业级应用的需求。

❌
❌