普通视图

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

【高斯泼溅】告别近看模糊!Mapmost如何重塑场景细节

作者 Mapmost
2025年12月9日 10:13

最近几年,只要和3D、空间影像沾点边的行业,都在聊同一个技术:3DGS
它速度快、画面真实,被很多人称为“下一代3D拍摄方案”。

连华为也把它搬上了发布会:
鸿蒙6里的Remy空间图片,就是用3DGS技术,让普通照片变成能旋转查看的3D模型。

听起来很神奇——但问题是:
3DGS真有那么好?它适合用在哪些地方?近看效果怎么样?

如果你也好奇这项技术到底能做到什么,那这篇文章正好可以给你一个清晰的答案。

能撑大场景,却怕放大看

看了鸿蒙6发布会,基本都是以人物这个级别的案例,很多人以为3DGS只能玩“小模型”,其实城市级的大场景3DGS也能hold住。

鸿蒙remy案例

大场景案例

从远处看,一切都挺丝滑。
模型连贯、光影真实,大场景稳得很。
这时候大多数人会说一句:“这效果已经够好了吧?”

可如果你把镜头怼近——
边缘开始糊,LOGO糊成一团。
“远看真香,近看劝退”
依旧是大多数3DGS框架逃不开的老毛病。

那为什么近看就糊了

3DGS本质上就是一堆高斯椭球堆在一起形成的模型

图中可以明显看到“Y”有很多椭圆,这正是无数高斯椭球的边界。

知道了3DGS的原理,那为什么“糊”就很好理解了。

  • 椭球太大,边缘拟合的不好:“Y”黄色椭圆边界明显超出本身范围。
  • 椭球太多,在给定资源下算不动:“Y”内部冗余了很多小椭球。
  • 椭球分布不均:“Y”内的小椭球不向边缘分布,集中在内部。

总结来说,高斯椭球的数量、大小和分布共同影响着最终模型的质量。

适度数量的大小椭球分布在合适区域可以得到渲染快速、精度较高的模型。

大部分模型大结构建不好,近看细节又有些“糊”,

就是应该细节的地方只用了大椭球应付一下,小椭球在纹理不丰富的区域过度集中导致的。

Mapmost高斯溅建模平台如何保持细节

现实中,我们经常遇到的情况是细节处没有足够的高斯椭球(下图草地),仅靠附近区域少数的大高斯椭球来补足。因此如何让这些细节处补足合适数量的小高斯椭球也是主要的优化方向之一。

结合上述分析,Mapmost高斯泼溅建模平台提出了以下改进:

  • 该加就加:细节较多的地方多加入高斯椭球
  • 该动就动:冗余高斯椭球往细节较多的地方挪一挪
  • 该小就小:大高斯椭球定期自动缩放

简单来说,就是每个高斯椭球更懂得自己该长什么样,放在哪里更合适。

通过以上的改进,Mapmost高斯泼溅建模算法相较于原生3DGS算法取得了较大的建模结果进步。

相较于友商,建模结果也是相当能打。

在大场景下,Mapmost高斯泼溅建模平台在交通标识和店铺LOGO边缘细节方面保持的更好。

建模场景概览

除了这种最需要高精度的场景外,Mapmost高斯泼溅建模平台也另外提供了中低两个挡位,让不同用户都能在画质与速度之间,选到最合适的平衡点。

快来试试吧!

3DGS一直存在“远看惊艳、近看糊”的宿命;现在,Mapmost高斯泼溅建模平台通过对高斯椭球行为的智能调控,让细节也能稳稳在线。不论大场景还是小物件,不论专业用户还是普通爱好者,你都能在这里得到更清晰、更真实、更可信的3D模型。

准备好体验真正的高清3DGS了吗?

把你的素材交给Mapmost高斯泼溅建模平台,让我们帮你还原世界的每一处细节。

uniapp开发app使用海康威视播放监控视频流如何使用以及遇到了什么问题

作者 随笔记
2025年12月9日 09:53

uniapp开发的app中需要使用海康威视播放视频流

第一步:

去官网https://open.hikvision.com/download/5c67f1e2f05948198c909700?type=20下载相关的SD

image.png 因为我是使用uniapp开发的app,选择下载了H5视频播放器开发包V2.5.1,同时它会有相关的demo下载,功能还是比较全的

第二步:

运行demo

image.png

第三步:将相关的js文件SDK放在项目中

image.png

在项目中引入:


<script module="vedio" lang="renderjs">
export default {
data() {
return {
playData: [],
player: null,
accessToken: null,
playerSdkLoaded: false, // SDK是否加载完成
isPlayerReady: false, // 标记播放器是否初始化完成(新增)
JSPlugin: null // 缓存SDK实例,避免重复加载(新增)
}
},
mounted() {
const that = this;
// 先修复removeEventListener空指针问题(关键)
this.fixRemoveEventListener();
// 只加载一次SDK,缓存到JSPlugin
import('@/static/js/h5player.min.js').then(res => {
console.log(res, 'SDK加载完成');
that.JSPlugin = res.JSPlugin;
that.playerSdkLoaded = true;
// 初始化播放器(首次加载)
that.initPlayer();
}).catch(err => {
console.error('SDK加载失败:', err);
})
},
beforeDestroy() {
// 页面销毁时彻底销毁播放器
this.destroyPlayer().then(() => {
this.player = null;
this.isPlayerReady = false;
this.JSPlugin = null;
});
},
methods: {
// 修复removeEventListener空指针(核心)
fixRemoveEventListener() {
const originalRemove = EventTarget.prototype.removeEventListener;
EventTarget.prototype.removeEventListener = function(type, listener, options) {
if (this) { // 仅当当前对象存在时执行
try {
originalRemove.call(this, type, listener, options);
} catch (err) {
if (err.message.includes('removeEventListener')) {
console.warn('解绑事件忽略:', type);
} else {
throw err;
}
}
}
};
},
// 切换播放地址(核心改造)
async changePlayUrl(newUrl, oldUrl, instance) {
if (!newUrl || newUrl === oldUrl) return; // 空地址/相同地址不处理
console.log('切换流地址:', newUrl);
// 1. 先彻底销毁旧播放器
await this.destroyPlayer();
// 2. 确保SDK已加载,重新初始化播放器
if (!this.JSPlugin) {
console.error('SDK未加载完成');
return;
}
await this.initPlayer();
// 3. 播放器初始化完成后立即播放(取消setTimeout,改用实例就绪判断)
if (this.player && this.isPlayerReady) {
this.player.JS_Play(newUrl, {
playURL: newUrl,
mode: 0,
PlayBackMode: 1,
keepDecoder: 0
}, 0).then(res => {
console.log('播放成功:', res);
}).catch(err => {
console.error('播放失败:', err);
// H.265解码失败时降级处理(可选)
if (err.message.includes('h265')) {
uni.showToast({
title: 'H.265解码失败,请切换H.264流',
icon: 'none'
});
}
});
}
},
// 销毁播放器(Promise封装,确保彻底)
destroyPlayer() {
return new Promise(resolve => {
if (this.player && this.isPlayerReady) {
try {
this.player.JS_Stop(); // 先停止播放(海康标准方法)
this.player.off(); // 解绑事件
this.player.destroy(); // 销毁实例
// 清空容器DOM,避免残留
const container = document.getElementById('video-container');
if (container) container.innerHTML = '';
} catch (err) {
console.warn('销毁播放器错误:', err);
} finally {
this.player = null;
this.isPlayerReady = false;
resolve();
}
} else {
resolve();
}
});
},
// 全屏播放
wholeFullScreen() {
if (!this.player) return;
this.player.JS_FullScreenDisplay(true).then(() => {
console.log('全屏成功');
}).catch(e => {
console.error('全屏失败:', e);
})
},
// 初始化播放器(锁定软解,稳化解码)
initPlayer() {
return new Promise((resolve) => {
if (!this.JSPlugin) {
resolve(false);
return;
}
try {
const my_player = new this.JSPlugin({
szId: 'video-container', // 容器ID
szBasePath: "/static/js/", // SDK路径
iMaxSplit: 1, // 移动端只需要1路播放,减少性能消耗
openDebug: false, // 关闭debug,减少日志干扰
mseWorkerEnable: true, // 开启多线程解码,提升软解性能
bSupporDoubleClickFull: true,
// ========== 核心:锁定软解,避免H.265硬解不稳定 ==========
decodeType: 'soft', // 强制软解(关键!放弃auto硬解)
enableSoftDecode: true, // 显式开启软解
supportH265: true,
isFlushURI: true, // 切换流清空URI缓存
reconnectTimes: 3, // 解码失败重连
reconnectInterval: 2000,
// ========== 移动端适配 ==========
renderType: 'canvas', // canvas渲染更兼容
lowLatency: false, // 关闭低延迟,换稳定性
maxBufferLength: 5, // 降低缓冲,减少内存占用
});
// 绑定错误回调,捕获解码异常
my_player.JS_SetWindowControlCallback({
pluginErrorHandler: (iWndIndex, iErrorCode, oError) => {
console.error('播放器错误:', iWndIndex, iErrorCode, oError);
if (oError && oError.message.includes('h265')) {
this.isPlayerReady = false;
}
},
firstFrameDisplay: (iWndIndex, iWidth, iHeight) => {
console.log('首帧显示:', iWndIndex, iWidth, iHeight);
this.isPlayerReady = true; // 首帧显示标记就绪
},
performanceLack: (iWndIndex) => {
console.warn('性能不足:', iWndIndex);
}
});
this.player = my_player;
this.isPlayerReady = true; // 标记初始化完成
resolve(true);
} catch (err) {
console.error('初始化播放器失败:', err);
this.isPlayerReady = false;
resolve(false);
}
});
},
// 截取设备序号和通道号(保留)
getHashParts(str) {
const parts = str.split("#");
return {
before: parts[0],
after: parts.length > 1 ? parts[1] : "",
};
}
},
}
</script>

在项目中也遇到了很多问题,也都一一解决了:

1:因为开发的app,在浏览器的手机模拟器能够播放,但是在真机运行的时候提示JSPlugin没有定义

企业微信截图_17649838975105.png 后来经过调整,查资料是因为js文件引入没有加载成功,后来就改变了引入方式,变成这样

image.png

2:因为是多个视频流进行切换,在切换的时候会出现这样的问题,原因是实例未销毁

image.png

3:在切换的时候会出现[H5player]-%c2025-12-08 10:57:44.823%c-Error: color: blue color: black VideoDecoder not support h265: at app-view.js:45399,导致播放不出来,以下是解决办法

UniApp 中使用海康威视 H5 播放器切换视频流时出现 [H5player] VideoDecoder not support h265 错误,核心原因是移动端浏览器 / 设备不支持 H.265(HEVC)解码,且海康威视 H5 播放器默认未启用兼容降级策略。以下是分维度的解决方案,从「解码兼容」「流格式转换」「播放器配置」三个层面彻底解决:

一、核心问题定位

H.265(HEVC)是高效视频编码格式,但移动端存在两大兼容问题:

  1. 硬件层面:中低端安卓 /iOS 设备的视频解码芯片不支持 H.265 硬解;
  2. 浏览器层面:移动端 Chrome/Safari 等浏览器的VideoDecoder API(WebCodecs)对 H.265 支持极差,仅部分高版本安卓设备兼容;
  3. 播放器层面:海康 H5 播放器默认优先使用硬解,未检测到 H.265 支持时直接报错,未自动降级为软解 / 转码。

image.png

以上就是我使用海康威视的经验分享,大家如果有相同的问题或者其它好的经验,可以评论区讨论哦

告别“祖传”defineProperty!Vue 3 靠 Proxy 练就了什么“神功”?

2025年12月9日 09:08

你好,我是大布布将军,一个喜欢把技术原理扒得底裤都不剩的前端显微镜。

今天咱们不聊 API 怎么调,咱们来聊聊 Vue 的灵魂——响应式系统

很多兄弟在面试时都被问过:“Vue 2 和 Vue 3 的响应式有啥区别?” 大部分人只能背出:“Vue 2 用 defineProperty,Vue 3 用 Proxy。” 面试官追问:“那 Proxy 好在哪?具体怎么实现的?” 这时候,空气通常会突然安静…… 😅

别慌!今天咱们就来一场“闭门交流”,扒一扒 Vue 3 为什么抛弃了勤勤恳恳的“老兵” Object.defineProperty,转而拥抱了 ES6 的“新贵” Proxy。看完这篇,下次面试你直接给面试官手写一个响应式系统,稳了!


一、Vue 2 的痛:那个勤奋但死板的“门卫”

在 Vue 2 的时代,响应式系统的核心是 Object.defineProperty。你可以把它想象成一个非常勤奋但脑子不太转弯的门卫

当 Vue 2 初始化一个组件时,它会拿着你的 data 对象,挨个属性遍历,给每个属性都安插一个“门卫”(getter/setter)。

痛点一:无法预知未来(属性增删)

门卫只能看守已经存在的属性。

data() {
  return {
    userInfo: {
      name: '老王'
    }
  }
}

// 后来...
this.userInfo.age = 18; // 门卫:???这谁?我不认识,不管!

因为 age 是后来加的,门卫没登记过,所以你改了 age,视图压根不会理你。 解决办法? 被迫祭出祖传补丁 Vue.set() 或者 this.$set()。这感觉就像是你买了新家具,还得专门去派出所给家具报个户口,麻烦不?

痛点二:数组的“特殊待遇”

如果你有一个包含 1000 个对象的数组,Vue 2 如果要用 defineProperty 给每个索引(0, 1, 2...)都安插门卫,那性能直接原地爆炸。

所以 Vue 2 选择了“偷懒”:它重写了数组的 7 个方法(push, pop, splice 等)。 这意味着:

  • arr.push(1) -> 响应式触发。
  • arr[0] = 100 -> 门卫不理你。
  • arr.length = 0 -> 门卫依然不理你。

✨ 二、Vue 3 的救星:Proxy(拦截器)

Vue 3 引入了 ES6 的 Proxy,彻底改变了玩法。

如果说 defineProperty 是给每个房间门口站个岗,那 Proxy 就是直接给整个房子罩了一层激光防御网

不管你是想进房间、爬窗户、还是拆墙(增删属性),只要你触碰了这个对象,Proxy 全都知道

Proxy 强在哪?

  1. 全方位拦截: 能够拦截对象的 13 种操作(读、写、删除、遍历等)。
  2. 惰性处理: Vue 2 是一上来就递归遍历所有层级,把所有属性都变成响应式(初始化慢)。Vue 3 是你访问到哪一层,我才代理哪一层(由 reactive 实现),性能大大提升。
  3. 数组完美支持: 再也不用重写数组方法了,下标修改也能拦截!

✨ 三、手写一个“迷你” Vue 3 响应式系统

光说不练假把式。咱们用几十行代码,还原 Vue 3 响应式的核心逻辑。

核心三件套:

  1. targetMap:存数据的桶。
  2. track (追踪) :谁用了这个数据?记录下来(收集依赖)。
  3. trigger (触发) :数据变了,通知那些用过的人(触发更新)。

第一步:准备存储结构

我们需要一个地方来存放“谁依赖了哪个对象的哪个属性”。这个结构有点绕,但逻辑很清晰: WeakMap (存对象) -> Map (存属性) -> Set (存副作用函数)

const targetMap = new WeakMap();

// 当前正在运行的副作用函数(也就是哪个组件正在读取数据)
let activeEffect = null;

第二步:收集依赖 (Track)

当代码读取属性(触发 get)时,我们把当前的 activeEffect 存到桶里。

  if (!activeEffect) return; // 如果没有人在依赖,就不管

  // 1. 找对象
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }

  // 2. 找属性
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }

  // 3. 存入依赖(去重)
  dep.add(activeEffect);
}

第三步:触发更新 (Trigger)

当代码修改属性(触发 set)时,我们要去桶里把对应的函数拿出来执行一遍。

  const depsMap = targetMap.get(target);
  if (!depsMap) return; // 这个对象没人关注,散了吧

  const dep = depsMap.get(key);
  if (dep) {
    // 遍历所有依赖这个属性的函数,执行它们
    dep.forEach(effect => effect());
  }
}

第四步:Proxy 登场!(Reactive)

现在把上面两步串起来。

  return new Proxy(target, {
    // 拦截读取操作
    get(target, key, receiver) {
      // 🎯 关键点:有人读了,赶紧记录下来!
      track(target, key);
      
      // Reflect 是 Proxy 的好基友,保证上下文(this)正确
      return Reflect.get(target, key, receiver);
    },
    
    // 拦截设置操作
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      
      // 🎯 关键点:值变了?赶紧通知大家更新!
      if (oldValue !== value) {
        trigger(target, key);
      }
      return result;
    }
  });
}

✨ 四、跑起来试试?

我们要模拟一个 Vue 的 effect(副作用),你可以把它理解为 Vue 的组件渲染函数。

function effect(fn) {
  activeEffect = fn; // 标记当前正在运行的函数
  fn();              // 立即执行一次,触发 get,从而完成依赖收集
  activeEffect = null; // 执行完复位
}

// --- 测试开始 ---

const user = reactive({ name: '老王', age: 30 });

let myText = '';

// 假设这是组件的模板渲染
effect(() => {
  console.log('👀 渲染函数执行了!');
  myText = `${user.name} 今年 ${user.age} 岁`;
});

console.log(myText); 
// 输出: "老王 今年 30 岁" (首次渲染)

console.log('--- 准备修改数据 ---');

// 修改数据,应该会自动触发上面的 effect
user.age = 31; 
// 控制台自动输出: "👀 渲染函数执行了!"

console.log(myText);
// 输出: "老王 今年 31 岁" (更新成功!)

看到没?我们没有调用任何更新函数,只是简单的 user.age = 31myText 就自动更新了!这就是响应式的魔法。


✨ 总结一下

Vue 3 的响应式系统之所以强大,全靠 Proxy 这个“全能管家”。

  1. 它解决了 defineProperty 的先天不足(无法监听新增属性、数组下标)。
  2. 它配合 WeakMapMapSet 构建了一套精确的依赖收集系统
  3. 它让代码更干净,我们不需要再写 Vue.set 这种奇怪的代码了。

当然,Vue 3 源码中还有很多复杂的边界处理(比如嵌套对象怎么处理?数组长度修改怎么处理?ref 又是怎么回事?),但核心原理就在这几十行代码里。

下次面试官再问你,你就把这段代码甩给他,告诉他:“这就是 Vue 3 的内功心法!”

lg_90841_1619336946_60851ef204362.png


🔥 觉得有收获?点个赞/在看,下期咱们聊聊 Vue 3 的 Composition API 到底是怎么吊打 Mixin 的!

深入理解 JavaScript 继承:从原型链到 call/apply 的灵活运用

2025年12月9日 09:01

在 JavaScript 这门语言中,继承是一个绕不开的话题。不同于 Java、C++ 等传统面向对象语言的“类继承”,JavaScript 采用的是基于**原型(Prototype)**的继承机制。这种机制既灵活又强大,但也常常让初学者感到困惑。

本文将带你从基础出发,深入剖析 JavaScript 中的继承方式,并重点讲解 callapply 在构造函数继承中的妙用。文章结合原理、代码示例与思考,助你真正掌握 JS 继承的本质。


一、原型继承:JS 的根基

JavaScript 中每个函数都有一个 prototype 属性,每个对象都有一个内部属性 [[Prototype]](可通过 __proto__ 访问),指向其构造函数的 prototype 对象。

function Animal(name) {
  this.name = name;
}
Animal.prototype.say = function() {
  console.log(`${this.name} is an animal.`);
};

const dog = new Animal('Dog');
dog.say(); // Dog is an animal.

这就是最经典的原型链继承。子类实例通过 __proto__ 指向父类的 prototype,从而实现方法共享。

但原型继承有一个明显问题:无法在不创建父类实例的情况下,向父类构造函数传递参数。这引出了我们接下来要讲的——构造函数继承


二、构造函数继承:借助 call / apply

构造函数继承的核心思想是:在子类构造函数中,调用父类构造函数,并将 this 指向子类实例

这就需要用到 Function.prototype.callapply

function Animal(name) {
  this.name = name;
}

function Dog(name, breed) {
  // 关键:使用 call 将 Animal 的 this 指向当前 Dog 实例
  Animal.call(this, name);
  this.breed = breed;
}

const myDog = new Dog('Buddy', 'Golden Retriever');
console.log(myDog.name);   // Buddy
console.log(myDog.breed);  // Golden Retriever

✨ call vs apply:区别在哪?

  • call(thisArg, arg1, arg2, ...)逐个传参
  • apply(thisArg, [arg1, arg2, ...])以数组形式传参

两者都能指定函数执行时的 this 指向,且立即执行函数。在继承场景中,call 更常用,因为参数通常是已知的。

📌 关键点call/apply 并不会建立原型链!它们只是“借用”了父类构造函数来初始化子类实例的属性。


三、组合继承:原型 + 构造函数

既然原型继承能共享方法,构造函数继承能传参,那能不能两者结合?

当然可以!这就是经典的 组合继承(Combination Inheritance)

function Animal(name) {
  this.name = name;
}
Animal.prototype.say = function() {
  console.log(`${this.name} says hello!`);
};

function Dog(name, breed) {
  Animal.call(this, name); // 构造函数继承:传参 + 初始化属性
  this.breed = breed;
}
Dog.prototype = new Animal(); // 原型继承:共享方法
Dog.prototype.constructor = Dog; // 修正 constructor 指向

const dog = new Dog('Max', 'Husky');
dog.say(); // Max says hello!

这种方式几乎完美,但有一个小瑕疵:父类构造函数被调用了两次(一次在 new Animal() 设置原型,一次在 Dog 内部)。


四、更优雅的方式:寄生组合继承

为了解决重复调用问题,我们可以使用 寄生组合继承(Parasitic Combination Inheritance) —— 也是现代框架(如 React 早期)推荐的方式。

核心思想:不通过 new Parent() 设置子类原型,而是用一个空函数作为中介

这正是你上传的 2.html 中提到的:“利用空对象作为中介”。

function inheritPrototype(Child, Parent) {
  const F = function() {};      // 空构造函数
  F.prototype = Parent.prototype;
  Child.prototype = new F();    // 避免调用 Parent()
  Child.prototype.constructor = Child;
}

function Animal(name) {
  this.name = name;
}
Animal.prototype.say = function() {
  console.log(`${this.name} speaks.`);
};

function Cat(name, color) {
  Animal.call(this, name);
  this.color = color;
}

inheritPrototype(Cat, Animal);

const kitty = new Cat('Luna', 'white');
kitty.say(); // Luna speaks.

这种方式只调用一次父类构造函数,效率更高,是目前最推荐的继承模式。


五、ES6 Class:语法糖下的本质

ES6 引入了 class 语法,让继承看起来更“传统”:

class Animal {
  constructor(name) {
    this.name = name;
  }
  say() {
    console.log(`${this.name} talks.`);
  }
}

class Bird extends Animal {
  constructor(name, wingspan) {
    super(name); // 等价于 Animal.call(this, name)
    this.wingspan = wingspan;
  }
}

但请注意:class 本质上仍是基于原型的语法糖super() 底层依然是通过 call 调用父类构造函数。

理解底层机制,才能在遇到边界情况(如 this 绑定、混入 Mixin、动态继承)时游刃有余。


六、思考:为什么 JS 的继承如此特别?

JavaScript 的继承不是“复制”,而是“链接”。它强调**行为委托(delegation)**而非“拥有”。

  • 原型链:对象 → 原型 → 原型的原型……直到 null
  • call/apply:临时改变上下文,实现“借用”
  • 组合继承:兼顾属性初始化与方法复用

这种设计赋予 JS 极大的灵活性,也带来了学习曲线。但一旦掌握,你就能写出更高效、更可维护的代码。


结语

继承不是目的,复用与扩展才是。无论是古老的原型链,还是现代的 class,理解其背后的运行机制,才能真正驾驭 JavaScript。

💡 建议:不要死记语法,多动手画原型链图,多调试 this 指向。真正的高手,看得见“看不见的链接”。


欢迎点赞、收藏、评论交流!
如果你觉得这篇文章对你有帮助,不妨分享给正在学习 JS 的朋友。前端路上,我们一起成长 🌱


Vue 3 内存泄漏排查与性能优化:从入门到精通的工具指南

作者 木易士心
2025年12月9日 08:18

@TOC

概述

在单页面应用(SPA)无缝交互的背后,潜藏着一个常常被忽视的恶魔:内存泄漏。它如同幽灵般,在用户长时间使用后悄然吞噬浏览器资源,导致页面卡顿、崩溃,严重影响用户体验。对于追求极致性能的 Vue 3 应用而言,掌握内存管理与泄漏排查,是每一位高级前端工程师的必修课。

本文将带你深入 Vue 3 的内存世界,从响应式系统的底层机制出发,剖析泄漏根源,再通过一套覆盖开发 → 测试 → 生产 → 自动化 CI/CD 的全链路工具体系,助你成为真正的性能架构师。

一、深入理解:Vue 3 的响应式系统如何影响内存?

Vue 3 使用 Proxy + EffectScope 构建了全新的响应式系统。每一个 refreactive 对象都会被包裹在一个 EffectScope 中,而组件实例本身就是一个 EffectScope。当组件卸载时,Vue 会自动清理其内部的所有 effect(即依赖收集的 watcher),但前提是这些 effect 没有被外部作用域意外捕获

关键点:Vue 能自动清理组件内的响应式依赖,但无法清理你手动创建的全局资源(如定时器、事件监听器、WebSocket 连接等)。这些才是内存泄漏的主要来源。

1.实战案例:一个典型的定时器泄漏(深度剖析)

<!-- LeakyComponent.vue -->
<script setup>
import { ref, onMounted } from 'vue';
const currentTime = ref(new Date().toLocaleTimeString());

onMounted(() => {
  setInterval(() => {
    currentTime.value = new Date().toLocaleTimeString(); // ⚠️ 闭包引用 currentTime
  }, 1000);
});
</script>

为什么这会导致泄漏?

  1. setInterval 返回一个全局计时器 ID,其回调函数形成了对 currentTime闭包引用
  2. currentTime 是一个 ref,属于组件实例的作用域。
  3. 即使组件被 v-if 移除,只要定时器未清除,JavaScript 引擎就认为该组件实例“仍被使用”,阻止垃圾回收(GC)
  4. 结果:组件实例、DOM 节点、所有响应式数据全部滞留内存。
graph TD
    %% 节点定义
    Window[Window<br/>全局对象]
    Timer[Timer<br/>setInterval ID]
    Closure[Callback Closure<br/>回调函数闭包]
    Component[Component Instance<br/>组件实例]
    Ref[currentTime ref<br/>响应式引用]
    DOM[DOM Nodes<br/>DOM节点]
    RemovedDOM[ Removed DOM<br/>已移除的DOM]
    %% 样式定义
    classDef normal fill:#e1f5fe,stroke:#01579b,stroke-width:2px
    classDef leaked fill:#ffebee,stroke:#c62828,stroke-width:2px
    classDef removed fill:#f5f5f5,stroke:#9e9e9e,stroke-width:2px,dashed
    %% 应用样式
    class Window,Timer,Closure,Component,Ref normal
    class RemovedDOM removed
    %% 引用关系(红色强引用)
    Window -.->|强引用| Timer
    Timer -.->|强引用| Closure
    Closure -.->|强引用| Component
    Component -.->|强引用| Ref
    Component -.->|强引用| DOM
    %% DOM被移除
    DOM -.->|已被移除| RemovedDOM
    %% 标注说明
    subgraph "内存泄漏路径"
        direction LR
        Window --> Timer --> Closure --> Component
    end
    %% 添加警告标记
    classDef warning fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    note[" 闭包持有组件引用<br/>阻止GC回收"]:::warning
    note --> Closure

图示:箭头“强引用”路径。即使 DOM 被移除,Window → Timer → Closure → Component Instance 的引用链依然存在,GC 无法回收。

2.正确修复:使用 onUnmounted 清理

import { ref, onMounted, onUnmounted } from 'vue';

let timerId: number | null = null;
const currentTime = ref('');

onMounted(() => {
  timerId = window.setInterval(() => {
    currentTime.value = new Date().toLocaleTimeString();
  }, 1000);
});

