普通视图

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

『译』资深前端开发者如何看待React架构

2025年9月14日 09:28

原文:How Senior Frontend Developers think about React Architecture

作者:Scripting Soul

小声 BB

本文在翻译过程中确保意思传达准确的前提下,会加入很多本人的个人解释和一些知识补充(使用引用块&&括号标注) ,像这样

(我是一个平平无奇的知识补充块)

🎊如果觉得文章内容有用,交个朋友,点个赞再走~ 🎊

一些比较清晰的视角,可以学习到如何高屋建瓴地去构造 React 组件。和读者分享什么才是一个资深开发者应该关注和做到的事情。

正文

好久没写了。

当我们谈论 React 时,我们通常会想到组件。无论是一个简单的按钮、一整个表格,还是一个带图表的完整仪表盘页面。这是我们大多数人开始时的方式。我们打开设计稿,看看屏幕上有什么,然后尝试用代码去还原。

但是,当我们已经开发 React 应用多年,做过数十个功能、上千个组件、几百个边界情况后,我们会意识到,React 其实不只是关于组件,而是关于架构。

而高级前端开发者看待架构的方式,和初级开发者完全不同。我在许多文章中都强调过这一点。这并不是因为他们知道某本书里隐藏的某个神秘设计模式,而是因为……他们看待系统的方式不一样。

img

架构在第一个组件之前就开始了

初级开发者从 UI 开始:

“这是一个界面,我先来为它构建组件。”

资深开发者不会从这里开始。他们从边界开始。

  • 这个功能属于哪里?
  • 它依赖什么?
  • 谁拥有数据,这些数据应该传播到什么程度?

他们认为组件不是第一步,而是最后一步。组件只是更深层次内容的表层。其下隐藏着一个流程:数据、状态、业务规则、副作用,最后才是 UI。

这种思维转变改变了一切。它能防止在功能增长时架构的崩塌。在我看来,这是最重要的事情之一。

他们保持关注点分离

资深开发者通过艰难的经验学到一件事:当关注点混在一起时,项目就会(某种程度上)腐烂(变成屎山代码)。

初级开发者会很开心地在组件里放一个 API 调用,把表单验证和 UI 渲染混在一起,然后到处加 useEffect 来修修补补。它确实能工作……(能撑住一段时间)。

资深开发者会保持分离:

  • UI 层:纯粹的展示型组件。没有逻辑,没有副作用。
  • 状态层:数据存储、更新和同步的地方。
  • 领域逻辑层:业务规则所在,独立于 UI。

为什么要这么严格?因为分离带来的是自由。如果 API 变了,只需要改领域逻辑层。如果 UI 重新设计了,只需要改组件。每一层都能独立呼吸,而不会扼住其他层的喉咙。

这就是为什么大型应用能存活多年,而不是被自己的重量压垮。

他们不追求完美,而是让改变变得便宜

初级开发者想要“完美”的架构,想预测未来。

资深开发者知道未来总会出乎意料。API 会崩,设计会转向,产品经理会提出全新的需求。再多的远见也无法预测一切。

所以,他们不追求完美,而是为变化而设计:

  • 清晰的边界,方便替换。
  • 层与层之间的松耦合,方便重构。
  • 不做过度设计,只有在真正值得时才做抽象。

所以,我会说,真正的技能不在于今天做出最完美的架构,而在于让明天的改动毫无痛苦。

数据流如河流

React 的核心是数据向下流动、动作向上流动。但在真实的应用中,这些流会成倍增加。数据来自 API、缓存、Redux、context、socket 等等。

初级开发者会把 state 放在感觉方便的任何地方。

资深开发者会问:这个 state 真正属于哪里?

  • 只属于一个组件?保持本地化。
  • 跨越整个功能?放在 context 或某个状态切片中。
  • 属于整个应用?用 Redux、Zustand 或其他 store 集中管理。

一个很好的比喻是:他们把数据流当作河流。水流清晰地向下流动,系统就是健康的;如果它泄漏、积水或泛滥,系统就会崩溃。

他们按“意义”组织代码,而不是按“外观”

这是最隐形但也最深刻的区别之一。

初级开发者按组件组织代码:按钮、卡片、表单。

资深开发者按领域组织代码:用户、支付、设置。

为什么?因为 UI 是临时的。同一个“卡片”可能在十个地方出现,每个地方的含义都不一样。但领域,比如“用户”、“交易”、“通知”,这些东西是长期存在的。

所以,资深开发者不会有一个叫 components/ 的文件夹,而是有 features/ 或 domains/。每个领域都管理自己的 UI、状态和逻辑。代码库开始像是产品的地图,而不是一堆小部件的集合。

这就是为什么新工程师走进资深开发者的项目,能立刻知道东西应该放在哪。

他们拥抱“约束”,而不是“可选项”

初级开发者常犯的一个错误是过度灵活。

“让我们把这个组件做得超级可复用。让它接受 10 个 props,这样什么场景都能用。”

资深开发者走向了相反的方向:我们能限制什么?

  • 按钮组件只接受它真正需要的 props……别的不要。
  • API 层应该返回有类型、可预测的数据……没有猜测。
  • 团队的模式应该是严格的——没有无尽的变体。

约束减少了心理负担。它让系统以一种最好的方式变得无聊。每个人都以同样的节奏编码。架构变得可预测,而可预测性就是力量。

他们知道架构是为人服务的,而不是为机器

这是最后也是最重要的一个教训。我在其他文章里也强调过这一点。

架构根本不是关于代码的。它是关于人的。

  • 新开发者能不能不问别人就知道该把文件放哪?
  • 两个工程师能不能并行工作而不互相干扰?
  • 未来的维护者能不能在没有上下文的情况下读懂这段代码的意义?

这才是架构的真正考验。它并不总是关乎性能、优雅或巧妙的抽象,而是它能否帮助人们无痛地协作。

简而言之……

React 给了我们组件。组件是砖头。但架构才是建筑。

资深开发者不会纠结该用 Redux 还是 Zustand,也不会纠结文件应该放在 src/components 还是 src/ui。这些都是细节。他们思考的是更深层次的东西:边界、数据流、领域、约束,以及人。

(有幸看过高手写代码,确实直接能上手,可以直接从目录结构,文件命名和方法命名,配合注释直接推导出功能,这也是后续学习的目标)

他们构建的系统能够让变更成本很低,让复杂性被控制,让代码的意义清晰可见。

而且他们解释得如此简单,以至于让人觉得理所当然。这就是资深开发者的特质。

我希望这篇文章能帮你用一个全新的视角看待前端开发。如果我有遗漏的地方,可以在评论区补充,让更多人看到。

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. 增加更多配置,比如速度、数组大小等

有时间的话..

Flutter Assets & Media

2025年9月13日 17:54

Flutter Assets & Media

概述

Flutter 应用程序由代码和资源(assets)两部分组成。资源是被打包到应用程序安装包中的文件,可在运行时访问。这些资源包括静态数据文件、配置文件、图标和各种媒体文件。

支持的资源类型

Flutter 支持多种资源类型:

  • 静态数据文件:JSON 配置文件、文本文件等
  • 图片格式:JPEG、WebP、GIF、动画 WebP/GIF、PNG、BMP、WBMP
  • 字体文件:TTF、OTF 等字体格式
  • 其他媒体文件:音频、视频等

资源配置

在 pubspec.yaml 中指定资源

在项目根目录的 pubspec.yaml 文件中,通过 flutter 部分的 assets 字段来指定应用程序所需的资源:

flutter:
  assets:
    - assets/my_icon.png
    - assets/background.png

配置规则

  • 具体文件:可以指定具体的文件路径
  • 整个目录:要包含某个目录下的所有资源,在目录名称后加上 /
flutter:
  assets:
    - assets/images/
  • 子目录处理:目录配置仅包含当前目录下的直接文件,不包含子目录中的文件
  • 包含子目录:如需包含子目录文件,需为每个子目录单独创建条目

配置示例

flutter:
  assets:
    - assets/
    - assets/images/
    - assets/fonts/
    - assets/data/config.json

资源变体(Asset Variants)

概念

Flutter 支持资源变体机制,允许为不同的设备分辨率或其他特性提供不同版本的资源文件。

目录结构示例

assets/
  images/
    icon.png          # 1.0x (基础分辨率)
    2.0x/
      icon.png        # 2.0x 分辨率
    3.0x/
      icon.png        # 3.0x 分辨率

配置方式

pubspec.yaml 中只需声明主资源:

flutter:
  assets:
    - assets/images/icon.png

系统会自动识别并包含所有变体文件。

自动选择机制

在运行时,Flutter 会根据设备的像素密度自动选择最合适的资源变体:

  • 设备像素比为 2.0 时,加载 2.0x/icon.png
  • 设备像素比为 3.0 时,加载 3.0x/icon.png
  • 如果没有对应变体,则使用基础版本

资源加载

图片资源加载

Flutter 提供了多种图片加载方式,每种都适用于不同的场景:

1. 本地资源图片加载
使用 Image.asset()

最常用的本地图片加载方式:

Image.asset('assets/images/logo.png')

带参数的高级用法:

Image.asset(
  'assets/images/logo.png',
  width: 100,
  height: 100,
  fit: BoxFit.cover,
  alignment: Alignment.center,
  repeat: ImageRepeat.noRepeat,
  semanticLabel: '应用程序标志',
)
使用 AssetImage

用于需要 ImageProvider 的场景:

Container(
  decoration: BoxDecoration(
    image: DecorationImage(
      image: AssetImage('assets/images/background.png'),
      fit: BoxFit.cover,
    ),
  ),
)
2. 网络图片加载
使用 Image.network()

直接从网络 URL 加载图片:

Image.network('https://example.com/image.jpg')

带参数的用法:

Image.network(
  'https://example.com/image.jpg',
  width: 200,
  height: 200,
  fit: BoxFit.cover,
  loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
    if (loadingProgress == null) return child;
    return Center(
      child: CircularProgressIndicator(
        value: loadingProgress.expectedTotalBytes != null
            ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
            : null,
      ),
    );
  },
  errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) {
    return Icon(Icons.error);
  },
)
使用 NetworkImage

用于需要 ImageProvider 的场景:

Container(
  decoration: BoxDecoration(
    image: DecorationImage(
      image: NetworkImage('https://example.com/image.jpg'),
    ),
  ),
)
3. 文件系统图片加载
使用 Image.file()

从设备文件系统加载图片:

Image.file(File('/path/to/image.jpg'))
使用 FileImage

用于需要 ImageProvider 的场景:

Container(
  decoration: BoxDecoration(
    image: DecorationImage(
      image: FileImage(File('/path/to/image.jpg')),
    ),
  ),
)
4. 内存图片加载
使用 Image.memory()

从内存中的字节数据加载图片:

Image.memory(uint8List)
使用 MemoryImage

用于需要 ImageProvider 的场景:

Container(
  decoration: BoxDecoration(
    image: DecorationImage(
      image: MemoryImage(uint8List),
    ),
  ),
)
5. 高级加载方式
使用 FadeInImage 实现渐显效果
FadeInImage.assetNetwork(
  placeholder: 'assets/images/loading.gif',
  image: 'https://example.com/image.jpg',
  fadeInDuration: Duration(milliseconds: 300),
)

