阅读视图
“怀芯声学”完成数千万元天使轮融资
丰田据悉计划通过金融机构出售190亿美元的股票
React 核心揭秘:虚拟 DOM 原理与 Diff 算法深度解析
在前端工程化领域,React 的虚拟 DOM(Virtual DOM)机制经常被误解。许多开发者认为“虚拟 DOM 的引入是为了提升性能”,这一观点既不准确也不严谨。
本文将从源码架构视角,深入剖析 React 虚拟 DOM 的内存结构、安全性设计,以及 Reconciler(协调器)层核心的 Diff 算法实现。
一、引言:打破“虚拟 DOM 更快”的迷思
首先必须澄清一个技术事实:没有任何框架的运行时性能可以超越极致优化的原生 DOM 操作。
虚拟 DOM 本质上是 JavaScript 对象,React 在每一次更新时,都需要经过“创建对象 -> Diff 比对 -> 生成 Patch -> 更新真实 DOM”这一过程。相比直接操作 innerHTML 或 appendChild,它多出了繁重的 JS 计算层。
既然如此,为何 React 依然选择虚拟 DOM?其核心价值在于:
- 性能下限的保障:手动优化 DOM 操作极其依赖开发者水平。虚拟 DOM 结合批处理(Batch Update)机制,提供了一个“足够快”的性能下限,避免了低效 DOM 操作导致的页面卡顿。
- 跨平台能力:虚拟 DOM 是对 UI 的抽象描述(Abstract Syntax Tree of UI)。这一抽象层使得 React 可以通过不同的渲染器(Renderer)映射到不同平台:Web 端映射为 DOM,Native 端映射为原生视图(React Native),甚至映射为 PDF 或终端 UI。
- 声明式编程与开发效率:开发者只需关注状态(State)的变化,无需手动维护 DOM 状态,极大降低了应用复杂度。
二、核心结构:虚拟 DOM 在内存中的形态
React 的开发流程经历了 JSX -> Babel 编译 -> React.createElement -> ReactElement 对象的转化过程。
1. 内存结构与 React.createElement
JSX 仅仅是语法糖。在编译时,标签会被转换为 React.createElement 调用。该函数的主要职责是处理参数,构建并返回一个描述节点的 JavaScript 对象,即虚拟 DOM 节点(VNode)。
JavaScript
// 简化的 ReactElement 结构演示
const ReactElement = function(type, key, ref, props, owner) {
const element = {
// 核心安全标识
$$typeof: REACT_ELEMENT_TYPE,
// 元素的内置属性
type: type,
key: key,
ref: ref,
props: props,
// 记录创建该元素的组件
_owner: owner,
};
return element;
};
2. $$typeof 与 XSS 防御
在上述结构中,$$typeof 属性至关重要,它是 React 防止 XSS 攻击的一道防线。
攻击场景:假设服务器端存在漏洞,允许用户存储任意 JSON 对象,而前端直接将该对象作为组件渲染。黑客可以构造一个恶意的 JSON 对象来模拟 ReactElement。
防御机制:
REACT_ELEMENT_TYPE 是一个 Symbol 类型的值:
JavaScript
const REACT_ELEMENT_TYPE = Symbol.for('react.element');
由于 JSON 不支持 Symbol 类型,当数据经过 JSON.stringify 序列化再传输时,Symbol 会丢失。React 在渲染时会严格校验 element.$$typeof === REACT_ELEMENT_TYPE。如果数据来自不受信任的服务端 JSON,该属性将缺失或无效,React 会拒绝渲染,从而拦截潜在的 XSS 攻击。
三、算法揭秘:Diff 算法的设计权衡
React 的核心是协调(Reconciliation),即通过 Diff 算法计算新旧虚拟 DOM 树差异的过程。
在计算机科学中,计算两棵树的最小编辑距离(Edit Distance)的标准算法复杂度为
O(n3)O(n3)
。对于一个包含 1000 个节点的应用,这将导致 10 亿次计算,在浏览器端显然不可接受。
为了将复杂度降低至
O(n)O(n)
,React 基于 Web UI 的特点,实施了大胆的启发式算法(Heuristic Algorithm) ,主要基于以下三大策略:
策略一:分层比较(Tree Diff)
Web UI 中,DOM 节点跨层级移动的操作极其罕见。React 选择忽略跨层级的节点移动。
Diff 算法只对同一层级的节点进行比较。如果一个 DOM 节点在更新前后跨越了层级,React 不会尝试复用它,而是直接销毁旧节点,并在新位置重新创建新节点。
策略二:类型检查(Component Diff)
React 认为:不同类型的组件产生的树结构几乎完全不同。
- 如果组件类型(type)发生变化(例如从 div 变为 p,或从 ComponentA 变为 ComponentB),React 会判定为“脏组件”,不再深入比较子树,直接销毁旧组件及其所有子节点,并创建新组件。
- 如果组件类型相同,则认为结构相似,仅更新属性(Props),并递归比对子节点。
策略三:Key 标识(Element Diff)
对于同一层级的一组子节点,开发者可以通过 key 属性提供唯一标识。React 使用 key 来判断节点是否仅仅是发生了位置移动,从而复用现有 DOM 节点,避免不必要的销毁和重建。
四、源码级复盘:如何遍历与比对(Diff Flow)
React 的 Diff 过程本质上是一个**深度优先遍历(DFS)**的过程。从根节点开始,沿着深度向下比较,直到叶子节点,然后回溯。
以下通过简化的伪代码,展示 React 协调器的核心比对流程:
JavaScript
/**
* 简化的 Diff 算法逻辑
* @param {HTMLElement} parentNode 父真实DOM
* @param {Object} oldVNode 旧虚拟DOM
* @param {Object} newVNode 新虚拟DOM
*/
function diff(parentNode, oldVNode, newVNode) {
// 1. 如果新节点不存在,说明被删除了
if (!newVNode) {
parentNode.removeChild(oldVNode.dom);
return;
}
// 2. 如果旧节点不存在,说明是新增
if (!oldVNode) {
const newDOM = createDOM(newVNode);
parentNode.appendChild(newDOM);
return;
}
// 3. 节点类型变化或 Key 变化:暴力替换
if (
oldVNode.type !== newVNode.type ||
oldVNode.key !== newVNode.key
) {
const newDOM = createDOM(newVNode);
parentNode.replaceChild(newDOM, oldVNode.dom);
return;
}
// 4. 类型相同:复用 DOM,更新属性
const el = (newVNode.dom = oldVNode.dom);
updateProps(el, oldVNode.props, newVNode.props);
// 5. 递归处理子节点 (Children Diff)
diffChildren(el, oldVNode.children, newVNode.children);
}
/**
* 子节点对比:利用 Map 进行 O(1) 查找
*/
function diffChildren(parentDOM, oldChildren, newChildren) {
// 建立旧节点的 Map 索引:Key -> Node
const keyMap = {};
oldChildren.forEach((child, index) => {
const key = child.key || index;
keyMap[key] = child;
});
// 记录上一个不需要移动的节点索引
let lastIndex = 0;
newChildren.forEach((newChild, index) => {
const key = newChild.key || index;
const oldChild = keyMap[key];
if (oldChild && oldChild.type === newChild.type) {
// 命中缓存:复用节点
diff(parentDOM, oldChild, newChild);
// 判断是否需要移动
if (oldChild.index < lastIndex) {
// 如果当前旧节点的位置在 lastIndex 之前,说明它被“插队”了,需要移动真实 DOM
// 伪代码:parentDOM.insertBefore(newChild.dom, refNode);
} else {
// 不需要移动,更新 lastIndex
lastIndex = oldChild.index;
}
} else {
// 未命中:创建新节点
const newDOM = createDOM(newChild);
// 插入逻辑...
}
});
// 清理 keyMap 中未被复用的旧节点(删除操作)
// ...
}
关键点解析
-
DFS 遍历:React 会优先深入处理子节点。当父节点属性更新完毕后,立即进入 diffChildren。
-
Key Map 优化:在 diffChildren 阶段,通过构建 keyMap,React 将查找复用节点的时间复杂度从
O(n2)O(n2)降低到了
O(n)O(n)。
-
LastIndex 移动判定:React 维护一个 lastIndex 游标。如果复用的节点在旧集合中的索引小于 lastIndex,说明该节点在新集合中被移到了后面,此时执行 DOM 移动操作;否则保持不动。这是一种基于顺序优化的策略。
五、总结
React 的虚拟 DOM 并非为了追求极致的单次渲染性能,而是为了提供可维护性、跨平台能力和性能安全感。
Diff 算法通过放弃对跨层级移动的支持、假设不同类型产生不同树、以及利用 Key 进行同级复用这三大启发式策略,成功将复杂的
O(n3)O(n3)
树比对问题转化为线性的
O(n)O(n)
问题。理解这一机制,不仅有助于编写高性能的 React 组件,更是深入掌握现代前端框架设计哲学的必经之路。
小鹏布局行业首个人形机器人全链条量产基地
银河通用机器人在南宁新设公司
离开腾讯后,他想做一款能改变历史的三国游戏
文 | 贝果树
编辑 | 果脯
对于熟悉三国历史的玩家来说,《我的三国》的游玩体验,可能类似于主角拿到剧本的穿越文学。
公元264年,离陈寿写下《三国志》还有十余年,这时的陈寿,在官场上还资历尚浅。但你看重陈寿的才能,打算提拔他为官职太常。重臣董厥出来劝阻,说这恐难服众。不过,你知道自己不会看错人,便在对话框里自信地打下:“此子将来必能名留青史”。
此子将来必能写就《三国志》
这是《我的三国》在1月2日发布的实机演示中的内容。《我的三国》是一款由AI驱动的历史模拟游戏,玩家将作为三国故事中的人物,下达指令,触发事件,推进历史的进程。
游戏实机演示截图
有玩家在评论区说这是一款“AI美术,AI文案,AI配音”的“3A”大作。在游戏中,AI会根据玩家的选择推演剧情,有极大的自由度。而这部“3A”作品,最终在B站上收获了33万的播放量。
《我的三国》的诞生,源于制作人胡景皓“想用AI做一款前所未有的游戏”的初衷。
在腾讯工作时,胡景皓就觉得“如果只是在传统游戏上加上AI,不能完全体现出它的价值”。生成式AI有强大的内容创造能力,他相信如果仅比较内容丰富度,能用AI制作出超越GTA5或《博德之门3》那样体量的游戏。
AI的参与为游戏开发带来了一些不一样的地方。臭皮匠工作的成员除了胡景皓都是学生。有人还在读大二大三,他有意保持这种年轻的状态:有了AI带来的“技术平权”,“工作经验其实没那么重要”。
也有一些新的问题。在实机演示的评论区,不少玩家质疑AI“记忆如何保留”;交互与Token成本挂钩,如何选择营收方式?;AI生成内容怎么应对审核?
现在《我的三国》还没有做出一个真正可玩的版本,他们也在探索。但胡景皓相信,“等大家真的能玩到的那天,就能感受到AI游戏的潜力”。
以下是我们和《我的三国》制作人胡景皓的对话内容,经整理后呈现。
用AI做出一款“前所未有”的游戏
为什么选择从腾讯自己出来做游戏?
我在腾讯期间的工作分为两个阶段。在大模型出来之前,我们主要做的是NLP(自然语言处理)、CV多模态相关的事情。而大模型出来之后,我们就开始探索大模型在一些游戏当中的应用,比如给《和平精英》《英雄联盟手游》赋能,做一些锦上添花的功能。
《和平精英》的AI队友“花傲天”
但从我个人感觉来看,如果只是在一个传统的游戏里加上AI,可能没法最大化它在游戏中的价值。我更希望能利用AI重新开发一款前所未有,或者说以前技术上无法实现的游戏。
针对这点,在职期间我就自己做了很多头脑风暴,也尝试过开发一些小的AI游戏。反复检验过后,我觉得这件事情本身是可行的,而且很有意思,加上当时脑子里已经对《我的三国》有了一定的构思,所以去年9月份的时候才最终决定离职,出来做自己的一款游戏。
在你的设想中,这个“前所未有”具体体现在哪里?
有三方面。
第一种是用AI进行角色扮演,这是之前的游戏做不到的。AI能让游戏角色给玩家一种真正的“人”的感觉。从《我的三国》来说,就是让张飞像张飞,关羽像关羽。
第二种是动态叙事。之前所有游戏里的剧情都是预先生成好的,而AI则能随着游戏进程,动态地生成剧情。
游戏实机演示截图
第三种是AIGC。现在很多游戏都用AI去生成游戏素材,但我们的目标是让这些素材和内容能够实时生成。或者即使是离线生成,我们也希望人参与的部分尽可能小,让 AI 生成尽可能多的AIGC资产。
听起来这些能力都和生成式AI有关。这一轮AI浪潮出现的也大多是生成式AI,和之前相比会有什么不同?
上一轮AI能做的事情更多是工具性的。比方说NLP功能,更多被用来给玩家言论分类、过滤违规内容等。它被用于提升效率,而不是生成内容。
而在这一轮,AI变成了一个创作者。我们这款游戏的核心,就是让AI能够尽可能地释放它的创造能力。
游戏实机演示截图
有了AI的创造能力,你们现在能做出多大内容体量的游戏?
如果抛开3A大作在引擎和渲染技术上的积累,仅就叙事内容的丰富度而言,我认为我们甚至可以做出超越GTA5或《博德之门3》这样内容体量的游戏。AI有强大的内容创造能力。而我们只需引导AI去生成相应的内容,就能构建出足够的自由度,让游戏呈现出近乎无穷无尽的内容生态。
不一样的三国游戏
为什么选择三国题材?
关于这点,我们其实有好几个维度的思考。
首先,做AI游戏最难的点在于怎么让AI理解你这个游戏的世界观。如果它本身是一个架空的世界观,AI其实会不知道这里的人是什么,对于这个世界的运行规则也不了解,因此也没办法推测可能会发生哪些事件,最终导致很容易在生成内容时产生幻觉。
而如果是一个具体的历史题材,AI其实已经充分学习过里面的语料,包括了解里面的每一个人物、每一个事件,以及每一个设定和背景。在开发过程中,我们就不再需要给AI灌注太多额外的知识,只需要保证它能完成一套合理的叙事逻辑就行。
游戏实机演示截图
其次在中国,三国是一个非常大的IP。大家喜欢三国IP,核心还是喜欢三国的故事。但之前的三国游戏大多在剧情、谋略和人物的塑造上有所欠缺,更多是数值和机制的玩法。而我们希望在《我的三国》里,玩家能参与故事的进程,通过不同的谋略和选择改变历史。
游戏实机演示截图
最后,是考虑到目前AI在内容生成上,更偏向于图文或声音的模块,而在游戏引擎和动作上还不能做到那么实时。但我们观察到,一些历史游戏爱好者则对这种图文交互的玩法接受度很高,用AI则能升级这些体验。
但现在其实也有小部分三国游戏符合你的描述,比如《英雄立志传:三国志》。与他们相比,《我的三国》又会有哪些不同?
《英雄立志传:三国志》综合了很多机制,做了一个很全面的三国游戏。但从体验角度,AI游戏跟传统游戏最大的核心点差异,在于自由度的呈现方式上。
在传统游戏里,交互被限制在已经暴露出来的玩法上。比方说通过互动增加属性值或触发事件。但它们没办法真正意义上去推演历史或进行宏大叙事。比如,杀掉曹操后世界会发生什么变化?这部分不借助AI很难做。如果能给传统游戏加上AI,可玩性会有很大提升,这也是我们想做的事。
游戏实机演示截图
这会产生Token成本。之前一些产品会用IAA模式维持营收,比如Token不够了就需要看广告。《我的三国》未来会采用什么样的营收模式?
《我的三国》会是一款买断制游戏。在我们的游戏里,Token成本是可控的。我们实时生成的内容以文本为主,而现在文本模型的价格没那么高。
另外我们有缓存机制。当不同玩家玩到相同状态时,游戏就会加载之前生成的内容,而不是实时生成。所以成本会随着游戏进程慢慢降低。
之所以选择买断制,是因为我们更希望以游戏质量,而不是以成本导向定价。订阅制或IAA有点像买牛肉按斤算,在乎的是顾客花了多少成本。而买断制是以内容整体呈现定价。就像一道菜好不好吃,如果顾客觉得好吃,就可以有一些溢价。
如果我们觉得自己在做很好的游戏,那么买断制其实更合理。
但从玩家角度看,有些人在剧情上可能还是偏向做选择,而不是思考和输出。大家对需要手打输入的模式接受度高吗?
游戏里会给玩家大量选择空间。比如你的手下会提供一些计策,可以选择采纳或修改这些意见。演示里绝大部分用输入,是为了展示游戏更好的自由度。但对于部分对创造要求不那么高的玩家,也可以通过预设选项来进行游戏。
玩家可以直接选择预设选项或对预设选项进行更改
还有很多玩家关心AI记忆能否保留,这个问题怎么解决?
核心解决方案是把AI生成的剧本定期结算到游戏的系统状态里。比方说一场战役打完,我们只会记录谁获得了多少土地、谁的数值提高了、哪个将领死掉了,而把具体的剧情发展过程扔掉。
这是一种选择性记忆。我们会定义哪些内容是需要上下文记忆的,哪些不需要。结算就是把一个故事变成一个可以被记录的游戏状态。随着游戏时长增长,我们只会记录那些会影响后续游戏发展的重要内容,有点像副本结算的感觉。所以不会面临传统AI游戏的记忆系统问题。
之后除了Demo里已有的玩法,还会在哪些方面增加内容?
未来会增加能够进行交互的地图系统,还会有一个很有意思的后宫系统。而对于已经放出的功能,我们也会进行重新设计,让界面更美观、交互方式更自然。我们对演示过的功能可能都会做一些重构和调整。
AI的参与改变了传统的开发过程
你们的工作室叫臭皮匠工作室,能简单介绍一下你们是怎样组建这个团队,以及如何分工的吗?
我们最早是三个同学一起组建的工作室,但现在团队已经扩到了九人。
我们的分工没有那么明确。我自己负责产品和技术,但也会写代码、做调研;我们的设计同学、美术同学会做一些前端开发;程序同学也会参与游戏的讨论。所以我们的游戏其实是大家一起集思广益设计出来的。
现在团队扩建以后,这种分工方式也没有太大改变。每个人会有一块自己负责的技术,但也都会写代码去实现模块部分。所有的同学都会参与到游戏设计里面去。
我希望大家能聚在一��,核心还是对这个游戏本身感兴趣,而不仅仅是实现一个功能。我其实很喜欢这种人人都参与到游戏设计的感觉。
游戏实机演示截图
听起来,AI的应用改变了传统的团队结构和开发流程?
是的,我们感觉下来,AI带来了“技术平权”。以前没有编程经验的同学可能不敢尝试实现功能,但现在给他一些AI工具,他们也能开始搭界面、做设计。每个人都能做很多之前不敢想的事,核心驱动力变成了大家的兴趣与热情,这是我最看重的一点。
你们从立项到出Demo似乎很快。
AI确实能在做游戏上带来非常高的提效。在传统模式里,按我们的想法做一款游戏其实非常难,有很多决策步骤。但当我们用AI去解决这些步骤后,效率跟之前比会有一个质的提升。
游戏实机演示截图
这段时间正好是AIGC应用落地的一个热点周期。有没有感受到大家都开始用AI做游戏?
其实所有的小团队、大团队都会尝试用AI来提效。之前我在腾讯的时候,就有很多组都在往AI方向转型,用AI来做一些原本需要很多人力去做的事情。
而用做AI原生游戏,或者把AI作为核心玩法的也有不少。但我们感觉下来,大家的思路都不太一样,所以做出来的游戏也千差万别。可能大家都还处于一个探索的阶段。
用AI做游戏会不会遇到一些新的问题?
许多任务看起来AI能够完成,但往往不如预期。例如,让AI生成一个《王者荣耀》的英雄,它实际做不好。因为AI并不真正理解这个游戏的具体玩法。
我们在开发中也经常遇到这类问题。我们会以为已经提供了足够的信息让AI生成内容,却因为某些上下文的缺失,导致生成的内容并没有那么好。这时,我们就必须从头思考,究竟缺了哪一方面的信息,才让结果跟我们想象中的有所偏差?这种思考是我们经常要做的。
这会不会对开发周期有一些影响?内部是否已经定了一个大概的上线日期?
我们的目标是今年年底能把游戏上线。但我们也预留了一些空间用来探索。因为开发过程中一定会出现问题和需要重新迭代的部分。
所以,我们在做的过程中不担心可能会遇到的问题,因为我们从一开始就已经预留了时间。
《我的三国》里面有大量的AI生成内容。这在找发行或拿版号审核时,会不会遇到困难?
我们可能打算自发行。传统发行商对AI游戏还没有很充足的经验,而我们也想自己积累发行经验。
关于审核,有很多AI游戏一开始立项是游戏,但做着做着就做成了一款应用。所以关于这块的政策或审批,我们目前还不是很清楚。但我觉得随着时间发展,应该会有更多案例让我们更清楚地看到审核制度。
目前我们肯定还是先把产品做好,再去观察目前的要求以及对AI生成内容的态度,然后再去申请版号。
市场如何看待这类AI游戏项目?大家会怎么估值?
我感觉大家都非常感兴趣,但还是以一种观望的状态去看待。
投资机构觉得2026年是AI应用落地时期,未来会有爆发性的趋势。但之前出现了很多关于AI游戏的概念,而实际可玩性并没想象中高;AI游戏也没有太多先例,所以他们更多是处于观察期。
而我们聊完后,发现其实大家还是很认可我们在做的事情。在做出一个可玩的版本时,我们就会开启比较正式的融资。
我相信大家在玩过我们的游戏后,就能感受到AI游戏的潜力。
本文首发自“36氪游戏”。
携程:联合创始人辞任,吴亦泓及萧杨为新任独立董事
Flutter——List.map()
一、map
map 是 Dart 中 List 集合的核心转换方法,作用是遍历列表中的每一个元素,对每个元素执行指定的转换逻辑,最终返回一个新的可迭代对象(Iterable) 。
-
核心特点:不会修改原列表,而是返回新的迭代对象(需要手动转成
List); -
语法:
Iterable<T> map<T>(T Function(E element) convert)-
convert:转换函数,接收原列表的单个元素,返回转换后的元素; -
T:转换后元素的类型(可省略,Dart 会自动推导); -
E:原列表元素的类型。
-
二、基础用法(必掌握)
1. 基本类型转换
最常见的场景:将列表中的元素做简单转换(如数字转字符串、数值运算等)。
void main() {
// 原列表:整数列表
List<int> numbers = [1, 2, 3, 4, 5];
// 1. 转换:每个数字乘以2 → 返回 Iterable<int>
Iterable<int> doubledIterable = numbers.map((int num) {
return num * 2;
});
// 2. 转成 List(关键:map返回的是Iterable,需用toList()转成List)
List<int> doubledList = doubledIterable.toList();
print("原列表:$numbers"); // 原列表:[1, 2, 3, 4, 5](原列表不变)
print("转换后:$doubledList"); // 转换后:[2, 4, 6, 8, 10]
// 简化写法(箭头函数):单行逻辑推荐用箭头函数
List<String> numToString = numbers.map((num) => num.toString()).toList();
print("数字转字符串:$numToString"); // [1, 2, 3, 4, 5]
}
2. 自定义对象转换
实战中更常用的场景:将自定义对象列表转换为其他格式(如提取对象的某个属性、转成 DTO 等)。
// 定义自定义对象
class User {
final String name;
final int age;
User({required this.name, required this.age});
}
void main() {
List<User> users = [
User(name: "张三", age: 20),
User(name: "李四", age: 25),
User(name: "王五", age: 30),
];
// 场景1:提取所有用户的姓名 → 字符串列表
List<String> userNames = users.map((user) => user.name).toList();
print("用户姓名:$userNames"); // [张三, 李四, 王五]
// 场景2:转换为新的Map列表(如接口请求参数)
List<Map<String, dynamic>> userMaps = users.map((user) {
return {
"username": user.name,
"user_age": user.age,
"is_adult": user.age >= 18, // 新增衍生字段
};
}).toList();
print("转Map列表:$userMaps");
// 输出:[{username: 张三, user_age: 20, is_adult: true}, ...]
}
三、关键注意事项(避坑)
1. 必须用 toList() 转成列表
map 方法返回的是 Iterable(可迭代对象),不是 List,如果直接使用会导致部分 List 方法(如 add、remove)无法调用:
void main() {
List<int> nums = [1,2,3];
// 错误用法:Iterable 没有 add 方法
// nums.map((e) => e*2).add(4);
// 正确用法:先转List
List<int> newNums = nums.map((e) => e*2).toList();
newNums.add(4); // [2,4,6,4]
}
2. 惰性执行特性
map 方法的转换逻辑不会立即执行,而是在遍历 Iterable(如调用 toList()/forEach())时才执行:
void main() {
List<int> nums = [1,2,3];
// 定义map转换,但未执行
Iterable<int> iter = nums.map((e) {
print("执行转换:$e");
return e*2;
});
print("还未执行转换");
// 调用toList()时,才会遍历并执行转换逻辑
List<int> list = iter.toList();
// 输出顺序:
// 还未执行转换
// 执行转换:1
// 执行转换:2
// 执行转换:3
}
3. 原列表修改不影响已生成的 Iterable
map 是基于原列表当时的状态生成迭代对象,后续修改原列表不会改变已生成的 Iterable:
void main() {
List<int> nums = [1,2,3];
Iterable<int> iter = nums.map((e) => e*2);
// 修改原列表
nums.add(4);
// 转换后的列表包含原列表的3个元素(1,2,3),不包含新增的4
List<int> list = iter.toList();
print(list); // [2,4,6]
}
四、高级用法
1. 链式调用
map 可与其他列表方法(where、sort、take 等)链式调用,实现复杂转换:
void main() {
List<int> nums = [1,2,3,4,5,6,7,8];
// 需求:筛选偶数 → 乘以10 → 转字符串 → 取前3个
List<String> result = nums
.where((e) => e % 2 == 0) // 筛选偶数:[2,4,6,8]
.map((e) => e * 10) // 乘以10:[20,40,60,80]
.map((e) => "数值:$e") // 转字符串:["数值:20", ...]
.take(3) // 取前3个:["数值:20", "数值:40", "数值:60"]
.toList();
print(result); // [数值:20, 数值:40, 数值:60]
}
2. 处理空值(null safety)
Dart 空安全下,处理可能包含 null 的列表:
void main() {
List<int?> nums = [1, null, 3, null, 5];
// 方式1:过滤null后转换
List<int> result1 = nums
.where((e) => e != null) // 过滤null
.map((e) => e!) // 非空断言(已过滤,安全)
.toList();
print(result1); // [1,3,5]
// 方式2:给null设置默认值
List<int> result2 = nums.map((e) => e ?? 0).toList();
print(result2); // [1,0,3,0,5]
}
五、map vs forEach(易混淆对比)
很多新手会混淆 map 和 forEach,核心区别如下:
| 特性 | map |
forEach |
|---|---|---|
| 核心作用 | 转换元素,返回新的 Iterable | 遍历元素执行操作,无返回值 |
| 返回值 | Iterable<T> |
void(无返回值) |
| 是否修改原列表 | 否 | 否(但可在回调中手动修改元素) |
| 典型场景 | 元素类型转换、提取属性 | 遍历执行副作用(如打印、存储) |
void main() {
List<int> nums = [1,2,3];
// map:转换并返回新列表
List<int> mapResult = nums.map((e) => e*2).toList();
// forEach:遍历执行操作,无返回值
nums.forEach((e) {
print("遍历元素:$e"); // 打印每个元素
});
}
总结
-
核心作用:
List.map()是列表元素转换的核心方法,返回 Iterable,需用toList()转成列表; - 关键特性:惰性执行、不修改原列表、支持空安全和链式调用;
- 避坑点:必须转 List 才能使用 List 方法,空列表调用 map 不会报错(返回空 Iterable);
- 使用场景:类型转换、提取对象属性、生成新格式数据(如接口参数)。
掌握 map 方法后,能大幅简化列表转换的代码,是 Dart 开发中最常用的列表操作之一。
中国通号全资子公司等在广东成立湾区低空研究院公司
市场消息:星展银行、华侨银行、大华银行参与竞购汇丰印尼资产
京津冀协同发展12年外贸进出口值增长25.7%
存储芯片成本持续攀升,手机厂商或于3月初集中调价
中国石油旗下公司在大连成立燃料油新公司
通往“全干”之路一:前端部署
年底入职了一家创业小公司,感觉还是很幸运的。由于前端就我1个人而且没有运维,很自然前端项目部署的工作就落在我的肩上。
第一周我搭建起了公司的后台管理系统框架,按需求开发了两个页面,主要是文件上传相关的。然后那周剩余的时间,我就想先部署上去。
一、常见的前端部署
部署环境:JumpServer开源堡垒机
部署所需配置文件就是nginx.conf
部署步骤:
1、账号密码登录堡垒机
2、安装nginx
3、让豆包提供一份标准nginx.conf
4、上传dist文件
5、解压dist.zip到nginx目录/usr/share/nginx/html/
6、启动nginx
后续项目更新只需要上传,并解压文件到指定目录,前端页面刷新后即可看到更新。 这种部署方式比较常见,也比较简单,半天不到即可搞定。在这里不得不提一下AI编程工具对开发效率的提升,特别是新项目来说。
二、亚马逊容器云部署
然后是第二周在另一个前端项目里开发了用户侧的显示界面,也需要部署上去。听面试我的后端大佬说,后端服务是在亚马逊上,采用docker集群部署。还好之前的工作也接触的docker,所以也不是很慌。
部署环境:亚马逊堡垒机
部署所需配置文件:
1、nginx.conf:配置静态资源和前端api请求代理,此文件放前端项目里,然后打包进docker镜像。
2、front-model.yaml:此文件放服务器上,主要配置nginx服务的端口、内存占用,以及镜像地址等。可让AI生成一份,然后修改对应的名称即可。
3、xxx-ingress:服务器上路由文件,主要配置前端路由转到nginx服务。
配置好以上文件后,即可按下面步骤完成部署:
1、打包构建
npm run build:test
2、打镜像
docker build -t front-model:v1.0.1 .
3、amazonaws镜像重命名
docker tag front-model:v1.0.1 628639829879.dkr.ecr.us-east-1.amazonaws.com/front-model:v1.0.1
4、amazonaws登录(先安装aws client)
aws ecr get-login-password --region us-east-1 | docker login --username xxx --password-stdin xxx.dkr.ecr.us-east-1.amazonaws.com
5、推送镜像到amazonaws仓库
docker push 628639829879.dkr.ecr.us-east-1.amazonaws.com/front-model:v1.0.1
6、修改front-model.yaml镜像tag
sudo vim front-model.yaml
7、应用yaml
kubectl apply -f front-model.yaml
8、重启pod服务
kubectl rollout restart deployment/front-model
9、查看指定pod状态
kubectl get pods | grep front-model
遇到的问题:
1、docker客户端提示缺少win包,然后下载进度卡住拉不下来,原因是docker的下载终端在鼠标点击后默认暂停了。
2、前端资源的mime类型不对,需修改nginx.conf。
3、api请求没有经过nginx,原因是ingress的path不支持正则表达式的写法,需要拆开单独写。
大家也发现了上面的部署方式都是纯手工,比较繁琐。后面会考虑做成脚本自动执行,或者接入CICD。
“麦迪克”获数千万元Pre-A轮融资
为旌科技完成新一轮3亿元融资,投资方为君信资本等
解决iOS页面返回缓存问题:pageshow事件详解与实战方案
在iOS移动端前端开发中,很多开发者都会遇到一个棘手的痛点:使用JS跳转页面后,当用户返回上一页时,页面会直接复用之前的缓存状态,导致页面数据不刷新、DOM状态异常——尤其在支付场景中,支付完成返回支付前页面时,订单状态、支付按钮状态无法及时同步,严重影响用户体验,甚至可能引发业务异常。
这个问题的核心根源,是iOS Safari浏览器内置的「Back-Forward Cache」(简称BF Cache,即后退/前进缓存)机制。BF Cache会主动缓存页面的DOM结构、JS运行状态等完整信息,当用户通过后退、前进按钮切换页面时,浏览器会直接复用缓存内容,无需重新加载页面,以此提升页面切换性能,但这种优化在需要实时数据更新的场景中,反而会带来困扰。
本文将结合实际开发场景,详细拆解该问题的解决核心——pageshow事件的用法,同时科普pageshow事件的核心特性与实战技巧,帮助大家彻底解决iOS页面缓存导致的刷新异常问题,提升移动端开发体验。
一、先搞懂:为什么iOS返回页面不刷新?
与PC端浏览器不同,iOS Safari为了进一步优化移动端的性能和用户体验,引入了BF Cache缓存机制:当用户从页面A跳转至页面B时,浏览器会将页面A的完整状态(包括DOM结构、JS变量、页面渲染结果)全部缓存;当用户从页面B返回页面A时,浏览器不会重新触发页面的load事件,而是直接从BF Cache中读取缓存内容,快速渲染展示页面。
这种机制在普通静态页面场景下十分友好,能大幅提升页面切换速度,但在需要实时数据更新的场景(如支付、表单提交、实时数据列表等)中,就会出现明显问题:返回页面后,页面仍保持跳转前的旧状态,无法同步最新的数据(如订单支付状态、表单提交结果、实时统计数据等)。
这里需要明确一个关键区别:常规的load事件,仅在页面首次加载(或强制刷新)时触发,当页面从BF Cache中恢复显示时,load事件不会被触发——这也是我们常规的load事件初始化逻辑,在返回页面时失效的核心原因。
二、核心解决方案:pageshow事件(专门应对缓存恢复场景)
为了解决BF Cache带来的缓存困扰,浏览器原生提供了pageshow事件。它的核心作用是:监听页面「显示」的所有场景,包括页面首次加载显示、从BF Cache恢复显示,正好弥补了load事件无法监听缓存恢复场景的不足,是解决iOS页面缓存问题的最优方案。
2.1 pageshow事件核心详解
pageshow是浏览器原生DOM事件,属于Window对象,无需额外引入任何依赖,直接监听即可使用,其核心特性如下,方便大家快速掌握:
(1)触发时机
- 页面首次加载完成后,成功显示在浏览器窗口时触发(触发顺序在load事件之后);
- 页面从BF Cache(或其他浏览器缓存)中恢复显示时触发(这是解决iOS缓存问题的最关键场景);
- 无论页面是通过刷新、后退、前进等何种方式显示,只要最终呈现在用户视野中,都会触发该事件。
(2)关键属性:event.persisted
pageshow事件对象(event)包含一个核心布尔属性——persisted,这是判断页面是否从缓存中恢复的唯一关键依据,无需额外判断逻辑:
- event.persisted = true:表示当前页面是从BF Cache中恢复的(即用户返回页面时,复用了之前的缓存);
- event.persisted = false:表示页面是首次加载、强制刷新(Ctrl+F5)或从非缓存状态显示的,属于常规加载场景。
通过persisted属性,我们可以精准区分页面的显示场景,进而针对性执行刷新逻辑——仅在页面从缓存恢复时触发刷新操作,既有效解决缓存问题,又不会影响页面正常加载的性能,兼顾体验与效率。
(3)与load、pagehide事件的区别
很多开发者容易混淆pageshow与load、pagehide事件,导致使用场景出错,这里用表格清晰区分三者的核心差异,方便大家快速对照使用:
| 事件名称 | 触发时机 | 缓存恢复时是否触发 | 核心作用 |
|---|---|---|---|
| load | 页面首次加载完成(所有资源加载完毕) | 不触发 | 首次加载时初始化页面、加载数据 |
| pageshow | 页面显示时(首次加载、缓存恢复均触发) | 触发 | 监测页面显示状态,处理缓存恢复场景 |
| pagehide | 页面隐藏时(跳转、关闭标签页、最小化) | 触发 | 页面隐藏前保存当前状态,避免数据丢失 |
2.2 pageshow实战:解决iOS返回页面不刷新问题
结合实际开发中最常见的支付场景,为大家提供2个可直接复制使用的实战代码示例,分别适配不同的业务需求,兼顾实用性和易用性。
示例1:基础版——缓存恢复时强制刷新页面
适合对页面实时性要求极高的场景(如支付后必须同步最新订单状态、避免用户重复操作),当页面从缓存恢复时,直接强制刷新页面,确保页面数据完全最新,无任何延迟。
// 监听pageshow事件,专门处理页面从缓存恢复的场景
window.addEventListener('pageshow', function(event) {
// 判断当前页面是否从BF Cache中恢复
if (event.persisted) {
// 强制刷新页面(可根据需求替换为具体的刷新逻辑)
window.location.reload();
}
});
示例2:进阶版——缓存恢复时仅更新数据(不强制刷新)
强制刷新会重新加载页面所有资源,可能增加加载耗时、影响用户体验。进阶方案仅重新请求接口、更新页面DOM,不刷新整个页面,既能保证数据实时性,又能兼顾页面性能。
// 初始化页面数据(首次加载、缓存恢复均需执行,复用逻辑减少冗余)
function initPageData() {
// 模拟请求接口,获取最新数据(实际开发中替换为真实接口地址)
fetch('/api/order/status')
.then(res => res.json())
.then(data => {
// 更新页面DOM,展示最新订单状态
document.querySelector('.order-status').textContent = data.status;
// 处理支付按钮状态(如已支付则置灰,禁止重复点击)
if (data.status === '已支付') {
document.querySelector('.pay-btn').disabled = true;
}
});
}
// 页面首次加载时,初始化数据
window.addEventListener('load', initPageData);
// 监听pageshow事件,缓存恢复时重新初始化数据(不刷新整个页面)
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
initPageData(); // 仅更新数据,兼顾性能与实时性
}
});
三、补充方案:结合其他方式,彻底规避缓存问题
pageshow事件是解决iOS页面缓存问题的核心方案,但在部分极端场景下(如浏览器缓存策略特殊、业务场景复杂),可结合以下补充方案,形成“核心+辅助”的组合拳,进一步确保效果,避免缓存问题遗漏。
3.1 禁用页面缓存(服务端配合)
通过服务端设置HTTP响应头,明确告诉浏览器不要缓存当前页面,从根源上避免BF Cache机制生效,适合对实时性要求极高的页面(如支付页、订单详情页、表单提交页)。
服务端响应头设置(以Node.js Express为例,其他语言可参考对应语法):
// Node.js Express示例(订单页为例)
app.get('/order', (req, res) => {
// 设置响应头,禁止浏览器缓存当前页面
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
// 渲染订单页面(根据实际业务逻辑调整)
res.render('order');
});
辅助方案(HTML meta标签,优先级低于HTTP响应头,仅作为补充):
<!-- 页面头部添加meta标签,辅助禁用缓存(兼容部分旧浏览器) -->
<meta http-equiv="Cache-Control" content="no-store, no-cache" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
3.2 利用history API管理状态
在跳转页面(如跳转到支付页)前,通过history.replaceState方法添加状态标记,返回页面时检测该标记,触发对应刷新逻辑,适合单页应用(SPA)或页面跳转逻辑复杂的场景,灵活性更高。
// 跳转到支付页面前,添加状态标记(标记当前页面需要刷新)
function goToPayment() {
// 替换当前历史记录,添加needRefresh标记(避免新增历史记录)
history.replaceState({ needRefresh: true }, document.title);
// 跳转到支付页面(替换为实际支付页地址)
window.location.href = '/payment';
}
// 页面初始化时,检测历史状态标记
window.addEventListener('load', function() {
const state = history.state;
// 若存在needRefresh标记,说明是从支付页返回,执行刷新逻辑
if (state && state.needRefresh) {
initPageData(); // 重新加载数据,更新页面状态
history.replaceState(null, document.title); // 重置状态,避免重复触发刷新
}
});
四、注意事项与最佳实践
- 兼容性友好:pageshow事件兼容所有现代浏览器,包括iOS Safari、Android Chrome、PC端主流浏览器,无需额外处理兼容性,可直接在项目中使用;
- 避免过度强制刷新:尽量优先选择“仅更新数据”的进阶方案,减少window.location.reload()的使用,避免重复加载资源,提升用户体验;
- 核心场景双重保障:支付、订单等核心业务场景,建议组合使用“pageshow监听 + 服务端禁用缓存”,双重规避缓存问题,确保业务逻辑正常;
- 表单场景补充处理:若页面包含表单,返回时需重置表单状态,可在pageshow事件中添加表单重置逻辑(如form.reset()),避免表单残留旧数据。
五、总结
iOS页面返回不刷新的核心原因,是Safari浏览器的BF Cache缓存机制,而pageshow事件作为浏览器原生提供的解决方案,能精准监听页面缓存恢复场景,结合event.persisted属性,可灵活实现页面刷新逻辑,是解决该问题的最直接、高效的方式。
实际开发中,可根据业务场景灵活选择基础版(强制刷新)或进阶版(仅更新数据)方案,配合服务端禁用缓存、history API等辅助方式,既能彻底解决缓存问题,又能兼顾页面性能和用户体验。
如果大家在使用pageshow事件时遇到其他问题(如事件触发异常、数据更新不及时、兼容性异常等),欢迎在评论区交流讨论,共同避坑、提升开发效率~
图片对比组件技
本组件是一个基于原生 HTML/CSS/JS 开发的交互式图片对比工具(Image Comparison Slider),常用于展示产品渲染前后、照片修图前后或场景变化的效果。
效果如图:
代码如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Halo 图片对比效果</title>
<style>
/* 基础样式复现 */
body {
margin: 0;
padding: 0;
background: #f5f5f5;
}
.sp-ba-wrap {
max-width: 1440px;
min-width: 343px;
margin: 0 auto;
margin-top: 120px;
padding: 0 20px;
}
.sp-ba {
position: relative;
margin: 0 auto;
max-width: 1200px;
z-index: 2;
}
/* 核心对比滑块样式 */
.banda-slider {
display: block;
overflow: hidden;
position: relative;
border-radius: 16px;
width: 100%;
line-height: 0;
}
.banda-slider img {
width: 100%;
height: auto;
display: block;
user-select: none;
}
.banda-reveal {
left: 0;
top: 0;
bottom: 0;
overflow: hidden;
position: absolute;
right: 50%;
/* 初始位置 */
z-index: 1;
border-right: 2px solid #fff;
}
.banda-reveal>img {
height: 100%;
width: 200%;
/* 这里必须是父容器的2倍才能保证内容不拉伸 */
max-width: none;
object-fit: cover;
}
/* 交互控件:透明滑块 */
.banda-range {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
opacity: 0;
cursor: ew-resize;
z-index: 10;
}
/* 装饰用的中间圆圈 */
.banda-handle {
background: #000;
border-radius: 50%;
color: #fff;
height: 48px;
width: 48px;
left: 50%;
top: 50%;
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
}
.banda-handle:before,
.banda-handle:after {
content: "";
border: solid white;
border-width: 0 2px 2px 0;
display: inline-block;
padding: 3px;
}
.banda-handle:before {
transform: rotate(135deg);
margin-right: 4px;
}
.banda-handle:after {
transform: rotate(-45deg);
margin-left: 4px;
}
@media only screen and (max-width: 888px) {
.sp-ba-wrap {
margin-top: 40px;
}
}
</style>
</head>
<body>
<div class="sp-ba-wrap">
<div class="sp-ba">
<div class="banda-slider" id="mySlider">
<img src="https://cdn.shopify.com/s/files/1/0268/7297/1373/files/55379ad14f3eb2af576acdc527686e4e_3840x2000_6af3220c-9331-4d97-bec8-3687cd8745f9.jpg?v=1711012998"
alt="Before">
<div class="banda-reveal" id="revealLayer">
<img src="https://cdn.shopify.com/s/files/1/0268/7297/1373/files/83793ec91a3bdd17ce22ed844b4e4aeb_3840x2000_b58d63a4-2a39-4550-b890-ff6519f49952.jpg?v=1711012992"
alt="After" id="revealImg">
</div>
<input type="range" min="0" max="100" value="50" class="banda-range" id="rangeInput">
<div class="banda-handle" id="handle"></div>
</div>
</div>
</div>
<script>
// 逻辑实现:监听滑动条并实时更新 UI
const range = document.getElementById('rangeInput');
const revealLayer = document.getElementById('revealLayer');
const revealImg = document.getElementById('revealImg');
const handle = document.getElementById('handle');
const slider = document.getElementById('mySlider');
range.addEventListener('input', (e) => {
const value = e.target.value;
// 1. 更新遮罩层的宽度(实际上是修改 right 距离)
// 原理:当 value 增加,左侧显示更多,revealLayer 需要向右移
revealLayer.style.right = (100 - value) + '%';
// 2. 更新分隔小圆圈的位置
handle.style.left = value + '%';
// 3. 动态调整内部图片的宽度,防止拉伸
// 因为 revealLayer 的宽度在变,其内部图片需要反向维持比例
const containerWidth = slider.offsetWidth;
revealImg.style.width = containerWidth + 'px';
});
// 窗口大小改变时重置图片宽度
window.addEventListener('resize', () => {
revealImg.style.width = slider.offsetWidth + 'px';
});
// 初始化执行一次
revealImg.style.width = slider.offsetWidth + 'px';
</script>
</body>
</html>
一、 实现的效果
-
视觉表现:页面中间展示一张图片,通过一条可移动的垂直分割线将画面分为左右两部分,分别显示不同的内容(如:白昼与黑夜、修图前与修图后)。中心配有一个黑色圆形手柄提示用户可进行操作。
-
交互表现:
- 手动拖拽:用户点击并左右拖动中间的手柄,即可实时改变两侧图片的显示比例。
- 移动端适配:支持触摸滑动,在手机或平板上拥有流畅的交互体验。
- “揭开”感:手柄移动的过程类似于拨开一张蒙版,视觉反馈直观且平滑。
二、 实现思路
-
分层堆叠(Layering)
将两张分辨率完全一致的图片放置在同一个父容器中。底层图片(Bottom Image)作为固定基准,顶层图片(Top Image)嵌套在一个带有遮罩属性的容器中。
-
动态裁剪(Clipping)
给顶层图片容器设置
overflow: hidden。通过改变这个容器的宽度(例如从50%变为30%),它就像一扇“移动的门”,遮挡掉上层图片的一部分,从而露出底层的图片。 -
视觉对齐(Alignment Fix)
- 挑战:默认情况下,如果父容器变窄,内部图片通常会随之缩小或变形。
- 对策:给上层图片设置一个固定的宽度(通常等于外层大容器的宽度),使其不随遮罩容器的缩放而缩放。这样,上下两张图片的内容就能在视觉上完美重合。
-
隐形控制(Invisible Control)
在整个组件最顶层覆盖一个完全透明的 HTML 滑动条
<input type="range">。这样做可以利用浏览器原生的高性能滑动监听,无需自己写复杂的鼠标位移计算逻辑。
三、 实现原理
1. 核心 CSS 结构
-
遮罩原理:利用
position: absolute进行定位。遮罩层banda-reveal充当“视口”,通过修改它的right或width属性来控制露出的比例。 -
布局优化:使用
line-height: 0和display: block消除图片底部常见的像素间隙,确保容器高度完全由图片撑开。
2. 数值映射
组件通过 JavaScript 实时获取滑块的数据并进行映射:
- 滑块当前值: (取值范围 )
- 遮罩层宽度: (决定揭开多少内容)
- 手柄偏移量: (确保手柄始终在分割线上)
3. 同步逻辑代码
JavaScript 监听 input 事件,实现数据驱动 UI:
JavaScript
// 核心同步逻辑示例
rangeInput.addEventListener('input', (e) => {
const sliderValue = e.target.value;
// 1. 改变遮罩层宽度(拨开视觉效果)
revealLayer.style.width = sliderValue + '%';
// 2. 同步移动中间的控制手柄
handle.style.left = sliderValue + '%';
});