onUnmounted(() => {
  if (timerId !== null) {
    clearInterval(timerId);
    timerId = null; // 避免重复清理
  }
});

最佳实践:所有在 onMounted 中创建的外部资源,都必须在 onUnmounted 中显式释放。

二、主流内存检测工具深度对比

工具名称 适用阶段 核心能力 是否支持自动定位泄漏对象 是否适合 CI/CD
vue-performance-monitor 开发调试 实时堆内存可视化、FPS 监控
memory-monitor-sdk 测试/生产 内存趋势监控、阈值告警、数据上报 ❌(需结合日志分析) ✅(配合告警)
Chrome DevTools 深度调试 堆快照对比、Retainers 分析、分配跟踪 ✅(手动)
Memlab 自动化测试 自动生成泄漏报告、引用链溯源 ✅(自动)

三、各工具深度解析与实战指南

1. vue-performance-monitor:开发者的“实时仪表盘”

这是一款Vue专用插件,安装后会在你的应用界面上添加一个可拖拽的监控面板,方便在开发时实时查看。

  • 安装
    npm install vue-performance-monitor
    
  • 在Vue3项目中使用
    import { createApp } from 'vue';
    import App from './App.vue';
    // 导入组件
    import { PerformanceMonitor } from 'vue-performance-monitor';
    
    const app = createApp(App);
    // 注册为全局组件
    app.component('PerformanceMonitor', PerformanceMonitor);
    app.mount('#app');
    
  • 在组件模板中使用: 你可以在任意组件中放置<PerformanceMonitor />标签来显示监控面板。可以通过show-memory等属性控制显示内容。
<template>
<div id="app">
  <!-- 你的应用内容 -->
  <router-view />
  <!-- 监控面板将悬浮于页面上 -->
  <PerformanceMonitor
    :auto-collect="true"
    :show-memory="true"
    :auto-send-data="sendPerformanceData"
  />
</div>
</template>

总结来说,vue-performance-monitor 的核心价值在于开发阶段的实时可视化和便捷的数据上报。为了让你更清楚它在我们上次讨论的工具链中的定位,

2. memory-monitor-sdk:生产环境的“哨兵”

这是一个功能强大的通用内存监控SDK,尤其适合需要详细记录、分析内存趋势或模拟移动端环境的场景。

  • 安装
    npm install memory-monitor-sdk
    
  • 基础使用: 在主入口文件(如main.js)中初始化:
    import { memoryMonitor } from 'memory-monitor-sdk';
    
    // 开始监控,参数分别为:间隔(ms)、模拟内存上限(MB)、变化阈值(MB)、是否显示面板
    memoryMonitor.startMonitoring(2000, 300, 20, true);
    

这类SDK的主要目标是在网页运行时实时监控和上报内存使用情况,帮助开发者发现潜在的内存泄漏或内存异常增长。

3. Chrome DevTools:精准定位泄漏对象(关键技巧)

Step-by-step 泄漏分析流程:

  1. 打开 Memory → Take heap snapshot(快照1)
  2. 执行操作(如打开/关闭弹窗组件)
  3. 点击 🗑️ Collect garbage
  4. 再次 Take heap snapshot(快照2)
  5. 选择快照2 → View: Comparison → Filter: LeakyComponent

你会看到类似下图的结果: 在这里插入图片描述

解读:

  • Delta: +1 表示新增了一个未释放的实例。
  • 点击该条目,下方 Retainers 面板显示引用链:Window → (closure) → setup() → currentTime
  • 结论:闭包持有组件上下文,阻止 GC。

进阶技巧:使用 Allocation instrumentation on timeline 录制,可看到对象是在哪一行代码分配的!

4. Memlab:自动化泄漏检测(CI/CD 集成)

Memlab 不仅能检测泄漏,还能生成 HTML 报告,包含:

  • 泄漏对象数量与大小
  • window 到泄漏对象的完整引用路径
  • 建议修复位置(基于源码映射)
# 在 CI 中运行
memlab run --scenario ./leak-scenario.js --output ./reports/

适用场景:回归测试、发布前内存健康检查。

四、利用现代 JavaScript 特性预防泄漏

1.WeakRef + FinalizationRegistry(实验性但强大)

虽然 Vue 3 尚未原生集成,但你可以在高级场景中使用:

import { ref, onMounted, onUnmounted } from 'vue';

const cache = new WeakMap<object, string>();
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`对象 ${heldValue} 已被回收`);
});

export default {
  setup() {
    const data = {};
    cache.set(data, 'cached value');
    registry.register(data, 'my-data-object');

    onUnmounted(() => {
      // 手动清理非必要引用
      cache.delete(data);
    });
  }
}

注意:WeakRef 不能用于响应式数据(Vue 的 Proxy 会干扰弱引用),但可用于缓存、元数据存储等场景。

五、构建你的三层内存防御体系

层级 目标 工具组合 关键动作
L1:开发层 快速反馈 vue-performance-monitor + DevTools 每次功能开发后观察内存是否回落
L2:测试/预发层 场景验证 memory-monitor-sdk + 手动快照 模拟用户长时间操作,监控 10 分钟内存趋势
L3:自动化层 回归防护 Memlab + CI Pipeline 每次 PR 合并前运行内存泄漏测试

健康指标参考

  • 单次操作后内存增长 < 5MB
  • 10 次开关组件后,内存应回落至初始 ±10%
  • 生产环境 JS Heap 持续 > 800MB 应触发告警

六、 Vue 3 内存管理黄金法则

  1. 所有副作用必须配对清理
    onMountedonUnmountedwatch → 返回清理函数。

  2. 避免在全局挂载组件实例
    window.myComp = instance 是泄漏重灾区。

  3. 慎用 v-if vs v-show

    • v-if:彻底销毁,释放内存(适合重型组件)
    • v-show:保留实例,仅隐藏(适合频繁切换)
  4. 第三方库要手动 destroy
    如 ECharts、Mapbox、WebSocket 等,务必在 onUnmounted 中调用 .dispose().close()

  5. 使用 effectScope 管理复杂逻辑

    const scope = effectScope();
    scope.run(() => {
      const r = ref(0);
      watch(r, () => { /* ... */ });
    });
    onUnmounted(() => scope.stop()); // 一次性清理所有 effect
    

七、总结:从调试者到性能架构师

内存泄漏不是“偶然 bug”,而是架构设计与工程规范缺失的必然结果。通过本文构建的工具链与最佳实践,你不仅能:

  • 快速定位现有泄漏;
  • 预防未来问题;
  • 量化性能健康度;
  • 自动化保障交付质量。

这才是现代前端工程化的真正内涵——让性能可见、可控、可预测

最终目标:让用户无论使用 5 分钟还是 5 小时,体验始终如一流畅。

附录:推荐学习资源

React Fiber 原理与实践 Demo

2025年12月9日 02:15

一、浏览器渲染与任务调度简析

理解 React Fiber,首先理解浏览器的主线程任务与渲染阶段。

浏览器主线程:事件循环(Event Loop)大致流程

  1. 宏任务阶段
    • 从队列取出宏任务执行(script、setTimeout、I/O 等)。
  2. 微任务阶段
    • 清空所有微任务队列(Promise.then、queueMicrotask 等)。
    • 微任务若过多会阻塞后续渲染。
  3. 渲染阶段
    • 判断是否需要渲染(16.6ms/帧)。
    • 如需渲染,则执行 requestAnimationFrame 回调、样式计算、布局、绘制、合成等。
  4. 空闲阶段(Idle)
    • requestIdleCallback 空闲调度(Fiber diff 典型用法)。

简化版伪代码如下:

while (true) {
  let macroTask = taskQueue.pop();
  if (macroTask) execute(macroTask);

  while (microTaskQueue.hasTasks()) {
    let microTask = microTaskQueue.pop();
    execute(microTask);
  }

  if (shouldRender()) {
    runAnimationFrames();      // requestAnimationFrame 回调
    recalculateStyles();       // 样式计算
    layout();                  // 布局
    paint();                   // 绘制
    composite();               // 合成
  }

  if (hasIdleTime()) {
    runIdleCallbacks(deadline); // Fiber diff 主要在此处理
  }
}

常见问题解答

  • 每次 Event Loop 都会渲染吗?
    仅有变更时渲染,但微任务一定会被清空执行。

  • requestAnimationFrame 在哪?
    微任务后、渲染前。

  • requestIdleCallback 在哪?
    渲染后、下一帧前的空闲期。

  • 为什么页面会卡顿?
    JS 或渲染步骤若单次占用大于 16.6ms,就会阻塞页面,导致掉帧卡顿。


二、Fiber 的设计动机与核心思想

为什么 Fiber 能解决卡顿体验?

早期 React 渲染/更新 DOM 时,采用同步递归,若组件树很大,则执行过程中无法中断、让步于用户交互,主线程易被阻塞。

Fiber 架构的目标

  • 把同步大任务切分为很多小任务(fiber 单元)
  • 利用浏览器 Idle 阶段(requestIdleCallback),让主线程可以适时中断、恢复渲染
  • 实现“可中断/可恢复”渲染,避免严重卡顿

三、React Fiber 手写演进实践

1. React 原生渲染结构

import React from 'react';
import ReactDOM from 'react-dom';

const container = document.querySelector('#root');
const element = React.createElement(
  'div',
  { title: 'div', name: 'div' },
  'div  ',
  React.createElement(
    'h1', null, 'h1', React.createElement('p', null, 'p')
  ),
  React.createElement('h2', null, 'h2')
);

ReactDOM.render(element, container);

2. 手写简易 createElement 与同步递归 render(会卡顿)

createElement.js

function createElement(type, props, ...children) {
  // 构建虚拟 DOM 节点
  return {
    type,
    props: {
      ...props,
      // 非对象的子元素(如字符串)转换为 TEXT_ELEMENT
      children: children.map(
        child => (typeof child === 'object' ? child : createTextElement(child))
      ),
    },
  };
}

// 创建文本类型节点(即 { type: 'TEXT_ELEMENT', ... })
function createTextElement(text) {
  return {
    type: 'TEXT_ELEMENT',
    props: {
      nodeValue: text,
      children: [],
    },
  };
}
export default { createElement };

render.js

function render(element, container) {
  // 根据类型创建对应的 DOM 节点
  const dom =
    element.type === 'TEXT_ELEMENT'
      ? document.createTextNode('')
      : document.createElement(element.type);

  // 赋值属性(过滤掉 children)
  Object.keys(element.props)
    .filter(key => key !== 'children')
    .forEach(name => (dom[name] = element.props[name]));

  // 递归渲染子元素
  element.props.children.forEach(child => render(child, dom));
  // 将当前 dom 节点追加到父节点
  container.appendChild(dom);
}
export default { render };

使用方式

import { createElement } from './createElement.js';
import { render } from './render.js';

const container = document.querySelector('#root');
const element = createElement(
  'div',
  { title: 'div', name: 'div' },
  'div  ',
  createElement('h1', null, 'h1', createElement('p', null, 'p')),
  createElement('h2', null, 'h2')
);
render(element, container);

说明: 递归同步渲染,遇到大数据或深层节点会阻塞页面,无中断点,用户体验差。


3. 第一版 Fiber 分片渲染——初步可中断

核心思路

  • 将渲染任务分为一个个 fiber 节点,每次只做一点(处理一个 fiber)。
  • 利用 requestIdleCallback 在浏览器空闲阶段执行,主线程忙则随时让步。
  • 便于大树分批渲染,不阻塞主线程。

简化代码:

let nextUnitOfWork = null; // 下一个可执行的 fiber 单元

function workLoop(deadline) {
  let shouldYield = false;
  // 主循环:每次只做一小部分工作,若时间不够则退出等待空闲
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYield = deadline.timeRemaining() < 1; // 剩余时间小于 1ms 就交出主线程
  }
  requestIdleCallback(workLoop); // 注册下一轮
}
requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {
  // 为当前 fiber 创建对应 dom 节点
  if (!fiber.dom) fiber.dom = createDOM(fiber);
  // 将当前 fiber 的 dom 插入父节点(直接挂载,后续可改为批量)
  if (fiber.parent) fiber.parent.dom.appendChild(fiber.dom);

  // 创建子 fiber,形成链表
  const elements = fiber.props?.children || [];
  let prevSibling = null;
  elements.forEach((child, i) => {
    const newFiber = {
      parent: fiber,
      props: child.props,
      type: child.type,
      dom: null,
      sibling: null
    };
    if (i === 0) fiber.child = newFiber;  // 第一个挂到 child
    else prevSibling.sibling = newFiber;  // 其余的挂到 sibling
    prevSibling = newFiber;
  });

  // 返回下一个要执行的 fiber
  if (fiber.child) return fiber.child;
  let next = fiber;
  while (next) {
    if (next.sibling) return next.sibling;
    next = next.parent;
  }
}

function createDOM(fiber) {
  // 根据 fiber 类型创建 dom 节点
  const dom = fiber.type === 'TEXT_ELEMENT'
    ? document.createTextNode('')
    : document.createElement(fiber.type);
  // 赋值属性
  Object.keys(fiber.props || {})
    .filter(key => key !== 'children')
    .forEach(name => dom[name] = fiber.props[name]);
  return dom;
}

// 开始渲染,将根 fiber 作为第一个分片任务
function render(element, container) {
  nextUnitOfWork = {
    dom: container,
    props: { children: [element] }
  };
}
export default { render };

优点:可分片调度,主线程流畅。
缺点:频繁操作 DOM,页面会“逐步”渲染,闪烁、不连贯。


4. 第二版:优化-批量挂载 DOM,减少重排重绘

  • 先以 fiber 链处理所有节点,创建好 DOM,但暂不真正挂载。
  • 所有 fiber 处理完后,一次性 commit(批量 appendChild 上树)。

关键实现:

let nextUnitOfWork = null; // 下一个分片任务
let wipRoot = null;        // work in progress 的根节点

function workLoop(deadline) {
  let shouldYield = false;
  // 分片遍历任务
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYield = deadline.timeRemaining() < 1; // 用时快到头则暂停
  }
  // 如果所有 fiber 都遍历完成,将节点批量挂载
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }
  requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {
  // 创建 dom 节点
  if (!fiber.dom) fiber.dom = createDOM(fiber);
  // 遍历子元素,生成 fiber 链表(child、sibling)
  const elements = fiber?.props?.children || [];
  let prevSibling = null;
  elements.forEach((child, i) => {
    const newFiber = {
      parent: fiber,
      props: child.props,
      type: child.type,
      dom: null,
      sibling: null
    };
    if (i === 0) fiber.child = newFiber;
    else prevSibling.sibling = newFiber;
    prevSibling = newFiber;
  });
  // 返回下一个分片任务
  if (fiber.child) return fiber.child;
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) return nextFiber.sibling;
    nextFiber = nextFiber.parent;
  }
}

// 挂载 fiber 树到 dom(批量 appendChild,减少重排重绘)
function commitRoot() {
  commitWork(wipRoot.child);
  wipRoot = null;
}
function commitWork(fiber) {
  if (!fiber) return;
  const parentDom = fiber.parent.dom;
  parentDom.appendChild(fiber.dom);
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

function createDOM(element) {
  // 创建 dom 或 text 节点
  const dom = element.type === "TEXT_ELEMENT"
    ? document.createTextNode("")
    : document.createElement(element.type);
  // 处理属性
  Object.keys(element.props || {})
    .filter(key => key !== "children")
    .forEach(name => dom[name] = element.props[name]);
  return dom;
}

function render(element, container) {
  // 设置根 fiber
  wipRoot = { dom: container, props: { children: [element] } };
  nextUnitOfWork = wipRoot;
}
export default { render };

优点:减少了 DOM 频繁插入,提高性能。
缺点:无 diff,依然是全量渲染。


5. 第三版:引入 Diff 算法,按需递增/删除/更新

  • 每次对“新旧 fiber 树”做 diff,只处理有变动的部分,极大提升渲染效率。
  • 用 effectTag 等标记,commit 阶段只批量更新必要 DOM 节点。

精华代码片段:

let nextUnitOfWork = null; // 下一个待处理的 fiber 节点
let wipRoot = null;        // work in progress 的根
let currentRoot = null;    // 当前已经挂载的 fiber 树
let deletions = [];        // 待删除的 fiber 列表

function workLoop(deadline) {
  let shouldYield = false;
  // 分片执行 fiber
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYield = deadline.timeRemaining() < 1;
  }
  // fiber 收敛后进行真正 commit
  if (!nextUnitOfWork && wipRoot) commitRoot();
  requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);

// 执行单个 fiber 单元的处理
function performUnitOfWork(fiber) {
  // 创建 dom
  if (!fiber.dom) fiber.dom = createDOM(fiber);

  const elements = fiber.props.children || [];
  // diff 新旧 children fiber
  reconcileChildren(fiber, elements);

  // 深度优先,下一个单元是 child 或 sibling 或父级 sibling
  if (fiber.child) return fiber.child;
  let f = fiber;
  while (f) {
    if (f.sibling) return f.sibling;
    f = f.parent;
  }
}

// commit 阶段,批量处理 effectTag 标记的操作
function commitRoot() {
  deletions.forEach(commitWork);   // 执行所有待删除节点的移除
  commitWork(wipRoot.child);       // 执行新 fiber 树的挂载或更新
  currentRoot = wipRoot;           // 更新当前 fiber 树
  wipRoot = null;
  deletions = [];
}
function commitWork(fiber) {
  if (!fiber) return;
  const parentDom = fiber.parent.dom;
  // 处理对应 effectTag 的 DOM 操作
  if (fiber.effectTag === "PLACEMENT" && fiber.dom) {
    parentDom.appendChild(fiber.dom); // 新增节点
  } else if (fiber.effectTag === "UPDATE" && fiber.dom) {
    updateDOM(fiber.dom, fiber.alternate.props, fiber.props); // 属性更新
  } else if (fiber.effectTag === "DELETION" && fiber.dom) {
    parentDom.removeChild(fiber.dom); // 移除节点
  }
  // 递归处理子及兄弟 fiber
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

// 对比新旧 props,赋值/删除属性
function updateDOM(dom, prevProps, nextProps) {
  // 移除旧属性
  Object.keys(prevProps)
    .filter(key => key !== "children" && !(key in nextProps))
    .forEach(key => (dom[key] = ""));
  // 新增或更新属性
  Object.keys(nextProps)
    .filter(key => key !== "children")
    .forEach(key => (dom[key] = nextProps[key]));
}

function createDOM(element) {
  // 创建 dom 或文本节点
  const dom = element.type === "TEXT_ELEMENT"
    ? document.createTextNode("")
    : document.createElement(element.type);
  // 属性赋值
  Object.keys(element.props || {})
    .filter(key => key !== "children")
    .forEach(name => dom[name] = element.props[name]);
  return dom;
}

// 子节点 diff 对比,新建/复用/删除 fiber,设置 effectTag
function reconcileChildren(wipFiber, elements) {
  let index = 0; // 新 children 下标
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child; // 老 fiber 链
  let prevSibling = null;

  // 对新旧 fiber/element 进行一一对比
  while (index < elements.length || oldFiber) {
    const element = elements[index];
    // 类型一样则尝试复用
    const sameType = oldFiber && element && oldFiber.type === element.type;
    let newFiber;
    if (sameType) {
      // 复用旧 DOM,标记为 UPDATE
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE"
      };
    }
    if (element && !sameType) {
      // 新增节点
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT"
      };
    }
    if (oldFiber && !sameType) {
      // 旧 fiber 需删除
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }
    // 向后推进旧 fiber 链
    if (oldFiber) oldFiber = oldFiber.sibling;
    // 构建新 fiber 链表
    if (newFiber) {
      if (index === 0) wipFiber.child = newFiber;
      else prevSibling.sibling = newFiber;
      prevSibling = newFiber;
    }
    index++;
  }
}

// 调用入口,挂载新树,设置 diff 数据
function render(element, container) {
  wipRoot = {
    dom: container,
    props: { children: [element] },
    alternate: currentRoot, // 前一棵树的快照
  };
  deletions = [];
  nextUnitOfWork = wipRoot;
}
export default { render };

优点:精细增量型更新、批量挂载、极致减少无效 DOM 操作。Fiber 就此具备现代前端最佳性能。


结语:Fiber 的意义

  • 可中断渲染:让主线程更流畅,不卡界面
  • 批量挂载 & 精细 Diff:处理大树如丝般顺滑
  • 光采未尽:真正的 React Fiber 还覆盖优先级、生命周期等调度,值得深入挖掘!

你可以基于上述代码,自行迭代尝试支持更新、删除、优先级等 Fiber 更复杂特性,助你底层原理 “触类旁通”。


手搓一个 Ollama 本地 SSE 全栈聊天助手

作者 LeeAt
2025年12月9日 00:44

在写的一个小项目中,考虑到调用大模型 API 的经费不足,于是选择使用 Ollama 进行部署

技术栈

后端

  • Node.js + Express
  • TS
  • Ollama
  • Prisma

前端

  • React + TS
  • Tailwindcss

什么是 SSE ?

SSE,全称是 Server-Sent Events,即服务器推送事件,SSE是基于 HTTP 协议的单向通信技术,服务器主动向客户端推送数据,客户端能够通过 EventSource API 或者 Fetch API 进行接收。

代码实现

后端实现

ollama

// ollamaService.ts

export class OllamaService {
  private baseURL: string
  private defaultModel: string

  constructor() {
    this.baseURL = process.env.OLLAMA_BASE_URL 
    this.defaultModel = process.env.OLLAMA_MODEL
  }


// 流式输出
  async *chatStream(   // async * 标记为 异步生成器函数
    message: string,
  ): AsyncGenerator<string> {
    try {
      const prompt = this.buildPrompt(message)

      const response = await axios.post(
        `${this.baseURL}/api/generate`,
        {
          model: this.defaultModel,
          prompt: prompt,
          stream: true,   // 启用流式
          options: {
            temperature: 0.7,
            top_p: 0.9,
            num_predict: 512,
          }
        } as OllamaGenerateRequest,
        {
          responseType: 'stream',  // 接收流式响应
          timeout: 60000,
        }
      )

      // 处理流式响应
      for await (const chunk of response.data) {
        const lines = chunk.toString().split('\n').filter((line: string) => line.trim())
        
        for (const line of lines) {
          try {
            const data = JSON.parse(line) as OllamaGenerateResponse
            if (data.response) {
              yield data.response    // 生成器 
            }
          } catch (e) {
            // 忽略解析错误
          }
        }
      }
    } catch (error) {
      //
    }
  }
  