从网络加载,本地占位:

FadeInImage.memoryNetwork(
  placeholder: kTransparentImage, // 需要 import 'package:transparent_image/transparent_image.dart';
  image: 'https://example.com/image.jpg',
)
使用 CachedNetworkImage(第三方包)

首先在 pubspec.yaml 中添加依赖:

dependencies:
  cached_network_image: ^3.2.3

然后使用:

CachedNetworkImage(
  imageUrl: 'https://example.com/image.jpg',
  placeholder: (context, url) => CircularProgressIndicator(),
  errorWidget: (context, url, error) => Icon(Icons.error),
  fadeInDuration: Duration(milliseconds: 300),
  memCacheWidth: 200,
  memCacheHeight: 200,
)
图片加载方式选择指南
图片来源 推荐方式 使用场景
应用内静态资源 Image.asset() 应用图标、背景图、UI 装饰图
网络资源 Image.network()CachedNetworkImage 用户头像、动态内容图片
设备文件系统 Image.file() 用户选择的照片、相机拍摄的图片
内存字节数据 Image.memory() 经过处理的图片数据、生成的图片
性能优化建议
  1. 使用合适的图片格式:WebP 格式通常比 PNG/JPEG 更小
  2. 提供多分辨率资源:使用 1.0x、2.0x、3.0x 资源变体
  3. 网络图片缓存:使用 CachedNetworkImage 避免重复下载
  4. 控制图片尺寸:使用 widthheight 参数避免加载过大图片
  5. 懒加载:对于列表中的图片,考虑使用懒加载机制

文本资源加载

使用 rootBundle
import 'package:flutter/services.dart' show rootBundle;

Future<String> loadAsset() async {
  return await rootBundle.loadString('assets/data/config.json');
}
使用 DefaultAssetBundle
Future<String> loadAssetWithContext(BuildContext context) async {
  return await DefaultAssetBundle.of(context).loadString('assets/data/config.json');
}

二进制资源加载

import 'package:flutter/services.dart' show rootBundle;

Future<ByteData> loadBinaryAsset() async {
  return await rootBundle.load('assets/audio/sound.mp3');
}

字体管理

字体配置

pubspec.yaml 中配置自定义字体:

flutter:
  fonts:
    - family: Raleway
      fonts:
        - asset: fonts/Raleway-Regular.ttf
        - asset: fonts/Raleway-Italic.ttf
          style: italic
        - asset: fonts/Raleway-Bold.ttf
          weight: 700
    - family: RobotoMono
      fonts:
        - asset: fonts/RobotoMono-Regular.ttf
        - asset: fonts/RobotoMono-Bold.ttf
          weight: 700

字体使用

Text(
  'Hello, Flutter!',
  style: TextStyle(
    fontFamily: 'Raleway',
    fontSize: 18,
    fontWeight: FontWeight.bold,
    fontStyle: FontStyle.italic,
  ),
)

字体权重配置

fonts:
  - family: MyFont
    fonts:
      - asset: fonts/MyFont-Thin.ttf
        weight: 100
      - asset: fonts/MyFont-Light.ttf
        weight: 300
      - asset: fonts/MyFont-Regular.ttf
        weight: 400
      - asset: fonts/MyFont-Medium.ttf
        weight: 500
      - asset: fonts/MyFont-Bold.ttf
        weight: 700
      - asset: fonts/MyFont-Black.ttf
        weight: 900

Android 原生应用图标资源文件、变量、xml 文件引用

AndroidManifest.xml 引用应用图标资源

Android/app/src/main/res/mipmap/ic_launcher/不同 dpi 的 ic_launcher.png

Android/app/src/main/res/mipmap/ic_launcher_round/不同 dpi 的 ic_launcher_round.png

 <application
    android:icon="@mipmap/ic_launcher"
    android:roundIcon="@mipmap/ic_launcher_round">
        ....
</application>

_ 注意文件夹和文件名称相同只是分别率不同 _

AndroidManifest.xml 引用变量、xml 文件

strings.xml(Android)定义字符串变量

Android/app/src/main/res/values/strings.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">kotlin</string>
    <string name="navigation_drawer_open">Open navigation drawer</string>
    <string name="navigation_drawer_close">Close navigation drawer</string>
    <string name="nav_header_title">Android Studio</string>
    <string name="nav_header_subtitle">android.studio@android.com</string>
    <string name="nav_header_desc">Navigation header</string>
    <string name="action_settings">Settings</string>
    <string name="menu_home">Home</string>
    <string name="menu_gallery">Gallery</string>
    <string name="menu_slideshow">Slideshow</string>
</resources>

colors.xml(Android)定义颜色变量

Android/app/src/main/res/values/colors.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>
</resources>

themes.xml(Android)定义颜色变量

Android/app/src/main/res/values/themes/themes.xml

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.Kotlin" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
        <!-- Customize your theme here. -->
    </style>
    <style name="Theme.Kotlin.NoActionBar">
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>
    </style>
    <style name="Theme.Kotlin.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
    <style name="Theme.Kotlin.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
</resources>

AndroidManifest.xml 引用 colors.xml、strings.xml、themes.xml 变量

Android 会去 values 目录下递归所有目录下的文件夹下的 xml 文件。

<application
        android:label="@string/app_name"
        android:theme="@style/Theme.Kotlin">
        <activity
            android:label="@string/app_name"
           >
          ....
        </activity>
    </application>
styles.xml(Android)定义样式变量

Android/app/src/main/res/values/styles.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
      <!-- 这里注意@drawable/launch_background是在drawable目录下的一个launch_background.xml文件:具体目录:
Android/app/src/main/res/drawable/launch_background.xml -->
        <item name="android:windowBackground">@drawable/launch_background</item>
    </style>
</resources>

Android 原生和 Flutter 对比总结

AndroidManifest.xml 位置

Android Flutter
Android/app/manifests/AndroidManifest.xml Android/app/src/main/AndroidManifest.xml

变量 xml 文件对比

  1. Android 和 Flutter 都会去 values 目录下递归所有目录下的文件夹下的 xml 文件。
Android Flutter
Android/app/src/main/res/values/*/ Android/app/src/main/res/values/*/
  1. Flutter 会区分 night 文件目录,而 Android 不会。
Android Flutter
Android/app/src/main/res/values/themes/themes.xml、Android/app/src/main/res/values/themes/themes.xml(night) Android/app/src/main/res/values/style.xml、Android/app/src/main/res/values-night/style.xml
  1. Android 和 Flutter 的变量、文件引用方式相同
Android Flutter
@string/app_name、@color/primary、@style/Theme.Kotlin 、@drawable/launch_background @string/app_name、@color/primary、@style/Theme.Kotlin 、@drawable/launch_background
  1. Android 和 Flutter 的变量文件定义变量规则相同
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <paths>
        <root-path name="root" path="" />
        <files-path name="files" path="" />
        <cache-path name="cache" path="" />
        <external-path name="external" path="" />
        <external-files-path name="external_files" path="" />
        <external-cache-path name="external_cache" path="" />
    </paths>
</resources>
image.pngimage.png

Flutter 平台特定资源

Android 平台

应用图标配置
  • 路径:android/app/src/main/res/
  • 文件:各 mipmap-* 文件夹中的 ic_launcher.png
  • 要求:按照 Android 开发者指南提供不同分辨率的图标
  • 注意:如果您重命名了.png 文件,您还必须在 AndroidManifest.xml 的标签的 android:icon android:icon="@mipmap/ic_launcher" 属性中更新相应的名称。
所需尺寸规格:
  • mipmap-ldpi/ (0.75x) - 36x36px (基准 48px)
  • mipmap-mdpi/ (1.0x) - 48x48px (基准尺寸)
  • mipmap-hdpi/ (1.5x) - 72x72px
  • mipmap-xhdpi/ (2.0x) - 96x96px
  • mipmap-xxhdpi/ (3.0x) - 144x144px
  • mipmap-xxxhdpi/ (4.0x) - 192x192px
使用在线工具生成
  1. Android Asset Studio (官方网站)
  • 网址:romannurik.github.io/AndroidAsse…
  • 功能:自动生成各种分辨率的图标
  • 上传一张高分辨率图片(建议 1024x1024px)
  • 自动生成所有 Android 密度的图片
  1. MakeAppIcon
  • 网址:makeappicon.com/
  • 同时支持 iOS 和 Android
  • 功能:自动生成各种分辨率的图标
  • 建议:上传一张高分辨率图片(建议 1024x1024px)
  • 自动生成所有 iOS 和 Android 密度的图片
应用 Label 配置
  • 路径:android/app/src/main/res/AndroidManifest.xml
  • 应用: Label 会应用 AndroidManifest.xml 的标签的 android:label 属性中相应的名称。
  • 注意:如果您重新名称,您必须在 AndroidManifest.xml 的标签的 android:label 属性中更新相应的名称。
完整示例代码
 <application
        android:networkSecurityConfig="@xml/network_security_config"
        android:name=".MyApp"
        android:label="xxxxx xxxx xxxx"
        android:icon="@mipmap/ic_launcher"
        android:roundIcon="@mipmap/ic_launcher_round"
        >
        ....
</application>

iOS 平台

应用图标配置
  • 路径:ios/Runner/Assets.xcassets/AppIcon.appiconset/
  • 要求:提供 iOS 要求的各种尺寸图标文件
所需尺寸规格:

略(直接使用自动生成的图片。规格太多了不讲了)

使用在线工具生成
  1. MakeAppIcon
  • 网址:makeappicon.com/
  • 同时支持 iOS 和 Android
  • 功能:自动生成各种分辨率的图标
  • 建议:上传一张高分辨率图片(建议 1024x1024px)
  • 自动生成所有 iOS 和 Android 密度的图片
应用 Label 配置
  • 路径:ios/Runner/Info.plist
  • 应用: Label 会应用 Info.plist 的CFBundleName标签下的xxxxx xxxx xxxx 属性中相应的名称。
  • 注意:如果您重新名称,您必须在 Info.plist 的CFBundleName标签下的xxxxx xxxx xxxx 属性更新相应的名称。
<key>CFBundleName</key>
<string>xxxxx xxxx xxxx</string>

Flutter 更新启动图(也叫闪屏页(也称为启动页))

简单梳理:只展示图片,更高级的动画展示需要研读 Android 和 iOS 的启动图配置。

概念

在 Flutter 框架加载时,Flutter 会使用原生平台机制绘制启动页。此启动页将持续到 Flutter 渲染应用程序的第一帧。