    private buildPrompt(message: string): string {  
        return \`你是 AI 助手。\n\n用户: \${message}\n助手: \`  
    }
    
// 导出单例
export const ollamaService = new OllamaService()

SSE 路由实现

// ollama.ts

router.post("/chat/stream", async (req, res) => {
    const { message } = req.body;
    
    // 设置 SSE 响应头
    res.setHeader("Content-Type","text/event-stream");
    res.setHeader("Cache-Control","no-cache");
    res.setHeader("Connection","keep-alive");
    
    try{
    // 流式输出
    // 异步迭代生成器
    for await (const chunk of ollamaService.chatStream(message)){
        res.write(`data:${JSON.stringify({chunk})}\n\n`);
    }
    
    // 发送结束标记
    res.write(`data:${JSON.stringify({done:true})}\n\n`);
    res.end();
    } catch(e){
    res.write(`data:${JSON.stringify({e:"生成失败"})}\n\n`);
    res.end()
        }
})

前端实现

API 封装

// ollama.ts
export const chatWithOllamaStream = async (  
    request: { message: string },  
    onChunk: (chunk: string) => void,  
    onError?: (error: Error) => void  
): Promise<void> => {  
    const response = await fetch('http://localhost:5000/api/ollama/chat/stream', {  
    method: 'POST',  
    headers: { 'Content-Type': 'application/json' },  
    body: JSON.stringify(request),  
})  
  
    // 获取 ReadableStream  
    const reader = response.body?.getReader()  
    const decoder = new TextDecoder()  
  
    while (true) {  
    const { done, value } = await reader.read()  
    if (done) break  
  
    // 解析 SSE 格式  
    const text = decoder.decode(value)  
    const lines = text.split('\n').filter(line => line.startsWith('data:'))  
  
    for (const line of lines) {  
    const data = JSON.parse(line.replace('data:', '').trim())  
    if (data.chunk) {  
        onChunk(data.chunk) // 回调更新 UI  
    }  
    if (data.done) return  
        }  
    }  
}

Chat组件

// Chat.tsx
const ChatDetailPage = () => {  
const [messages, setMessages] = useState<Message[]>([])  
const [isLoading, setIsLoading] = useState(false)  
  
const handleSendMessage = async (content: string) => {  
// 添加用户消息  
const userMessage = { id: Date.now(), role: 'user', content }  
setMessages(prev => [...prev, userMessage])  
  
// 创建空的 AI 消息  
const aiMessageId = Date.now() + 1  
const aiMessage = { id: aiMessageId, role: 'assistant', content: '' }  
setMessages(prev => [...prev, aiMessage])  
  
// 流式接收 AI 回复  
await chatWithOllamaStream(  
{ message: content },  
(chunk) => {  
// 每收到一个字,更新消息  
setMessages(prev =>  
    prev.map(msg =>   msg.id === aiMessageId  
        ? { ...msg, content: msg.content + chunk }  : msg  )  
)  
    }  
        )  
}  
  
return (  
    <div>  
        {messages.map(msg => (  
            <div key={msg.id}>  
            <strong>{msg.role}:</strong> 
            {msg.content}  
            </div>   ))}  
        <input onSubmit={handleSendMessage} />  
    </div>  
    )  
}

Vue中key的作用与Diff算法原理深度解析

2025年12月9日 00:28

引言

在现代前端框架Vue和React中,列表渲染是日常开发中频繁遇到的需求。Vue提供了v-for指令来简化列表渲染,而在使用v-for时,我们经常需要指定一个特殊的属性——key。这个看似简单的属性实际上承载着重要的性能优化和正确性保证功能。本文将深入探讨Vue中key的内部原理,分析其作用机制,并通过实际代码示例揭示为什么正确使用key对于构建高效、可靠的Vue应用至关重要。

一、key的基本概念与作用

1.1 什么是key?

在Vue中,keyv-for指令的一个特殊属性,用于给每个被渲染的元素或组件提供一个唯一的标识符。它的核心作用是帮助Vue的虚拟DOM系统更高效地识别和跟踪每个节点,从而在数据变化时能够准确地更新DOM。

html

复制下载运行

 <div id="root">
    <!-- 遍历数组 -->
    <h2>人员信息</h2>
    <button @click.once="addPerson">添加一个赵六</button>
    <ul>
      <!-- 不给key,Vue会默认把index作为key -->
      
       <!-- 使用id作为key -->
        <li v-for="item in items" :key="item.id">{{ item.name }}</li>
        
       <!-- 使用index作为key -->
        <li v-for="(item, index) in items" :key="index">{{ item.name }}</li>
        
    </ul>

  </div>

  <script>
    new Vue({
      el: '#root',
      data: {
        persons: [
          { id: '001', name: '张三', age: 18 },
          { id: '002', name: '李四', age: 19 },
          { id: '003', name: '王五', age: 20 }
        ]
      },
      methods: {
        addPerson() {
          const p = { id: '004', name: '赵六', age: 21 };
          this.persons.unshift(p);
        }
      }
    })
  </script>

1.2 为什么需要key?

当Vue更新使用v-for渲染的元素列表时,默认采用"就地更新"策略。这意味着如果数据项的顺序发生改变,Vue不会移动DOM元素来匹配数据项的顺序,而是就地更新每个元素。这种策略在简单情况下是高效的,但在某些场景下可能导致问题。

想象一下:如果不使用key,Vue如何知道列表中哪个元素对应哪个数据项?它只能通过位置(索引)来匹配。当列表顺序发生变化时(如插入、删除或重新排序),这种简单的匹配方式就会失效,导致不必要的DOM操作,甚至出现渲染错误。

二、虚拟DOM与diff算法

要深入理解key的作用,首先需要了解Vue的虚拟DOM和diff算法工作机制。

2.1 虚拟DOM的概念

虚拟DOM(Virtual DOM)是真实DOM的轻量级JavaScript对象表示。Vue在每次数据变化时,不会直接操作真实DOM,而是:

  1. 根据新数据生成新的虚拟DOM树
  2. 将新旧虚拟DOM树进行对比(diff算法)
  3. 找出差异,只更新真实DOM中需要变化的部分

这种机制避免了昂贵的DOM操作,大大提升了性能。

2.2 diff算法的基本原理

Vue的diff算法并不是简单地将整个虚拟DOM树全部替换,而是采用了一些优化策略:

  1. 同级比较:只对同一层级进行比较,不会跨层级比较,复杂度从O(n³)降低到O(n)
  2. key标识:通过key来识别哪些节点是相同的,可以复用
  3. 类型判断:如果节点类型不同,直接替换整个节点

2.3 key在diff算法中的作用

在diff算法中,key起着节点标识符的作用。算法对比新旧虚拟DOM时,会按照以下规则处理:

三、key的对比规则详解

3.1 规则一:找到相同key的情况

当新旧虚拟DOM中存在相同key的节点时,Vue会进一步比较节点内容:

  1. 内容未变化:如果虚拟DOM节点内容完全一致,Vue会直接复用之前的真实DOM,不进行任何更新操作。这是最高效的情况。

  2. 内容发生变化:如果节点标签、属性或内容发生了变化,Vue会:

    • 生成新的真实DOM
    • 替换掉之前的真实DOM
    • 触发相应的生命周期钩子(如updated

3.2 规则二:未找到相同key的情况

当新虚拟DOM中的节点在旧虚拟DOM中找不到相同key时,Vue会认为这是一个全新的节点,执行以下操作:

  1. 创建新的真实DOM元素
  2. 将新DOM插入到合适位置
  3. 如果旧DOM中还有未处理的节点(新列表中不存在的节点),将其移除

四、使用index作为key的潜在问题

4.1 问题一:逆序操作导致的效率问题

让我们通过一个实际例子来理解这个问题。在本文开头的示例代码中,我们使用index作为key:

html

复制下载运行

<li v-for="(p, index) in persons" :key="index">
  {{p.name}}-{{p.age}}
  <input type="text">
</li>

当我们点击"添加一个赵六"按钮时,会在数组开头插入一个新人员:

javascript

复制下载

addPerson() {
  const p = { id: '004', name: '赵六', age: 21 };
  this.persons.unshift(p);  // 在数组开头添加
}

使用index作为key时,会发生什么?

假设初始状态:

  • 索引0:张三 (key=0)
  • 索引1:李四 (key=1)
  • 索引2:王五 (key=2)

添加"赵六"后:

  • 索引0:赵六 (key=0) ← 原来张三的位置!
  • 索引1:张三 (key=1) ← 原来李四的位置!
  • 索引2:李四 (key=2) ← 原来王五的位置!
  • 索引3:王五 (key=3) ← 新增

Vue在对比新旧虚拟DOM时,会按照key进行匹配:

  • key=0:旧节点是"张三",新节点是"赵六" → 内容变化,重新渲染
  • key=1:旧节点是"李四",新节点是"张三" → 内容变化,重新渲染
  • key=2:旧节点是"王五",新节点是"李四" → 内容变化,重新渲染
  • key=3:旧节点不存在 → 创建新节点

结果:所有列表项都被重新渲染,包括它们内部的输入框!这导致了不必要的DOM操作,降低了性能。

4.2 问题二:输入类DOM的错乱问题

这个问题更加严重。继续上面的示例,如果用户在输入框中输入了一些内容:

  1. 用户在"张三"的输入框中输入"AAA"
  2. 用户在"李四"的输入框中输入"BBB"
  3. 用户在"王五"的输入框中输入"CCC"

现在点击"添加一个赵六"按钮,使用index作为key时会发生:

由于key的错位,Vue认为:

  • key=0的节点从"张三"变成了"赵六" → 更新文本内容,但输入框的DOM被复用!
  • key=1的节点从"李四"变成了"张三" → 更新文本内容,输入框DOM复用!

结果:用户看到的界面是:

  • 赵六 [显示AAA] ← 实际上这是原来张三的输入框!
  • 张三 [显示BBB] ← 实际上这是原来李四的输入框!
  • 李四 [显示CCC] ← 实际上这是原来王五的输入框!
  • 王五 [空输入框]

这导致了严重的界面错乱问题:输入框的内容与它前面的标签名不匹配!

五、正确使用key的实践指南

5.1 最佳实践:使用唯一标识作为key

最理想的方式是使用数据项本身的唯一标识作为key:

html

复制下载运行

<!-- 使用id作为key,这是最佳实践 -->
<li v-for="p in persons" :key="p.id">
  {{p.name}}-{{p.age}}
  <input type="text">
</li>

使用id作为key后,再次执行添加操作:

  1. 添加"赵六"(id='004')到数组开头

  2. Vue通过key匹配节点:

    • key='004':新节点,创建
    • key='001':找到相同key,位置变化但节点相同,移动DOM
    • key='002':找到相同key,位置变化但节点相同,移动DOM
    • key='003':找到相同key,位置变化但节点相同,移动DOM

结果

  • 只有"赵六"被创建为新DOM
  • 其他三个人员只是位置移动,DOM被复用
  • 输入框内容保持正确:"张三"的输入框仍显示"AAA",以此类推

5.2 使用index作为key的适用场景

尽管存在潜在问题,但在某些特定场景下,使用index作为key是可以接受的:

  1. 静态列表:列表数据只展示,不会被修改(添加、删除、排序)
  2. 无状态组件:列表项是纯展示,没有内部状态(如表单输入)
  3. 性能要求不高:数据量小,对性能要求不严格

html

复制下载运行

<!-- 仅用于展示的静态列表,可以使用index作为key -->
<ul>
  <li v-for="(item, index) in staticItems" :key="index">
    {{ item }}
  </li>
</ul>

5.3 其他key选择策略

除了数据库ID,还可以考虑以下唯一标识:

  1. 复合key:当单个字段不唯一时,可以使用多个字段组合

    html

    复制下载运行

    <li v-for="user in users" :key="`${user.name}-${user.birthday}`">
    
  2. 生成唯一ID:对于本地数据,可以使用Date.now()或UUID

    javascript

    复制下载

    // 添加数据时生成唯一ID
    addItem() {
      this.items.push({
        id: Date.now(), // 使用时间戳作为唯一ID
        name: '新项目'
      })
    }
    

六、Vue 3中key的改进

Vue 3在虚拟DOM和diff算法方面做了进一步优化,但key的基本原理和作用保持不变。Vue 3的主要改进包括:

  1. 更快的diff算法:使用最长递增子序列算法优化节点移动
  2. 更好的TypeScript支持:提供更好的类型提示
  3. Fragments支持:允许组件有多个根节点,需要正确使用key

在Vue 3中,仍然需要遵循相同的key使用原则。实际上,由于Vue 3的性能优化,正确使用key带来的收益更加明显。

七、key在组件列表中的应用

v-for用于组件列表时,key的作用同样重要:

html

复制下载运行

<!-- 组件列表必须使用key -->
<user-profile
  v-for="user in users"
  :key="user.id"
  :user="user"
></user-profile>

如果没有key,当users数组变化时,Vue可能会:

  1. 错误地复用组件实例
  2. 导致组件内部状态混乱
  3. 触发错误的生命周期钩子

八、常见误区与陷阱

8.1 误区一:使用随机数作为key

html

复制下载运行

<!-- 错误示例:每次渲染都生成新的随机key -->
<li v-for="item in items" :key="Math.random()">

这种做法会导致每次渲染时,所有节点都被认为是新节点,完全失去key的优化作用。

8.2 误区二:在条件渲染中混合使用key

html

复制下载运行

<!-- 可能有问题:条件渲染导致key不稳定 -->
<div v-for="item in items">
  <div v-if="item.visible" :key="item.id">
    {{ item.name }}
  </div>
  <div v-else :key="item.id + '-hidden'">
    隐藏内容
  </div>
</div>

这种情况下,同一个数据项在不同条件下可能有不同的key,可能导致不必要的DOM重新创建。

8.3 误区三:忽略动态组件的key

html

复制下载运行

<!-- 动态组件也需要key -->
<component
  :is="currentComponent"
  :key="currentComponent"  <!-- 重要-->
></component>

没有key,Vue可能会复用组件实例,导致状态残留。

九、性能优化实践

9.1 大型列表的优化策略

对于大型列表(数百或数千项),除了正确使用key,还可以考虑:

  1. 虚拟滚动:只渲染可视区域内的元素
  2. 分页加载:分批加载和渲染数据
  3. 惰性渲染:使用v-show替代v-if减少DOM创建

9.2 监控和调试工具

  1. Vue Devtools:检查组件更新,识别不必要的重新渲染

  2. Chrome Performance面板:分析DOM操作和渲染性能

  3. 自定义性能监控

    javascript

    复制下载

    // 在组件中添加性能监控
    updated() {
      console.timeEnd('component-update')
    },
    beforeUpdate() {
      console.time('component-update')
    }
    

十、总结

key在Vue中不仅仅是一个普通的属性,它是连接数据与DOM的桥梁,是Vue高效渲染机制的核心组成部分。正确理解和使用key可以帮助我们:

  1. 提升性能:通过最小化DOM操作,减少不必要的渲染
  2. 保证正确性:避免因DOM复用导致的界面错乱
  3. 优化用户体验:保持表单状态,提供流畅的交互

在实际开发中,应该养成以下良好习惯:

  • 只要使用v-for,就始终提供key
  • 优先使用数据项的唯一标识作为key
  • 避免使用index作为key,除非列表是静态的、简单的
  • 在Vue 3中,遵循相同的原则,利用新特性进一步优化

通过深入理解key的内部原理,我们不仅可以避免常见的陷阱,还能编写出更高效、更可靠的Vue应用。记住,良好的key使用习惯是Vue开发中的最佳实践之一,值得每个Vue开发者深入掌握和 consistently应用。

自研2025版flutter3.38实战抖音app短视频+聊天+直播商城系统

作者 xiaoyan2015
2025年12月8日 23:22

经过半个月高强度研发,最新款Flutter3.38+Dart3.10打造短视频+直播+聊天应用,正式完结了。

未标题-aa.png

未标题-3.png

运用技术

  • 编码工具:VScode
  • 跨平台框架:Flutter3.38.2+Dart3.10.0
  • 状态管理:get: ^4.7.3
  • 缓存服务:get_storage: ^2.1.1
  • 瀑布流组件:flutter_staggered_grid_view^0.7.0
  • 轮播图组件:card_swiper^3.0.1
  • toast弹窗组件:shirne_dialog^4.8.6
  • 视频套件:media_kit: ^1.2.3
  • svg图片:flutter_svg: ^2.2.3
  • 缓存图片:cached_network_image: ^3.4.1

z1.gif

flutter3实现一个类似抖音app首页上下左右联动效果。上下滚动切换短视频、左右滚动切换页面模块。

z2.gif

直播页面实现右侧滑入直播进场/左侧滑入礼物提示动效、商品列表、礼物、商品讲解、弹幕消息等功能。

z3.gif

项目结构目录

flutter3-douyin使用最新跨平台框架flutter3.38.2构建项目模板。

360截图20251202112944055.png

『flutter3.38抖音app』短视频+聊天+直播入场-礼物侧边滑进动效 - bilibili

未标题-3_1.png

未标题-4.png

未标题-7.png

未标题-8.png

项目入口配置

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:media_kit/media_kit.dart';
import 'package:shirne_dialog/shirne_dialog.dart';

import 'utils/common.dart';

// 引入布局页面
import 'layouts/index.dart';

// 引入路由配置
import 'router/index.dart';

void main() async {
  // 初始化get_storage存储
  await GetStorage.init();

  // 初始化media_kit视频套件
  WidgetsFlutterBinding.ensureInitialized();
  MediaKit.ensureInitialized();

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // 是否windows平台
    bool isWindows() {
      if (kIsWeb) return false;
      
      final platform = Theme.of(context).platform;
      return platform == TargetPlatform.windows;
    }

    return GetMaterialApp(
      title: 'Flutter3 DYMALL',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFF2C55)),
        useMaterial3: true,
        // 修复windows下字体不一致情况 - Web 平台特殊处理
        // fontFamily: Platform.isWindows ? 'Microsoft YaHei' : null
        fontFamily: isWindows() ? 'Microsoft YaHei' : null
      ),
      home: const Layout(),
      // 初始化路由
      initialRoute: Common.isLogin() ? '/' : '/login',
      // 路由页面
      getPages: routePages,
      // 初始化弹窗key
      navigatorKey: MyDialog.navigatorKey,
    );
  }
}

未标题-9.png

未标题-12.png

未标题-13.png

flutter3自定义抖音app首页模块联动

如下图:实现一个类似抖音app首页左右切换页面,上下切换短视频效果。且顶部状态栏+Tab菜单+底部导航栏一起联动效果。

p4-3.gif

各个tab页面模板

f6d5d9016b765c8e3927602608ae7693_1289798-20251205232946737-697311119.png

@override
Widget build(BuildContext context) {
  return Scaffold(
    key: scaffoldKey,
    extendBodyBehindAppBar: true,
    appBar: AppBar(
      forceMaterialTransparency: true,
      backgroundColor: [0, 1, 4, 5].contains(videoModuleController.videoTabIndex.value) ? null : Colors.transparent,
      foregroundColor: [0, 1, 4, 5].contains(videoModuleController.videoTabIndex.value) ? Colors.black : Colors.white,
      titleSpacing: 1.0,
      leading: Obx(() => IconButton(
        icon: Badge.count(
          backgroundColor: Colors.red,
          count: 6,
          child: Icon(Icons.sort_rounded, color: tabColor(),),
        ),
        onPressed: () {
          // 自定义打开右侧drawer
          scaffoldKey.currentState?.openDrawer();
        },
      )),
      title: Obx(() {
        return ScrollConfiguration(
          behavior: CustomScrollBehavior().copyWith(scrollbars: false),
          child: TabBar(
            ...
          ),
        );
      }),
      actions: [
        Obx(() => IconButton(icon: Icon(Icons.search_rounded, color: tabColor(),), onPressed: () {},),),
      ],
    ),
    body: ScrollConfiguration(
      behavior: CustomScrollBehavior().copyWith(scrollbars: false),
      child: PageView(
        controller: pageController,
        onPageChanged: (index) {
          videoModuleController.updateVideoTabIndex(index);
          setState(() {
            tabController.animateTo(index, duration: Duration(milliseconds: 200), curve: Curves.easeInOut);
          });
        },
        children: [
          ...tabModules
        ],
      ),
    ),
    // 侧边栏
    drawer: Drawer(
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(right: Radius.circular(15.0))),
      clipBehavior: Clip.antiAlias,
      width: 300,
      child: Container(
        ...
      ),
    ),
  );
}

左右切换长列表页面,保持切换Tab使得页面滚动状态保持不变,页面开启缓存功能。

GlobalKey<ScaffoldState> scaffoldKey = GlobalKey();

VideoModuleController videoModuleController = Get.put(VideoModuleController());

late TabController tabController = TabController(initialIndex: videoModuleController.videoTabIndex.value, length: tabList.length, vsync: this);
late PageController pageController = PageController(initialPage: videoModuleController.videoTabIndex.value, viewportFraction: 1.0);

List<String> tabList = ['订阅', '逛逛', '直播', '团购', '短剧', '关注', '同城', '精选'];
final tabModules = [
  KeepAliveWrapper(child: SubscribeModule()),
  KeepAliveWrapper(child: BrowseModule()),
  KeepAliveWrapper(child: LiveModule()),
  KeepAliveWrapper(child: BuyingModule()),
  KeepAliveWrapper(child: DramaModule()),
  AttentionModule(),
  LocalModule(),
  RecommendModule()
];

cdef5921126a2ab06d2782024b2f43b1_1289798-20251205232839995-542313656.png

class KeepAliveWrapper extends StatefulWidget {
  final Widget child;
  const KeepAliveWrapper({super.key, required this.child});

  @override
  State<KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}

class _KeepAliveWrapperState extends State<KeepAliveWrapper> with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child;
  }

  @override
  bool get wantKeepAlive => true;
}

001360截图20251130234546042.png

002360截图20251201001241922.png

003360截图20251201001409147.png

003360截图20251201001640713.png

004360截图20251201001951040.png

004360截图20251201002509643.png

004360截图20251201003006451.png

004360截图20251201005209179.png

004360截图20251201005321817.png

005360截图20251201074639885.png

005360截图20251201074737013.png

005360截图20251201075250762.png

005360截图20251202095434004.png

005360截图20251202095434007.png

005360截图20251202102655543.png

005360截图20251202102949864.png

006360截图20251202103841262.png

006360截图20251202104321022.png

006360截图20251202104919246.png

006360截图20251202105314112.png

006360截图20251202105524264.png

006360截图20251202105541335.png

007360截图20251202105955398.png

008360截图20251202110048233.png

008360截图20251202110222359.png

flutter3实现短视频功能

004360截图20251201002757876.png

如上图:tab菜单悬浮在短视频页面上,短视频页面采用全屏沉浸式并延伸到状态栏。

底部播放进度条支持拖拽、点击、显示视频时长

@override
Widget build(BuildContext context) {
  return Container(
    color: Colors.black,
    child: Column(
      children: [
        Expanded(
          child: Stack(
            children: [
              PageView.builder(
                scrollDirection: Axis.vertical,
                controller: pageController,
                onPageChanged: (index) async {
                  // ...
                },
                itemCount: videoList.length,
                itemBuilder: (context, index) {
                  return Stack(
                    children: [
                      // 视频区域
                      Positioned(
                        top: 0,
                        left: 0,
                        right: 0,
                        bottom: 0,
                        child: GestureDetector(
                          child: Stack(
                            children: [
                              // 短视频插件
                              Visibility(
                                visible: videoModuleController.videoPlayIndex.value == index && position > Duration.zero,
                                child: Video(
                                  controller: videoController,
                                  fit: BoxFit.cover,
                                ),
                              ),
                              // 播放/暂停按钮
                              StreamBuilder(
                                stream: player.stream.playing,
                                builder: (context, playing) {
                                  return Visibility(
                                    visible: playing.data == false,
                                    child: Center(
                                      child: IconButton(
                                        padding: EdgeInsets.zero,
                                        onPressed: () {
                                          player.playOrPause();
                                        },
                                        icon: Icon(
                                          playing.data == true ? Icons.pause : Icons.play_arrow_rounded,
                                          color: Colors.white60,
                                          size: 80,
                                        ),
                                        style: ButtonStyle(
                                          backgroundColor: WidgetStateProperty.all(Colors.black.withAlpha(15))
                                        ),
                                      ),
                                    ),
                                  );
                                },
                              ),
                            ],
                          ),
                          onTap: () {
                            player.playOrPause();
                          },
                        ),
                      ),
                      // 右侧操作栏
                      Positioned(
                        bottom: 15.0,
                        right: 6.0,
                        child: Column(
                          spacing: 15.0,
                          children: [
                            ...
                          ],
                        ),
                      ),
                      // 底部信息区域
                      Positioned(
                        bottom: 15.0,
                        left: 10.0,
                        right: 80.0,
                        child: Column(
                          ...
                        ),
                      ),
                      // mini播放进度条
                      Positioned(
                        bottom: 0.0,
                        left: 6.0,
                        right: 6.0,
                        child: Visibility(
                          visible: videoModuleController.videoPlayIndex.value == index && position > Duration.zero,
                          child: Listener(
                            child: SliderTheme(
                              data: SliderThemeData(
                                trackHeight: sliderDraging ? 6.0 : 2.0,
                                thumbShape: RoundSliderThumbShape(enabledThumbRadius: 4.0), // 调整滑块的大小
                                overlayShape: RoundSliderOverlayShape(overlayRadius: 0), // 去掉Slider默认上下边距间隙
                                inactiveTrackColor: Colors.white24, // 设置非活动进度条的颜色
                                activeTrackColor: Colors.white, // 设置活动进度条的颜色
                                thumbColor: Colors.white, // 设置滑块的颜色
                                overlayColor: Colors.transparent, // 设置滑块覆盖层的颜色
                              ),
                              child: Slider(
                                value: sliderValue,
                                onChanged: (value) async {
                                  // debugPrint('当前视频播放时间$value');
                                  setState(() {
                                    sliderValue = value;
                                  });
                                  // 跳转播放时间
                                  await player.seek(duration * value.clamp(0.0, 1.0));
                                },
                                onChangeEnd: (value) async {
                                  setState(() {
                                    sliderDraging = false;
                                  });
                                  // 继续播放
                                  if(!player.state.playing) {
                                    await player.play();
                                  }
                                },
                              ),
                            ),
                            onPointerMove: (e) {
                              setState(() {
                                sliderDraging = true;
                              });
                            },
                          ),
                        ),
                      ),
                      // 播放位置指示器
                      Positioned(
                        bottom: 100.0,
                        left: 10.0,
                        right: 10.0,
                        child: Visibility(
                          visible: sliderDraging,
                          child: DefaultTextStyle(
                            style: TextStyle(color: Colors.white54, fontSize: 18.0, fontFamily: 'Arial'),
                            child: Row(
                              mainAxisAlignment: MainAxisAlignment.center,
                              spacing: 8.0,
                              children: [
                                Text(position.label(reference: duration), style: TextStyle(color: Colors.white)),
                                Text('/', style: TextStyle(fontSize: 14.0)),
                                Text(duration.label(reference: duration)),
                              ],
                            ),
                          )
                        ),
                      ),
                    ],
                  );
                },
              ),
              /// 固定层
              // 红包广告
              Ads(),
            ],
          ),
        ),
      ],
    ),
  );
}

综上就是flutter3.38仿写抖音app的一些知识分享,整个项目包含了短视频+直播+聊天功能模块,运用到的知识点还是非常多的,希望对大家有所帮助哈!

electron38.2-vue3os系统|Vite7+Electron38+Pinia3+ArcoDesign桌面版OS管理系统

基于electron38+vite7+vue3 setup+elementPlus电脑端仿微信/QQ聊天软件

2025最新款Electron38+Vite7+Vue3+ElementPlus电脑端后台admin系统

2025原创研发Tauri2.9+Vite7.2+Vue3+ArcoDesign客户端OS管理系统Exe

2025最新版Tauri2.8+Vite7.1+Vue3+ElementPlus客户端聊天软件Exe

最新自创Tauri2.9+Vite7.1+Vue3+ElementPlus桌面端通用后台系统管理Exe模板

基于flutter3.32+window_manager仿macOS/Wins风格桌面os系统

flutter3.27+bitsdojo_window电脑端仿微信Exe应用

最新版Vite7+Vue3+Pinia3+ArcoDesign网页版webos后台管理系统

基于uniapp+vue3+uvue仿抖音app短视频+聊天+直播app系统

基于uniapp+vue3+deepseek+markdown搭建app版流式输出AI模板

vue3.5+deepseek+arco+markdown搭建web版流式输出AI模板

unios-admin手机版后台|uniapp+vue3全端admin管理系统

基于uni-app+vue3+uvui跨三端仿微信app聊天模板

React中的'插槽'

2025年12月8日 23:10

一、最基础的:children prop(默认插槽)

基本用法

// 父组件
function App() {
  return (
    <Card>
      <h2>这是标题</h2>
      <p>这是内容...</p>
      <button>点击</button>
    </Card>
  );
}

// 子组件 Card(类似 Vue 的默认插槽)
function Card({ children }) {
  return (
    <div className="card">
      {children}  {/* 这里就是插槽位置 */}
    </div>
  );
}

处理没有 children 的情况

jsx

function Card({ children }) {
  return (
    <div className="card">
      {children || <p>默认内容</p>}
    </div>
  );
}

二、命名插槽(类似 Vue 的具名插槽)

方法1:使用多个 props

// 子组件:定义多个插槽位置
function Layout({ header, sidebar, content, footer }) {
  return (
    <div className="layout">
      <header className="header">{header}</header>
      <aside className="sidebar">{sidebar}</aside>
      <main className="content">{content}</main>
      <footer className="footer">{footer}</footer>
    </div>
  );
}

// 父组件使用
function App() {
  return (
    <Layout
      header={<h1>网站标题</h1>}
      sidebar={
        <nav>
          <a href="/">首页</a>
          <a href="/about">关于</a>
        </nav>
      }
      content={<p>主要内容区域...</p>}
      footer={<p>版权信息 © 2024</p>}
    />
  );
}

方法2:使用 children 对象(更接近 Vue 语法)

// 子组件
function Card({ children }) {
  // children 可以是对象
  return (
    <div className="card">
      <div className="card-header">
        {children.header || <h3>默认标题</h3>}
      </div>
      <div className="card-body">
        {children.body || <p>默认内容</p>}
      </div>
      <div className="card-footer">
        {children.footer}
      </div>
    </div>
  );
}

// 父组件
function App() {
  return (
    <Card>
      {{
        header: <h2>自定义标题</h2>,
        body: <p>自定义内容...</p>,
        footer: <button>确认</button>
      }}
    </Card>
  );
}

三、作用域插槽(带数据的插槽)

方法1:使用 render props

// 子组件:提供数据给插槽
function DataList({ data, children }) {
  return (
    <div className="list">
      {data.map((item, index) => (
        // 调用 children 函数,传递数据
        <div key={item.id}>
          {children(item, index)}
        </div>
      ))}
    </div>
  );
}

// 父组件使用
function App() {
  const users = [
    { id: 1, name: '张三', age: 25 },
    { id: 2, name: '李四', age: 30 }
  ];
  
  return (
    <DataList data={users}>
      {(user, index) => (  // 这里接收子组件传递的数据
        <div className="user-item">
          <span>{index + 1}. </span>
          <strong>{user.name}</strong>
          <span> ({user.age}岁)</span>
        </div>
      )}
    </DataList>
  );
}

方法2:使用函数作为 children

// 子组件
function Toggle({ children }) {
  const [isOn, setIsOn] = useState(false);
  
  const toggle = () => setIsOn(!isOn);
  
  return children({ 
    isOn, 
    toggle,
    onText: '开启',
    offText: '关闭'
  });
}

// 父组件
function App() {
  return (
    <Toggle>
      {({ isOn, toggle, onText, offText }) => (
        <button onClick={toggle}>
          {isOn ? onText : offText}
        </button>
      )}
    </Toggle>
  );
}

四、组合组件模式(类似 Web Components 的 <slot>

方法1:使用特殊的子组件

// 定义插槽组件
const CardHeader = ({ children }) => children;
const CardBody = ({ children }) => children;
const CardFooter = ({ children }) => children;

// 容器组件
function Card({ children }) {
  // 从 children 中提取不同插槽的内容
  let header, body, footer;
  
  React.Children.forEach(children, child => {
    if (child.type === CardHeader) {
      header = child.props.children;
    } else if (child.type === CardBody) {
      body = child.props.children;
    } else if (child.type === CardFooter) {
      footer = child.props.children;
    }
  });
  
  return (
    <div className="card">
      {header && <div className="card-header">{header}</div>}
      {body && <div className="card-body">{body}</div>}
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}

// 父组件使用(类似 Vue 的语法)
function App() {
  return (
    <Card>
      <CardHeader>
        <h2>用户信息</h2>
      </CardHeader>
      <CardBody>
        <p>姓名:张三</p>
        <p>年龄:25</p>
      </CardBody>
      <CardFooter>
        <button>编辑</button>
      </CardFooter>
    </Card>
  );
}

方法2:使用 context + 特殊组件

jsx

// 更高级的实现
const CardContext = React.createContext({});

const Card = ({ children }) => {
  const [slots, setSlots] = useState({});
  
  return (
    <CardContext.Provider value={{ registerSlot: setSlots }}>
      {children}
      <div className="card">
        <div className="card-header">{slots.header}</div>
        <div className="card-body">{slots.body}</div>
        <div className="card-footer">{slots.footer}</div>
      </div>
    </CardContext.Provider>
  );
};

const CardHeader = ({ children }) => {
  const { registerSlot } = useContext(CardContext);
  
  useEffect(() => {
    registerSlot(prev => ({ ...prev, header: children }));
  }, [children, registerSlot]);
  
  return null; // 不渲染自身
};

五、使用第三方库实现插槽

1. React Slot(专门库)

npm install react-slot
import { Slot, Fill } from 'react-slot';

function Layout() {
  return (
    <div>
      <header>
        <Slot name="header" />
      </header>
      <main>
        <Slot name="content" />
      </main>
    </div>
  );
}

function App() {
  return (
    <Layout>
      <Fill name="header">
        <h1>我的网站</h1>
      </Fill>
      <Fill name="content">
        <p>欢迎光临</p>
      </Fill>
    </Layout>
  );
}

六、实战案例:实现一个完整的弹窗组件

// 弹窗组件(支持插槽)
function Modal({ 
  isOpen, 
  onClose, 
  title,
  children,
  footer 
}) {
  if (!isOpen) return null;
  
  return (
    <div className="modal-overlay">
      <div className="modal">
        {/* 标题插槽 */}
        <div className="modal-header">
          <h3>{title}</h3>
          <button onClick={onClose} className="close-btn">×</button>
        </div>
        
        {/* 默认插槽(主要内容) */}
        <div className="modal-body">
          {children}
        </div>
        
        {/* 底部插槽 */}
        <div className="modal-footer">
          {footer || (  // 默认底部
            <button onClick={onClose}>关闭</button>
          )}
        </div>
      </div>
    </div>
  );
}

// 使用示例
function App() {
  const [showModal, setShowModal] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowModal(true)}>打开弹窗</button>
      
      <Modal
        isOpen={showModal}
        onClose={() => setShowModal(false)}
        title="用户设置"
        footer={  // 具名插槽
          <>
            <button onClick={() => setShowModal(false)}>取消</button>
            <button onClick={() => alert('保存成功')}>保存</button>
          </>
        }
      >
        {/* 默认插槽内容 */}
        <form>
          <label>
            用户名:
            <input type="text" />
          </label>
          <label>
            邮箱:
            <input type="email" />
          </label>
        </form>
      </Modal>
    </div>
  );
}

七、高级模式:动态插槽

动态决定插槽位置

function DynamicLayout({ children, layout = 'default' }) {
  // 根据布局类型动态渲染插槽
  const layouts = {
    default: (
      <div className="default-layout">
        <div className="top">{children.top}</div>
        <div className="main">{children.main}</div>
      </div>
    ),
    sidebar: (
      <div className="sidebar-layout">
        <aside>{children.sidebar}</aside>
        <main>{children.main}</main>
      </div>
    ),
    grid: (
      <div className="grid-layout">
        {children.grid?.map((item, index) => (
          <div key={index} className="grid-item">{item}</div>
        ))}
      </div>
    )
  };
  
  return layouts[layout];
}

// 使用
<DynamicLayout layout="sidebar">
  {{
    sidebar: <nav>导航菜单</nav>,
    main: <article>主要内容</article>
  }}
</DynamicLayout>

八、与 Vue 插槽的对比

Vue 插槽 React 对应实现 代码示例
<slot /> {children} <div>{children}</div>
<slot name="header"> 多个 props header={content}
作用域插槽 render props {(data) => <div>{data}</div>}
<template #header> 组件组合 <Card><CardHeader>...</CardHeader></Card>

九、最佳实践建议

何时使用哪种方式?

  1. 简单内容传递 → children prop
  2. 多个固定区域 → 多个 props(命名插槽)
  3. 需要向父组件传递数据 → render props
  4. 类似 Vue 的具名插槽语法 → 组件组合模式
  5. 复杂插槽逻辑 → Context + 特殊组件

性能考虑

// ❌ 避免每次渲染都创建新的插槽内容
<Card>
  {{
    header: <Header />,  // 每次都是新组件
    body: <Body />
  }}
</Card>

// ✅ 使用 useMemo 或提取到组件外部
const cardContent = useMemo(() => ({
  header: <Header />,
  body: <Body />
}), [dependencies]);

<Card>{cardContent}</Card>

reaact 中的性能优化有很多需要注意的地方,欢迎大家说说平时遇到的情况

React Router 路由模式详解:HashRouter vs BrowserRouter

作者 北辰alk
2025年12月8日 23:05

在现代前端单页面应用(SPA)开发中,路由管理是至关重要的一环。React Router 作为 React 生态中最流行的路由库,提供了两种主要的路由模式:HashRouter 和 BrowserRouter。本文将深入探讨这两种模式的实现原理、使用场景和差异。

1. React Router 简介

React Router 是 React 官方推荐的路由库,它通过管理 URL 与组件之间的映射关系,实现了单页面应用的多视图切换功能。目前 React Router 已发展到 v6 版本,提供了更加简洁和强大的 API。

1.1 安装 React Router

npm install react-router-dom

1.2 基本使用

import React from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import Home from './components/Home';
import About from './components/About';
import Contact from './components/Contact';

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/about">关于</Link>
        <Link to="/contact">联系</Link>
      </nav>
      
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

2. HashRouter 模式

2.1 什么是 HashRouter

HashRouter 使用 URL 的 hash 部分(即 # 号后面的内容)来管理路由。这种模式兼容性最好,可以在所有浏览器中运行,并且不需要服务器端配置。

示例 URL:

http://example.com/#/home
http://example.com/#/about
http://example.com/#/users/123

2.2 HashRouter 实现原理

2.2.1 核心机制

HashRouter 的核心原理是利用 window.location.hash 属性和 hashchange 事件:

class SimpleHashRouter {
  constructor() {
    this.routes = {};
    this.currentUrl = '';
    
    // 监听 hashchange 事件
    window.addEventListener('hashchange', this.refresh.bind(this), false);
    window.addEventListener('load', this.refresh.bind(this), false);
  }
  
  // 注册路由
  route(path, callback) {
    this.routes[path] = callback || function() {};
  }
  
  // 路由刷新
  refresh() {
    this.currentUrl = window.location.hash.slice(1) || '/';
    if (this.routes[this.currentUrl]) {
      this.routes[this.currentUrl]();
    }
  }
  
  // 导航到新路由
  navigate(path) {
    window.location.hash = '#' + path;
  }
}

// 使用示例
const router = new SimpleHashRouter();

router.route('/', () => {
  document.getElementById('content').innerHTML = '<h1>首页</h1>';
});

router.route('/about', () => {
  document.getElementById('content').innerHTML = '<h1>关于我们</h1>';
});

2.2.2 React Router 中的 HashRouter 实现

React Router 的 HashRouter 组件内部实现更加复杂,但基本原理相同:

import React, { useState, useEffect } from 'react';
import { Router } from 'react-router-dom';

function HashRouter({ children }) {
  const [location, setLocation] = useState({
    pathname: window.location.hash.slice(1) || '/',
    search: '',
    hash: '',
    state: null
  });

  useEffect(() => {
    const handleHashChange = () => {
      setLocation(prev => ({
        ...prev,
        pathname: window.location.hash.slice(1) || '/'
      }));
    };

    window.addEventListener('hashchange', handleHashChange);
    
    return () => {
      window.removeEventListener('hashchange', handleHashChange);
    };
  }, []);

  const history = {
    push: (path) => {
      window.location.hash = '#' + path;
    },
    replace: (path) => {
      window.location.replace('#' + path);
    },
    go: (n) => {
      window.history.go(n);
    },
    goBack: () => {
      window.history.back();
    },
    goForward: () => {
      window.history.forward();
    },
    location
  };

  return (
    <Router history={history} location={location}>
      {children}
    </Router>
  );
}

2.3 HashRouter 使用示例

import React from 'react';
import { HashRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';

function Home() {
  const navigate = useNavigate();
  
  return (
    <div>
      <h1>首页</h1>
      <button onClick={() => navigate('/about')}>跳转到关于页面</button>
    </div>
  );
}

function About() {
  return <h1>关于我们</h1>;
}

function User({ id }) {
  return <h1>用户详情 - ID: {id}</h1>;
}

function App() {
  return (
    <HashRouter>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/about">关于</Link>
        <Link to="/user/123">用户123</Link>
      </nav>
      
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/user/:id" element={<User />} />
      </Routes>
    </HashRouter>
  );
}

export default App;

2.4 HashRouter 流程图

graph TD
    A[用户点击链接或调用 navigate] --> B[更新 window.location.hash]
    B --> C[触发 hashchange 事件]
    C --> D[React Router 监听器捕获事件]
    D --> E[更新 Router 内部状态]
    E --> F[重新渲染匹配的组件]
    F --> G[页面内容更新]

3. BrowserRouter 模式

3.1 什么是 BrowserRouter

BrowserRouter 使用 HTML5 History API 来管理路由,创建的是真实的 URL 路径,不包含 # 符号。这种模式创建的 URL 更加美观,更符合用户习惯。

示例 URL:

http://example.com/home
http://example.com/about
http://example.com/users/123

3.2 BrowserRouter 实现原理

3.2.1 History API 核心方法

BrowserRouter 依赖于 HTML5 History API,主要方法包括:

  • history.pushState(): 添加新的历史记录
  • history.replaceState(): 替换当前历史记录
  • popstate 事件: 当用户导航历史记录时触发
class SimpleBrowserRouter {
  constructor() {
    this.routes = {};
    this.currentUrl = '';
    
    // 监听 popstate 事件
    window.addEventListener('popstate', this.refresh.bind(this), false);
    window.addEventListener('load', this.refresh.bind(this), false);
  }
  
  // 注册路由
  route(path, callback) {
    this.routes[path] = callback || function() {};
  }
  
  // 路由刷新
  refresh() {
    this.currentUrl = window.location.pathname || '/';
    if (this.routes[this.currentUrl]) {
      this.routes[this.currentUrl]();
    }
  }
  
  // 导航到新路由
  push(path) {
    window.history.pushState(null, null, path);
    this.refresh();
  }
  
  replace(path) {
    window.history.replaceState(null, null, path);
    this.refresh();
  }
}

// 使用示例
const router = new SimpleBrowserRouter();

router.route('/', () => {
  document.getElementById('content').innerHTML = '<h1>首页</h1>';
});

router.route('/about', () => {
  document.getElementById('content').innerHTML = '<h1>关于我们</h1>';
});

3.2.2 React Router 中的 BrowserRouter 实现

React Router 的 BrowserRouter 组件实现:

import React, { useState, useEffect } from 'react';
import { Router } from 'react-router-dom';

function BrowserRouter({ children }) {
  const [location, setLocation] = useState({
    pathname: window.location.pathname,
    search: window.location.search,
    hash: window.location.hash,
    state: null
  });

  useEffect(() => {
    const handlePopState = () => {
      setLocation({
        pathname: window.location.pathname,
        search: window.location.search,
        hash: window.location.hash,
        state: window.history.state
      });
    };

    window.addEventListener('popstate', handlePopState);
    
    return () => {
      window.removeEventListener('popstate', handlePopState);
    };
  }, []);

  const history = {
    push: (path, state) => {
      window.history.pushState(state, '', path);
      setLocation({
        pathname: window.location.pathname,
        search: window.location.search,
        hash: window.location.hash,
        state
      });
    },
    replace: (path, state) => {
      window.history.replaceState(state, '', path);
      setLocation({
        pathname: window.location.pathname,
        search: window.location.search,
        hash: window.location.hash,
        state
      });
    },
    go: (n) => {
      window.history.go(n);
    },
    goBack: () => {
      window.history.back();
    },
    goForward: () => {
      window.history.forward();
    },
    location
  };

  return (
    <Router history={history} location={location}>
      {children}
    </Router>
  );
}

3.3 BrowserRouter 使用示例

import React from 'react';
import { BrowserRouter, Routes, Route, Link, useParams } from 'react-router-dom';

function Home() {
  return <h1>首页</h1>;
}

function About() {
  return <h1>关于我们</h1>;
}

function User() {
  const { id } = useParams();
  return <h1>用户详情 - ID: {id}</h1>;
}

function NotFound() {
  return <h1>404 - 页面未找到</h1>;
}

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/about">关于</Link>
        <Link to="/user/123">用户123</Link>
      </nav>
      
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/user/:id" element={<User />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

3.4 BrowserRouter 流程图

graph TD
    A[用户点击链接或调用 navigate] --> B[调用 history.pushState]
    B --> C[更新 URL 但不刷新页面]
    C --> D[React Router 更新内部状态]
    D --> E[重新渲染匹配的组件]
    E --> F[页面内容更新]
    
    G[用户点击浏览器前进/后退] --> H[触发 popstate 事件]
    H --> I[React Router 监听器捕获事件]
    I --> J[更新 Router 内部状态]
    J --> K[重新渲染匹配的组件]
    K --> L[页面内容更新]

4. 两种模式的对比

4.1 功能特性对比

特性 HashRouter BrowserRouter
URL 美观度 较差(包含 #) 较好(纯路径)
兼容性 所有浏览器 IE10+
服务器配置 不需要 需要配置支持
SEO 友好性 较差 较好
实现原理 hashchange 事件 History API

4.2 服务器配置要求

HashRouter 服务器配置

HashRouter 不需要特殊服务器配置,因为 # 后面的内容不会发送到服务器。

BrowserRouter 服务器配置

BrowserRouter 需要服务器配置,确保所有路由都返回 index.html:

Express 服务器配置:

const express = require('express');
const path = require('path');
const app = express();

// 静态文件服务
app.use(express.static(path.join(__dirname, 'build')));

// 所有路由都返回 index.html
app.get('*', function(req, res) {
  res.sendFile(path.join(__dirname, 'build', 'index.html'));
});

app.listen(9000);

Nginx 服务器配置:

server {
  listen 80;
  server_name example.com;
  root /usr/share/nginx/html;
  index index.html;
  
  location / {
    try_files $uri $uri/ /index.html;
  }
}

4.3 选择建议

  • 使用 HashRouter 的情况

    • 静态网站托管(如 GitHub Pages)
    • 不支持 History API 的旧浏览器
    • 没有服务器配置权限
    • 快速原型开发
  • 使用 BrowserRouter 的情况

    • 有自己的服务器并可以配置
    • 需要 SEO 友好的 URL
    • 现代浏览器环境
    • 生产环境应用

5. 实际应用示例

5.1 动态路由应用

import React, { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Link, useParams } from 'react-router-dom';

// 模拟数据获取
const fetchUser = (id) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        id,
        name: `用户 ${id}`,
        email: `user${id}@example.com`
      });
    }, 500);
  });
};

function UserList() {
  const users = [
    { id: 1, name: '张三' },
    { id: 2, name: '李四' },
    { id: 3, name: '王五' }
  ];
  
  return (
    <div>
      <h1>用户列表</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>
            <Link to={`/user/${user.id}`}>{user.name}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

function UserDetail() {
  const { id } = useParams();
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    const loadUser = async () => {
      setLoading(true);
      const userData = await fetchUser(id);
      setUser(userData);
      setLoading(false);
    };
    
    loadUser();
  }, [id]);
  
  if (loading) return <div>加载中...</div>;
  if (!user) return <div>用户不存在</div>;
  
  return (
    <div>
      <h1>用户详情</h1>
      <p><strong>ID:</strong> {user.id}</p>
      <p><strong>姓名:</strong> {user.name}</p>
      <p><strong>邮箱:</strong> {user.email}</p>
    </div>
  );
}

function App() {
  return (
    <BrowserRouter>
      <nav style={{ padding: '10px', borderBottom: '1px solid #ccc' }}>
        <Link to="/" style={{ marginRight: '10px' }}>首页</Link>
        <Link to="/users">用户列表</Link>
      </nav>
      
      <div style={{ padding: '20px' }}>
        <Routes>
          <Route path="/" element={<h1>欢迎来到用户管理系统</h1>} />
          <Route path="/users" element={<UserList />} />
          <Route path="/user/:id" element={<UserDetail />} />
        </Routes>
      </div>
    </BrowserRouter>
  );
}

export default App;

5.2 路由守卫示例

import React, { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';

// 模拟认证状态
const useAuth = () => {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  
  const login = () => {
    setIsAuthenticated(true);
    localStorage.setItem('isAuthenticated', 'true');
  };
  
  const logout = () => {
    setIsAuthenticated(false);
    localStorage.removeItem('isAuthenticated');
  };
  
  useEffect(() => {
    const authStatus = localStorage.getItem('isAuthenticated');
    if (authStatus === 'true') {
      setIsAuthenticated(true);
    }
  }, []);
  
  return { isAuthenticated, login, logout };
};

// 路由守卫组件
function ProtectedRoute({ children }) {
  const { isAuthenticated } = useAuth();
  const location = useLocation();
  
  if (!isAuthenticated) {
    // 重定向到登录页,并保存当前路径以便登录后返回
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  
  return children;
}

function LoginPage() {
  const { login } = useAuth();
  const location = useLocation();
  const from = location.state?.from?.pathname || '/';
  
  const handleLogin = () => {
    login();
    // 登录后跳转到之前尝试访问的页面或首页
    window.location.href = from;
  };
  
  return (
    <div>
      <h1>登录页面</h1>
      <button onClick={handleLogin}>登录</button>
    </div>
  );
}

function Dashboard() {
  const { logout } = useAuth();
  
  return (
    <div>
      <h1>仪表板</h1>
      <p>这是受保护的页面</p>
      <button onClick={logout}>退出登录</button>
    </div>
  );
}

function PublicPage() {
  return <h1>公开页面</h1>;
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<PublicPage />} />
        <Route path="/login" element={<LoginPage />} />
        <Route 
          path="/dashboard" 
          element={
            <ProtectedRoute>
              <Dashboard />
            </ProtectedRoute>
          } 
        />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

6. 总结

React Router 提供了两种主要的路由模式:HashRouter 和 BrowserRouter,它们各有优缺点和适用场景。

  • HashRouter 基于 URL hash 和 hashchange 事件,兼容性好,无需服务器配置,适合静态托管和简单应用。
  • BrowserRouter 基于 HTML5 History API,URL 美观,SEO 友好,但需要服务器配置,适合现代浏览器和生产环境应用。

在实际开发中,应根据项目需求、目标用户浏览器环境和服务器配置能力来选择合适的路由模式。对于大多数现代 Web 应用,BrowserRouter 是更好的选择,因为它提供了更好的用户体验和开发体验。

无论选择哪种模式,React Router 都提供了强大而灵活的路由管理能力,可以帮助开发者构建复杂的单页面应用程序。在这里插入图片描述

一个空函数,如何成就 JS 继承的“完美方案”?

作者 xhxxx
2025年12月8日 22:50

JavaScript 继承的终极方案:寄生组合式继承详解

在 JavaScript 的世界里,继承一直是开发者绕不开的话题。由于其基于原型(prototype)的独特机制,实现高效、安全、可维护的继承并非易事。从早期的原型链继承、构造函数继承,到组合继承,再到如今被广泛推崇的 寄生组合式继承(Parasitic Combination Inheritance) ,我们终于找到了一个近乎完美的解决方案。

本文将结合实践与原理,带你深入理解为什么寄生组合式继承被称为“JavaScript 继承的终极方案”。


一、继承的痛点:为什么需要“终极方案”?

在 ES6 class 语法出现之前,JavaScript 的继承主要依赖函数和原型。但每种方式都有明显缺陷:

1. 原型链继承

function Animal() {}
Animal.prototype.eat = function() { console.log('eating'); };

function Cat() {}
Cat.prototype = new Animal(); // ❌ 问题:调用了 Animal 构造函数!
  • 缺点:必须执行父类构造函数,可能带来副作用(如初始化 DOM、发送请求),且无法传参。

2. 构造函数继承

function Cat(name) {
  Animal.call(this, name); // ✅ 实例属性继承
}
  • 缺点:无法继承原型上的方法,方法无法复用,内存浪费。

3. 组合继承(常用但有冗余)

function Cat(name) {
  Animal.call(this, name); // 第一次调用 Animal
}
Cat.prototype = new Animal(); // 第二次调用 Animal ❌
  • 缺点:父类构造函数被调用了 两次,效率低下。

二、寄生组合式继承:优雅的解决方案

核心思想

  • Parent.call/apply(this) 继承实例属性(支持传参、无副作用)
  • 用一个空的中介函数继承原型方法(不调用父类构造函数)

完整实现

 function Animal (name,age){
            this.name = name;
            this.age = age;
        };
        Animal.prototype.species = '动物';
        function Cat (name,age,color){
            //{} 空对象 <- this
            // 
            // 构造函数式继承
            // 手动指定
            //Animal.call(this,name,age);
            Animal.apply(this,[name,age]);// 数组传递
            console.log(this);
            this.color = color;
            
        }
        // 
        function extend (Child,Parent){
            var F = function(){};//函数表达式 有开销但不大
             F.prototype = Parent.prototype;
             Child.prototype = new F();// 实例的修改,不会影响到原型对象
             Child.prototype.constructor = Child;
        }
        extend(Cat,Animal);
        Cat.prototype.eat = function(){
            console.log('吃');
        }
        const cat = new Cat('小白',2,'黑色');
        console.log(cat.species);

关键点解析:

  1. 为什么使用空函数 F
    空函数 F 的唯一作用是作为一个“中介桥梁”。它本身不执行任何逻辑(无副作用),但通过 new F() 创建的对象会自动将其内部原型([[Prototype]])指向 F.prototype。当我们把 F.prototype 设置为 Parent.prototype 时,这个新对象就成为了一个自身为空、但能访问父类所有原型方法的代理对象

  2. Child.prototype = new F() 的本质是什么?
    这行代码创建了一个“干净”的原型对象:

    • 不是 Parent 的实例(不会调用 Parent 构造函数)
    • 自身没有属性(避免污染子类原型)
    • 它的 __proto__ 指向 Parent.prototype,因此能通过原型链访问所有父类方法
    • 这相当于现代写法:Object.create(Parent.prototype)
  3. 实例属性 vs 原型方法的分离继承

    • 实例属性(如 nameagecolor)通过 Animal.apply(this, [name, age]) 在子类构造函数中初始化,每个实例独立,支持传参。
    • 原型方法/共享属性(如 specieseat)通过原型链继承,所有实例共享,节省内存。
    • 两者解耦,各司其职,互不干扰。
  4. 为什么说“实例的修改不会影响到原型对象”?
    因为 Cat.prototype 是一个独立的新对象(由 new F() 创建),它只是链接到 Animal.prototype,而非与之相等。
    所以:

    Cat.prototype.meow = function() {};
    console.log(Animal.prototype.meow); // undefined ✅ 安全隔离
    

    而如果直接写 Cat.prototype = Animal.prototype,就会造成原型污染。

  5. 性能与安全性双赢

    • 父类构造函数仅在子类实例化时调用一次(通过 apply/call
    • 原型方法零复制、零冗余,完全复用
    • 无副作用、无内存浪费、类型系统完整

虽然Child.prototype.constructor = Child;这行代码依然会将f的constructor修改为Child,但是我们并不关心它,因为我们创建它的初衷就是利用它将Child连接到Animal的原型链上,至于f最后怎么样我们并不关心


三、为什么它是“终极方案”?

特性 寄生组合式继承 其他方式
✅ 不重复调用父构造函数 ✔️ 只在 call 时调用一次 组合继承调用两次
✅ 支持传参 ✔️ 原型链继承不支持
✅ 方法复用 ✔️ 所有实例共享原型方法 构造函数继承无法复用
✅ 原型链完整 ✔️ instanceof 正确 多数方式可做到
✅ 无副作用 ✔️ 不执行 new Parent() 原型链/组合继承会执行

💡 关键优势:通过空函数 F 作为中介,只继承原型,不执行父类构造逻辑,既安全又高效。


四、原理图解

Cat.prototype (new F())
    │
    └─ [[Prototype]] → F.prototype = Animal.prototype
                            │
                            ├
                            └─ constructor: Animal

// 修复后:
Cat.prototype.constructor = Cat
  • Cat.prototype 是一个空壳对象,自身无属性
  • 但它通过原型链无缝访问 Animal.prototype 的所有方法
  • 每个 new Cat() 实例通过 Animal.apply(this) 初始化自己的属性,互不干扰

五、现代替代:ES6 class 也是这么干的!

虽然我们现在常用:

class Animal {
  constructor(name) { this.name = name; }
  sayName() { console.log(`I am ${this.name}`); }
}

class Cat extends Animal {
  constructor(name, color) {
    super(name);
    this.color = color;
  }
  meow() { console.log('Meow!'); }
}

但你知道吗?extends 在底层正是采用了寄生组合式继承的思想
Babel 编译后的代码几乎就是我们上面手写的 extend 模式。

所以,理解寄生组合继承,就是理解现代 JavaScript 继承的本质。


六、总结

  • 寄生组合式继承 = 构造函数继承(实例属性) + 寄生式原型继承(原型方法)
  • 它解决了所有传统继承方式的痛点,是 ES5 时代最推荐的继承模式
  • 即使在 ES6+ 时代,理解它依然至关重要——因为 class 只是语法糖,底层逻辑不变
  • 如果你在阅读老项目或面试中遇到继承问题,这套方案就是你的“终极武器”

🌟 记住这个模板

function extend(Child, Parent) {
  const F = function() {};
  F.prototype = Parent.prototype;
  Child.prototype = new F();
  Child.prototype.constructor = Child;
}

掌握它,你就掌握了 JavaScript 继承的精髓。


JavaScript 原型继承与函数调用机制详解

作者 wwwwW
2025年12月8日 22:45

JavaScript 原型继承与函数调用机制详解

在 JavaScript 的面向对象编程体系中,原型继承(Prototype Inheritance)是其核心机制之一。不同于传统语言如 Java 或 C++ 使用的类继承模型,JavaScript 通过原型链实现对象之间的属性和方法共享。本文将围绕原型继承、callapply 方法的作用、构造函数继承、中介空对象模式等关键概念展开深入解析,并结合实际代码示例帮助读者理解这一灵活而强大的继承机制。


一、函数调用中的 this 指向:call 与 apply

在 JavaScript 中,函数是第一类对象,这意味着函数可以作为参数传递、赋值给变量,也可以拥有自己的属性和方法。其中,callapply 是所有函数都具备的两个内置方法,用于显式指定函数执行时的 this 上下文

1.1 call 与 apply 的基本用法

  • fn.call(thisArg, arg1, arg2, ...) :第一个参数为 this 的绑定对象,其余参数逐个传入。
  • fn.apply(thisArg, [arg1, arg2, ...]) :第一个参数同样为 this 的绑定对象,但后续参数以数组形式传入。

两者的核心区别仅在于参数传递方式,功能完全一致:立即执行函数并绑定指定的 this

javascript
编辑
function greet(greeting) {
  console.log(`${greeting}, I'm ${this.name}`);
}

const person = { name: 'Alice' };
greet.call(person, 'Hello');   // Hello, I'm Alice
greet.apply(person, ['Hi']);   // Hi, I'm Alice

1.2 在构造函数继承中的应用

在模拟“类继承”时,子类构造函数常需调用父类构造函数以初始化实例属性。此时,callapply 被用来将父类构造函数的 this 绑定到子类实例上:

javascript
编辑
function Animal(name, age) {
  this.name = name;
  this.age = age;
}

function Cat(color, name, age) {
  // 将 Animal 的 this 指向当前 Cat 实例
  Animal.apply(this, [name, age]); // 或 Animal.call(this, name, age);
  this.color = color;
}

这样,Cat 实例不仅拥有自己的 color 属性,还通过父类构造函数获得了 nameage


二、原型继承:共享方法的关键

仅仅通过构造函数继承属性是不够的——方法若定义在构造函数内部,会导致每个实例都拥有独立副本,浪费内存。因此,方法应定义在原型(prototype)上,通过原型链实现共享。

2.1 直接赋值原型的问题

一种看似简单的继承方式是:

javascript
编辑
Cat.prototype = Animal.prototype;

但这会导致父子类共享同一个原型对象。一旦修改 Cat.prototype(如添加新方法),Animal.prototype 也会被污染:

javascript
编辑
Cat.prototype.meow = function() { console.log('Meow!'); };
// 此时 Animal.prototype 也拥有了 meow 方法!

这显然违背了封装原则。

2.2 使用空对象作为中介(寄生组合式继承)

为解决上述问题,业界普遍采用“中介空对象”模式(也称寄生组合式继承):

javascript
编辑
function extend(Parent, Child) {
  var F = function() {};          // 创建空构造函数
  F.prototype = Parent.prototype; // 将其原型指向父类原型
  Child.prototype = new F();      // 子类原型 = 空函数实例
  Child.prototype.constructor = Child; // 修正 constructor 指向
}
为什么有效?
  • F 是一个空函数,其实例 new F() 几乎不携带额外属性。
  • new F() 的 __proto__ 指向 Parent.prototype,形成原型链。
  • 修改 Child.prototype 不会影响 Parent.prototype,因为它们是不同的对象

最终原型链结构如下:

text
编辑
cat.__proto__new F() → Animal.prototypeObject.prototype

三、完整继承示例分析

结合上述技术,我们可以构建一个健壮的继承体系:

javascript
编辑
function Animal(name, age) {
  this.name = name;
  this.age = age;
}
Animal.prototype.species = '动物';
Animal.prototype.breathe = function() { console.log('呼吸'); };

function Cat(name, age, color) {
  Animal.apply(this, [name, age]); // 构造函数继承属性
  this.color = color;
}

// 原型继承方法
function extend(Parent, Child) {
  var F = function() {};
  F.prototype = Parent.prototype;
  Child.prototype = new F();
  Child.prototype.constructor = Child;
}
extend(Animal, Cat);

// 扩展子类特有方法
Cat.prototype.eat = function() { console.log("eat jerry"); };

const cat = new Cat('加菲猫', 2, '黄色');
console.log(cat.species); // 动物(来自 Animal.prototype)
cat.eat();                // eat jerry
cat.breathe();            // 呼吸

此模式实现了:

  • 属性继承:通过 apply/call 复用父类构造逻辑;
  • 方法继承:通过中介原型链共享父类方法;
  • 隔离性:子类扩展不影响父类。

四、动态语言特性:属性遮蔽(Shadowing)

JavaScript 是动态语言,对象属性可在运行时修改。当实例属性与原型属性同名时,实例属性会“遮蔽”原型属性

javascript
编辑
function Cat() {}
Cat.prototype.species = '猫科动物';

const cat = new Cat();
console.log(cat.species); // 猫科动物

cat.species = 'hello';    // 在实例上创建新属性
console.log(cat.species); // hello
console.log(Cat.prototype.species); // 仍是 猫科动物

这体现了 JavaScript 的属性查找机制:先查自身,再沿原型链向上查找。


五、总结:JavaScript 继承的最佳实践

尽管 ES6 引入了 class 语法糖,但其底层仍基于原型链。理解传统继承机制对掌握 JavaScript 本质至关重要。推荐使用以下组合模式:

  1. 构造函数继承属性:使用 Parent.apply(this, arguments)
  2. 原型继承方法:通过空函数中介实现 Child.prototype = new F()
  3. 修正 constructor:确保 Child.prototype.constructor === Child

这种“寄生组合式继承”兼顾效率与安全性,是 ES5 时代最成熟的继承方案。

随着现代开发转向 ES6+,我们虽可直接使用 class extends,但其背后仍是上述原理的封装。唯有深入理解原型、thiscall/apply 及原型链,才能真正驾驭 JavaScript 的面向对象编程。

提示:在实际项目中,除非需要兼容老旧环境,否则建议优先使用 class 语法,它更简洁且不易出错。但面试或底层框架开发中,原型继承知识仍不可或缺。


通过本文的系统梳理,相信你已对 JavaScript 原型继承机制有了更清晰的认识。掌握这些基础,将为你在前端工程化、框架原理理解乃至算法设计中打下坚实根基。

Vue3 源码学习笔记(一):环境搭建与初识Monorepo

2025年12月8日 22:36

前言 为啥想学习源码?

在这个AI触手可及的时代,解决问题变得前所未有的简单——提问、复制、粘贴、完成。作为CRUD开发者,我们熟练地搬运代码,却很少追问它们为何这样工作。久而久之,技术变成了黑盒,好奇心也在重复业务中逐渐褪去。

一次偶然的机会,接触到远方 os的源码课程,开始学习Vue3的源码实现。希望把这段时间的疑问和收获整理成笔记,作为自己这段学习旅程的一份纪念。

环境搭建

Vue3 源码本身就是采用 Monorepo 架构管理,所有核心模块(如 reactivity、runtime-core、compiler-core 等)都放在 packages 目录下。

核心配置