这意味着如果你不在应用程序的 main() 方法中调用 runApp() 函数(或者更具体地说,如果你不调用 FlutterView.render() 去响应 PlatformDispatcher.onDrawFrame 的话,启动页将永远持续显示。

完成一个启动页的配置需要满足俩个条件:1.启动页图片资源,背景(定义在 drawable 目录下的不同 xml 中:例如 launch_background.xml、normal_background.xml....);2.主题一般定义在 values 目录下的 themes.xml、style.xml...文件中文件命名看个人习惯。(主题的设置主要源于移动端暗模式、亮模式)

只要为什么是 drawable 目录吗?android 默认是 drawable 目录。且他们在安卓 api 中就是这样叫的。

Android

步骤

这只是一个简单示例,用于将图片添加到白色启动页的中间。当然可绘制对象资源来实现预期效果。更高级的动画展示需要系统学习 Android 的 XML 编写。

第一步:定义启动页图片资源、背景
<!-- launch_background.xml 文件 -->
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@color/bg_launch_color" />
    <item>
        <bitmap
            android:gravity="center"
            android:src="@drawable/launch_image" />
    </item>
</layer-list>
<!-- normal_background.xml 文件 -->
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@color/bg_normal_color" />
    <item>
        <bitmap
            android:gravity="center"
            android:src="@drawable/normal_image" />
    </item>
</layer-list>
第二步:定义主题 引用启动页图片资源、背景

在 styles.xml 或者 themes.xml 中应用不同的主题:launch_background.xml、normal_background.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
    <item name="android:windowBackground">@drawable/launch_background</item>
  </style>
  <style name="HomeTheme" parent="@android:style/Theme.Black.NoTitleBar">
    <item name="android:windowBackground">@drawable/home_background</item>
  </style>
</resources>

第三步:在 AndroidManifest.xml 中引用主题
<application
   // ...
   >
<activity
    android:name=".MyActivity"
    android:theme="@style/LaunchTheme"
    // ...
    >
   // ...
</activity>
    //...
</application>
<!-- 或者 -->
<application
   // ...
   >
<activity
    android:name=".MyActivity"
    android:theme="@style/HomeTheme"
    // ...
    >
   // ...
</activity>
    //...
</application>
第四步: 普通主题在 AndroidManifest.xml 中引用

当启动页消失后,它会应用在 FlutterActivity 上。普通主题的背景仅仅展示非常短暂的时间,例如,当启动页消失后、设备方向改变或者 Activity 恢复期间。因此建议普通主题的背景颜色使用与 Flutter UI 主要背景颜色相似的纯色。

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
    <item name="android:windowBackground">@drawable/launch_background</item>
  </style>
  <style name="HomeTheme" parent="@android:style/Theme.Black.NoTitleBar">
    <item name="android:windowBackground">@drawable/home_background</item>
  </style>
  <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
    <item name="android:windowBackground">@drawable/normal_background</item>
  </style>
</resources>

第五步: 普通主题在 AndroidManifest.xml 中引用
<application
   // ...
   >
<activity
    android:name=".MyActivity"
    android:theme="@style/LaunchTheme"
    // ...
    >
   <meta-data
        android:name="io.flutter.embedding.android.NormalTheme"
        android:resource="@style/NormalTheme"
        />
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
</activity>
    //...
</application>
<!-- 或者 -->
<application
   // ...
   >
<activity
    android:name=".MyActivity"
    android:theme="@style/HomeTheme"
    // ...
    >
   <meta-data
        android:name="io.flutter.embedding.android.NormalTheme"
        android:resource="@style/NormalTheme"
        />
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
</activity>
    //...
</application>

如此一来,Android 应用程序就会在在初始化时展示对应的启动页面和普通主题。

iOS

将图片添加到启动屏幕「splash screen」的中心,请导航至 .../ios/Runner 路径。在 Assets.xcassets/LaunchImage.imageset ,拖入图片,并命名为 LaunchImage.png, LaunchImage@2x.pngLaunchImage@3x.png。如果你使用不同的文件名,那你还必须更新同一目录中的 Contents.json 文件中对应的名称。

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

作者 力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 } ]

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

前端面试中值得关注的js题

作者 前端鱼
2025年9月13日 17:13

“这就像花一样。如果你爱上了一朵生长在一颗星星上的花,那么夜间,你看着天空就感到甜蜜愉快,所有的星星上都好像开着花。” -- 《小王子》(诠释:爱一个人会让你的整个世界都变得美好,充满ta的影子。)


总结了几道近期前端面试中问到的值得一看的js面试题,其实有些问题很基础,但是长时间没看又容易遗忘,希望这篇文章对你我有所帮助

1. 什么是事件循环机制?

面试极大概率会问!!!

如果我们把同步代码看作第一个宏任务,那么事件循环机制就是先执行一个宏任务,然后执行由这个宏任务运行过程中产生的所有Promise的回调等微任务。但由于微任务队列优先级高,如果执行一个微任务过程中又产生了一个微任务,那么这个新生的微任务会被放置队列尾部被执行且早于下一个宏任务。事件循环会不断重复“执行一个宏任务 → 清空微任务队列”的过程,如果涉及渲染,浏览器则会清空微任务队列后在进行布局和绘制。

特性 宏任务(Macrotask/Task) 微任务(Microtask)
示例 setTimeout, setInterval, setImmediate (Node), I/O 操作, UI 渲染, requestAnimationFrame Promise 的回调 (.then, .catch, .finally), MutationObserver, queueMicrotask
谁提供 由浏览器或 Node.js 环境提供 由 JavaScript 引擎自身提供(ES6 规范)
执行时机 在一次事件循环中只执行一个 在一次事件循环中全部执行完毕(直到队列清空)
队列优先级 高(总是在当前宏任务结束后、下一个宏任务开始前执行)

“如果你驯服了我,我们就互相不可缺少了。对我来说,你就是世界上唯一的了;我对你来说,也是世界上唯一的了。”-- 《小王子》(“驯服”即建立羁绊。诠释:爱是彼此需要,彼此成为对方的唯一。)

2. 什么是闭包?

也是老生常谈的面试题!

一个内部函数引用了它外部函数中的变量,当外部函数被调用执行后,其本地的执行上下文(包括其变量对象)通常应该被销毁,但这个被引用的变量和内部函数捆绑在了一起不会被回收,从而形成了闭包。简单来说,闭包就是能够访问其他函数内部变量的函数

它的主要应用场景可以归结为:创建私有数据,并持久化地操作这些数据

1. 创建私有变量(数据封装)

这是闭包最经典的应用。JavaScript 没有原生支持私有成员(ES6 的 # 语法除外),但通过闭包可以模拟。

function createCounter() {
  let count = 0; // count 是一个私有变量,外部无法直接访问

  return {
    increment: function() {
      count++;
      return count;
    },
    decrement: function() {
      count--;
      return count;
    },
    getValue: function() {
      return count;
    }
  };
}

const myCounter = createCounter();
console.log(myCounter.increment()); // 1
console.log(myCounter.increment()); // 2
console.log(myCounter.decrement()); // 1
console.log(myCounter.count); // undefined (无法直接访问)
// count 变量被安全地封装在闭包中,只能通过暴露的方法操作

2. 回调函数和事件处理

在异步操作(如定时器、事件监听、Ajax 请求)中,回调函数经常需要记住它被创建时的环境。

function setupAlert(message, delay) {
  setTimeout(function() { // 这个回调函数就是一个闭包
    alert(message); // 它记住了外部的 message 参数
  }, delay);
}

setupAlert('Hello World!', 2000); // 2秒后弹出 'Hello World!'

在循环中创建闭包是一个经典问题,也体现了闭包的作用:

// 错误做法:所有按钮都提示 “Button 6 clicked”
for (var i = 1; i <= 5; i++) {
  var btn = document.createElement('button');
  btn.textContent = 'Button ' + i;
  btn.onclick = function() {
    alert('Button ' + i + ' clicked'); // 这里的 i 是循环结束后的最终值 6
  };
  document.body.appendChild(btn);
}

// 正确做法:使用闭包或 let 创建独立的作用域
for (var i = 1; i <= 5; i++) {
  (function(j) { // 立即执行函数创建了一个新作用域,j 保存了当前 i 的值
    var btn = document.createElement('button');
    btn.textContent = 'Button ' + j;
    btn.onclick = function() {
      alert('Button ' + j + ' clicked'); // 闭包记住了当前作用域的 j
    };
    document.body.appendChild(btn);
  })(i); // 将 i 作为参数 j 传入
}

// 现代更简单的做法:使用 let(其块级作用域天然解决了这个问题)
for (let i = 1; i <= 5; i++) {
  let btn = document.createElement('button');
  btn.textContent = 'Button ' + i;
  btn.onclick = function() {
    alert('Button ' + i + ' clicked'); // 每个 i 都在一个独立的块级作用域中
  };
  document.body.appendChild(btn);
}

3. 函数柯里化(Currying)

柯里化是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下参数且返回结果的新函数的技术。

function makeAdder(x) { // 接收第一个参数 x
  return function(y) { // 返回一个闭包,它记住了 x
    return x + y; // 闭包可以操作外部的 x 和内部的 y
  };
}

const add5 = makeAdder(5); // 创建一个新函数,它总是给参数加 5
const add10 = makeAdder(10); // 创建一个新函数,它总是给参数加 10

console.log(add5(2));  // 7 (5 + 2)
console.log(add10(2)); // 12 (10 + 2)

4. 模块化模式(Module Pattern)

在 ES6 之前,闭包是创建模块、避免全局污染的主要方式。

var MyModule = (function() {
  var privateVar = 'I am private'; // 私有变量

  function privateFunction() {
    console.log(privateVar);
  }

  return { // 返回公共接口
    publicMethod: function() {
      privateFunction(); // 公共方法可以访问私有成员
    },
    setData: function(data) {
      privateVar = data;
    }
  };
})();

MyModule.publicMethod(); // "I am private"
MyModule.setData('New data');
MyModule.publicMethod(); // "New data"
console.log(MyModule.privateVar); // undefined (无法访问)

总结

场景 核心目的 关键点
私有变量 数据封装与隐藏 通过内部函数返回一个操作私有数据的公共接口
回调/事件处理 保持状态 函数记住它被定义时的上下文环境(变量值)
函数柯里化 部分应用,功能复用 预先填充一些参数,生成一个更具体的新函数
模块化 组织代码,命名空间 使用 IIFE 创建独立作用域,只暴露必要的部分

简单理解,只要你在一个函数内部定义了另一个函数,并且这个内部函数访问了外部函数的变量,那么你就创建了一个闭包。它使得数据能够“存活”于函数调用之后。

“如果你说你在下午四点来,从三点钟开始,我就开始感觉很快乐,时间越临近,我就越来越感到快乐。” -- 《小王子》 (诠释:爱是期待,是即将见到所爱之人时那种按捺不住的喜悦。)

3. object \ map \ weakmap 有啥区别

这个面试题几年没看了,昨天被问到居然想不起了,特别值得好好复习!篇幅较长、值得好好看!

开门见山

  • Object:最通用的基础数据结构,键只能是字符串或 Symbol;用来处理简单的、需要序列化的、键为字符串的数据结构。
  • Map:更强大的键值对集合,键可以是任何类型,且有序;用来处理复杂的、需要频繁操作和遍历的键值对集合。
  • WeakMap:专为对象作为键设计的弱引用集合,不阻止垃圾回收;用来处理需要与对象生命周期绑定的元数据,避免内存泄漏。

多个维度进行详细对比:

特性 Object Map WeakMap
键的类型 字符串、Symbol 任意值(对象、函数等) 只接受对象
键的顺序 无序(ES6后有一定规则) 插入顺序 无序(不可迭代)
大小获取 需手动计算 Object.keys(obj).length size 属性 size 属性
迭代性 需要 Object.keys() 等方法 直接可迭代 不可迭代
默认键 有原型链(可能有意外键) 纯净,无默认键 纯净,无默认键
性能 频繁增删时较差 频繁增删时更优 专为特定场景优化
垃圾回收 强引用,阻止回收 强引用,阻止回收 弱引用,不阻止回收
序列化 可用 JSON.stringify() 不能直接序列化 不能序列化

详细解释

1. 键的类型

  • Object:键只能是字符串Symbol。如果用其他类型作为键,会被自动转换为字符串。

    const obj = {};
    const key = { id: 1 };
    
    obj[key] = 'value'; // 键会被转换为字符串 "[object Object]"
    console.log(obj); // { "[object Object]": "value" }
    
  • Map:键可以是任何类型的值,包括对象、函数、数组等。

    const map = new Map();
    const objKey = {};
    const funcKey = function() {};
    
    map.set(objKey, '对象作为键');
    map.set(funcKey, '函数作为键');
    console.log(map.get(objKey)); // '对象作为键'
    
  • WeakMap只接受对象作为键null 除外)。

    const weakMap = new WeakMap();
    const objKey = {};
    
    weakMap.set(objKey, '有效');
    // weakMap.set('string', '错误'); // TypeError
    

2. 键的顺序

  • Object:传统上认为无序,ES6 后对字符串键有特定排序规则,但不保证遍历顺序。
  • Map严格保持键值对的插入顺序,遍历顺序与插入顺序一致。
  • WeakMap:无序,且无法遍历。

3. 大小获取

  • Object:需要手动计算 Object.keys(obj).length
  • Map:直接通过 map.size 获取
  • WeakMap:无法获取大小(因为随时可能有键被垃圾回收)

4. 迭代性

  • Object:需要借助 Object.keys()Object.values()Object.entries()
  • Map直接可迭代,有 forEach() 方法和迭代器
    const map = new Map([['a', 1], ['b', 2]]);
    
    for (let [key, value] of map) {
      console.log(key, value); // a 1, b 2
    }
    
  • WeakMap不可迭代,没有迭代相关的方法

5. 垃圾回收

这是 WeakMap 最独特的地方:

  • Object 和 Map:对键是强引用,即使对象在其他地方不再使用,只要还在 Object/Map 中,就不会被垃圾回收,可能导致内存泄漏。

    let obj = { data: 'important' };
    const map = new Map();
    map.set(obj, 'metadata');
    
    obj = null; // 对象仍然被 Map 引用,不会被回收
    
  • WeakMap:对键是弱引用,不阻止垃圾回收。

    let obj = { data: 'important' };
    const weakMap = new WeakMap();
    weakMap.set(obj, 'metadata');
    
    obj = null; // 如果没有其他引用,对象会被垃圾回收,WeakMap 中的条目自动移除
    

6.使用场景

Object 的使用场景
  • 简单的数据记录(如配置对象)
  • 需要 JSON 序列化的数据结构
  • 方法集合(如工具类)
  • 当键是已知的字符串时
// 配置对象
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};

// 工具类
const mathUtils = {
  add: (a, b) => a + b,
  multiply: (a, b) => a * b
};
Map 的使用场景
  • 需要频繁增删键值对的场景
  • 需要保持插入顺序的场景
  • 键类型复杂(需要对象作为键)
  • 需要遍历操作的场景
// 缓存系统
const cache = new Map();

function getData(key) {
  if (cache.has(key)) {
    return cache.get(key);
  }
  const data = fetchDataFromServer(key);
  cache.set(key, data);
  return data;
}

// DOM 节点映射
const nodeMap = new Map();
const buttons = document.querySelectorAll('button');

buttons.forEach(button => {
  nodeMap.set(button, { clickCount: 0 });
});
WeakMap 的使用场景
  • 为对象存储私有数据或元数据
  • DOM 节点关联数据(避免内存泄漏)
  • 缓存系统(自动清理)
// 私有数据存储
const privateData = new WeakMap();

class User {
  constructor(name) {
    privateData.set(this, { name });
  }
  
  getName() {
    return privateData.get(this).name;
  }
}

// DOM 数据关联
const domData = new WeakMap();
const element = document.getElementById('myElement');

domData.set(element, { loaded: false, data: null });

“爱情不是彼此凝视,而是一起朝同一个方向看去。” -- 《小王子》(诠释:真爱意味着拥有共同的目标和愿景,携手同行。)

4. 什么是强引用、弱引用?

这个问题到目前为止,尚未被问到,但是我觉得值得深究一下。 简单做个比喻,想象一下你和对象的关系:

  • 强引用:就像你用绳子紧紧绑住一个气球。只要你抓着绳子,气球就永远不会飞走。
  • 弱引用:就像你用细线轻轻系着气球。如果你放手了,一阵风吹来,气球就可能飞走。 在js中,垃圾回收机制(Garbage Collector, GC)就是那个"风"。

可是爱情不会被这么简单的定义,哪怕你紧紧地攥住那根绳子,一阵子风吹来,她也可以选择飞走。所以,朋友们,别太用力了呀。爱过了头,你会觉得累,她也会倍感压力。

强引用

强引用类型 示例 特点与用途 风险等级
变量声明 let obj = {}
const arr = []
最基本的引用类型,用于存储数据和对象引用。作用域结束时自动释放。 低(作用域结束时释放)
数组包含 arr.push(obj)
array[index] = obj
用于创建有序的对象集合,支持随机访问和迭代操作。 中(需要手动清理或移位)
对象属性 obj.child = childObj
parent[prop] = value
用于构建复杂的数据结构,对象嵌套和关系表示。 中(需要手动置null)
Map集合 map.set(key, obj)
map.set('string', obj)
键值对集合,键可以是任意类型,保持插入顺序,支持迭代。 高(容易内存泄漏)
Set集合 set.add(obj)
new Set([obj1, obj2])
值唯一性集合,用于去重和成员关系检查,支持迭代。 高(需要手动delete)
闭包引用 function() { use externalVar } 函数捕获外部变量,实现数据封装和私有状态维护。 中(需要理解作用域链)
DOM引用 document.getElementById()
querySelectorAll()
获取和操作DOM元素,用于界面交互和动态内容更新。 中(DOM移除后需手动置null)
定时器引用 setInterval(callback)
setTimeout(callback)
定时执行代码,回调函数闭包捕获外部变量引用。 高(必须clearInterval)
事件监听器 element.addEventListener()
eventEmitter.on()
事件处理机制,回调函数保持对相关对象的引用。 高(必须removeEventListener)
模块全局变量 export const cache = new Map() 模块级别的持久化存储,生命周期与程序相同。 高(需要设计清理策略)

弱引用

弱引用类型 示例 特点与用途 注意事项
WeakMap weakMap.set(obj, data) 对象作为键的弱引用映射,自动清理;用于DOM数据关联、对象元数据存储 键必须是对象,不可枚举
WeakSet weakSet.add(obj) 弱引用对象集合,自动清理;用于临时对象集合 值必须是对象,不可枚举
WeakRef new WeakRef(obj) 创建对象的弱引用,可手动检查;用于缓存系统 ES2021+,需要手动调用 deref()
FinalizationRegistry new FinalizationRegistry(callback) 对象被回收时执行回调;用于资源清理 ES2021+,用于清理辅助资源

核心特点对比表

特性 强引用 弱引用
垃圾回收 阻止 不阻止
访问可靠性 绝对可靠 不确定
内存泄漏风险 (需手动管理) (自动管理)
可枚举性 可枚举、可测大小 不可枚举、无法获知大小
键/值类型 任意类型 键必须是对象(WeakMap/WeakSet)
默认性 默认引用方式 需显式创建
主要用途 通用数据存储、长期持有 元数据关联、缓存、防止内存泄漏

具体示例对比

强引用示例 - 可能导致内存泄漏
class Cache {
    constructor() {
        this.data = new Map(); // 强引用Map
    }
    
    add(key, value) {
        this.data.set(key, value);
    }
}

let cache = new Cache();
let bigData = new Array(1000000).fill('test'); // 大量数据

cache.add('user1', bigData);

// 即使我们不再需要 bigData...
bigData = null;

// 数据仍然被 cache.data Map 强引用着,无法被回收!
// 这就是内存泄漏
弱引用示例 - 自动内存管理
class SmartCache {
    constructor() {
        this.data = new WeakMap(); // 弱引用WeakMap
    }
    
    add(key, value) {
        this.data.set(key, value);
    }
}

let smartCache = new SmartCache();
let user = { id: 1 }; // 对象作为键
let bigData = new Array(1000000).fill('test');

smartCache.add(user, bigData);

// 当我们不再需要 user 对象时...
user = null;
bigData = null;

// 由于没有其他强引用指向 user 对象,
// 它会被垃圾回收,WeakMap 中的条目也会自动移除
// 内存自动释放!

为什么需要弱引用?

1. 避免内存泄漏
// 传统方式 - 可能内存泄漏
const elementListeners = new Map();
const button = document.getElementById('myButton');

elementListeners.set(button, {
    onClick: () => console.log('clicked'),
    onHover: () => console.log('hovered')
});

// 即使从DOM移除button,数据仍然被Map强引用
// 使用WeakMap - 自动清理
const elementListeners = new WeakMap();
const button = document.getElementById('myButton');

elementListeners.set(button, {
    onClick: () => console.log('clicked'),
    onHover: () => console.log('hovered')
});

// 当button从DOM移除并被垃圾回收时,数据自动清理
2. 缓存实现
const cache = new WeakMap();

function getExpensiveData(obj) {
    if (cache.has(obj)) {
        return cache.get(obj);
    }
    
    const result = expensiveCalculation(obj);
    cache.set(obj, result); // 弱引用,不会阻止obj被回收
    return result;
}
3. 私有属性模拟
const privateData = new WeakMap();

class Person {
    constructor(name) {
        privateData.set(this, { name }); // this是弱引用的键
    }
    
    getName() {
        return privateData.get(this)?.name;
    }
}

let person = new Person('John');
console.log(person.getName()); // "John"

person = null; // Person实例可以被回收,私有数据自动清理

简单记

  • 强引用是"我坚决不放手",弱引用是"你走了我也不留"。
  • 强引用会阻止垃圾回收器回收其引用的对象,只要该强引用本身仍然处于可访问状态,它所指向的对象就会被视为‘存活’状态,从而无法被内存回收机制释放。
  • 弱引用允许其所引用的对象在失去所有强引用后被垃圾回收器正常回收

“也许世界上也有五千朵和你一模一样的花,但只有你是我独一无二的玫瑰。” -- 《小王子》

5. 如何对一个包含相同对象的数组进行去重?(场景题)

方法一:使用 JSON.stringify()(适用于简单对象)

这种方法先将对象转换为字符串,然后去重,最后再转回对象。

const arrayWithDuplicates = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
  { id: 1, name: 'Alice' }, // 重复对象
  { id: 3, name: 'Charlie' },
  { id: 2, name: 'Bob' }    // 重复对象
];