  • Monorepo:单仓库管理多个子包,能更方便地管理模块间的依赖、统一版本
  • pnpm workspace:pnpm 内置的 Monorepo 工具,特点是子包本地软链、命令轻量高效

初始化项目

  1. 本地创建一个Vue3文件夹,执行pnpm init
  2. 新建pnpm workspace.yaml
packages:
  - "packages/*" 
  1. 创建packages目录统一管理子项
  2. 执行pnpm install typescript -D -w, 安装到根目录
  3. 初始化ts配置,执行npx tsc --init
// 配置
{
  "compilerOptions": {
    // 编译目标:使用最新的 ECMAScript 版本
    // 生成的 JavaScript 代码会包含最新的 ES 特性
    "target": "ESNext",

    // 模块系统:使用 ES 模块格式(import/export)
    // 适用于现代浏览器和打包工具
    "module": "ESNext",

    // 模块解析策略:使用 Node.js 风格的模块解析
    // 会在 node_modules 中查找依赖
    "moduleResolution": "node",

    // 输出目录:编译后的 JavaScript 文件将放在 dist 文件夹中
    "outDir": "dist",

    // 允许导入 JSON 文件作为模块
    "resolveJsonModule": true,

    // 严格模式:设置为 false,关闭所有严格类型检查
    // 提供更宽松的类型检查,适合快速开发
    "strict": false,

    // 包含的库文件:ESNext(最新 ES 特性)和 DOM(浏览器 API)
    "lib": ["ESNext", "DOM"]
  }
}
  1. 根目录下新建一个 scripts/dev.js
/**
 * 打包开发环境
 *
 * node script/dev.js --format or cjs
 */
import { parseArgs } from 'node:util'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import  esbuild from 'esbuild'
import { createRequire } from 'node:module'

/**
 * 解析命令行参数
 */
const { values: { format }, positionals } = parseArgs({
    allowPositionals: true,
    options: {
        format: {
            type: 'string',
            short: 'f',
            default: 'esm',
        },
    },
})

//  创建 esm 的 __filename
const __filename = fileURLToPath(import.meta.url)
//  创建 esm 的 __dirname
const  __dirname = dirname(__filename)

const  require  = createRequire(import.meta.url)

const  target = positionals.length ? positionals[0] : 'vue'

// 构建入口文件路径:../packages/包名/src/index.ts
const  entry = resolve(__dirname, `../packages/${target}/src/index.ts`)

/**
 * --format cjs or esm
 * 构建输出文件路径:../packages/包名/dist/包名.格式index.js
 * 例如:packages/vue/dist/vue.cjsindex.js 或 packages/vue/dist/vue.esmindex.js
 */
const  outfile = resolve(
    __dirname,
    `../packages/${target}/dist/${target}.${format}index.js`
)
// 读取目标包的package.json文件
const pkg = require(`../packages/${target}/package.json`)

// 使用esbuild创建构建上下文并启动监听模式
esbuild.context({
    entryPoints: [entry],  // 指定入口文件
    outfile,               // 指定输出文件
    format,                // 打包格式:'cjs' 或 'esm'
    platform: format === 'cjs' ? 'node' : 'browser', // 根据格式选择平台:cjs用node,esm用browser
    sourcemap: true,       // 生成sourcemap文件,便于调试
    bundle: true,          // 将依赖打包到一个文件中
    globalName: pkg.buildOptions?.name // 从package.json中读取全局变量名(用于UMD格式)
}).then(ctx => ctx.watch())  // 启动监听模式,文件变化时自动重新构建
  1. packages目录新建reactivity、shared、vue文件夹
    • reactivity:响应式模块
    • shared:工具函数模块
    • vue:vue核心包
// package.json
{
  "name": "@vue/reactivity",
  "version": "1.0.0",
  "description": "响应式模块",
  "main": "iist/reactivity.cjs.js",
  "module": "dist/reactivity.esm.js",
  "files": [
    "index.js",
    "dist"
  ],
  "sideEffects": false,
  "buildOptions": {
    "name": "VueReactivity",
    "formats": [
      "esm-bundler",
      "esm-browser",
      "cjs",
      "global"
    ]
  }
}
// package.json
{
  "name": "@vue/shared",
  "version": "1.0.0",
  "description": "工具函数",
  "main": "dist/shared.cjs.js",
  "module": "dist/shared.esm.js",
  "files": [
    "index.js",
    "dist"
  ],
  "sideEffects": false,
  "buildOptions": {
    "name": "VueShared",
    "formats": [
      "esm-bundler",
      "esm-browser",
      "cjs",
      "global"
    ]
  }
}
{
  "name": "vue",
  "version": "1.0.0",
  "description": "vue核心包",
  "main": "dist/vue.cjs.js",
  "module": "dist/vue.esm.js",
  "files": [
    "dist"
  ],
  "sideEffects": false,
  "buildOptions": {
    "name": "Vue",
    "formats": [
      "esm-bundler",
      "esm-browser",
      "cjs",
      "global"
    ]
  }
}
  1. 根目录package.json添加node scripts/dev.js reactivity --format cjs
{
  "name": "vue3源码",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "dev": "node scripts/dev.js reactivity --format cjs"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^24.3.0",
    "esbuild": "^0.25.9",
    "prettier": "^3.6.2",
    "typescript": "^5.9.2"
  },
  "dependencies": {
    "vue": "^3.5.21"
  }
}

验证输出

  • 新建reactivity/src/index.ts
// 测试函数
export function fn(a,b) {
  return a + b
}
  • 执行pnpm dev,在reactivity/dist目录输出如下

微信图片_20251208211758_6_6.png

到这一步,项目搭建完成!

  • 项目结构
Vue3/
├── node_modules/              # Node.js 依赖模块
├── packages/                  # 多包工作区
│   ├── reactivity/           # 响应式系统包
│   │   ├── dist/            # 构建输出目录
│   │   ├── src/             # 源代码目录
│   │   │   └── index.ts     # 响应式系统入口文件
│   │   └── package.json     # reactivity 包配置
│   ├── shared/              # 共享工具包
│   │   ├── src/             # 共享代码源代码
│   │   └── package.json     # shared 包配置
│   └── vue/                 # Vue 主包
│       ├── src/             # Vue 源代码
│       └── package.json     # vue 包配置
├── scripts/                  # 构建脚本目录
│   ├── dev.js               # 开发构建脚本
│   └── .prettierrc          # Prettier 代码格式化配置
├── package.json             # 项目根配置
├── pnpm-lock.yaml           # pnpm 依赖锁文件
├── pnpm-workspace.yaml      # pnpm 工作区配置
├── tsconfig.json            # TypeScript 配置
├── External Libraries/      # 外部库(IDE 生成)
└── Scratches and Consoles/ # 临时文件和终端(IDE 功能)

相关链接

(注:本文为学习笔记,如有理解不当之处,欢迎指正交流。)

React Native 工作原理深度解析:Bridge 机制与核心架构

作者 北辰alk
2025年12月8日 22:19

一、引言:为什么需要了解工作原理?

React Native 作为目前最流行的跨平台移动开发框架之一,其独特的"用JavaScript开发原生应用"的理念吸引了无数开发者。但真正掌握 React Native,不仅需要会使用它,更需要深入理解其底层工作原理。本文将深度剖析 React Native 的核心架构Bridge 机制,并通过大量代码示例和流程图,让你彻底理解 React Native 是如何工作的。

二、React Native 整体架构概览

1. 三层架构模型

graph TB
    subgraph "JavaScript 层"
        A[JSX/React组件] --> B[Virtual DOM<br>虚拟DOM]
        B --> C[React Reconciler<br>协调器]
    end
    
    subgraph "Bridge 桥接层"
        C --> D[MessageQueue<br>消息队列]
        D --> E[Serialization<br>序列化/反序列化]
        E --> F[Batched Bridge<br>批量桥接]
    end
    
    subgraph "Native 原生层"
        F --> G[iOS/Android<br>原生模块]
        G --> H[UIKit/View<br>原生UI组件]
        H --> I[GPU渲染<br>屏幕显示]
    end
    
    J[开发者] --> A
    I --> K[用户界面]
    
    style D fill:#e1f5fe
    style E fill:#e1f5fe
    style F fill:#e1f5fe

2. 核心组件交互流程

// 简单的React Native组件示例
import React from 'react';
import { View, Text, Button } from 'react-native';

const SimpleApp = () => {
  const [count, setCount] = React.useState(0);

  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>计数器: {count}</Text>
      <Button 
        title="增加" 
        onPress={() => setCount(count + 1)} 
      />
    </View>
  );
};

这个简单的组件背后,隐藏着复杂的跨平台交互机制。让我们一步步拆解。

三、JavaScript 层深度解析

1. JSX 到虚拟DOM的转换

// 开发者编写的JSX代码
<View style={styles.container}>
  <Text>Hello World</Text>
  <Button title="Press Me" />
</View>

// 经过Babel转换后的JavaScript代码
React.createElement(
  View, 
  { style: styles.container },
  React.createElement(Text, null, 'Hello World'),
  React.createElement(Button, { title: 'Press Me' })
);

// 对应的虚拟DOM对象
const virtualDOM = {
  type: 'View',
  props: {
    style: { flex: 1 },
    children: [
      {
        type: 'Text',
        props: {
          children: 'Hello World'
        }
      },
      {
        type: 'Button',
        props: {
          title: 'Press Me'
        }
      }
    ]
  }
};

2. React Reconciler(协调器)工作原理

// 简化的Reconciler实现概念
class Reconciler {
  constructor() {
    this.previousTree = null;
    this.currentTree = null;
  }
  
  // 差异对比算法
  diff(newTree, oldTree) {
    const changes = [];
    
    // 1. 节点类型不同,完全替换
    if (newTree.type !== oldTree.type) {
      changes.push({
        type: 'REPLACE',
        node: newTree
      });
    }
    // 2. 属性不同,更新属性
    else if (this.propsChanged(newTree.props, oldTree.props)) {
      changes.push({
        type: 'UPDATE_PROPS',
        props: newTree.props
      });
    }
    
    // 3. 递归处理子节点
    const childChanges = this.diffChildren(newTree.children, oldTree.children);
    changes.push(...childChanges);
    
    return changes;
  }
  
  diffChildren(newChildren, oldChildren) {
    // 使用key优化列表更新
    const changes = [];
    
    // 简化的列表diff算法
    const maxLength = Math.max(newChildren.length, oldChildren.length);
    for (let i = 0; i < maxLength; i++) {
      if (i >= newChildren.length) {
        // 删除节点
        changes.push({ type: 'REMOVE', index: i });
      } else if (i >= oldChildren.length) {
        // 新增节点
        changes.push({ type: 'INSERT', node: newChildren[i], index: i });
      } else {
        // 递归比较
        const childChanges = this.diff(newChildren[i], oldChildren[i]);
        if (childChanges.length > 0) {
          changes.push({
            type: 'UPDATE_CHILD',
            index: i,
            changes: childChanges
          });
        }
      }
    }
    
    return changes;
  }
}

四、Bridge 机制:React Native 的核心

1. Bridge 是什么?

Bridge 是 React Native 架构中最核心的概念,它是连接 JavaScript 运行时和 Native 原生环境的桥梁。Bridge 的主要职责包括:

  1. 通信中介:在 JS 线程和 Native 线程之间传递消息
  2. 异步通信:确保线程安全,避免阻塞
  3. 序列化/反序列化:转换不同环境间的数据格式
  4. 消息队列:管理消息的发送和接收顺序

2. Bridge 架构详解

sequenceDiagram
    participant JS as JavaScript线程
    participant MQ as MessageQueue
    participant S as Serializer
    participant B as BatchedBridge
    participant NM as NativeModules
    participant UI as Native UI线程
    
    Note over JS,UI: 初始化阶段
    JS->>MQ: 注册JS模块和方法
    MQ->>S: 序列化模块信息
    S->>B: 传输到Native
    B->>NM: 注册Native模块
    NM->>UI: 初始化UI组件
    
    Note over JS,UI: 运行时通信
    JS->>MQ: 调用Native方法(showAlert)
    MQ->>S: 序列化调用信息
    S->>B: 批量发送消息
    B->>NM: 分发到对应模块
    NM->>UI: 执行原生代码(显示弹窗)
    UI->>NM: 执行完成
    NM->>B: 返回结果
    B->>S: 序列化结果
    S->>MQ: 放入消息队列
    MQ->>JS: 回调JavaScript

3. Bridge 实现代码解析

// Bridge核心实现概念代码
class ReactNativeBridge {
  constructor() {
    this.messageQueue = [];
    this.callbackQueue = new Map();
    this.callbackId = 0;
    this.isProcessing = false;
    
    // 初始化Native通信
    this.setupNativeCommunication();
  }
  
  // 设置Native通信
  setupNativeCommunication() {
    // 创建通信通道
    if (window.ReactNativeWebView) {
      // WebView环境
      this.postMessage = (message) => {
        window.ReactNativeWebView.postMessage(JSON.stringify(message));
      };
    } else {
      // 原生环境(简化表示)
      this.postMessage = (message) => {
        // 实际是通过JSI或原生桥接调用
        global.__nativeCall(JSON.stringify(message));
      };
    }
    
    // 设置消息接收器
    this.setupMessageReceiver();
  }
  
  // 调用Native方法
  callNative(moduleName, methodName, args, callback) {
    const messageId = this.generateMessageId();
    
    const message = {
      id: messageId,
      module: moduleName,
      method: methodName,
      args: this.serializeArgs(args),
    };
    
    // 如果有回调函数,保存起来
    if (callback) {
      this.callbackQueue.set(messageId, callback);
    }
    
    // 将消息加入队列
    this.messageQueue.push(message);
    
    // 触发批量处理
    this.processQueue();
    
    return messageId;
  }
  
  // 处理消息队列
  processQueue() {
    if (this.isProcessing || this.messageQueue.length === 0) {
      return;
    }
    
    this.isProcessing = true;
    
    // 批量处理消息(React Native默认每16ms批量一次)
    setTimeout(() => {
      const batch = this.messageQueue.slice();
      this.messageQueue = [];
      
      // 发送到Native
      this.postMessage({
        type: 'BATCH',
        messages: batch,
      });
      
      this.isProcessing = false;
      
      // 检查是否有新消息
      if (this.messageQueue.length > 0) {
        this.processQueue();
      }
    }, 16); // 约等于一帧的时间
  }
  
  // 序列化参数
  serializeArgs(args) {
    // React Native使用特殊的序列化格式
    // 支持基本类型、数组、对象
    return args.map(arg => {
      if (typeof arg === 'function') {
        // 函数会被转换为callbackId
        const callbackId = this.generateCallbackId();
        this.callbackQueue.set(callbackId, arg);
        return {
          __type: 'function',
          __id: callbackId,
        };
      } else if (Array.isArray(arg)) {
        return {
          __type: 'array',
          __value: this.serializeArgs(arg),
        };
      } else if (typeof arg === 'object' && arg !== null) {
        return {
          __type: 'object',
          __value: Object.keys(arg).reduce((obj, key) => {
            obj[key] = this.serializeArgs([arg[key]])[0];
            return obj;
          }, {}),
        };
      } else {
        return {
          __type: 'primitive',
          __value: arg,
        };
      }
    });
  }
  
  // 反序列化结果
  deserializeResult(result) {
    if (result.__type === 'array') {
      return result.__value.map(this.deserializeResult.bind(this));
    } else if (result.__type === 'object') {
      return Object.keys(result.__value).reduce((obj, key) => {
        obj[key] = this.deserializeResult(result.__value[key]);
        return obj;
      }, {});
    } else if (result.__type === 'function') {
      // 返回一个包装函数,调用Native
      return (...args) => {
        return this.callNative('CallbackModule', 'invoke', 
          [result.__id, args]);
      };
    } else {
      return result.__value;
    }
  }
  
  // 接收Native消息
  receiveNativeMessage(messageStr) {
    const message = JSON.parse(messageStr);
    
    if (message.type === 'RESPONSE') {
      // 处理Native返回结果
      const callback = this.callbackQueue.get(message.id);
      if (callback) {
        const result = this.deserializeResult(message.result);
        callback(result);
        this.callbackQueue.delete(message.id);
      }
    } else if (message.type === 'CALLBACK') {
      // Native调用JS回调
      const callback = this.callbackQueue.get(message.callbackId);
      if (callback) {
        const args = this.deserializeResult(message.args);
        callback(...args);
      }
    } else if (message.type === 'EVENT') {
      // Native发送的事件
      this.emitEvent(message.eventName, message.data);
    }
  }
  
  generateMessageId() {
    return `msg_${Date.now()}_${this.callbackId++}`;
  }
  
  generateCallbackId() {
    return `cb_${Date.now()}_${this.callbackId++}`;
  }
}

// 全局Bridge实例
global.__ReactNativeBridge = new ReactNativeBridge();

五、Native 原生层实现

1. iOS Native 模块实现

// RCTBridgeModule.h (简化)
@protocol RCTBridgeModule <NSObject>
+ (NSString *)moduleName;
@optional
- (NSDictionary *)constantsToExport;
- (void)setBridge:(RCTBridge *)bridge;
@end

// AlertManager.m - iOS原生模块
#import <React/RCTBridgeModule.h>
#import <React/RCTLog.h>
#import <UIKit/UIKit.h>

@interface AlertManager : NSObject <RCTBridgeModule>
@end

@implementation AlertManager

// 导出模块名称
RCT_EXPORT_MODULE();

// 导出常量(同步)
- (NSDictionary *)constantsToExport {
  return @{
    @"Version": @"1.0.0",
    @"Platform": @"iOS"
  };
}

// 导出方法(异步)
RCT_EXPORT_METHOD(showAlert:(NSString *)title
                  message:(NSString *)message
                  options:(NSDictionary *)options
                  resolver:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject) {
  
  // 必须在主线程执行UI操作
  dispatch_async(dispatch_get_main_queue(), ^{
    UIAlertController *alert = [UIAlertController
      alertControllerWithTitle:title
      message:message
      preferredStyle:UIAlertControllerStyleAlert];
    
    // 添加按钮
    UIAlertAction *okAction = [UIAlertAction
      actionWithTitle:@"OK"
      style:UIAlertActionStyleDefault
      handler:^(UIAlertAction *action) {
        resolve(@{@"buttonClicked": @"OK"});
      }];
    
    UIAlertAction *cancelAction = [UIAlertAction
      actionWithTitle:@"Cancel"
      style:UIAlertActionStyleCancel
      handler:^(UIAlertAction *action) {
        resolve(@{@"buttonClicked": @"Cancel"});
      }];
    
    [alert addAction:okAction];
    [alert addAction:cancelAction];
    
    // 获取当前显示的ViewController
    UIViewController *rootController = [UIApplication sharedApplication]
      .keyWindow.rootViewController;
    
    [rootController presentViewController:alert animated:YES completion:nil];
  });
}

// 导出线程配置(可选)
- (dispatch_queue_t)methodQueue {
  return dispatch_get_main_queue(); // UI操作必须在主线程
}

@end

2. Android Native 模块实现

// AlertModule.java - Android原生模块
package com.example.app;

import android.app.AlertDialog;
import android.content.DialogInterface;

import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.Arguments;

import java.util.HashMap;
import java.util.Map;

public class AlertModule extends ReactContextBaseJavaModule {
    private final ReactApplicationContext reactContext;

    public AlertModule(ReactApplicationContext reactContext) {
        super(reactContext);
        this.reactContext = reactContext;
    }

    @Override
    public String getName() {
        return "AlertManager";
    }

    @Override
    public Map<String, Object> getConstants() {
        final Map<String, Object> constants = new HashMap<>();
        constants.put("Version", "1.0.0");
        constants.put("Platform", "Android");
        return constants;
    }

    @ReactMethod
    public void showAlert(String title, String message, 
                         ReadableMap options, Promise promise) {
        // 在主线程执行UI操作
        getCurrentActivity().runOnUiThread(new Runnable() {
            @Override
            public void run() {
                AlertDialog.Builder builder = new AlertDialog.Builder(
                    getCurrentActivity());
                
                builder.setTitle(title)
                    .setMessage(message)
                    .setPositiveButton("OK", 
                        new DialogInterface.OnClickListener() {
                            @Override
                            public void onClick(DialogInterface dialog, int which) {
                                WritableMap result = Arguments.createMap();
                                result.putString("buttonClicked", "OK");
                                promise.resolve(result);
                            }
                        })
                    .setNegativeButton("Cancel", 
                        new DialogInterface.OnClickListener() {
                            @Override
                            public void onClick(DialogInterface dialog, int which) {
                                WritableMap result = Arguments.createMap();
                                result.putString("buttonClicked", "Cancel");
                                promise.resolve(result);
                            }
                        })
                    .setOnCancelListener(new DialogInterface.OnCancelListener() {
                        @Override
                        public void onCancel(DialogInterface dialog) {
                            promise.reject("CANCELLED", "Dialog was cancelled");
                        }
                    });
                
                AlertDialog dialog = builder.create();
                dialog.show();
            }
        });
    }
}

// AlertPackage.java - 注册模块
package com.example.app;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class AlertPackage implements ReactPackage {
    @Override
    public List<ViewManager> createViewManagers(
        ReactApplicationContext reactContext) {
        return Collections.emptyList();
    }

    @Override
    public List<NativeModule> createNativeModules(
        ReactApplicationContext reactContext) {
        List<NativeModule> modules = new ArrayList<>();
        modules.add(new AlertModule(reactContext));
        return modules;
    }
}

六、UI组件渲染流程

1. 从JSX到原生UI的完整流程

flowchart TD
    A[开发者编写JSX] --> B[Babel转译]
    B --> C[React.createElement]
    C --> D[Virtual DOM树]
    D --> E[Reconciliation Diff]
    E --> F[生成UI更新指令]
    
    subgraph "Bridge传输"
        F --> G[序列化UI指令]
        G --> H[通过Bridge传输]
        H --> I[反序列化]
    end
    
    subgraph "Native渲染"
        I --> J{iOS平台?}
        J --> K[是]
        J --> L[否]
        
        K --> M[创建UIView]
        M --> N[设置AutoLayout约束]
        N --> O[添加到视图层级]
        
        L --> P[创建View]
        P --> Q[设置LayoutParams]
        Q --> R[添加到ViewGroup]
    end
    
    O --> S[Core Animation渲染]
    R --> T[SurfaceFlinger渲染]
    
    S --> U[GPU绘制]
    T --> U
    
    U --> V[屏幕显示]

2. 具体渲染示例

// React Native UI组件示例
const ComplexUI = () => {
  const [visible, setVisible] = useState(false);
  
  return (
    <View style={styles.container}>
      <Text style={styles.title}>React Native渲染演示</Text>
      
      <ScrollView style={styles.scrollView}>
        {Array.from({ length: 50 }).map((_, i) => (
          <View key={i} style={styles.item}>
            <Text>项目 {i + 1}</Text>
            <Switch 
              value={visible}
              onValueChange={setVisible}
            />
          </View>
        ))}
      </ScrollView>
      
      <Modal visible={visible}>
        <View style={styles.modal}>
          <Text>这是一个模态框</Text>
          <Button 
            title="关闭" 
            onPress={() => setVisible(false)} 
          />
        </View>
      </Modal>
    </View>
  );
};

// 对应的Native渲染指令(简化表示)
const renderCommands = [
  {
    type: 'CREATE_VIEW',
    reactTag: 1,
    viewName: 'RCTView',
    props: { style: { flex: 1 } }
  },
  {
    type: 'CREATE_VIEW',
    reactTag: 2,
    viewName: 'RCTTextView',
    props: { text: 'React Native渲染演示' }
  },
  {
    type: 'ADD_CHILD',
    parentTag: 1,
    childTag: 2
  },
  // ... 更多命令
];

七、新架构:JSI 和 Fabric

1. 传统Bridge的问题

// 传统Bridge的局限性示例
class TraditionalBridgeProblem {
  constructor() {
    // 1. 序列化/反序列化开销大
    this.bigData = { /* 大量数据 */ };
    
    // 2. 异步通信延迟
    setTimeout(() => {
      // 调用Native方法
      NativeModules.DataProcessor.process(this.bigData, (result) => {
        // 回调有延迟
        console.log('处理完成:', result);
      });
    }, 0);
    
    // 3. 内存占用高
    this.cachedData = [];
  }
  