// 方法1.1:使用 Set 和 JSON.stringify
const uniqueArray = Array.from(
  new Set(arrayWithDuplicates.map(item => JSON.stringify(item)))
).map(item => JSON.parse(item));

console.log(uniqueArray);
// 输出: [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}, {id: 3, name: 'Charlie'}]

// 方法1.2:使用 reduce 和 JSON.stringify
const uniqueArray2 = arrayWithDuplicates.reduce((acc, current) => {
  const stringified = JSON.stringify(current);
  if (!acc.has(stringified)) {
    acc.set(stringified, current);
  }
  return acc;
}, new Map()).values();

console.log(Array.from(uniqueArray2));

⚠️ 局限性:

  • 对象属性的顺序必须完全一致
  • 不能处理包含函数、undefined、循环引用的对象
  • 性能相对较差(需要序列化和反序列化)

方法二:使用自定义比较函数(推荐)

这种方法更灵活,可以自定义比较逻辑。

// 方法2.1:基于特定属性去重
function removeDuplicatesByKey(array, key) {
  const seen = new Set();
  return array.filter(item => {
    const value = item[key];
    if (seen.has(value)) {
      return false;
    }
    seen.add(value);
    return true;
  });
}

// 使用 id 作为去重依据
const uniqueById = removeDuplicatesByKey(arrayWithDuplicates, 'id');
console.log(uniqueById);

// 方法2.2:基于多个属性去重
function removeDuplicatesByKeys(array, keys) {
  const seen = new Set();
  return array.filter(item => {
    const keyValue = keys.map(key => item[key]).join('|');
    if (seen.has(keyValue)) {
      return false;
    }
    seen.add(keyValue);
    return true;
  });
}

// 使用 id 和 name 作为复合键去重
const uniqueByIdAndName = removeDuplicatesByKeys(arrayWithDuplicates, ['id', 'name']);
console.log(uniqueByIdAndName);

方法三:使用 Lodash 等工具库(最方便)

如果你不介意使用第三方库,Lodash 提供了现成的解决方案。

// 首先安装 lodash: npm install lodash
import _ from 'lodash';

// 方法3.1:深度比较去重
const uniqueArray = _.uniqWith(arrayWithDuplicates, _.isEqual);
console.log(uniqueArray);

// 方法3.2:基于属性去重
const uniqueById = _.uniqBy(arrayWithDuplicates, 'id');
console.log(uniqueById);

方法四:通用深度比较去重函数

如果你想要一个不依赖库的完整解决方案:

function deepEqual(obj1, obj2) {
  if (obj1 === obj2) return true;
  
  if (typeof obj1 !== 'object' || obj1 === null || 
      typeof obj2 !== 'object' || obj2 === null) {
    return false;
  }
  
  const keys1 = Object.keys(obj1);
  const keys2 = Object.keys(obj2);
  
  if (keys1.length !== keys2.length) return false;
  
  for (const key of keys1) {
    if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
      return false;
    }
  }
  
  return true;
}

function removeDuplicatesDeep(array) {
  return array.filter((item, index, self) => {
    return index === self.findIndex(obj => deepEqual(obj, item));
  });
}

const uniqueArray = removeDuplicatesDeep(arrayWithDuplicates);
console.log(uniqueArray);

方法五:使用 Map 和自定义哈希函数(性能最优)

对于大型数组,这是性能最好的方法。

function createObjectHash(obj) {
  // 创建一个唯一的字符串哈希来表示对象内容
  return Object.entries(obj)
    .sort(([a], [b]) => a.localeCompare(b)) // 排序确保属性顺序不影响哈希
    .map(([key, value]) => `${key}:${typeof value === 'object' ? JSON.stringify(value) : value}`)
    .join('|');
}

function removeDuplicatesWithHash(array) {
  const seen = new Set();
  return array.filter(item => {
    const hash = createObjectHash(item);
    if (seen.has(hash)) {
      return false;
    }
    seen.add(hash);
    return true;
  });
}

const uniqueArray = removeDuplicatesWithHash(arrayWithDuplicates);
console.log(uniqueArray);

总结与推荐

方法 优点 缺点 适用场景
JSON.stringify() 简单易用 有限制,性能差 简单对象,快速原型
自定义比较函数 灵活,可定制 需要写更多代码 按特定属性去重
Lodash 最方便,功能强大 需要引入外部依赖 生产环境,复杂需求
深度比较函数 完全控制,无依赖 代码复杂,性能中等 需要精确控制的场景
哈希函数 性能最佳 哈希函数需要精心设计 大型数据集,性能要求高

推荐选择:

  • 如果是简单需求,用 方法一方法二
  • 如果是生产环境,用 方法三(Lodash)
  • 如果追求最佳性能,用 方法五
  • 如果需要精确控制比较逻辑,用 方法四

“人们已经忘记了这个世界真理,但你不要忘记它。你要永远为你驯服的东西负责。” -- 《小王子》

6. 对一个空数组([])使用 some 方法 会返回什么?

当时被问到这个情况懵了...

最直接的回答:对一个空数组([])使用 some 方法,无论你提供的条件(回调函数)是什么,它都会返回 false。

详细解释和原因

  • Array.prototype.some() 的工作原理: some() 方法用于测试数组中是否至少有一个元素通过了提供的回调函数的测试。一旦找到一个使得回调函数返回“真值”(truthy)的元素,它会立即返回 true 并停止遍历。如果遍历完所有元素都没有找到这样的元素,则返回 false。

  • 数学逻辑: 从逻辑学的角度来看,some()方法是在验证一个命题:“存在一个数组中的元素 X,使得条件函数(X) 为真”。

    • 对于一个非空数组,我们需要检查其中的元素来证明或否定这个命题。
    • 对于一个空数组,这个“存在”的命题没有任何对象可以来验证它。在数学和逻辑学中,这种“断言一个空集合中存在某元素”的命题被自动认为是假(false)的。因为你根本无法找到一个元素来证明它为真。
  • 规范定义: ECMAScript 语言规范明确定义了这种行为。根据规范,some 方法在遇到空数组时,根本不会执行你提供的回调函数,而是直接返回 false


last 彩蛋

以前的我总想要一个结果,这样的心态往往会导致忽略了过程。但其实**过程正是由每一个小小的结果所组成。**希望大家不要在追求结果的过程中迷了路!

最后我想说爱的核心是责任、理解、付出和独一无二的羁绊,愿你我珍惜眼前人,珍惜每一个当下,共勉!

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语句就可以应对各种题型了。

Flutter. Draggable 和 DragTarget

作者 JarvanMo
2025年9月12日 09:57

1. 引言

Draggable 是一个可以被拖动到 DragTarget 上的 Widget。

以下是一些最常用的属性。

Draggable:

  • childchildWhenDraggingdatafeedbackonDragCompletedonDragEndonDragStartedonDragUpdate

drag1.gifDragTarget:

  • builderonAcceptWithDetails

我稍微修改了官方示例,以突出显示上述属性的用法。

下面的代码展示了我们如何定义 Draggable and DragTarget.


                 Draggable<int>(
                    // Data 是当前 Draggable 要存储的值.
                    data: 10,
                    onDragStarted: controller.onDragStarted,
                    onDragUpdate: (details) {
                      controller.onDragUpdate(details);
                    },
                    onDragEnd: (details) {
                       controller.onDragEnd(details);
                    } ,
                    onDragCompleted:  controller.onDragCompleted,
                    feedback: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Container(
                        color: Colors.deepOrange.shade300,
                        height: 100,
                        width: 100,
                        child: Column(
                          children: [
                            Text('Draggable').withStyle(fontSize: 12, color: Colors.black),
                            Text('feedback').withStyle(fontSize: 16, color: Colors.black),
                          ],
                        )
                      ),
                    ),
                    childWhenDragging: Container(
                      height: 100.0,
                      width: 100.0,
                      color: Colors.pink.shade200,
                      child:  Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Center(child: Column(
                          children: [
                            Text('Draggable'),
                            Text('Child When Dragging'),
                          ],
                        )),
                      ),
                    ),
                    child: Padding(
                      padding:  EdgeInsets.all(8.0),
                      child: Container(
                        height: 100.0,
                        width: 100.0,
                        color: Colors.lightGreenAccent,
                        child:  Center(child: Column(
                          children: [
                            Text('Draggable'),
                            Text ('child').withStyle(fontSize: 16),
                          ],
                        )),
                      ),
                    ),
                  ),
                  DragTarget<int>(
                    builder: (BuildContext context, List<dynamic> accepted, List<dynamic> rejected) {
                      return Container(
                        height: 100.0,
                        width: 100.0,
                        color: Colors.cyan,
                        child: Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: Center(child: Column(
                            children: [
                              Text('DragTarget').withStyle(fontSize: 16),
                              Text('Value is updated to: ${controller.acceptedData}'),
                            ],
                          )),
                        ),
                      );
                    },
                    onAcceptWithDetails: (DragTargetDetails<int> details) {
                      controller.onAcceptWithDetails(details);
                    },
                  ),

Draggable 是一个非常特殊的 Widget,因为它除了 child 属性之外,还有 childWhenDraggingfeedback 属性。

  • child 是在拖动开始前显示的不可移动的 Widget
  • 当拖动开始时,child 会被 childWhenDragging 替换。
  • feedback 是跟着你的指针(手指或鼠标)移动的可移动的 Widget

DragTarget 只有 builder 属性,它会根据 Draggable 传递过来的数据来构建子 Widget。

事件会按照以下顺序触发:

  1. DragStart — 拖动开始时,只会触发一次;
  2. DragUpdate — 在 feedback 改变位置时,会多次触发;
  3. AcceptWithDetails — 当 DraggableDragTarget 接受时,只会触发一次;
  4. DragEnd — 当 Draggable 被放下时(任何地方),只会触发一次;
  5. DragCompleted — 当 Draggable 被放到 DragTarget 上时,只会触发一次。

以下是这些事件的处理程序。

View:

//Draggable  
onDragStarted: controller.onDragStarted,  
onDragUpdate: (details) {  
controller.onDragUpdate(details);  
},  
onDragEnd: (details) {  
controller.onDragEnd(details);  
} ,  
onDragCompleted: controller.onDragCompleted,  
...  
//DragTarget  
onAcceptWithDetails: (DragTargetDetails<int> details) {  
controller.onAcceptWithDetails(details);  
},

Controller:

  void onDragStarted() {
    _isDragging = true;
    Get.snackbar('onDragStart', '', duration: Duration(seconds: 1));
  }

  void onDragUpdate(DragUpdateDetails details) {
    _position = details.globalPosition;
    update();
  }

  void onDragEnd(DraggableDetails details) {
    _isDragging = false;
    Get.snackbar('onDragEnd', 'wasAccepted: ${details.wasAccepted}',
        duration: Duration(seconds: 1));
    update();
  }

  void onDragCompleted() {
    _isDragging = false;
    Get.snackbar('onDragComplete', '', duration: Duration(seconds: 1));
  }

  void onAcceptWithDetails(DragTargetDetails<int> details) {
    acceptedData += details.data;
    Get.snackbar('onAcceptWithDetails', 'data:${details.data}', duration: Duration(seconds: 1));
  }