  // 频繁调用性能差
  frequentCalls() {
    for (let i = 0; i < 1000; i++) {
      // 每次调用都需要经过Bridge
      NativeModules.Utility.doSomething(i);
    }
  }
}

2. JSI(JavaScript Interface)架构

// JSI C++ 接口概念
class JSIInterface {
public:
  // JavaScript可以直接调用C++函数
  static Value callNativeFunction(
    Runtime &runtime,
    const Value &thisValue,
    const Value *arguments,
    size_t count
  ) {
    // 直接执行,无需序列化
    int result = NativeCalculator::add(
      arguments[0].asNumber(),
      arguments[1].asNumber()
    );
    
    return Value(result);
  }
  
  // C++可以直接操作JavaScript对象
  static void modifyJSObject(Runtime &runtime, Object &jsObject) {
    // 直接设置属性
    jsObject.setProperty(runtime, "modified", true);
    
    // 直接调用JavaScript函数
    Function jsFunc = jsObject.getPropertyAsFunction(runtime, "callback");
    jsFunc.call(runtime, Value("Hello from C++"));
  }
};

// JavaScript端使用
global.nativeCalculator.add = (a, b) => {
  // 实际上是直接调用C++函数
  return a + b; // 通过JSI直接执行
};

3. Fabric 渲染器

// Fabric渲染流程(对比传统)
const FabricRenderer = {
  // 同步渲染
  renderSync(element, container) {
    // 1. 同步更新Shadow Tree
    const shadowNode = this.createShadowNode(element);
    
    // 2. 直接调用Native组件
    // 无需经过Bridge异步通信
    NativeFabricUIManager.createNode(
      shadowNode,
      container
    );
    
    // 3. 立即提交更新
    NativeFabricUIManager.commitUpdate();
  },
  
  // 并发渲染支持
  concurrentRender(element) {
    // 可中断的渲染过程
    return this.startTransition(() => {
      // 高优先级更新
      this.renderSync(element);
    });
  }
};

// 使用示例
import { unstable_createElement as createElement } from 'react-native';

const FabricComponent = () => {
  // 使用Fabric渲染器
  return createElement('View', {
    style: { flex: 1 },
    onLayout: (event) => {
      // 事件处理更高效
      console.log('布局完成:', event.nativeEvent.layout);
    }
  });
};

八、性能优化与最佳实践

1. Bridge 通信优化

// 优化Bridge通信的示例
class BridgeOptimization {
  constructor() {
    this.batchUpdates = [];
    this.batchTimer = null;
  }
  
  // 1. 批量更新
  scheduleUpdate(updateFn) {
    this.batchUpdates.push(updateFn);
    
    if (!this.batchTimer) {
      this.batchTimer = setTimeout(() => {
        this.flushUpdates();
      }, 16); // 一帧的时间
    }
  }
  
  flushUpdates() {
    const updates = this.batchUpdates;
    this.batchUpdates = [];
    this.batchTimer = null;
    
    // 批量发送到Native
    NativeModules.BatchProcessor.processBatch(updates);
  }
  
  // 2. 减少序列化数据
  optimizeData(data) {
    // 只传输必要数据
    const optimized = {
      id: data.id,
      // 避免传输函数
      // callback: data.callback, // 不要这样
      type: data.type
    };
    
    // 使用共享内存(如果支持)
    if (global.SharedArrayBuffer) {
      const buffer = new SharedArrayBuffer(1024);
      // ... 填充数据
      return { buffer };
    }
    
    return optimized;
  }
  
  // 3. 使用TurboModules(新架构)
  setupTurboModules() {
    if (global.__turboModuleProxy) {
      // 按需加载原生模块
      const MyTurboModule = global.__turboModuleProxy.get('MyModule');
      
      // 同步调用
      const result = MyTurboModule.doSomethingSync();
      console.log('同步结果:', result);
    }
  }
}

// 4. 内存管理优化
class MemoryOptimization {
  constructor() {
    this.cleanupCallbacks = new Set();
    this.nativeReferences = new WeakMap();
  }
  
  registerCleanup(instance, cleanupFn) {
    this.cleanupCallbacks.add(cleanupFn);
    
    // 组件卸载时清理
    const originalWillUnmount = instance.componentWillUnmount;
    instance.componentWillUnmount = () => {
      if (originalWillUnmount) {
        originalWillUnmount.call(instance);
      }
      cleanupFn();
      this.cleanupCallbacks.delete(cleanupFn);
    };
  }
  
  // 释放Native资源
  releaseNativeResources() {
    this.cleanupCallbacks.forEach(fn => fn());
    this.cleanupCallbacks.clear();
  }
}

2. 实际优化案例

// 优化前的代码
class UnoptimizedComponent extends React.Component {
  componentDidMount() {
    // 频繁调用Bridge
    this.interval = setInterval(() => {
      NativeModules.Sensor.getData((data) => {
        this.setState({ sensorData: data });
        // 每次都会序列化/反序列化
      });
    }, 16); // 每帧都调用!
  }
  
  render() {
    // 大数据量列表
    return (
      <FlatList
        data={this.state.hugeArray}
        renderItem={({ item }) => (
          <View>
            <Text>{JSON.stringify(item)}</Text>
            {/* 每次渲染都创建新对象 */}
          </View>
        )}
      />
    );
  }
}

// 优化后的代码
class OptimizedComponent extends React.Component {
  constructor(props) {
    super(props);
    // 1. 使用ref避免重复创建函数
    this.handleData = this.handleData.bind(this);
    this.renderItem = this.renderItem.bind(this);
    
    // 2. 使用requestAnimationFrame优化频繁更新
    this.animationFrame = null;
    this.lastUpdate = 0;
  }
  
  componentDidMount() {
    // 使用requestAnimationFrame替代setInterval
    const update = () => {
      this.animationFrame = requestAnimationFrame(update);
      
      const now = Date.now();
      if (now - this.lastUpdate > 100) { // 限制为10fps
        this.lastUpdate = now;
        
        // 批量获取数据
        NativeModules.Sensor.getBatchData(
          ['temperature', 'humidity'],
          this.handleData
        );
      }
    };
    
    update();
  }
  
  componentWillUnmount() {
    if (this.animationFrame) {
      cancelAnimationFrame(this.animationFrame);
    }
    
    // 清理Native资源
    NativeModules.Sensor.unregister();
  }
  
  handleData = (data) => {
    // 使用函数式更新避免多次setState
    this.setState(prevState => ({
      sensorData: {
        ...prevState.sensorData,
        ...data
      }
    }));
  };
  
  // 使用React.memo和useMemo优化渲染
  renderItem = React.memo(({ item }) => {
    return (
      <View>
        <Text>{item.id}</Text>
        {/* 只渲染必要数据 */}
      </View>
    );
  });
  
  render() {
    // 使用getItemLayout优化FlatList
    return (
      <FlatList
        data={this.state.filteredArray} // 使用过滤后的数据
        renderItem={this.renderItem}
        keyExtractor={item => item.id}
        getItemLayout={(data, index) => ({
          length: 50,
          offset: 50 * index,
          index,
        })}
        initialNumToRender={10}
        maxToRenderPerBatch={5}
        windowSize={5}
        removeClippedSubviews={true}
      />
    );
  }
}

九、调试与问题排查

1. Bridge 通信调试工具

// Bridge调试工具
class BridgeDebugger {
  constructor() {
    this.messageLog = [];
    this.performanceLog = [];
    
    // 拦截Bridge通信
    this.interceptBridge();
  }
  
  interceptBridge() {
    const originalCall = global.__ReactNativeBridge.callNative;
    
    global.__ReactNativeBridge.callNative = (...args) => {
      const startTime = performance.now();
      const messageId = originalCall.apply(this, args);
      const endTime = performance.now();
      
      // 记录性能数据
      this.performanceLog.push({
        id: messageId,
        duration: endTime - startTime,
        timestamp: Date.now(),
        args: args.slice(0, -1) // 排除callback
      });
      
      // 限制日志大小
      if (this.performanceLog.length > 1000) {
        this.performanceLog.shift();
      }
      
      return messageId;
    };
  }
  
  // 分析性能瓶颈
  analyzePerformance() {
    const stats = {
      totalCalls: this.performanceLog.length,
      avgDuration: 0,
      slowCalls: [],
      frequentModules: {}
    };
    
    let totalDuration = 0;
    
    this.performanceLog.forEach(log => {
      totalDuration += log.duration;
      
      // 识别慢调用
      if (log.duration > 100) { // 超过100ms
        stats.slowCalls.push(log);
      }
      
      // 统计模块调用频率
      const moduleName = log.args[0];
      stats.frequentModules[moduleName] = 
        (stats.frequentModules[moduleName] || 0) + 1;
    });
    
    stats.avgDuration = totalDuration / stats.totalCalls;
    
    return stats;
  }
  
  // 生成性能报告
  generateReport() {
    const stats = this.analyzePerformance();
    
    console.group('React Native Bridge 性能报告');
    console.log(`总调用次数: ${stats.totalCalls}`);
    console.log(`平均耗时: ${stats.avgDuration.toFixed(2)}ms`);
    console.log(`慢调用(${stats.slowCalls.length}次):`, stats.slowCalls);
    console.log('模块调用频率:', stats.frequentModules);
    console.groupEnd();
    
    // 给出优化建议
    this.giveRecommendations(stats);
  }
  
  giveRecommendations(stats) {
    const recommendations = [];
    
    if (stats.avgDuration > 50) {
      recommendations.push('平均调用时间过长,考虑使用批量更新');
    }
    
    if (stats.slowCalls.length > 10) {
      recommendations.push('存在多个慢调用,建议优化相应Native模块');
    }
    
    // 找出调用最频繁的模块
    const mostFrequent = Object.entries(stats.frequentModules)
      .sort((a, b) => b[1] - a[1])[0];
    
    if (mostFrequent && mostFrequent[1] > 100) {
      recommendations.push(
        `模块"${mostFrequent[0]}"调用频繁(${mostFrequent[1]}次),考虑优化`
      );
    }
    
    if (recommendations.length > 0) {
      console.group('优化建议');
      recommendations.forEach((rec, i) => {
        console.log(`${i + 1}. ${rec}`);
      });
      console.groupEnd();
    }
  }
}

// 使用示例
if (__DEV__) {
  const debugger = new BridgeDebugger();
  
  // 定期生成报告
  setInterval(() => {
    debugger.generateReport();
  }, 30000); // 每30秒
}

十、总结与未来展望

1. 核心要点总结

React Native 的工作原理可以概括为以下几个关键点:

  1. 三层架构:JavaScript层 + Bridge桥接层 + Native原生层
  2. 异步通信:通过Bridge进行序列化/反序列化的消息传递
  3. 虚拟DOM:React协调器负责计算UI更新差异
  4. 原生渲染:最终由iOS/Android原生组件渲染到屏幕

2. Bridge 机制的核心价值与局限

优势:

  • 实现真正的跨平台开发
  • 保持原生应用性能和体验
  • 支持热重载,提升开发效率
  • 庞大的JavaScript生态可利用

局限:

  • 异步通信带来的性能开销
  • 序列化/反序列化的成本
  • 内存占用相对较高
  • 调试相对复杂

3. 新架构的革命性改进

graph LR
    A[传统Bridge架构] --> B[新架构 JSI+Fabric]
    
    subgraph "传统架构问题"
        C[异步通信延迟]
        D[序列化开销大]
        E[内存占用高]
        F[渲染不同步]
    end
    
    subgraph "新架构改进"
        G[同步直接调用]
        H[共享内存]
        I[并发渲染]
        J[统一渲染器]
    end
    
    C -.-> G
    D -.-> H
    E -.-> I
    F -.-> J

4. 学习与实践建议

  1. 初学者:先掌握基本使用,理解组件生命周期和状态管理
  2. 中级开发者:深入研究Bridge机制,学习性能优化技巧
  3. 高级开发者:探索新架构,参与开源社区贡献
  4. 架构师:根据项目需求选择合适的架构方案

5. 未来发展趋势

  1. JSI全面普及:消除Bridge性能瓶颈
  2. Fabric渲染器成熟:提供更流畅的UI体验
  3. TypeScript深度集成:更好的类型安全
  4. Web平台支持:真正的"一次编写,处处运行"
  5. AI辅助开发:智能化代码生成和优化

结语

React Native 的成功不仅在于其技术实现,更在于它巧妙地平衡了开发效率和应用性能。Bridge 机制作为其核心,虽然存在一些性能开销,但也为跨平台开发提供了可行的解决方案。

随着新架构的不断成熟,React Native 正在向更高效、更强大的方向发展。作为开发者,理解其底层原理不仅能帮助我们写出更好的代码,也能让我们在面对复杂问题时,能够从根源上找到解决方案。

无论你是刚开始接触 React Native,还是已经在生产环境中使用它,希望本文能为你提供有价值的参考。技术的道路永无止境,保持好奇,持续学习,我们才能在快速变化的技术世界中立于不败之地。

在这里插入图片描述

vue3+ts 中使用pinia状态管理

作者 红色乌鸦
2025年12月8日 22:06

下面详细介绍在 Vue3 + TypeScript 项目中使用 Pinia ^3.0.4 的完整步骤和最佳实践,包含核心概念、代码示例和常见场景。

一、环境准备

确保项目已安装 Vue3 + TypeScript,然后安装 Pinia:

bash

运行

# npm
npm install pinia@^3.0.4

# yarn
yarn add pinia@^3.0.4

# pnpm
pnpm add pinia@^3.0.4

二、初始化 Pinia

在项目入口文件(如 main.ts)中创建并挂载 Pinia 实例:

typescript

运行

// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
// 创建 Pinia 实例
const pinia = createPinia()
// 挂载到 Vue 应用
app.use(pinia)

app.mount('#app')

三、核心概念:定义 Store

Pinia 中只有 store 概念(替代 Vuex 的 State/Mutation/Action/Getter),通过 defineStore 定义,推荐按功能模块划分 store。

3.1 基础示例(用户 Store)

创建 src/stores/user.ts

typescript

运行

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 第一个参数:store 唯一 ID(必须唯一)
// 第二个参数:store 配置对象
export const useUserStore = defineStore('user', () => {
  // ========== 1. 状态(State):用 ref/reactive 定义 ==========
  const name = ref<string>('张三')
  const age = ref<number>(20)
  const permissions = ref<string[]>(['read', 'write'])

  // ========== 2. 计算属性(Getters):用 computed 定义 ==========
  // 注意:getter 依赖状态自动缓存,类似 Vue 组件的 computed
  const fullInfo = computed(() => {
    return `姓名:${name.value},年龄:${age.value}`
  })

  const hasAdminPermission = computed(() => {
    return permissions.value.includes('admin')
  })

  // ========== 3. 方法(Actions):普通函数(支持同步/异步) ==========
  // 同步 Action
  function updateName(newName: string) {
    name.value = newName
  }

  // 异步 Action(示例:模拟接口请求)
  async function fetchUserInfo(userId: number) {
    try {
      // 模拟 API 请求
      const res = await new Promise<{ name: string; age: number }>((resolve) => {
        setTimeout(() => {
          resolve({ name: '李四', age: 25 })
        }, 1000)
      })
      name.value = res.name
      age.value = res.age
    } catch (error) {
      console.error('获取用户信息失败:', error)
    }
  }

  // ========== 4. 暴露状态/计算属性/方法 ==========
  return {
    name,
    age,
    permissions,
    fullInfo,
    hasAdminPermission,
    updateName,
    fetchUserInfo
  }
})

3.2 类型提示说明

  • Pinia 结合 TypeScript 会自动推导类型,无需手动声明(如 name 自动推导为 Ref<string>)。
  • 如果需要显式指定类型,可在 ref/computed 中明确标注(如上例)。

四、在组件中使用 Store

4.1 基础使用(组合式 API)

vue

<!-- src/components/UserInfo.vue -->
<template>
  <div>
    <h3>{{ userStore.fullInfo }}</h3>
    <p>是否有管理员权限:{{ userStore.hasAdminPermission ? '是' : '否' }}</p>
    <button @click="handleUpdateName">修改姓名</button>
    <button @click="handleFetchUser">异步获取用户信息</button>
  </div>
</template>

<script setup lang="ts">
// 导入定义的 store
import { useUserStore } from '@/stores/user'

// 获取 store 实例(Pinia 会自动管理单例,多次调用返回同一个实例)
const userStore = useUserStore()

// 方法示例
const handleUpdateName = () => {
  userStore.updateName('王五')
}

const handleFetchUser = async () => {
  await userStore.fetchUserInfo(1)
}
</script>

4.2 解构 Store(保持响应式)

直接解构 store 会丢失响应式,需使用 storeToRefs

typescript

运行

import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
// 解构状态/计算属性(保持响应式)
const { name, age, fullInfo } = storeToRefs(userStore)
// 方法可直接解构(无需 ref)
const { updateName } = userStore

// 使用
console.log(name.value) // 响应式
updateName('赵六') // 正常调用

五、高级用法

5.1 重置 Store 状态

调用 $reset() 方法重置状态到初始值:

typescript

运行

const userStore = useUserStore()
userStore.$reset() // 重置所有状态

5.2 批量修改状态

使用 $patch 方法批量更新(性能更优):

typescript

运行

// 方式1:对象形式(适合简单更新)
userStore.$patch({
  name: '钱七',
  age: 30
})

// 方式2:函数形式(适合复杂更新)
userStore.$patch((state) => {
  state.permissions.push('admin')
  state.age += 1
})

5.3 监听 Store 变化

使用 $subscribe 监听状态变化(类似 Vue 的 watch):

typescript

运行

const userStore = useUserStore()
// 监听所有状态变化
const unsubscribe = userStore.$subscribe((mutation, state) => {
  console.log('状态变化:', mutation, state)
  // mutation.type:'direct'(直接修改)| 'patch object' | 'patch function'
  // mutation.storeId:store 唯一 ID
})

// 取消监听(组件卸载时调用)
onUnmounted(() => {
  unsubscribe()
})

// 单独监听某个属性(用 Vue 的 watch)
watch(() => userStore.age, (newAge, oldAge) => {
  console.log('年龄变化:', newAge, oldAge)
})

5.4 Store 之间相互调用

一个 store 可以导入并使用另一个 store:

typescript

运行

// src/stores/cart.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', () => {
  const userStore = useUserStore()
  const cartList = ref<Array<{ id: number; name: string }>>([])

  // 根据用户权限过滤购物车
  const filteredCart = computed(() => {
    if (userStore.hasAdminPermission) {
      return cartList.value
    }
    return cartList.value.filter(item => item.id < 10)
  })

  function addToCart(item: { id: number; name: string }) {
    // 使用 userStore 的状态
    if (userStore.age >= 18) {
      cartList.value.push(item)
    }
  }

  return { cartList, filteredCart, addToCart }
})

5.5 持久化存储(可选)

结合 pinia-plugin-persistedstate 实现状态持久化(如 localStorage):

bash

运行

# 安装插件
npm install pinia-plugin-persistedstate

typescript

运行

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
// 注册持久化插件
pinia.use(piniaPluginPersistedstate)

typescript

运行

// 修改 user store,添加持久化配置
export const useUserStore = defineStore('user', () => {
  // ... 原有代码
}, {
  // 持久化配置
  persist: {
    key: 'user-store', // 自定义存储的 key
    storage: localStorage, // 存储方式(localStorage/sessionStorage)
    paths: ['name', 'age'] // 只持久化 name 和 age,默认所有状态
  }
})

六、类型扩展(可选)

如果需要扩展 Pinia 的类型(如自定义插件),可创建 src/types/pinia.d.ts

typescript

运行

import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // 扩展 store 实例的属性/方法
    $log: () => void
  }
}

// 在 main.ts 中注册
pinia.use(({ store }) => {
  store.$log = () => {
    console.log(`[${store.$id}]`, store.$state)
  }
})

// 使用
userStore.$log() // 打印 store 状态

七、注意事项

  1. Store ID 唯一性defineStore 的第一个参数必须唯一,否则会导致状态冲突。
  2. 避免在非组件中滥用 Store:如果在工具函数中使用 Store,确保 Pinia 已初始化。
  3. 异步 Action 错误处理:异步 Action 需手动捕获异常,避免页面崩溃。
  4. 响应式保持:解构 Store 状态时必须使用 storeToRefs,方法可直接解构。

以上就是 Vue3 + TypeScript 中使用 Pinia ^3.0.4 的完整指南,涵盖了基础用法、高级特性和最佳实践,可根据项目需求灵活调整。

事件冒泡和事件捕获详解

作者 之恒君
2025年12月8日 21:12

事件流描述了事件在 DOM 树中传播的顺序。理解事件流是掌握事件处理的关键。

1. 基本概念

事件流三阶段

// 事件传播的完整流程
1. 捕获阶段 (Capturing Phase): 从上往下
2. 目标阶段 (Target Phase): 到达目标元素
3. 冒泡阶段 (Bubbling Phase): 从下往上

示例 DOM 结构

<div id="grandparent" style="padding: 50px; background-color: #f0f0f0;">
  Grandparent
  <div id="parent" style="padding: 30px; background-color: #e0e0e0;">
    Parent
    <button id="child" style="padding: 20px; background-color: #d0d0d0;">
      Click me!
    </button>
  </div>
</div>
// JavaScript
const grandparent = document.getElementById('grandparent');
const parent = document.getElementById('parent');
const child = document.getElementById('child');

2. 捕获阶段 (Capturing Phase)

特点

  • 从 window → document → ... → 目标元素
  • 自上而下传播
  • 默认不监听此阶段

使用方法

// 在 addEventListener 的第三个参数设置为 true
grandparent.addEventListener('click', function() {
  console.log('Grandparent: 捕获阶段');
}, true);  // true 表示在捕获阶段处理

parent.addEventListener('click', function() {
  console.log('Parent: 捕获阶段');
}, true);

child.addEventListener('click', function() {
  console.log('Child: 捕获阶段');
}, true);

3. 冒泡阶段 (Bubbling Phase)

特点

  • 从目标元素 → ... → document → window
  • 自下而上传播
  • 默认行为(第三个参数为 false 或省略)

使用方法

grandparent.addEventListener('click', function() {
  console.log('Grandparent: 冒泡阶段');
}, false);  // false 或不指定表示冒泡阶段

parent.addEventListener('click', function() {
  console.log('Parent: 冒泡阶段');
});

child.addEventListener('click', function() {
  console.log('Child: 冒泡阶段');
});

4. 完整事件流演示

// 清理之前的监听器
function clearAllListeners() {
  const listeners = [];
  return function addListener(element, handler, useCapture) {
    element.addEventListener('click', handler, useCapture);
    listeners.push({ element, handler, useCapture });
  };
}

// 添加完整事件监听
const listener = clearAllListeners();

// 捕获阶段
listener(grandparent, function() {
  console.log('1. Grandparent: 捕获阶段');
}, true);

listener(parent, function() {
  console.log('2. Parent: 捕获阶段');
}, true);

listener(child, function(e) {
  console.log('3. Child: 目标阶段');
}, true);

// 冒泡阶段
listener(child, function(e) {
  console.log('4. Child: 目标阶段');
}, false);

listener(parent, function() {
  console.log('5. Parent: 冒泡阶段');
}, false);

listener(grandparent, function() {
  console.log('6. Grandparent: 冒泡阶段');
}, false);

// 点击按钮输出:
// 1. Grandparent: 捕获阶段
// 2. Parent: 捕获阶段
// 3. Child: 目标阶段
// 4. Child: 目标阶段
// 5. Parent: 冒泡阶段
// 6. Grandparent: 冒泡阶段

5. 事件对象

event 对象

child.addEventListener('click', function(event) {
  console.log('事件对象属性:');
  console.log('event.target:', event.target);         // 实际触发的元素
  console.log('event.currentTarget:', event.currentTarget); // 当前处理元素
  console.log('event.eventPhase:', event.eventPhase);     // 当前阶段
  // 1: 捕获, 2: 目标, 3: 冒泡
}, false);

6. 控制事件传播

6.1 event.stopPropagation()

阻止事件继续传播

grandparent.addEventListener('click', function(e) {
  console.log('Grandparent: 捕获阶段');
}, true);

parent.addEventListener('click', function(e) {
  console.log('Parent: 捕获阶段');
  e.stopPropagation();  // 停止传播
}, true);

child.addEventListener('click', function(e) {
  console.log('Child: 永远执行不到这里');
}, true);

// 点击 child 输出:
// Grandparent: 捕获阶段
// Parent: 捕获阶段
// 停止传播,后续事件不会执行

6.2 event.stopImmediatePropagation()

阻止事件传播,并阻止同一元素的其他监听器

function handler1() {
  console.log('handler1');
}

function handler2() {
  console.log('handler2');
}

function handler3() {
  console.log('handler3 执行前停止');
  event.stopImmediatePropagation();
}

function handler4() {
  console.log('handler4 不会执行');
}

child.addEventListener('click', handler1);
child.addEventListener('click', handler2);
child.addEventListener('click', handler3);
child.addEventListener('click', handler4);

// 点击 child 输出:
// handler1
// handler2
// handler3 执行前停止
// handler4 不会执行

6.3 event.preventDefault()

阻止默认行为

document.getElementById('myLink').addEventListener('click', function(e) {
  e.preventDefault();  // 阻止链接跳转
  console.log('链接被点击,但不会跳转');
});

form.addEventListener('submit', function(e) {
  e.preventDefault();  // 阻止表单提交
  console.log('表单提交被阻止');
});

7. 事件委托 (Event Delegation)

7.1 使用冒泡机制

<ul id="todoList">
  <li>任务1 <button class="delete">删除</button></li>
  <li>任务2 <button class="delete">删除</button></li>
  <li>任务3 <button class="delete">删除</button></li>
  <li>任务4 <button class="delete">删除</button></li>
</ul>
// ❌ 低效的方法:为每个按钮添加监听器
const buttons = document.querySelectorAll('.delete');
buttons.forEach(button => {
  button.addEventListener('click', function() {
    this.parentElement.remove();
  });
});

// ✅ 高效的事件委托
const todoList = document.getElementById('todoList');
todoList.addEventListener('click', function(event) {
  if (event.target.classList.contains('delete')) {
    event.target.parentElement.remove();
  }
});

7.2 动态添加元素

// 添加新任务
function addNewTask() {
  const newLi = document.createElement('li');
  newLi.innerHTML = `新任务 <button class="delete">删除</button>`;
  todoList.appendChild(newLi);
  // 无需为新按钮添加事件监听
}

8. 实际应用示例

示例1:嵌套菜单

<div class="menu">
  <button class="menu-toggle">菜单1</button>
  <div class="submenu">
    <a href="#">选项1</a>
    <a href="#">选项2</a>
    <a href="#">选项3</a>
  </div>
</div>

<div class="menu">
  <button class="menu-toggle">菜单2</button>
  <div class="submenu">
    <a href="#">选项A</a>
    <a href="#">选项B</a>
  </div>
</div>
// 事件委托处理所有菜单
document.addEventListener('click', function(event) {
  const target = event.target;
  
  if (target.classList.contains('menu-toggle')) {
    // 切换菜单
    const submenu = target.nextElementSibling;
    submenu.style.display = submenu.style.display === 'block' ? 'none' : 'block';
  } else if (target.tagName === 'A') {
    // 处理菜单项点击
    console.log('选择了:', target.textContent);
  } else {
    // 点击外部,关闭所有菜单
    document.querySelectorAll('.submenu').forEach(menu => {
      menu.style.display = 'none';
    });
  }
});

示例2:模态框

// 事件委托实现点击外部关闭
document.addEventListener('click', function(event) {
  const modal = document.getElementById('modal');
  const closeBtn = document.getElementById('closeModal');
  const openBtn = document.getElementById('openModal');
  
  if (event.target === openBtn) {
    modal.style.display = 'block';
  } else if (event.target === closeBtn || event.target === modal) {
    modal.style.display = 'none';
  } else if (modal.style.display === 'block') {
    // 防止冒泡
    event.stopPropagation();
  }
});

// 阻止模态框内容点击关闭
document.getElementById('modalContent').addEventListener('click', function(event) {
  event.stopPropagation();
});

9. 性能优化

9.1 减少事件监听器数量

// ❌ 性能差:每个元素都添加监听器
document.querySelectorAll('.item').forEach(item => {
  item.addEventListener('click', handleClick);
});

// ✅ 性能好:一个父元素监听
document.getElementById('container').addEventListener('click', function(event) {
  if (event.target.classList.contains('item')) {
    handleClick(event);
  }
});

// 或者使用 closest
document.addEventListener('click', function(event) {
  const item = event.target.closest('.item');
  if (item) {
    handleClick(event, item);
  }
});

9.2 防抖和节流

// 节流处理
function throttle(func, delay) {
  let lastCall = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastCall >= delay) {
      lastCall = now;
      func.apply(this, args);
    }
  };
}

// 防抖处理
function debounce(func, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// 使用
window.addEventListener('scroll', throttle(function() {
  console.log('滚动事件(节流)');
}, 100));

window.addEventListener('resize', debounce(function() {
  console.log('调整大小事件(防抖)');
}, 250));

10. 事件阶段常量

// 事件阶段常量
const Event = {
  NONE: 0,            // 无
  CAPTURING_PHASE: 1,  // 捕获
  AT_TARGET: 2,        // 目标
  BUBBLING_PHASE: 3    // 冒泡
};

// 使用
element.addEventListener('click', function(event) {
  switch(event.eventPhase) {
    case Event.CAPTURING_PHASE:
      console.log('捕获阶段');
      break;
    case Event.AT_TARGET:
      console.log('目标阶段');
      break;
    case Event.BUBBLING_PHASE:
      console.log('冒泡阶段');
      break;
  }
}, true);

11. 高级技巧

11.1 自定义事件

// 创建自定义事件
const customEvent = new CustomEvent('myEvent', {
  bubbles: true,     // 是否冒泡
  cancelable: true,  // 是否可取消
  detail: {          // 自定义数据
    message: 'Hello World',
    time: new Date()
  }
});

// 监听自定义事件
document.addEventListener('myEvent', function(event) {
  console.log('自定义事件触发:', event.detail);
  console.log('是否冒泡:', event.bubbles);
  console.log('目标元素:', event.target);
});

// 触发事件
setTimeout(() => {
  document.dispatchEvent(customEvent);
}, 1000);

11.2 一次性事件监听

// 传统方法
let handled = false;
element.addEventListener('click', function handler(event) {
  if (handled) return;
  handled = true;
  console.log('只执行一次');
  element.removeEventListener('click', handler);
});

// 使用 { once: true }
element.addEventListener('click', function() {
  console.log('只执行一次');
}, { once: true });

// 捕获阶段也适用
element.addEventListener('click', function() {
  console.log('捕获阶段只执行一次');
}, { capture: true, once: true });

11.3 被动事件监听

// 提高滚动性能
document.addEventListener('wheel', function(event) {
  // 这里不会调用 preventDefault
  console.log('滚动事件');
}, { passive: true });

// 尝试调用会出错
document.addEventListener('wheel', function(event) {
  // 在 passive 为 true 时,调用 preventDefault 会报错
  // event.preventDefault(); // TypeError
}, { passive: true });

12. 常见问题

12.1 阻止默认行为和冒泡

document.getElementById('link').addEventListener('click', function(event) {
  event.preventDefault();  // 阻止默认行为
  event.stopPropagation(); // 阻止冒泡
  
  console.log('链接被点击,但不会跳转,也不会冒泡');
});

// 在表单中使用
document.getElementById('form').addEventListener('submit', function(event) {
  if (!isValid()) {
    event.preventDefault();
    event.stopPropagation();
    return false;
  }
});

12.2 移除事件监听器

function handleClick() {
  console.log('点击事件');
}

// 添加
element.addEventListener('click', handleClick);

// 移除(必须使用相同的函数引用)
element.removeEventListener('click', handleClick);

// ❌ 错误:匿名函数无法移除
element.addEventListener('click', function() {
  console.log('匿名函数');
});
element.removeEventListener('click', function() {
  console.log('无法移除');
}); // 不生效

13. 现代框架中的事件处理

React

function MyComponent() {
  const handleClick = (event) => {
    console.log('React 事件是合成事件');
    console.log('事件目标:', event.target);
    console.log('冒泡行为:', event.nativeEvent.bubbles);
  };

  const handleSubmit = (event) => {
    event.preventDefault();  // 阻止表单提交
    console.log('表单提交被阻止');
  };

  return (
    <div onClick={handleClick}>
      <form onSubmit={handleSubmit}>
        <button type="submit">提交</button>
      </form>
    </div>
  );
}

Vue

<template>
  <div @click="handleClick">
    <form @submit.prevent="handleSubmit">
      <button type="submit">提交</button>
    </form>
  </div>
</template>

<script>
export default {
  methods: {
    handleClick(event) {
      console.log('Vue 事件处理');
      event.stopPropagation(); // 原生方法
    },
    handleSubmit() {
      console.log('.prevent 修饰符自动阻止默认行为');
    }
  }
}
</script>

14. 总结

关键点总结

特性 事件捕获 事件冒泡
传播方向 上 → 下 下 → 上
默认启用 ❌ 需显式设置 ✅ 默认
使用场景 少,特殊需求 多,事件委托
添加方法 addEventListener(..., true) addEventListener(..., false)

最佳实践

  1. 使用事件委托:减少监听器数量,提高性能
  2. 理解事件流:知道事件如何传播
  3. 合理使用停止传播:但不要滥用
  4. 使用被动监听器:提高滚动性能
  5. 注意内存泄漏:及时移除不需要的监听器
  6. 利用框架特性:React/Vue 等框架有优化

记忆口诀

事件三阶段,捕获、目标 和 冒泡

从上往下 叫捕获,从下往上 叫冒泡

目标在中间,两阶段都要到

委托用冒泡,性能高可靠

通过理解事件冒泡和捕获,你可以更有效地处理 DOM 事件,优化性能,并写出更优雅的事件处理代码。

Hora Dart:我为什么从 jiffy 用户变成了新日期库的作者

2025年12月8日 20:42

hora.png

作为一个 Flutter 开发者,你是否也曾为日期处理而烦恼?在 jiffy、intl、date_format 之间徘徊,却总觉得少点什么?

实际上,在开发 Hora 时,我最初的灵感来源于 JavaScript 生态中备受好评的 day.js。day.js 以其简洁的 API 和强大的插件系统征服了前端开发者。我心想:为什么 Dart 生态不能有一个类似但更优秀的日期库呢?

于是我开始将 day.js 的核心功能,使用 Dart 的语言特性重新实现

  • Sealed Classes - 替代 JavaScript 的字符串枚举
  • Extension Methods - 实现 day.js 的插件机制
  • Pattern Matching - 处理复杂的日期逻辑
  • Null Safety - 避免 day.js 中的空值问题
  • Tree-shakable - 让性能超越 day.js

这不仅仅是一次简单的移植,而是对现代日期处理理念在 Dart 生态中的完整实践。

为什么需要另一个日期库?

说实话,开发 Hora 的初衷源于我自己的痛点。作为一名曾经的 jiffy 用户,在实际项目中使用时遇到了很多让人困扰的问题:

我的 jiffy 使用经历

最初选择 jiffy 是因为它模仿了著名的 moment.js API,看起来很熟悉:

// jiffy 的 API 看起来不错
Jiffy().add(days: 7).format('yyyy-MM-dd')

但真正用起来却发现差强人意

  1. 高级功能支持有限

    • 没有内置的业务日计算(需要自己实现周末和节假日逻辑)
    • 月份边界处理有时不够直观(1月31日加1个月会得到2月28日)
    • 插件系统不如 Hora 完善,扩展性有限
  2. 不可变性保证不足:虽然大部分操作是新的,但某些边缘情况下可能修改原对象

  3. 国际化复杂度:配置相对复杂,需要手动加载语言包,不如 Hora 的 tree-shakable 设计优雅

从 day.js 到 Hora:理念与实践

day.js 的成功在于它优雅解决了以下问题:

  1. API 简洁 - dayjs().format() 而不是复杂的配置
  2. 插件机制 - 可按需扩展,保持核心精简
  3. 不可变性 - 每次操作返回新实例
  4. 链式调用 - 直观的方法链

而 Hora 在此基础上,利用 Dart 的优势更进一步:

// day.js 风格的 API,但更安全
final now = Hora.now();
final result = now
    .add(1, TemporalUnit.month)
    .startOf(TemporalUnit.day)
    .format('YYYY-MM-DD');

// 使用 Dart 的 sealed class 确保类型安全
TemporalUnit unit = TemporalUnit.parse('month'); // 编译时检查

// 通过 Extension 实现插件,比 day.js 更优雅
import 'package:hora/src/plugins/business_day.dart';
final businessDay = now.addBusinessDays(5);

痛定思痛,决定自己动手

在分析了现有库的痛点后,我意识到 Dart 生态需要一个功能更全面的日期库:

  • intl:功能强大,但 API 相对复杂,体积较大,初始化重
  • date_format:轻量,但功能单一,仅支持格式化,缺乏操作能力
  • jiffy:API 友好但高级功能支持不足,缺少企业级特性

我想创造一个功能完整的日期库,满足从简单格式化到复杂业务计算的各种需求。这就是 Hora 的诞生故事。

Hora 的核心优势

1. 🎯 丰富的功能特性

Hora 提供了比其他库更全面的日期时间处理能力:

// Hora - 功能强大且易用
final now = Hora.now();
final nextWeek = now.add(1, TemporalUnit.week);

// 支持复杂的业务日计算
final businessDays = now.addBusinessDays(10);

// 财年和季度计算
final fiscalYear = now.fiscalYear(startMonth: 4);
final fiscalQuarter = now.fiscalQuarter(startMonth: 4);

// 重复事件模式
final meetings = Recurrence.weekly(
  start: now,
  daysOfWeek: {DateTime.monday, DateTime.friday},
);

// jiffy - 基础功能有限
final jiffyNextWeek = Jiffy().add(weeks: 1);

// date_format - 只支持格式化,不支持操作

2. 🔒 不可变性设计

Hora 采用不可变设计,所有操作都返回新实例:

final date = Hora.of(year: 2024, month: 12, day: 8);
final modified = date.copyWith(hour: 10);

print(date.hour);      // 0 - 原始实例不变
print(modified.hour);  // 10 - 新实例

这避免了在复杂应用中因意外修改导致的状态问题。

3. 🌍 完善的国际化支持

Hora 内置 143 种语言支持,并且设计为 tree-shakable:

import 'package:hora/hora.dart';
import 'package:hora/src/locales/ja.dart'; // 按需导入

// 默认英文
final us = Hora.now();
print(us.format('MMMM D, YYYY')); // December 8, 2024

// 切换到中文
final cn = Hora.now(locale: const HoraLocaleZhCn());
print(cn.format('YYYY年M月D日')); // 2024年12月8日

// 切换到日文
final jp = Hora.now(locale: const HoraLocaleJa());
print(jp.format('YYYY年M月D日')); // 2024年12月8日

相比之下:

  • intl 需要额外配置,初始化较重
  • jiffy 支持的语言较少
  • date_format 几乎没有国际化支持

4. 📅 智能的时间单位处理

Hora 区分固定时长单位日历相关单位

// 固定时长单位(使用 Duration)
final exactWeek = now.add(7, TemporalUnit.day); // 精确的 7 天

// 日历相关单位(考虑月份长度、闰年等)
final nextMonth = now.add(1, TemporalUnit.month); // 智能处理月份变化

// HoraDuration 更是支持混合单位
final duration = HoraDuration(
  years: 1,
  months: 6,
  days: 15
);
print(duration.humanize()); // "a year and 6 months"

5. 🔌 灵活的插件系统

Hora 采用 Dart Extension 实现插件机制,无需显式注册:

import 'package:hora/src/plugins/week_year.dart';

final h = Hora.now();
print(h.weekYear());       // 通过扩展方法调用
print(h.weekOfWeekYear()); // 无需额外配置

6. ⚡ 轻量且高性能

  • 零外部依赖:只依赖 Dart 内置的 meta
  • Tree-shakable:未使用的代码会被编译器优化掉
  • 纯 Dart 实现:无平台限制,支持 Flutter Web、Server、Desktop

7. 📊 完整的插件生态

Hora 通过 20+ 个插件覆盖了企业级应用的各种需求:

  • business_day:业务日计算
  • recurrence:重复事件
  • calendar:日历生成
  • relative_time:相对时间
  • custom_parse_format:自定义解析
  • timezone:时区处理
  • fiscal_year:财年计算
  • week_year:ISO 周年
  • 等等...

功能对比表

特性 Hora intl date_format jiffy
不可变性 ✅ 完全不可变 ⚠️ DateTime 可变 ⚠️ DateTime 可变 ⚠️ 部分可变
国际化 ✅ 143+ 语言,Tree-shakable ✅ 完整但较重 ❌ 不支持 ✅ 有限支持
插件系统 ✅ 20+ 插件,无侵入 ❌ 无 ❌ 无 ⚠️ 基础扩展
高级功能 ✅ 业务日、重复事件、日历 ❌ 无 ❌ 无 ⚠️ 基础功能
链式操作 ✅ 流畅 API ❌ 不支持 ❌ 不支持 ✅ 支持
时间单位 ✅ 区分固定时长和日历单位 ⚠️ 需手动计算 ❌ 不支持 ⚠️ 概念模糊
格式解析 ✅ 自定义格式解析 ⚠️ 有限支持 ❌ 不支持 ⚠️ 基础支持
时区支持 ✅ 内置支持 ❌ 需要额外包 ❌ 不支持 ❌ 无
财年计算 ✅ 多国财年支持 ❌ 无 ❌ 不支持 ❌ 无
依赖 ✅ 仅 meta 包 ❌ 较多依赖 ✅ 0 ⚠️ 轻量

实际应用示例

场景1:业务时间计算

// Hora - 内置业务日计算,一行搞定
import 'package:hora/src/plugins/business_day.dart';

final start = Hora.of(year: 2024, month: 12, day: 20); // 周五
final end = start.addBusinessDays(5); // 自动跳过周末和节假日
print(end.format('YYYY-MM-DD')); // 2024-12-27

// jiffy - 需要手动实现
var current = start;
int businessDays = 0;
while (businessDays < 5) {
  current = current.add(1, TemporalUnit.day);
  if (current.weekday <= 5) businessDays++; // 需要手动判断周末
}

// intl - 完全没有业务日功能

场景2:智能的时间单位处理

// Hora - 区分固定时长和日历单位
final jan31 = Hora.of(year: 2024, month: 1, day: 31);

// 固定时长 - 精确的 7 天
final exact7Days = jan31.add(7, TemporalUnit.day);
print(exact7Days.format('YYYY-MM-DD')); // 2024-02-07

// 日历单位 - 智能处理月份
final nextMonth = jan31.add(1, TemporalUnit.month);
print(nextMonth.format('YYYY-MM-DD')); // 2024-02-29 (闰年2月末)

// jiffy - 容易混淆,需要自己计算
final jiffy1 = Jiffy.parse('2024-01-31').add(days: 7); // 类似固定时长
final jiffy2 = Jiffy.parse('2024-01-31').add(months: 1); // 但逻辑不透明

场景4:强大的插件系统

Hora 的插件系统通过 Dart Extension 实现,无需显式注册:

// Calendar 插件 - 生成日历
import 'package:hora/src/plugins/calendar.dart';

final monthCal = Hora.now().monthCalendar();
print('该月有 ${monthCal.weekCount} 周');

// Recurrence 插件 - 复杂的重复事件
import 'package:hora/src/plugins/recurrence.dart';

// 每个周一和周五的会议
final meeting = Recurrence.weekly(
  start: Hora.now(),
  daysOfWeek: {DateTime.monday, DateTime.friday},
);
print('接下来5次会议时间:');
meeting.take(5).forEach(print);

// 自定义重复模式 - 如每3天一次
final custom = Recurrence.custom(
  start: Hora.now(),
  generator: (current) => current.add(3, TemporalUnit.day),
);

场景5:高级相对时间功能

// Relative Time 插件 - 比基础 fromNow 更强大
import 'package:hora/src/plugins/relative_time.dart';

final past = Hora.now().subtract(5, TemporalUnit.day);

// 自定义阈值配置
final config = RelativeTimeConfig(
  thresholds: RelativeTimeThresholds.strict,
  withoutSuffix: false,
);

// 详细的时间差分解
final diff = past.diffFromNowDetailed();
print(diff.format()); // "5 days, 0 hours, 0 minutes, 0 seconds ago"
print(diff.formatCompact()); // "5d"

// 短格式,适合 UI 显示
print(past.relativeFromNowShort()); // "-5d"

场景6:自定义格式解析

// Custom Parse Format 插件
import 'package:hora/src/plugins/custom_parse_format.dart';

// 解析各种格式的日期
final date1 = HoraParser.parse('25/12/2024', 'DD/MM/YYYY');
final date2 = HoraParser.parseMultiple(
  '2024-12-25',
  ['YYYY-MM-DD', 'DD/MM/YYYY', 'MM-DD-YYYY'],
);

// 支持复杂格式
final complex = HoraParser.parse(
  'Thursday, December 25, 2024 2:30 PM',
  'dddd, MMMM D, YYYY h:mm A',
);

// 其他库需要自己实现这些复杂的解析逻辑

场景7:流畅的链式 API

// Hora - 真正的流畅操作
final result = Hora.now()
    .startOf(TemporalUnit.month)      // 月初
    .addBusinessDays(10)              // 加10个工作日
    .withLocale(const HoraLocaleJa()) // 切换到日语
    .format('YYYY年M月D日');          // 格式化

// jiffy - 链式支持但功能有限
final jiffyResult = Jiffy()
    .startOf('month')
    .add(days: 10)  // 不能区分工作日
    .format('yyyy-MM-dd');  // 格式化选项有限

真实项目案例:项目管理应用

在开发一个项目管理工具时,我遇到了这样的需求:

// 需求:计算任务截止日期,排除周末和节假日
import 'package:hora/src/plugins/business_day.dart';

// 设置节假日
final usHolidays = HolidayCalendar.usCommon.merge(
  HolidayCalendar(fixedHolidays: [
    DateTime(2024, 12, 24), // 公司额外假期
  ]),
);

// 任务分配
final taskStart = Hora.of(year: 2024, month: 12, day: 20);
final deadline = taskStart.addBusinessDays(
  10,
  BusinessDayConfig(holidays: usHolidays),
);

// 生成里程碑报告
final milestones = Recurrence.weekly(
  start: taskStart,
  daysOfWeek: {DateTime.friday},
  count: 5,
).map((date) => {
  'week': date.isoWeek,
  'date': date.format('YYYY-MM-DD'),
  'deliverables': List.generate(5, (i) =>
    date.addBusinessDays(i)
  ),
});

// 这在 jiffy 中需要数百行代码来实现!

生产环境用例1:全球电商系统

// 处理不同时区的订单截止时间
import 'package:hora/src/plugins/timezone.dart';

// 订单在纽约下午6点截止
final nycTz = HoraTimezone.common['EST']!;
final orderDeadline = Hora.now()
    .withTimezone(nycTz)
    .copyWith(hour: 18, minute: 0, second: 0);

// 转换为各地时区显示
final tokyoTime = orderDeadline.inTimezone(HoraTimezone.common['JST']!);
final londonTime = orderDeadline.inTimezone(HoraTimezone.common['GMT']!);
final sydneyTime = orderDeadline.inTimezone(HoraTimezone.common['AEST']!);

// 批量处理不同时区的促销活动
final promotions = [
  {'city': 'New York', 'tz': HoraTimezone.common['EST']!},
  {'city': 'London', 'tz': HoraTimezone.common['GMT']!},
  {'city': 'Tokyo', 'tz': HoraTimezone.common['JST']!},
  {'city': 'Sydney', 'tz': HoraTimezone.common['AEST']!},
].map((loc) {
  final midnight = Hora.nowIn(loc['tz'])
      .endOf(TemporalUnit.day)
      .add(1, TemporalUnit.second);
  return {
    'city': loc['city'],
    'promoEnd': midnight.format('YYYY-MM-DD HH:mm:ss'),
    'localTime': midnight.wallClockIn(loc['tz'] as HoraTimezone),
  };
});

// jiffy 根本没有内置的时区支持!

生产环境用例2:企业财务系统

// 财务报表和季度结算
import 'package:hora/src/plugins/fiscal_year.dart';

// 不同国家的财年设置
final usGovConfig = FiscalYearConfig.usGovernment;  // 10月1日开始
final ukConfig = FiscalYearConfig.ukTax;           // 4月6日开始
final jpConfig = FiscalYearConfig.japan;           // 4月1日开始

// 生成财年报表
final now = Hora.now();
final reports = {
  'US Gov': {
    'fiscalYear': now.fiscalYearWithConfig(usGovConfig),
    'fiscalQuarter': now.fiscalQuarterWithConfig(usGovConfig),
    'period': now.fiscalPeriod(config: usGovConfig),
    'progress': '${(now.fiscalYearProgress(config: usGovConfig) * 100).toInt()}%',
    'daysRemaining': now.daysRemainingInFiscalYear(config: usGovConfig),
  },
  'UK': {
    'fiscalYear': now.fiscalYearWithConfig(ukConfig),
    'fiscalQuarter': now.fiscalQuarterWithConfig(ukConfig),
    'period': now.fiscalPeriod(config: ukConfig),
    'progress': '${(now.fiscalYearProgress(config: ukConfig) * 100).toInt()}%',
  },
  'Japan': {
    'fiscalYear': now.fiscalYearWithConfig(jpConfig),
    'fiscalQuarter': now.fiscalQuarterWithConfig(jpConfig),
    'period': now.fiscalPeriod(config: jpConfig),
  },
};

// 生成财年日历
final fyCalendar = now
    .startOfFiscalYearWithConfig(usGovConfig)
    .yearCalendar();

// jiffy:抱歉,不支持财年计算

生产环境用例3:SaaS 订阅管理

// 处理订阅周期和计费
import 'package:hora/src/plugins/recurrence.dart';

class SubscriptionPlan {
  final String id;
  final String name;
  final Recurrence billingCycle;
  final Map<String, dynamic> features;

  const SubscriptionPlan({
    required this.id,
    required this.name,
    required this.billingCycle,
    required this.features,
  });
}

// 定义订阅计划
final plans = [
  SubscriptionPlan(
    id: 'basic',
    name: 'Basic Plan',
    billingCycle: Recurrence.monthly(
      start: Hora.now(),
      interval: 1,
    ),
    features: {'seats': 5, 'storage': '100GB'},
  ),
  SubscriptionPlan(
    id: 'pro',
    name: 'Pro Plan',
    billingCycle: Recurrence.monthly(
      start: Hora.now(),
      interval: 1,
    ),
    features: {'seats': 20, 'storage': '1TB'},
  ),
  SubscriptionPlan(
    id: 'enterprise',
    name: 'Enterprise',
    billingCycle: Recurrence.yearly(
      start: Hora.now(),
      interval: 1,
    ),
    features: {'seats': -1, 'storage': 'unlimited'},
  ),
];

// 生成未来12个账期
class BillingService {
  List<Map<String, dynamic>> generateBillingSchedule(SubscriptionPlan plan) {
    return plan.billingCycle
        .take(12)
        .map((billingDate) => {
              'date': billingDate.format('YYYY-MM-DD'),
              'period_start': billingDate.format('YYYY-MM-DD'),
              'period_end': billingDate
                  .add(1, TemporalUnit.month)
                  .subtract(1, TemporalUnit.day)
                  .format('YYYY-MM-DD'),
              'days_in_period': billingDate.daysInMonth,
              'is_business_day': billingDate.isBusinessDay(),
            })
        .toList();
  }
}

// 处理试用期和付费转换
class TrialService {
  Hora calculateTrialEnd(Hora signupDate, Duration trialDuration) {
    return signupDate.add(
        Duration.inMilliseconds(trialDuration.inMilliseconds),
        TemporalUnit.day);
  }

  Hora calculateFirstBillingDate(Hora trialEnd) {
    // 确保第一个账期不是周末
    return trialEnd.addBusinessDays(1);
  }
}

// jiffy:需要手动处理所有重复逻辑和边界情况

为什么选择 Hora?

  1. 功能完整:覆盖企业级应用的各种日期时间需求
  2. 插件生态:20+ 插件,业务日、财年、时区、重复事件等
  3. 开发友好:清晰的 API 设计,丰富的示例代码
  4. 轻量高效:零外部依赖,tree-shakable,纯 Dart 实现

快速开始

dart pub add hora
import 'package:hora/hora.dart';

void main() {
  // 创建时间实例
  final now = Hora.now();

  // 时间操作
  final nextMonth = now.add(1, TemporalUnit.month);

  // 格式化输出
  print(now.format('YYYY-MM-DD HH:mm:ss'));

  // 相对时间
  final birthday = Hora.of(year: 1990, month: 6, day: 15);
  print(birthday.fromNow()); // "34 years ago"
}

迁移指南

从其他库迁移到 Hora 非常简单:

从 jiffy 迁移

// jiffy
Jiffy().add(days: 7).format('yyyy-MM-dd')

// Hora
Hora.now().add(7, TemporalUnit.day).format('YYYY-MM-DD')

从 intl 迁移

// intl
DateFormat('yyyy-MM-dd').format(DateTime.now())

// Hora
Hora.now().format('YYYY-MM-DD')

Hora vs jiffy:功能对比总览

为了让你更清楚地了解 Hora 的独特优势,这里是一个详细的对比:

🔥 jiffy 根本没有的功能:

功能类别 Hora jiffy 生产价值
业务日计算 ✅ 完整支持 ❌ 无 金融、企业应用必备
时区转换 ✅ 内置支持 ❌ 需要额外包 全球化应用必备
财年管理 ✅ 多国财年 ❌ 无 财务系统必备
重复事件 ✅ 复杂模式 ❌ 基础模式 订阅系统必备
自定义解析 ✅ 灵活配置 ❌ 有限支持 数据导入必备
日历生成 ✅ 月历/年历 ❌ 无 调度系统必备
详细时间差 ✅ 多种格式 ❌ 基础格式 分析报表必备
节假日管理 ✅ 多国日历 ❌ 无 本地化应用必备

💡 为什么这些功能很重要?

在实际的企业级应用中,你迟早会遇到这些需求:

  1. 业务日计算 - 订单处理、物流配送的时效承诺
  2. 时区处理 - 全球用户的同步操作
  3. 财年管理 - 财务报表、预算规划
  4. 重复事件 - 订续计费、定期提醒
  5. 自定义解析 - 处理各种来源的数据

jiffy 只能帮你做基础的日期操作,而 Hora 让你能够构建完整的企业级应用。

写在最后

从 jiffy 用户到 Hora 作者,这段开发经历让我学到了很多。我开始只是想解决自己在项目中的痛点,没想到最终创造了一个功能完整的日期库。

Hora 的核心设计理念其实很简单:

  1. 实用至上 - 解决真实的业务问题,而不是为了功能而功能
  2. 渐进增强 - 从简单的格式化开始,按需引入高级功能
  3. 保持简单 - 复杂的内部实现,简单的外部接口
  4. 性能优先 - 树枝摇动、零依赖,不影响应用性能

如果你也遇到了和我类似的困扰,希望 Hora 能帮你节省时间,让你专注于业务逻辑而不是日期处理的细节。

这个项目还在持续改进中,欢迎任何反馈和建议。


🎯 适用场景

  • 企业级应用:需要复杂的业务日期计算
  • 跨平台项目:Flutter Web、Mobile、Desktop 统一 API
  • 国际化产品:143+ 语言开箱即用
  • 性能敏感应用:轻量级实现,tree-shakable

🚀 立即开始

dart pub add hora

GitHub: github.com/fluttercand…
Pub.dev: pub.dev/packages/ho…
文档: 完整 API 文档

如果 Hora 对你的项目有帮助,欢迎给项目点个 ⭐️ Star,你的支持是我持续开发的动力!同时也别忘了在 Pub.dev 上点个 👍 Like,让更多开发者发现这个库!

让我们一起,用 Hora 让日期时间处理变得简单而优雅。

Vercel + Render 全栈博客部署实战指南

作者 一抹残云
2025年12月8日 20:34

从零到部署:使用 Vercel 部署前端、Render 部署后端的完整实操手册 ,其中用到的实例皆是本人个人项目实战demo,部署后前端地址


📋 目录

  1. 部署架构概览
  2. 前置准备
  3. 第一步:部署后端到 Render
  4. 第二步:初始化数据库
  5. 第三步:部署前端到 Vercel
  6. 第四步:部署后台管理到 Vercel
  7. 第五步:连接前后端
  8. 第六步:验证部署
  9. Monorepo 特殊配置说明
  10. 环境变量完整清单
  11. 免费额度说明
  12. 常见问题排查
  13. 优化建议
  14. 持续部署(CI/CD)

1. 部署架构概览

部署架构图

┌─────────────────────────────────────────────────────────────┐
│                         用户访问                              │
└──────────────────┬──────────────────┬───────────────────────┘
                   │                  │
         ┌─────────▼──────────┐  ┌───▼──────────────┐
         │   Vercel (前端)     │  │  Vercel (后台)    │
         │  blog-frontend     │  │   blog-admin     │
         │  静态资源 + SPA     │  │   管理界面        │
         └─────────┬──────────┘  └───┬──────────────┘
                   │                  │
                   │  API 请求 (/api) │
                   │                  │
         ┌─────────▼──────────────────▼──────────────┐
         │          Render (后端)                     │
         │         blog-server                       │
         │     Node.js + Express                     │
         │    (https://blog-2eqo.onrender.com)      │
         └─────────┬─────────────────────────────────┘
                   │
                   │ PostgreSQL
                   │
         ┌─────────▼─────────────────────────────────┐
         │      Render PostgreSQL                    │
         │         blog-postgres                     │
         │      Database: blog_system                │
         └───────────────────────────────────────────┘

技术栈

组件 平台 技术栈 费用
前端 Vercel React + Rsbuild + Ant Design 免费
后台管理 Vercel React + Rsbuild + Ant Design + Draft.js 免费
后端 API Render Node.js + Express + TypeScript 免费
数据库 Render PostgreSQL 16 免费

部署顺序

⚠️ 重要:必须按以下顺序部署,因为前端需要后端 API 的 URL:

  1. 后端 → Render (获取 API URL)
  2. 数据库初始化 → Render Shell
  3. 前端 → Vercel (配置 API 代理)
  4. 后台 → Vercel (配置 API 代理)
  5. 连接测试 → 更新 CORS 配置

2. 前置准备

2.1 GitHub 仓库准备

确保你的项目代码已推送到 GitHub:

cd /path/to/blog
git status
git add .
git commit -m "feat: prepare for deployment"
git push origin main

2.2 账号注册

Render 注册

  1. 访问 render.com
  2. 点击 "Get Started" → "Sign Up with GitHub"
  3. 授权 Render 访问你的 GitHub 仓库
  4. ✅ 无需信用卡,完全免费

Vercel 注册

  1. 访问 vercel.com
  2. 点击 "Sign Up" → "Continue with GitHub"
  3. 授权 Vercel 访问你的 GitHub 仓库
  4. ✅ 无需信用卡,完全免费

2.3 环境检查

确认项目包含以下文件:

# Render 配置
packages/blog-server/render.yaml              ✅
packages/blog-server/.env.render.example      ✅

# Vercel 配置
packages/blog-frontend/vercel.json            ✅
packages/blog-admin/vercel.json               ✅

# 数据库初始化
packages/blog-server/database/init.sql        ✅
packages/blog-server/scripts/init-database.ts ✅

3. 第一步:部署后端到 Render

3.1 创建 PostgreSQL 数据库

  1. 登录 Render Dashboard

  2. 点击右上角 "New +" → 选择 "PostgreSQL"

  3. 填写数据库配置:

    字段
    Name blog-postgres
    Database blog_system
    User blog_user(自动生成,可修改)
    Region 选择 Singapore(离中国最近)
    PostgreSQL Version 16(默认)
    Plan Free
  4. 点击 "Create Database"

  5. 等待约 1-2 分钟,状态变为 "Available"

  6. 保存连接信息(稍后需要):

    • 进入数据库 → "Info" 标签
    • 记录:
      • Hostname(例如:dpg-xxxx-a.singapore-postgres.render.com
      • Port(通常是 5432
      • Databaseblog_system
      • Usernameblog_user
      • Password(自动生成的长密码)

3.2 创建 Web Service(后端 API)

  1. 在 Render Dashboard,点击 "New +""Web Service"

  2. 点击 "Build and deploy from a Git repository""Next"

  3. 选择你的 GitHub 仓库 blog(如果没有显示,点击 "Configure account" 授权)

  4. 填写服务配置:

    字段 说明
    Name blog-server 服务名称
    Region Singapore 与数据库同区域
    Branch main Git 分支
    Root Directory packages/blog-server ⚠️ 重要:Monorepo 子目录
    Runtime Node 自动检测
    Build Command npm install && npm run build 安装依赖并编译 TypeScript
    Start Command npm start 启动服务
    Plan Free 免费计划
  5. 点击 "Advanced" 展开高级选项(暂不配置,稍后添加环境变量)

  6. 点击 "Create Web Service"

构建过程:约 3-5 分钟,在 "Logs" 标签查看进度

3.3 配置环境变量

构建完成后(状态显示 "Live" 或 "Deploy failed",先别管),配置环境变量:

  1. 在 Web Service 页面,点击 "Environment" 标签

  2. 点击 "Add Environment Variable",逐个添加以下变量:

基础配置

NODE_ENV=production

JWT 配置

JWT_SECRET=51de2d30c5629597bf6f41dbcf9f41beb714bb31e91adb5899d25fce64202fd9

💡 生产环境必须修改:使用以下命令生成新密钥:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_EXPIRES_IN=7d

CORS 配置(稍后更新)

FRONTEND_URL=https://your-frontend.vercel.app
ADMIN_URL=https://your-admin.vercel.app

⚠️ 注意:这两个 URL 在部署 Vercel 后需要更新为实际 URL

文件上传配置

MAX_FILE_SIZE=5242880
ALLOWED_IMAGE_TYPES=image/jpeg,image/png,image/gif,image/webp

数据库配置(从步骤 3.1 复制)

DB_HOST=<从数据库 Info 复制 Hostname>
DB_PORT=5432
DB_USER=<从数据库 Info 复制 Username>
DB_PASSWORD=<从数据库 Info 复制 Password>
DB_NAME=blog_system
  1. 点击 "Save Changes"

  2. Render 会自动触发重新部署(约 2 分钟)

3.4 查看部署日志

  1. 在 "Logs" 标签,确认看到以下内容:
🚀 Server is running on http://0.0.0.0:10000
📝 Environment: production
🗄️  Database: blog_system@dpg-xxxx.singapore-postgres.render.com:5432
  1. 如果看到错误,检查环境变量是否正确

3.5 获取 API URL

  1. 在 Web Service 顶部,复制服务 URL:

    https://blog-server-xxxx.onrender.com
    
  2. 保存这个 URL,稍后部署前端时需要

  3. 测试 API(可选):

    # 健康检查
    curl https://blog-server-xxxx.onrender.com/api/health
    
    # 获取分类(应该返回空数组,因为数据库未初始化)
    curl https://blog-server-xxxx.onrender.com/api/categories
    

4. 第二步:初始化数据库

4.1 使用 Render Shell

  1. 在 Web Service 页面,点击 "Shell" 标签

  2. 点击 "Launch Shell" 按钮

  3. 等待 Shell 启动(约 10 秒)

  4. 在 Shell 中运行初始化脚本:

    npm run db:init
    
  5. 成功后会看到:

    🚀 Starting database initialization...
     Connected to database
     Tables created successfully
     Seed data inserted successfully
     Admin user created successfully
    🎉 Database initialization complete!
    
    📝 Admin credentials:
       Username: admin
       Password: admin123
       Email: admin@blog.com
    

4.2 验证数据

在 Shell 中运行:

# 进入 PostgreSQL(如果需要)
psql $DATABASE_URL

# 查看所有表
\dt

# 查看分类
SELECT * FROM categories;

# 查看管理员
SELECT username, email FROM admin_users;

# 退出
\q

应该看到:

  • 4 张表:admin_users, categories, articles, images
  • 8 个分类(前端开发、后端开发等)
  • 1 个管理员账号

5. 第三步:部署前端到 Vercel

5.1 导入项目

  1. 登录 Vercel Dashboard

  2. 点击 "Add New..." → "Project"

  3. 选择你的 GitHub 仓库 blog

  4. 点击 "Import"

5.2 配置项目(blog-frontend)

Vercel 会自动检测到 Monorepo 结构,现在配置前端:

字段 说明
Project Name blog-frontend(可自定义) 项目名称
Framework Preset Other 不使用预设
Root Directory packages/blog-frontend ⚠️ 重要:点击 "Edit" 修改
Build Command npm install && cd packages/blog-frontend && npm run build ⚠️ Monorepo 构建命令
Output Directory ../../dist/packages/blog-frontend 构建输出目录
Install Command npm install --prefix ../.. && npm install 安装依赖

配置截图说明位置

Root Directory 配置

  1. 在 "Build and Output Settings" 下找到 "Root Directory"
  2. 点击 "Edit" 按钮
  3. 选择 packages/blog-frontend

Build Command 配置

  1. 勾选 "Override" 复选框
  2. 输入完整的 Monorepo 构建命令

5.3 配置 API 重写(重要)

⚠️ Vercel 项目配置会读取 vercel.json,但我们手动确认一下:

  1. 在部署前,确认项目根目录的 packages/blog-frontend/vercel.json 包含:
{
  "rewrites": [
    {
      "source": "/api/:path*",
      "destination": "https://blog-server-xxxx.onrender.com/api/:path*"
    },
    {
      "source": "/(.*)",
      "destination": "/index.html"
    }
  ]
}
  1. ⚠️ 重要:将 https://blog-server-xxxx.onrender.com 替换为你在步骤 3.5 获取的实际 Render URL

  2. 如果已修改,提交代码:

git add packages/blog-frontend/vercel.json
git commit -m "fix: update API URL in vercel.json"
git push origin main

5.4 部署

  1. 配置完成后,点击 "Deploy"

  2. ⏳ 构建时间:约 2-3 分钟

  3. 成功后会显示:

    ✅ Deployment Ready
    
  4. 点击 "Visit" 或复制部署 URL(例如:https://blog-frontend-xxxx.vercel.app

  5. 保存前端 URL,稍后需要更新 Render 的 CORS 配置

5.5 验证前端部署

访问前端 URL,应该看到:

  • ✅ 博客首页加载正常
  • ✅ 分类列表显示(从 Render API 获取)
  • ⚠️ 可能显示 "暂无文章"(正常,因为还没创建文章)

6. 第四步:部署后台管理到 Vercel

6.1 部署流程

与前端类似,重复步骤 5,但使用不同的配置:

  1. 在 Vercel Dashboard,点击 "Add New..." → "Project"

  2. 再次选择 blog 仓库

  3. Vercel 会提示 "This repository is already connected",选择 "Import Anyway"

  4. 配置项目:

字段
Project Name blog-admin
Framework Preset Other
Root Directory packages/blog-admin
Build Command npm install && cd packages/blog-admin && npm run build
Output Directory ../../dist/packages/blog-admin
Install Command npm install --prefix ../.. && npm install
  1. 确认 packages/blog-admin/vercel.json 中的 API URL 与前端一致

  2. 点击 "Deploy"

  3. 获取后台 URL(例如:https://blog-admin-xxxx.vercel.app

6.2 验证后台部署

访问后台 URL,应该看到:

  • ✅ 登录页面加载正常
  • ⚠️ 先不要登录(CORS 还未配置)

7. 第五步:连接前后端

7.1 更新 Render 的 CORS 配置

现在我们有了前端和后台的 URL,需要更新 Render 环境变量:

  1. 回到 Render Dashboard → blog-server 服务

  2. 进入 "Environment" 标签

  3. 更新以下变量:

    FRONTEND_URL=https://blog-frontend-xxxx.vercel.app
    
    ADMIN_URL=https://blog-admin-xxxx.vercel.app
    

    替换为你实际的 Vercel URL

  4. 点击 "Save Changes"

  5. Render 会自动重启服务(约 1 分钟)

7.2 测试 API 连接

  1. 打开浏览器控制台(F12)

  2. 访问前端 URL:https://blog-frontend-xxxx.vercel.app

  3. 在 Network 标签查看 API 请求:

    • ✅ 应该看到 /api/categories 请求成功
    • ✅ 响应头包含 Access-Control-Allow-Origin
  4. 如果看到 CORS 错误,检查:

    • Render 环境变量是否正确
    • 服务是否已重启
    • URL 是否包含 https://

7.3 验证登录功能

  1. 访问后台 URL:https://blog-admin-xxxx.vercel.app

  2. 使用默认账号登录:

    • 用户名admin
    • 密码admin123
  3. 登录成功后,应该进入 Dashboard

  4. 查看:

    • ✅ 分类列表显示
    • ✅ 文章列表为空(正常)
    • ✅ 可以创建新文章

8. 第六步:验证部署

8.1 测试前端访问

# 替换为你的前端 URL
FRONTEND_URL="https://blog-frontend-xxxx.vercel.app"

# 测试首页
curl -I $FRONTEND_URL
# 应该返回 200 OK

# 测试 API 代理
curl $FRONTEND_URL/api/categories
# 应该返回 JSON 分类列表

8.2 测试后台登录

  1. 打开浏览器控制台

  2. 访问 https://blog-admin-xxxx.vercel.app

  3. 输入账号密码,点击登录

  4. 在 Network 标签查看:

    • /api/auth/login 请求成功(200)
    • ✅ 返回 token 和用户信息
    • ✅ 自动跳转到 Dashboard

8.3 测试 API 调用

# 替换为你的后端 URL
API_URL="https://blog-server-xxxx.onrender.com"

# 测试健康检查
curl $API_URL/api/health
# 返回: {"status":"ok","timestamp":"..."}

# 测试获取分类
curl $API_URL/api/categories
# 返回: {"code":0,"message":"success","data":[...]}

# 测试登录
curl -X POST $API_URL/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123"}'
# 返回: {"code":0,"message":"Login successful","data":{"token":"...","user":{...}}}

8.4 测试图片上传

  1. 登录后台管理

  2. 进入 "文章管理" → "创建文章"

  3. 点击上传封面图片

  4. 选择一张图片(<5MB)

  5. 确认:

    • ✅ 上传成功
    • ✅ 图片预览显示
    • ✅ 发布文章后,前端可以看到图片

9. Monorepo 特殊配置说明

9.1 为什么需要特殊的构建命令?

项目使用 Nx Monorepo,所有 packages 共享根目录的 node_modules

blog/
├── node_modules/           # 根依赖
├── packages/
│   ├── blog-frontend/
│   │   ├── node_modules/   # 子项目依赖
│   │   └── package.json
│   └── blog-server/
│       └── ...
└── package.json            # 根 package.json

9.2 Vercel 构建命令解析

标准命令

npm install && cd packages/blog-frontend && npm run build

分解

  1. npm install - 安装根依赖(Nx, TypeScript 等)
  2. cd packages/blog-frontend - 进入前端目录
  3. npm run build - 构建前端(Rsbuild)

9.3 输出目录配置

为什么是 ../../dist/packages/blog-frontend

  • Vercel 从 packages/blog-frontend 目录开始
  • 输出到 ../../dist/packages/blog-frontend(相对于当前目录)
  • 实际路径:blog/dist/packages/blog-frontend

rsbuild.config.ts 配置

output: {
  distPath: {
    root: '../../dist/packages/blog-frontend'
  }
}

9.4 API 代理重写机制

vercel.json 配置

{
  "rewrites": [
    {
      "source": "/api/:path*",
      "destination": "https://blog-2eqo.onrender.com/api/:path*"
    }
  ]
}

工作原理

  1. 前端发起请求:/api/articles
  2. Vercel 拦截请求,重写为:https://blog-2eqo.onrender.com/api/articles
  3. 转发到 Render 后端
  4. 返回响应给前端

优点

  • ✅ 前端无需关心 API 域名
  • ✅ 避免 CORS 问题(同域请求)
  • ✅ 简化环境变量管理

10. 环境变量完整清单

10.1 Render 环境变量(blog-server)

变量名 说明
NODE_ENV production 生产环境标识
JWT_SECRET <随机 64 位字符串> JWT 签名密钥(必须修改)
JWT_EXPIRES_IN 7d Token 过期时间
FRONTEND_URL https://blog-frontend-xxxx.vercel.app 前端 URL(CORS)
ADMIN_URL https://blog-admin-xxxx.vercel.app 后台 URL(CORS)
DB_HOST <Render 数据库 Hostname> 数据库主机
DB_PORT 5432 数据库端口
DB_USER <Render 数据库 Username> 数据库用户
DB_PASSWORD <Render 数据库 Password> 数据库密码
DB_NAME blog_system 数据库名称
MAX_FILE_SIZE 5242880 最大文件大小(5MB)
ALLOWED_IMAGE_TYPES image/jpeg,image/png,image/gif,image/webp 允许的图片类型

10.2 Vercel 环境变量(可选)

blog-frontend 和 blog-admin

变量名 说明
VITE_API_BASE_URL <Render API URL>/api API 基础 URL(可选)

注意:由于使用了 vercel.jsonrewrites,前端可以直接用 /api 请求,无需配置环境变量。

10.3 安全性建议

生成安全的 JWT_SECRET

# 方法 1:Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# 方法 2:OpenSSL
openssl rand -hex 32

# 方法 3:在线工具
# https://www.uuidgenerator.net/

环境变量管理

  • ⚠️ 永远不要.env 提交到 Git
  • ✅ 使用 .env.example 作为模板
  • ✅ 生产环境的密钥与开发环境不同
  • ✅ 定期轮换 JWT_SECRET(需要用户重新登录)

11. 免费额度说明

11.1 Vercel 免费计划

项目 额度 说明
带宽 100 GB/月 足够个人博客使用
构建执行时间 100 GB-小时/月 约 100 次构建
部署数量 无限 每次 push 都会部署
项目数量 无限 可以部署多个项目
团队成员 1 人 免费计划仅支持个人
域名 自定义域名 支持绑定自己的域名

限制

  • 每次构建最多 45 分钟
  • 无服务器函数最多运行 10 秒
  • 环境变量最多 4KB

足够用于

  • ✅ 个人博客
  • ✅ 小型项目展示
  • ✅ 前端应用
  • ⚠️ 不适合高流量商业网站

11.2 Render 免费计划

项目 额度 说明
运行时间 750 小时/月 约 31 天,够用一整月
内存 512 MB 足够 Node.js 应用
CPU 共享 性能受限,但可接受
带宽 100 GB/月 出站流量限制
构建时间 每次 500 秒 足够 npm install + build
PostgreSQL 256 MB 存储 约可存储 10000+ 篇文章

重要限制

  • ⚠️ 服务会在 15 分钟无活动后休眠
  • ⚠️ 从休眠唤醒需要 30-50 秒(首次访问慢)
  • ⚠️ PostgreSQL 数据库 90 天未访问会被删除
  • ✅ 数据库不会休眠(始终在线)

足够用于

  • ✅ 个人博客后端
  • ✅ API 服务
  • ✅ 小型全栈应用
  • ⚠️ 不适合需要即时响应的应用

11.3 如何应对 Render 休眠?

方法 1:使用 UptimeRobot(推荐)

  1. 注册 uptimerobot.com(免费)
  2. 添加 Monitor:
    • Monitor Type: HTTP(s)
    • URL: https://blog-server-xxxx.onrender.com/api/health
    • Monitoring Interval: 5 分钟
  3. UptimeRobot 会每 5 分钟 ping 一次,保持服务唤醒

方法 2:使用 Cron-job.org

  1. 注册 cron-job.org
  2. 创建定时任务:
    • URL: https://blog-server-xxxx.onrender.com/api/health
    • Execution: 每 10 分钟

方法 3:前端定时请求(不推荐)

在前端添加定时器:

// 不推荐:消耗用户带宽
setInterval(() => {
  fetch('/api/health')
}, 5 * 60 * 1000) // 每 5 分钟

12. 常见问题排查

12.1 API 502/504 错误

症状:前端显示 "网络错误" 或 "502 Bad Gateway"

原因:Render 服务休眠,正在唤醒

解决方案

  1. 等待 30-50 秒,刷新页面

  2. 如果仍然失败,检查 Render 服务状态:

    • 进入 Render Dashboard → blog-server
    • 查看 "Logs" 是否有错误
    • 确认服务状态为 "Live"
  3. 如果服务启动失败,检查:

    # 在 Render Shell 中
    node dist/server.js
    # 查看错误信息
    

12.2 CORS 错误

症状:浏览器控制台显示:

Access to fetch at 'https://blog-xxxx.onrender.com/api/...'
from origin 'https://blog-frontend-xxxx.vercel.app' has been blocked by CORS policy

原因:Render 环境变量中的 FRONTEND_URLADMIN_URL 不正确

解决方案

  1. 检查 Render 环境变量:

    • FRONTEND_URL 必须与 Vercel 前端 URL 完全一致
    • 包含 https://
    • 不包含尾部斜杠 /
  2. 确认 CORS 配置(packages/blog-server/src/middleware/cors.ts):

    origin: [
      env.FRONTEND_URL,  // 前端 URL
      env.ADMIN_URL      // 后台 URL
    ]
    
  3. 重启 Render 服务(修改环境变量后自动重启)

12.3 Vercel 构建失败

症状:Vercel 部署失败,显示 "Build failed"

常见错误 1:找不到模块

Error: Cannot find module 'react'

解决方案

  • 检查 Build Command 是否包含 npm install
  • 确认 Root Directory 配置正确

常见错误 2:输出目录为空

Error: No Output Directory named "dist" found after the build

解决方案

  • 检查 Output Directory 配置:../../dist/packages/blog-frontend
  • 本地运行 npm run build 确认输出目录正确

常见错误 3:构建超时

Error: Command timed out after 45 minutes

解决方案

  • 检查 package.json 是否有重复依赖
  • 使用 npm ci 代替 npm install(更快)

12.4 数据库连接失败

症状:Render 日志显示:

Error: connect ETIMEDOUT

原因:数据库环境变量配置错误

解决方案

  1. 检查 Render 环境变量:

    • DB_HOSTDB_PORTDB_USERDB_PASSWORDDB_NAME
    • 与数据库 "Info" 标签的信息一致
  2. 确认数据库状态为 "Available"

  3. 测试连接(在 Render Shell):

    psql $DATABASE_URL -c "SELECT 1"
    

12.5 图片显示 404

症状:前端文章列表或详情页,封面图片显示 404

原因:数据库未初始化,或图片未上传

解决方案

  1. 确认数据库已初始化:

    # 在 Render Shell
    psql $DATABASE_URL -c "SELECT COUNT(*) FROM images"
    
  2. 如果表不存在,运行:

    npm run db:init
    
  3. 上传测试图片:

    • 登录后台管理
    • 创建文章并上传封面

12.6 登录失败

症状:输入账号密码后,提示 "用户名或密码错误"

原因:管理员账号未创建

解决方案

  1. 检查数据库中的管理员:

    # 在 Render Shell
    psql $DATABASE_URL -c "SELECT username, email FROM admin_users"
    
  2. 如果为空,重新初始化:

    npm run db:init
    
  3. 或手动创建管理员:

    npm run create-admin
    

13. 优化建议

13.1 防止 Render 休眠

使用 UptimeRobot 定期 ping API(推荐):

  1. 注册 uptimerobot.com
  2. 添加 HTTP Monitor
  3. URL: https://blog-server-xxxx.onrender.com/api/health
  4. 间隔: 5 分钟

效果

  • ✅ 服务始终保持活跃
  • ✅ 用户访问无需等待唤醒
  • ✅ 免费,无需额外成本

13.2 图片优化

问题:图片存储在数据库中,占用空间且加载慢

优化方案

  1. 压缩图片(前端上传前)

    • 使用 browser-image-compression
    • 限制宽度 1200px,质量 80%
  2. 迁移到对象存储(推荐)

    • 使用 Cloudflare R2(免费 10GB)
    • 或 AWS S3 + CloudFront
    • 数据库只存储 URL
  3. 添加图片缓存

    • Vercel 自动缓存静态资源
    • 后端添加 Cache-Control

13.3 性能监控

Vercel Analytics(免费)

  1. 进入 Vercel 项目 → "Analytics"
  2. 启用 Web Analytics
  3. 查看:
    • 页面加载时间
    • 用户访问量
    • 地理分布

Render Metrics(免费)

  1. 进入 Render 服务 → "Metrics"
  2. 查看:
    • CPU 使用率
    • 内存使用率
    • HTTP 请求量

13.4 数据库备份

Render 免费计划不提供自动备份,建议手动备份:

# 本地备份
pg_dump "<External Database URL>" > backup.sql

# 恢复
psql "<External Database URL>" < backup.sql

定期备份

  • 每周备份一次
  • 存储在 GitHub(加密)或 Google Drive

13.5 日志管理

Winston 日志配置(已集成):

// packages/blog-server/src/config/logger.ts
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.Console()
  ]
})

查看日志

  • Render Dashboard → "Logs"
  • 可以搜索、过滤、导出

14. 持续部署(CI/CD)

14.1 GitHub 集成

Vercel 自动部署

  • ✅ Push 到 main 分支 → 自动部署到生产环境
  • ✅ Pull Request → 自动部署预览环境
  • ✅ 每次部署生成唯一 URL

Render 自动部署

  • ✅ Push 到 main 分支 → 自动部署
  • ✅ 构建日志实时查看
  • ✅ 失败自动回滚到上一版本

14.2 分支策略

推荐工作流

main (生产环境)
  ↑
  └── develop (开发环境)
        ↑
        └── feature/* (功能分支)

Vercel 多环境配置

  1. 生产环境(main 分支)

    • 自动部署到 blog-frontend.vercel.app
  2. 预览环境(Pull Request)

    • 每个 PR 生成预览 URL
    • 例如:blog-frontend-git-feature-xxxx.vercel.app

14.3 版本回滚

Vercel 回滚

  1. 进入项目 → "Deployments"
  2. 找到之前的成功部署
  3. 点击 "..." → "Promote to Production"

Render 回滚

  1. 进入服务 → "Deploys"
  2. 找到之前的成功部署
  3. 点击 "..." → "Redeploy"

14.4 部署通知

Vercel 集成

  1. 项目 Settings → "Git""Deploy Hooks"
  2. 添加 Webhook URL(例如:Slack、Discord)
  3. 每次部署自动通知

Render 集成

  1. 服务 Settings → "Notifications"
  2. 添加邮箱或 Webhook
  3. 部署成功/失败自动通知

15. 总结

15.1 部署清单

完成以下所有步骤,你的全栈博客就成功部署了:

  • 创建 Render PostgreSQL 数据库
  • 部署后端到 Render
  • 配置环境变量
  • 初始化数据库
  • 部署前端到 Vercel
  • 部署后台到 Vercel
  • 更新 CORS 配置
  • 验证所有功能

15.2 最终架构

用户 → Vercel (前端/后台) → Render (API) → Render (PostgreSQL)
         ↓ 静态资源              ↓ 业务逻辑      ↓ 数据存储
       免费 100GB              免费 750h       免费 256MB

15.3 访问地址

服务 URL
前端 https://blog-frontend-xxxx.vercel.app
后台 https://blog-admin-xxxx.vercel.app
API https://blog-server-xxxx.onrender.com

15.4 下一步

  • 🎨 自定义域名(Vercel 和 Render 都支持)
  • 📊 配置 Analytics 和监控
  • 🔄 设置 UptimeRobot 防止休眠
  • 💾 定期备份数据库
  • 🚀 优化图片加载和缓存
  • 📝 创建第一篇文章

16. 参考链接


🎉 恭喜!你的全栈博客已成功部署!

如果遇到问题,请参考 常见问题排查 章节,或查看项目根目录的其他文档:

❌
❌