onDragUpdate 会接收 DragUpdateDetails,可用于检索位置。

onDragEnd 会接收 DraggableDetails,其中有一个 wasAccepted 属性。实际上,onDragEndDraggable 被拖放到 DragTarget 之外时很有用。

onAcceptWithDetails 会接收 DragTargetDetails,可用于检索 data(它被定义为 Draggable 的属性)。

我希望以上的解释足够清晰。😎

完整的代码在这里

让我们看看一些使用 Draggable 的实用例子。

2. 可重新排序的任务列表

drag2.gif

这个例子有意思的地方在于,任务 Widget 被放到了它自己身上。

这通过将 DragTarget 作为 Draggablechild 来实现。

   Draggable<int>(
          data: index,
          onDragStarted: () => controller.onDragStarted(index),
          onDragEnd: (_) => controller.onDragEnded(),
          feedback: Material(
            elevation: 4,
            child: Container(
              width: MediaQuery.of(context).size.width - 32,
              padding: EdgeInsets.all(16),
              color: Colors.blue.shade200,
              child: Text(
                task,
                style: TextStyle(fontSize: 16),
              ),
            ),
          ),
          childWhenDragging: Opacity(
            opacity: 0.3,
            child: TaskItem(task: task),
          ),
          child: DragTarget<int>(     //<-!
            builder: (context, candidateData, rejectedData) {
              return TaskItem(
                task: task,
                isDragging: isDragging,
                isHighlighted: candidateData.isNotEmpty,
              );
            },
            onAcceptWithDetails: (details) {
              controller.reorderTask(details.data, index);
            },
          ),
        );

完整的代码在这里

3. 带有分类的任务面板

image.png

drag3.gif 在这里,(与上一个例子相反),我们有的是在 DragTargets 内部的一个 Draggable 列表。每个分类(待办、进行中、已完成)都是一个 DragTarget,而每个任务则是一个 Draggable

DragTarget<Map<String, dynamic>>(
              builder: (context, candidateData, rejectedData) {
                return Container(
                  color:
                      candidateData.isNotEmpty ? category.color.withValues(alpha: 0.3) : null,
                  padding: EdgeInsets.all(8),
                  child: ListView.builder(
                    padding: EdgeInsets.only(bottom: 100),
                    itemCount: category.tasks.length,
                    itemBuilder: (context, taskIndex) {
                      return DraggableTask(
                          context: context,
                          taskName: category.tasks[taskIndex],
                          categoryIndex: categoryIndex,
                          );
                    },
                  ),
                );
              },
              onAcceptWithDetails: (details) {
                controller.moveTask(
                  details.data['taskName'],
                  details.data['fromCategoryIndex'],
                  categoryIndex,
                );
              },
            ),


完整的代码在这里

4. 拼图游戏模板

image.png

在这里,我们使用 onWillAcceptWithDetails 来检查 Draggable 是否会被 DragTarget 接受。


 return DragTarget<int>(
      builder: (context, candidateData, rejectedData) {
        return Container(
          decoration: BoxDecoration(
            color:
                candidateData.isNotEmpty ? Colors.green.withValues(alpha: .3) : Colors.white,
            borderRadius: BorderRadius.circular(12),
            border: Border.all(
              color: isPlaced ? Colors.green : Colors.blue.shade200,
              width: 2,
            ),
          ),
          child: isPlaced
              ? Center(
                  child: PlaceholderPuzzlePiece(
                    id: targetId,
                    isPlaced: true,
                  ),
                )
              : Center(
                  child: Text(
                    '$targetId',
                    style: TextStyle(
                      fontSize: 24,
                      fontWeight: FontWeight.bold,
                      color: Colors.blue.shade200,
                    ),
                  ),
                ),
        );
      },
      onWillAcceptWithDetails: (details) {  //<-!
        // Only accept if this is the correct target for this piece
        return controller.isPieceCorrect(targetId, details.data);
      },
      onAcceptWithDetails: (details) {
        controller.placePiece(details.data);
      },
    );

完整的代码在这里

5. Draggable vs LongPressDraggable

Flutter 还有一个类,叫 LongPressDraggable,它和 Draggable 很像,但拖动会在一个延迟后才发生。也就是说,在拖动开始之前,设备必须先识别到长按手势。

我修改了第一个官方示例,用 LongPressDraggable 替代了 Draggable。

它的行为开始变得不一样。如果我们立即开始拖动,什么都不会发生。我们必须在一个点上先长按,当 feedback Widget 出现后,才能开始拖动。

image.awebp

我不太确定为什么或者在什么时候会用 LongPressDraggable 来代替 Draggable,但我确信肯定存在合理的用例。

6. Draggable vs GestureDetector

GestureDetector 可以(除其他外)检测到 HorizontalDrag(水平拖动)、VerticalDrag(垂直拖动)和 Pan(任意方向的自由拖动)。

一些功能既可以用 Draggable 实现,也可以用 GestureDetector 实现。最终的选择取决于具体的用例和/或开发者的偏好。

7. 结论

Draggable 是最实用的 Flutter Widget 之一,每个开发者都应该熟悉它。😉

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 瞬间完成,背景图预加载,肉眼无闪屏。

别再踩坑了!这份 Vue3+TypeScript 项目教程,赶紧收藏!

作者 知否技术
2025年9月12日 08:14

功能演示

用户登录、商品增删改查

1.创建基于 Vite 的 Vue3 和 ts 项目

!!首先本地要安装配置 Node 环境

# 创建项目
npm create vite@latest zhifou-vue3-ts -- --template vue-ts
# 安装依赖
npm install

项目目录:

src/
├── assets/          # 静态资源
├── components/      # 组件
├── router/          # 路由
│   └── index.ts     # 路由入口文件
├── stores/          # Pinia状态管理
│   ├── index.ts     # Pinia入口配置
│   └── user.ts      # 用户相关状态
├── types/           # ts自定义类型
├── utils/           # 工具函数
│   └── axios.ts     # axios配置
├── views/           # 页面组件
│   ├── Home.vue     # 首页
│   └── Login.vue    # 登录页
├── App.vue          # 根组件
└── main.ts          # 入口文件

配置 vite.config.ts

  • 配置路径别名
  • 代理配置:配置后台接口请求路径
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      "@": resolve(__dirname, "src"), // 设置路径别名
    },
  },
  server: {
    port: 3000,
    proxy: {
      // 代理配置,解决跨域问题
      "/api": {
        target: "http://localhost:8083/zhifou-blog",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

2.安装配置 Element Plus

npm install element-plus @element-plus/icons-vue

在 main.ts 中配置 ElementPlus

import { createApp } from "vue";
import App from "./App.vue";

import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import locale from "element-plus/es/locale/lang/zh-cn";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
const app = createApp(App);

// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component);
}
app.use(ElementPlus, { locale }).mount("#app");

3.安装配置 Vue Router

npm install vue-router@4

在 main.ts 中配置路由

在 router 文件夹下新建 index.ts,然后配置路由:

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useUserStore } from "@/store/user";
const routes: Array<RouteRecordRaw> = [
  {
    path: "/",
    redirect: "/login",
  },
  {
    path: "/login",
    name: "Login",
    component: () => import("@/views/Login.vue"),
  },
  {
    path: "/home",
    name: "Home",
    component: () => import("@/views/Home.vue"),
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

// 在之前的路由守卫基础上添加
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore();
  // 判断路由是否需要认证
  if (to.path === "/login") {
    if (userStore.isLoggedIn) {
      next("/home");
    } else {
      next();
    }
  } else {
    const token = localStorage.getItem("token");
    if (token) {
      try {
        // 获取最新用户信息
        // await userStore.getCurrentUser();
        next();
      } catch (error) {
        // 刷新失败,跳转到登录页
        next("/login");
      }
    } else {
      // 没有token,跳转到登录页
      next("/login");
    }
  }
});
export default router;

上面的例子中只配置了登录页面和主页的路由,路由守卫对登录和 token 进行了校验。

4.安装配置配置 Pinia

pinia-plugin-persistedstate 是为 Pinia 设计的持久化存储插件,主要用于解决页面刷新后状态丢失的问题。它通过自动将 Store 数据同步到 localStorage、sessionStorage 或Cookie 中实现持久化,并在应用初始化时从存储中恢复状态。

npm install pinia pinia-plugin-persistedstate

在 store 文件夹下新建 index.ts 文件,然后配置 pina

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export default pinia

在 main.ts 中配置 pina

5. 安装配置 axios

npm install axios

在 utils 文件夹在新建 axios.ts 文件,配置 axios:

import axios, {
  AxiosInstance,
  InternalAxiosRequestConfig,
  AxiosResponse,
} from "axios";
// 创建axios实例
const service: AxiosInstance = axios.create({
  baseURL: "/api", // API基础路径
  timeout: 10000, // 请求超时时间
  headers: {
    "Content-Type": "application/json;charset=utf-8",
  },
});

// 请求拦截器
service.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // 在发送请求之前做些什么
    const token = localStorage.getItem("token");
    if (token) {
      config.headers["token"] = token;
    }
    return config;
  },
  (error: any) => {
    // 返回异常
    return Promise.reject(error);
  }
);

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, message } = response.data;
    if (code === 200) {
      return response;
    } else {
      // 处理业务错误
      return Promise.reject(new Error(message || "Error"));
    }
  },
  (error: any) => {
    if (error.response?.status === 401) {
      localStorage.removeItem("token");
    }
    return Promise.reject(error);
  }
);

export default service;

上面我们主要配置了axios 的请求拦截器和响应拦截器。请求拦截器主要是在请求头中添加了 token。响应拦截器主要是返回响应信息和异常信息。

这里我们要注意一点,axios 的响应拦截器类型是 AxiosResponse,返回的数据格式是:

也就是说后台返回的数据实际在 res 的 data 里面。

6. 封装 http 请求

在 /src/api 文件夹下面新建 index.ts 文件:

import axios from "../utils/axios";
import { ApiResponse, PageParams, PageResponse } from "../types";

/**
 * 通用GET请求
 * @param url 请求地址
 * @param params 请求参数
 * @returns 响应数据
 */
export const get = async <T>(url: string, params?: any): Promise<T> => {
  const res = await axios.get<ApiResponse<T>>(url, { params });
  return res.data.data;
};

/**
 * 通用POST请求
 * @param url 请求地址
 * @param data 请求体数据
 * @returns 响应数据
 */
export const post = async <T>(url: string, data?: any): Promise<T> => {
  const res = await axios.post<ApiResponse<T>>(url, data);
  return res.data.data;
};

/**
 * 通用PUT请求
 * @param url 请求地址
 * @param data 请求体数据
 * @returns 响应数据
 */
export const put = async <T>(url: string, data?: any): Promise<T> => {
  const res = await axios.put<ApiResponse<T>>(url, data);
  return res.data.data;
};

/**
 * 通用DELETE请求
 * @param url 请求地址
 * @param params 请求参数
 * @returns 响应数据
 */
export const del = async <T>(url: string, params?: any): Promise<T> => {
  const res = await axios.delete<ApiResponse<T>>(url, { params });
  return res.data.data;
};

/**
 * 分页请求
 * @param url 请求地址
 * @param params 分页参数
 * @returns 分页响应数据
 */
export const getPage = async <T>(
  url: string,
  params: PageParams
): Promise<PageResponse<T>> => {
  return get<PageResponse<T>>(url, params);
};

这里我们拿 get 请求举例,前面 axios 响应拦截器中返回的是 response,这里封装之后的 get 请求返回的是 res.data.data。如果后台返回的数据是:

{code:200,data:{username:'zhifou'},messeg:'请求成功'}

那么经过封装之后的 get 请求取到的就是:

{username:'zhifou'}

7. 用户登录

7.1 创建用户相关的 API

在 /src/api 文件夹下新建 user.ts 文件

import { get, post } from "./index";
import { LoginParams, LoginResponse, User } from "../types";

/**
 * 用户登录
 * @param params 登录参数
 * @returns 登录结果
 */
export const userLogin = async (params: LoginParams) => {
  return post<LoginResponse>("/user/login", params);
};

/**
 * 获取当前用户信息
 * @returns 用户信息
 */
export const getCurrentUserInfo = async () => {
  return get<User>("/user/currentUserInfo");
};

7.2 创建用户状态管理

在用户的 store 里面我们主要存储用户信息、token、用户登录状态。

import { defineStore } from "pinia";
import { User, LoginParams, UserState } from "../types";
import { userLogin, getCurrentUserInfo } from "../api/user";
import router from "../router";

export const useUserStore = defineStore("user", {
  state: (): UserState => ({
    userInfo: null,
    token: null,
    isLoggedIn: false,
  }),

  getters: {
    // 获取用户名
    getUsername: (state) => state.userInfo?.username || "",
  },

  actions: {
    // 登录
    async login(params: LoginParams) {
      try {
        const res = await userLogin(params);
        this.userInfo = res.userInfo;
        this.token = res.token;
        this.isLoggedIn = true;
        // 存储token到localStorage
        localStorage.setItem("token", res.token);
        return true;
      } catch (error: any) {
        throw new Error(error.message);
      }
    },

    // 退出
    logout() {
      this.userInfo = null;
      this.token = null;
      this.isLoggedIn = false;
      // 清除localStorage
      localStorage.removeItem("token");
      localStorage.removeItem("user-store");
      // 跳转到登录页
      router.push("/login");
    },

    // 获取当前用户信息
    async getCurrentUser() {
      try {
        const res = await getCurrentUserInfo();
        this.userInfo = res;
        this.isLoggedIn = true;
        return res;
      } catch (error) {
        console.error("获取用户信息失败:", error);
        this.logout();
        return null;
      }
    },
  },
  persist: {
    // 存储键名,默认是 store 的 id
    key: "user-store",
    storage: localStorage,
    // paths
    pick: ["userInfo", "token", "isLoggedIn"],
  },
});

7.3 用户登录页面

在 views 文件夹下面新建 Login.vue:

登录页面很简单,这里我们使用 el-card、el-form、el-button 完成登录页面的设计:

 <el-card class="login-card" shadow="hover">
  <el-form
    :model="loginForm"
    :rules="loginRules"
    ref="loginFormRef"
    class="login-form"
  >
    <el-form-item prop="username">
      <el-input
        v-model="loginForm.username"
        placeholder="请输入用户名"
        prefix-icon="User"
      ></el-input>
    </el-form-item>

    <el-form-item prop="password">
      <el-input
        v-model="loginForm.password"
        type="password"
        placeholder="请输入密码"
        prefix-icon="Lock"
      ></el-input>
    </el-form-item>
    <el-form-item>
      <el-button
        type="primary"
        class="login-button"
        @click="handleLogin"
        :loading="loading"
      >
        登录
      </el-button>
    </el-form-item>
  </el-form>
</el-card>

在 js 代码中,我们要定义以下变量:

import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { ref, reactive } from "vue";
import { useRouter } from "vue-router";
import { useUserStore } from "../store/user";
import { LoginParams } from "../types";
// 路由实例
const router = useRouter();
// 用户状态管理
const userStore = useUserStore();
// 表单引用
const loginFormRef = ref<FormInstance>();
// 加载状态
const loading = ref<boolean>(false);
// 登录表单数据
const loginForm = reactive<LoginParams>({
  username: "",
  password: "",
});
// 表单验证规则
const loginRules = reactive<FormRules>({
  username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
  password: [
    { required: true, message: "请输入密码", trigger: "blur" },
    { min: 6, message: "密码长度不能少于6位", trigger: "blur" },
  ],
});

点击登录按钮,首先要进行表单校验,然后调用 userStore 的登录方法,登录成功之后进入主页页面,否则捕获异常信息。

// 处理登录
const handleLogin = async () => {
  if (!loginFormRef.value) return;
  try {
    // 表单验证
    await loginFormRef.value.validate();
    // 显示加载状态
    loading.value = true;
    // 调用登录方法
    const success = await userStore.login(loginForm);
    if (success) {
      ElMessage.success("登录成功");
      // 跳转到首页
      router.push("/home");
    }
  } catch (error: any) {
    if (error.message) {
      ElMessage.error(error.message);
    }
  } finally {
    // 隐藏加载状态
    loading.value = false;
  }
};

在 useStore 的登录方法中,如果登录成功存储 store 信息,否则抛出异常。

8. 商品的增删改查

8.1 创建商品相关的 API

在 /src/api 文件夹下面新建 product.ts 文件

import { get, post, del, getPage } from "./index";
import { Product, PageParams } from "../types";

/**
 * 获取商品列表(分页)
 * @param params 分页查询参数
 * @returns 分页商品列表
 */
export const getProductList = (params: PageParams) => {
  return getPage<Product>("/product/page", params);
};

/**
 * 获取商品详情
 * @param id 商品ID
 * @returns 商品详情
 */
export const getProductDetail = (id: number) => {
  return get<Product>(`/product/info/${id}`);
};

/**
 * 新增/修改商品
 * @param data 商品数据
 * @returns
 */
export const createUpdateProduct = (data: Product) => {
  return post<Product>("/product/saveUpdate", data);
};

/**
 * 删除商品
 * @param id 商品ID
 * @returns 删除结果
 */
export const deleteProduct = (id: number) => {
  return del<{ success: boolean }>(`/product/delete/${id}`);
};

8.2 商品查询

商品查询页面包含搜索区域、列表区域、分页区域

    <!-- 搜索区域 -->
    <el-card class="search-card">
      <el-form :model="searchForm" inline>
        <el-form-item label="商品名称">
          <el-input
            v-model="searchForm.name"
            clearable
            @clear="handleSearch"
            placeholder="请输入商品名称"
            style="width: 200px"
          ></el-input>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">搜索</el-button>
          <el-button @click="resetSearch">重置</el-button>
          <el-button type="success" @click="handleAddProduct"> 添加商品 </el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <!-- 商品列表 -->
    <el-card class="table-card">
      <el-table :data="productList" border style="width: 100%" v-loading="loading">
        <el-table-column type="index" label="序号" width="55" />
        <el-table-column prop="name" label="商品名称"></el-table-column>
        <el-table-column prop="price" label="价格">
          <template #default="scope"> ¥{{ scope.row.price.toFixed(2) }} </template>
        </el-table-column>
        <el-table-column prop="stock" label="库存"></el-table-column>
        <el-table-column prop="createTime" label="创建时间"></el-table-column>
        <el-table-column label="操作" width="200">
          <template #default="scope">
            <el-button type="primary" size="small" @click="handleEdit(scope.row)">
              编辑
            </el-button>
            <el-button type="danger" size="small" @click="handleDelete(scope.row.id)">
              删除
            </el-button>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <div class="pagination">
        <el-pagination
          :current-page="pageParams.current"
          :page-size="pageParams.size"
          :page-sizes="[10, 20, 50]"
          :total="total"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        ></el-pagination>
      </div>
    </el-card>

商品查询相关变量和方法:

import { ref, reactive, onMounted } from "vue";
import { ElForm, FormInstance, ElMessage, ElMessageBox, FormRules } from "element-plus";
import { Product, PageParams } from "../types";
import { getProductList, createUpdateProduct, deleteProduct } from "../api/product";
import { useUserStore } from "../store/user";
// 用户状态管理
const userStore = useUserStore();
// 初始化时加载数据
onMounted(() => {
  fetchProductList();
});

// 加载状态
const loading = ref<boolean>(false);
// 商品列表数据
const productList = ref<Product[]>([]);
const total = ref<number>(0);
// 搜索表单
const searchForm = reactive({
  name: "",
});
// 分页参数
const pageParams = reactive<PageParams>({
  current: 1,
  size: 10,
  name: "",
});
// 获取商品列表
const fetchProductList = async () => {
  try {
    loading.value = true;
    const res = await getProductList(pageParams);
    productList.value = res.records;
    total.value = res.total;
  } catch (error) {
    ElMessage.error("获取商品列表失败");
  } finally {
    loading.value = false;
  }
};

// 搜索
const handleSearch = () => {
  pageParams.current = 1;
  pageParams.name = searchForm.name;
  fetchProductList();
};

// 重置搜索
const resetSearch = () => {
  pageParams.current = 1;
  pageParams.name = "";
  fetchProductList();
};
// 分页大小变化
const handleSizeChange = (size: number) => {
  pageParams.size = size;
  fetchProductList();
};
// 当前页变化
const handleCurrentChange = (page: number) => {
  pageParams.current = page;
  fetchProductList();
};

8.3 删除商品

// 删除商品
const handleDelete = async (id: number) => {
  try {
    const confirmResult = await ElMessageBox.confirm(
      "确定要删除这个商品吗?",
      "删除确认",
      {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning",
      }
    );
    if (confirmResult === "confirm") {
      await deleteProduct(id);
      ElMessage.success("商品删除成功");
      fetchProductList();
    }
  } catch (error: any) {
    // 如果是取消操作,不显示错误信息
    if (error != "cancel") {
      ElMessage.error("商品删除失败");
    }
  }
};

8.4 新增编辑商品

// 表单数据
let formData = reactive<Product>({
  id: "",
  name: "",
  price: 0,
  stock: 0,
  description: "",
});

// 打开添加商品弹窗
const handleAddProduct = () => {
  dialogTitle.value = "添加商品";
  dialogVisible.value = true;
};
// 打开编辑商品弹窗
const handleEdit = (product: Product) => {
  dialogTitle.value = "修改商品";
  // 填充表单数据
  formData = product;
  dialogVisible.value = true;
};
// 提交表单
const handleSubmit = async () => {
  if (!formRef.value) return;
  try {
    await formRef.value.validate();
    // 创建商品
    await createUpdateProduct(formData);
    ElMessage.success(`商品${formData.id ? "修改成功" : "添加成功"}`);
    // 重置提交
    resetSubmit(formRef.value);
  } catch (error: any) {
    if (error.message) {
      ElMessage.error(error.message);
    }
  }
};

9. 完整代码

前端:

git@gitee.com:zhifou-tech/zhifou-vue3-ts.git

后端

通过网盘分享的文件:zhifou-vue3-ts-springboot.zip
链接: https://pan.baidu.com/s/1_42KmE68ucoCAuTMyg8iXQ?pwd=6666 提取码: 6666 
昨天 — 2025年9月13日掘金 前端

可可图片编辑 HarmonyOS(5)滤镜效果

作者 万少
2025年9月13日 14:52

可可图片编辑 HarmonyOS(5)滤镜效果

前言

可可图片编辑也实现了滤镜效果,主要是利用 Image组件的 colorFilter 属性实现。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区


可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

滤镜的关键属性 colorFilter

colorFilter 的主要作用是给图像设置颜色滤镜效果。

其核心原理是使用一个 4x5 的颜色矩阵 对图片的每个像素颜色(RGBA)进行数学变换,从而得到一个新的颜色值。

参数类型:

它接受两种类型的参数:

  1. ColorFilter: ArkUI 自带的颜色滤镜对象,主要通过传递一个 4x5 的矩阵数组来使用。

  2. DrawingColorFilter: 来自 @kit.ArkGraphics2D

    绘图模块的、功能更丰富的颜色滤镜对象,提供了多种静态创建方法(如混合模式、矩阵、光照等)。

使用方法 1 :使用 ColorFilter 和 4x5 颜色矩阵

这是最基础和最灵活的方式。你需要构造一个包含 20 个数字的数组来表示一个 4x5 的矩阵。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

1. 矩阵计算规则

矩阵结构如下,它会对每个像素的原始颜色 [R, G, B, A] 进行运算:

输出颜色 [R', G', B', A'] 的计算公式为:

重要提示:计算时,原始颜色值(R,G,B,A)需要先归一化(Normalize)到 [0.0, 1.0] 的浮点数范围(1.0 对应 255)。

示例代码:

后续的效果都可以在当前示例代码上进行修改

2. 常用矩阵示例

a) 原图效果(单位矩阵) 保持图片原有色彩,不对其做任何改变。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

b) 灰度效果 将图片转换为黑白灰度图。常见的权重公式是 0.299*R + 0.587*G + 0.114*B

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

c) 颜色反转(负片效果) 将每个颜色通道取反。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

d) 纯色着色(文档中的核心方案) 忽略原图颜色,将其渲染为指定的目标颜色(例如 #4f0f48db)。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

  • 将 RGB 通道的前四列系数设为 0,完全丢弃原图颜色信息。
  • 将 RGB 通道的第五列设置为目标颜色归一化后的。
  • Alpha 通道通常保持不变。

假设目标色为 #4f0f48db,其归一化后的值为:

  • A = 0.31 (79/255)
  • R = 0.06 (15/255)
  • G = 0.28 (72/255)
  • B = 0.86 (219/255)

对应的矩阵为:


e) 棕褐色怀旧效果 (Sepia) ,这是一种经典的老照片效果,为图像添加温暖的棕褐色调。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

f) 亮度调节,整体提升或降低图像的亮度。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

g) 冷色调/暖色调 通过微调不同颜色通道的增益,营造冷暖感觉.

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

使用方法 2 :使用 DrawingColorFilter(推荐,更简单)

通过 '@kit.ArkGraphics2D' 模块提供的多种静态方法创建滤镜,这种方式通常更简洁易懂。

1. 导入模块

2. 使用预置方法创建滤镜

a) 创建混合模式滤镜 (createBlendModeColorFilter)

使用指定的颜色和混合模式(如 BlendMode.SRC_IN)进行混合,非常适合着色。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

b) 直接通过矩阵创建 (createMatrixColorFilter)

与原生 ColorFilter 类似,但使用 drawing 模块的接口。

可可图片编辑 HarmonyOS(5)滤镜效果-鸿蒙开发者社区

注意事项

性能:颜色滤镜的运算发生在渲染时,对于大图或频繁操作,可能会有性能开销。建议对处理后的结果进行缓存。

Alpha 通道:在处理透明度时务必小心,错误的矩阵设置可能导致图片完全透明或出现非预期的半透明效果。

对于简单的颜色覆盖/着色,优先推荐使用 drawing.ColorFilter.createBlendModeColorFilter,它的代码更简洁,意图更

清晰。对于复杂的颜色变换(如复古、色调分离等),再考虑使用自定义矩阵。

以往文章

前端文件下载的三种方式: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)

GDAL 读取遥感影像数据

作者 GIS之路
2025年9月13日 13:28

前言

为了进一步对遥感影像数据进行处理,需要访问遥感影像中的数据,即影像像元灰度值。在 GDAL 中,提供了 ReadRaster() 和 ReadAsArray() 两个方法用来访问影像数据。

本篇教程在之前一系列文章的基础上讲解

  • GDAL 简介[1]
  • GDAL 下载安装[2]
  • GDAL 开发起步[3]

如果你还没有看过,建议从那里开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 10

Python:3.11.7

GDAL:3.7.3

Numpy:1.24.3

2. ReadRaster 方法

ReadRaster方法返回一个栅格字节流数据,具有多个参数。

  • xoff,yoff :目标点距离全图原点的位置(以像元为单位)
  • xsize,ysize : 目标图像的矩形的长和宽(以像元为单位)
  • buf_xsize,buf_ysize :图像缩放大小。用来定义缩放后图像最终的宽和高
  • buf_type :对读取的数据类型进行转换(比如原图数据类型是short,可以将其缩小成byte
  • band_list :适应多波段的情况。可以指定要读取的波段。
from osgeo import gdal

# 启用异常处理(推荐)
gdal.UseExceptions()

# 打开影像文件
dataset = gdal.Open("LC08_L1TP_130042_20210212_20210304_01_T1_B1.TIF")

print(help(dataset.ReadRaster()))
# 获取目标波段数据
band = dataset.GetRasterBand(1)

print("波段数据:",band)

# 读取整个波段为字节流
bytes_stream = band.ReadRaster()

# 读取指定区域
xoff,yoff,xsize,ysize = 100,100,200,200
buf_xsize,buf_ysize = 200,200

data_bytes_region = band.ReadRaster(xoff,yoff,xsize,ysize,buf_xsize,buf_ysize)
# print("n 数据区域:",data_bytes_region)

# 指定数据类型
data_bytes_float = band.ReadRaster(buf_type=gdal.GDT_Float32)
# print("n 数据类型:",data_bytes_float3

3. ReadAsArray 方法

ReadAsArray方法与ReadRaster方法相似具有多个参数,不同的是起返回的数据为类型数组。

  • xoff,yoff :目标点距离全图原点的位置(以像元为单位)
  • xsize,ysize : 目标图像的矩形的长和宽(以像元为单位)
  • buf_xsize,buf_ysize :图像缩放大小。用来定义缩放后图像最终的宽和高
  • buf_type :对读取的数据类型进行转换(比如原图数据类型是short,可以将其缩小成byte
  • band_list :适应多波段的情况。可以指定要读取的波段。
from osgeo import gdal
import numpy as np

# 启用异常处理(推荐)
gdal.UseExceptions()

# 打开影像文件
dataset = gdal.Open("LC08_L1TP_130042_20210212_20210304_01_T1_B1.TIF")

band = dataset.GetRasterBand(1)

# 读取整个波段
array = band.ReadAsArray()
print(type(array)) # <class 'numpy.ndarray'>
print(array.shape) # (行数, 列数)

# 读取指定区域
xoff,yoff,xsize,ysize = 100,100,200,200
sub_array = band.ReadAsArray(xoff,yoff,xsize,ysize)

# 分块读取
buf_xsize,buf_ysize = 100,100
array_buf = band.ReadAsArray(buf_xsize=buf_xsize,buf_ysize=buf_ysize)

4. 读取多波段数据

本例中未进行波段合成,只有一个波段数据,所以直接用dataset.GetRasterBand(1)读取波段数据。

"""
读取多波段数据
"""
import numpy as np
from osgeo import gdal

# 启用异常处理(推荐)
gdal.UseExceptions()

# 打开影像文件
dataset = gdal.Open("D:AppLC81300422021043LGN00LC08_L1TP_130042_20210212_20210304_01_T1_B1.TIF")

def read_multi_band(dataset,method="array"):
    # 获取波段总数
    bands_count = dataset.RasterCount
    rows,cols = dataset.RasterYSize,dataset.RasterXSize

    if method == "array":
        # 使用 ReadAsArray
        data = np.zeros((rows,cols,bands_count))
        for i in range(bands_count):
            band = dataset.GetRasterBand(1)
            if band:
                data[:,:,i] = band.ReadAsArray()
    elif method == "raster":
        # 使用 ReadRaster
        data = np.zeros((rows,cols,bands_count))
        for i in range(bands_count):
            band = dataset.GetRasterBand(1)
            if band:
                data_bytes = band.ReadRaster()
                # 数据转换
                band_array = np.frombuffer(data_bytes,dtype=gdal.GetDataTypeName(band.DataType).lower())
                data[:, :, i] = band_array.reshape((rows, cols))

    return data

data_bytes = read_multi_band(dataset,'raster'data_array = read_multi_band(dataset,'array')  

print(f"数据相等:{data_bytes == data_array}") 

5. 性能测试

在GDAL中,使用ReadAsArray方法与ReadRaster方法读取栅格数据在效率上存在较大差距。经过测试发现,ReadRaster方法比起ReadAsArray方法要快很多。

"""
性能测试
"""
import time
import numpy as np
from osgeo import gdal

# 启用异常处理(推荐)
gdal.UseExceptions()

# 打开影像文件
dataset = gdal.Open("D:AppLC81300422021043LGN00LC08_L1TP_130042_20210212_20210304_01_T1_B1.TIF")

band = dataset.GetRasterBand(1)

def perfomanse(band):
    # 测试ReadArray
    start_time = time.time()
    array1 = band.ReadAsArray()
    time_array = time.time() - start_time

    # 测试ReadRaster
    start_time = time.time()
    data_bytes = band.ReadRaster()
    array2 = np.frombuffer(data_bytes,dtype=gdal.GetDataTypeName(band.DataType).lower())
    array2 = array2.reshape((band.YSize,band.XSize))
    time_raster = time.time() - start_time

    print(f"ReadArray时间:{time_array:.4f} s")
    print(f"ReadRaster时间:{time_raster:.4f} s")
    print(f"结果相等: {np.array_equal(array1, array2)}")

    return time_array, time_raster

perfomanse(band)

以下是输出结果。

6. 主要区别

ReadAsArray方法与ReadRaster方法存在以下区别。ReadAsArray()方法更简单、更直接,适合大多数应用场景;而ReadRaster()方法更灵活、更底层,适合需要精细控制的高级应用。

特性 ReadRaster() ReadAsArray()
返回类型 二进制字节流 (bytes) NumPy数组
内存使用 较低,需要手动处理 较高,自动转换为数组
灵活性 高,可控制数据类型和读取区域 较低,但更易用
性能 需要额外转换步骤 直接返回数组,性能更好
使用复杂度 较高,需要更多代码 较低,简单易用

参考资料

[1]GDAL 简介

[2]GDAL 下载安装

[3]GDAL 开发起步

❌
❌