普通视图

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

如何从Axios平滑迁移到Alova

作者 古韵
2025年12月11日 11:59

Alova 是谁?

Alova 是一个请求工具集,它可以配合axios、fetch等请求工具实现高效的接口集成,是用来优化接口集成体验的。此外,它还能提供一些 Axios 不具备的便利功能,比如:

  • 内置缓存机制:不用自己操心数据缓存,Alova 能帮你自动管理,提升性能。
  • 请求策略灵活:可以根据不同场景设置不同的请求逻辑,比如强制刷新、依赖请求等。
  • 多框架支持:无论是 Vue、React 还是 Svelte、Solid,Alova 都能很好地集成。
  • 状态管理集成:请求的数据可以直接与组件状态关联,减少冗余代码。
  • 强大的适配器系统:支持自定义适配器,比如我们这次要讲的 —— 用 Alova 来兼容你现有的 Axios 实例!

简单来说,Alova 在保留类似 Axios 易用性的基础上,还提供了更多开箱即用的高级功能请移步到alova官方文档查看。

为什么要从 Axios 迁移到 Alova?

你可能会问:“Axios 用得好好的,为什么要迁?” 其实,迁移不一定是“非换不可”,而是一种“锦上添花”的选择。以下是一些常见的迁移动机:

  1. 希望简化复杂请求逻辑的管理:比如依赖请求、串并行控制、自动重试等,Alova 提供了更优雅的解决方案。
  2. 想要内置缓存,减少重复请求:如果你的应用有很多重复请求,Alova 的缓存机制可以显著提升性能。
  3. 多框架适配更方便:如果你在多个项目中使用不同框架,Alova 能提供一致的体验。
  4. 想逐步优化现有项目:不想大动干戈重写代码,但又想引入更现代的请求管理方式。

如果你有以上需求,那 Alova 就非常值得尝试!

从 Axios 迁移到 Alova,到底难不难?

Alova 官方提供了非常友好的 Axios 迁移方案,核心就一个字:稳。

官方迁移指南的设计理念是:

  • 最小改动:你不需要一下子重写所有代码,只需引入 Alova,然后逐步迁移。
  • 渐进迁移:一个接口一个接口地改,节奏由你掌控。
  • 保持一致性:你现有的 Axios 实例、配置、拦截器,统统都能继续用!
  • 新老共存:迁移期间,Axios 和 Alova 可以同时运行,互不干扰。

是不是听起来就很贴心?接下来,我们就来看看具体怎么操作。

从 Axios 迁移到 Alova 的具体步骤

第一步:安装 Alova 和 Axios 适配器

首先,你需要通过包管理工具安装 Alova 以及它的 Axios 适配器。

# 如果你用 npm
npm install alova @alova/adapter-axios

# 或者用 yarn
yarn add alova @alova/adapter-axios

# 或者用 pnpm
pnpm install alova @alova/adapter-axios

第二步:创建 Alova 实例,并传入你的 Axios 实例

这一步是关键,你可以直接复用你现有的 Axios 实例!

import { createAlova } from 'alova';
import { axiosRequestAdapter } from '@alova/adapter-axios';
import axiosInstance from './your-axios-instance'; // 你原来就有的 axios 实例

const alovaInst = createAlova({
  statesHook, // 这里填你项目里用的 Hook,比如 VueHook / ReactHook / SvelteHook
  requestAdapter: axiosRequestAdapter({
    axios: axiosInstance // 把你原来的 axios 实例传进去
  })
});

你啥都不用改,原来的 axios 实例、拦截器、配置全都有效!

第三步:继续使用原有 Axios 代码,不用急着改

没错,哪怕你刚刚创建了 Alova 实例,你原来的 Axios 代码照样跑得好好的!你可以慢慢来,不用急着一口气全改完。

比如这样:

const getUser = id => axios.get(`/user/${id}`); // 你原来的写法,依旧能用

当然,你也可以开始尝鲜,用 Alova 的方式发起请求:

const getUser = id => alovaInst.Get(`/user/${id}`);

// 在组件里使用(以 React 为例)
const { loading, data, error } = useRequest(getUser(userId));

第四步:逐步把 Axios 请求改写为 Alova 请求

等你熟悉了 Alova,就可以开始把原来的 axios.getaxios.post 等方法,逐步替换为 Alova 的 alovaInst.GetalovaInst.Post,非常简单:

原来的写法:

const todoList = id => axios.get('/todo');

改写为 Alova 写法:

const todoList = id => alovaInst.Get('/todo');

带参数的 POST 请求:

// 原来的写法
const submitTodo = data =>
  axios.post('/todo', data, {
    responseType: 'json',
    responseEncoding: 'utf8'
  });

// Alova 写法
const submitTodo = data =>
  alovaInst.Post('/todo', data, {
    responseType: 'json',
    responseEncoding: 'utf8'
  });

看,参数基本都能直接对应过去,写法也类似,学习成本极低!

最后

从 Axios 迁移到 Alova,其实并没有想象中那么难,甚至可以说非常平滑,尤其是对老项目的兼容性做得非常友好。

所以,如果你:

  • 已经在使用 Axios,但想尝试更现代的请求管理方式;
  • 希望引入缓存、优化请求策略、提升应用性能;
  • 想逐步优化代码,又不想一夜之间重写所有逻辑;

不妨试试 Alova,按照这个迁移指南可以轻松上手。

如果觉得alova还不错,真诚希望你可以尝试体验一下,也可以给我们来一个免费的github stars

访问alovajs的官网查看更多详细信息:alovajs官网

有兴趣可以加入我们的交流社区,在第一时间获取到最新进展,也能直接和开发团队交流,提出你的想法和建议。

React 状态管理:Zustand 快速上手指南

作者 dorisrv
2025年12月11日 11:36

React 状态管理:Zustand 快速上手指南

🤔 为什么需要 Zustand?

在 React 应用开发中,随着应用规模的扩大,组件间的状态管理会变得越来越复杂。传统的 useStateuseContext 在处理全局状态或复杂状态逻辑时,可能会遇到以下问题:

  • 状态更新复杂,需要手动处理引用比较
  • 跨组件状态共享需要多层 Context.Provider 嵌套
  • 状态逻辑难以复用和测试
  • 性能优化需要手动实现

Zustand 就是为了解决这些问题而生的!它是一个轻量级的 React 状态管理库,具有以下特点:

  • 🚀 轻量级:核心代码只有约 1KB,无外部依赖
  • 🔧 简单易用:无需 Provider 包裹整个应用
  • 🎯 灵活:支持函数式和类组件
  • 高性能:内置选择器优化,避免不必要的重新渲染
  • 🔄 异步支持:轻松处理异步状态更新
  • 📦 中间件支持:支持持久化、DevTools 等扩展功能

💡 Zustand 基础实现

1. 安装 Zustand

npm install zustand
# 或
yarn add zustand
# 或
pnpm add zustand

2. 创建 Store

Zustand 的核心是 create 函数,用于创建一个全局状态管理 store:

import { create } from 'zustand';

// 创建一个简单的计数器 store
const useCounterStore = create((set) => ({
  // 状态
  count: 0,
  // 操作状态的方法
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

export default useCounterStore;

这里的 create 函数接收一个函数作为参数,该函数返回一个包含状态和操作方法的对象。set 函数用于更新状态,它接收一个回调函数,回调函数的参数是当前状态,返回值是要更新的状态部分。

3. 在组件中使用 Store

在任何组件中,只需调用创建的 hook 即可访问和更新状态:

import React from 'react';
import useCounterStore from './store/counterStore';

const Counter = () => {
  // 直接从 store 中获取状态和方法
  const { count, increment, decrement, reset } = useCounterStore();

  return (
    <div>
      <h2>当前计数: {count}</h2>
      <div>
        +
        -
        重置
      </div>
    </div>
  );
};

export default Counter;

🚀 Zustand 进阶用法

1. 使用选择器优化性能

如果只需要 store 中的部分状态,可以使用选择器来避免不必要的重新渲染:

import React from 'react';
import useCounterStore from './store/counterStore';

const CountDisplay = () => {
  // 只订阅 count 状态,其他状态变化不会导致此组件重新渲染
  const count = useCounterStore((state) => state.count);

  return <div>当前计数: {count}</div>;
};

const CounterActions = () => {
  // 只订阅操作方法
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      +
      -
    </div>
  );
};

2. 处理异步操作

Zustand 支持直接在 store 中处理异步操作:

import { create } from 'zustand';

// 创建一个包含异步操作的 store
const useUserStore = create((set) => ({
  users: [],
  loading: false,
  error: null,
  
  // 异步获取用户列表
  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/users');
      const users = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));

export default useUserStore;

在组件中使用:

import React, { useEffect } from 'react';
import useUserStore from './store/userStore';

const UserList = () => {
  const { users, loading, error, fetchUsers } = useUserStore();

  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

3. 使用中间件

Zustand 支持中间件扩展功能,例如持久化存储和 Redux DevTools 集成:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { devtools } from 'zustand/middleware';

// 创建一个带持久化和 DevTools 的 store
const useCounterStore = create(
  devtools(
    persist(
      (set) => ({
        count: 0,
        increment: () => set((state) => ({ count: state.count + 1 })),
        decrement: () => set((state) => ({ count: state.count - 1 })),
      }),
      {
        name: 'counter-storage', // 存储的键名
        storage: localStorage, // 存储方式 (localStorage 或 sessionStorage)
      }
    )
  )
);

export default useCounterStore;

4. 组合多个 Store

Zustand 支持将多个小 store 组合成一个大 store:

import { create } from 'zustand';
import { combine } from 'zustand/middleware';

// 创建两个独立的 store
const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

const useUserStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

// 或者使用 combine 中间件组合状态
const useCombinedStore = create(
  combine(
    // 初始状态
    { count: 0, user: null },
    // 设置函数
    (set) => ({
      increment: () => set((state) => ({ count: state.count + 1 })),
      setUser: (user) => set({ user }),
    })
  )
);

📝 Zustand 最佳实践

  1. 保持 Store 简洁:每个 store 只负责管理相关的状态,避免创建过于庞大的 store
  2. 使用选择器:始终使用选择器来获取需要的状态,减少不必要的重新渲染
  3. 合理使用中间件:根据需求选择合适的中间件,避免引入不必要的依赖
  4. 处理异步错误:在异步操作中始终处理错误,避免应用崩溃
  5. 命名规范:使用 useXxxStore 的命名规范,便于识别和使用
  6. 类型安全:如果使用 TypeScript,可以为 store 添加类型定义,提高代码质量

🎯 Zustand 适用场景

Zustand 适用于以下场景:

  • 中小型 React 应用
  • 需要全局状态管理但不想使用复杂配置的项目
  • 希望减少样板代码的项目
  • 需要处理异步状态的应用
  • 希望使用轻量级状态管理库的项目

🔧 Zustand 与其他状态管理库的比较

特性 Zustand Redux Jotai
大小 ~1KB ~2KB ~2KB
学习曲线
Provider 需要
异步支持 内置 需要中间件 内置
DevTools 支持
持久化支持 需要中间件

🚀 总结

Zustand 是一个轻量级、简单易用的 React 状态管理库,它提供了丰富的功能和灵活的使用方式,同时保持了代码的简洁性。无论是中小型应用还是大型项目,Zustand 都能很好地满足状态管理的需求。

如果你正在寻找一个替代 Redux 的轻量级状态管理库,或者想要简化现有的状态管理逻辑,那么 Zustand 绝对值得一试!

下一篇文章,我们将介绍另一个优秀的 React 状态管理库——Jotai,敬请期待!✨

Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节

作者 刘大华
2025年12月11日 11:08

大家好,我是大华!这篇我们来讲解Vue2和Vue3的核心区别在哪里?

Vue3是Vue2的升级版,不仅更快,还更好用。它解决了Vue2中一些让人头疼的问题,比如动态添加属性不响应、组件必须包在一个根元素里等等。

下面通过10个常见的对比例子,让你快速看懂Vue3到底新在哪儿、好在哪儿。

1. 响应式系统:Object.defineProperty vs Proxy

Vue 2 无法监听动态添加的属性(除非用 Vue.set);Vue 3 可以直接响应。

// Vue 2 不会触发更新
this.obj.newProp = 'hello'

// Vue 2 正确方式
this.$set(this.obj, 'newProp', 'hello')

// Vue 3 直接赋值即可响应
this.obj.newProp = 'hello'

2. Composition API(组合式 API)



export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}




import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++


3. TypeScript 支持

// Vue 3 + TypeScript(能更好的支持)

interface Props {
  msg: string
}
const props = defineProps()

Vue 2 虽可通过 vue-class-componentvue-property-decorator 支持 TS,但配置复杂且类型推导弱。


4. Fragment(多根节点)



  <header>Header</header>
  <main>Main</main>




  <header>Header</header>
  <main>Main</main>


5. Teleport(传送门)

将 modal 渲染到 body 下,避免样式嵌套问题


  Open Modal
  
    <div class="modal">
      <p>Hello from Teleport!</p>
      Close
    </div>
  



import { ref } from 'vue'
const open = ref(false)

Vue 2 需手动操作 DOM 或使用第三方库(如 portal-vue)。


6. Suspense(异步组件加载)




const res = await fetch('/api/data')
const data = await res.json()



  <div>{{ data }}</div>



  
    
      
    
    
      <div>Loading...</div>
    
  

Vue 2 无原生 ``,需自行管理 loading 状态。


7. 全局 API 变更

// Vue 2
Vue.component('MyButton', MyButton)
Vue.directive('focus', focusDirective)

// Vue 3
import { createApp } from 'vue'
const app = createApp(App)
app.component('MyButton', MyButton)
app.directive('focus', focusDirective)
app.mount('#app')

Vue 3 的应用实例彼此隔离,适合微前端或多实例场景。


8. 生命周期钩子命名变化

// Vue 2
export default {
  beforeDestroy() { /* cleanup */ },
  destroyed() { /* final */ }
}

// Vue 3(Options API 写法)
export default {
  beforeUnmount() { /* cleanup */ },
  unmounted() { /* final */ }
}

// Vue 3(Composition API)
import { onBeforeUnmount, onUnmounted } from 'vue'
onBeforeUnmount(() => { /* cleanup */ })
onUnmounted(() => { /* final */ })

9. v-model 多绑定












10. 显式声明 emits(推荐)



const emit = defineEmits(['submit', 'cancel'])

const handleSubmit = () => emit('submit')




const emit = defineEmits({
  submit: (payload) => typeof payload === 'string',
  cancel: null
})

Vue 2 中 $emit 无需声明,但不利于工具链和文档生成。


这些示例覆盖了 Vue2 和 Vue3 比较关键的差异点。通过代码对比,可以更清楚地看到 Vue3 在开发体验、性能、灵活性和工程化方面有明细的提升。

结尾

总的来说,Vue3 在保持简单上手的同时,增加了更多实用又强大的功能。不管是写代码更轻松了,还是对 TypeScript、大型项目的支持更好了,都让开发者的工作变得更高效。

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

📌往期精彩

《async/await 到底要不要加 try-catch?异步错误处理最佳实践》

《如何查看 SpringBoot 当前线程数?3 种方法亲测有效》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 10 个 MySQL 高级用法,让你的代码又快又好看》

下载视频

2025年12月11日 10:53

在命令行中下载视频,最常用且强大的工具是 youtube-dl(现已更名为 yt-dlp,功能更完善),它支持绝大多数视频网站,比如 YouTube、B 站、抖音等。

以下是具体的使用方法:

一、安装 yt-dlp

yt-dlp 是跨平台工具,支持 Windows、macOS、Linux。

  1. Windows 系统

    • 直接从 yt-dlp 官方 GitHub 仓库 下载 yt-dlp.exe
    • 将其放到容易找到的目录(比如 C:\tools),并把该目录添加到系统环境变量 Path 中,这样就能在命令行任意目录调用。
  2. macOS 系统使用 Homebrew 安装:

    brew install yt-dlp
    
  3. Linux 系统Ubuntu/Debian 系列:

    sudo apt update && sudo apt install yt-dlp
    

    其他发行版可从 GitHub 下载二进制文件。

二、基本下载命令

  1. 下载单个视频复制视频的网页链接,在命令行输入:

    yt-dlp [视频链接]
    

    示例(下载 B 站视频):

    yt-dlp https://www.bilibili.com/video/BV1xx411c7mZ
    

    视频会默认下载到当前命令行的工作目录。

  2. 指定视频格式和清晰度

    • 先查看视频支持的所有格式:

      yt-dlp -F [视频链接]
      

      输出会列出格式代码、分辨率、编码等信息,比如 248 对应 480p 视频,140 对应音频。

    • 下载指定格式的视频 + 音频(会自动合并):

      yt-dlp -f [视频格式代码]+[音频格式代码] [视频链接]
      

      示例(下载 1080p 视频):

      yt-dlp -f 137+140 https://www.youtube.com/watch?v=xxxxxx
      
  3. 下载整个播放列表复制播放列表链接,添加 -i 参数(忽略错误,防止个别视频下载失败中断):

    yt-dlp -i [播放列表链接]
    

三、注意事项

  1. 版权问题:仅可下载自己拥有版权或允许下载的视频,切勿用于侵权行为。

  2. 部分网站限制:一些网站有反爬机制,可能需要更新 yt-dlp 到最新版本:

    yt-dlp -U
    
  3. 需要 FFmpeg:如果要合并视频和音频、转换格式,需要安装 FFmpegyt-dlp 会自动调用它。


是否需要我帮你整理一份yt-dlp 常用参数速查表,方便你快速查询清晰度选择、批量下载等功能?

彻底讲透浏览器的事件循环,吊打面试官

2025年12月11日 10:40

第一层:幼儿园阶段 —— 为什么要有 Event Loop?

首先要明白一个铁律JavaScript 在浏览器中是单线程的

想象一下:你是一家餐厅唯一的厨师(主线程)。

  1. 客人点了一份炒饭(同步代码),你马上炒。

  2. 客人点了一份需要炖3小时的汤(耗时任务,如网络请求、定时器)。

如果你只有这一个线程,还要死等汤炖好才能炒下一个菜,那餐厅早就倒闭了(页面卡死)。

所以,浏览器给你配了几个服务员(Web APIs,如定时器模块、网络模块)。

  • 厨师(主线程) :只负责炒菜(执行 JS 代码)。

  • 服务员(Web APIs) :负责看火炖汤(计时、HTTP请求)。汤好了,服务员把“汤好了”这个纸条贴在厨房的**任务板(队列)**上。

  • Event Loop(事件循环) :就是厨师的一个习惯——炒完手里的菜,就去看看任务板上有没有新纸条。如果有,拿下来处理。

总结:Event Loop 是单线程 JS 实现异步非阻塞的核心机制。


第二层:小学阶段 —— 宏任务与微任务的分类

任务板上的纸条分两种,优先级不同。面试官最爱问这个分类。

1. 宏任务(Macrotask / Task)

这就像是新的客人进店。每次处理完一个宏任务,厨师可能需要休息一下(浏览器渲染页面),然后再接下一个。

  • 常见的

  •  script  (整体代码 script 标签)

  •  setTimeout  /  setInterval 

  •  setImmediate  (Node.js/IE 环境)

  • UI 渲染 / I/O

  •  postMessage 

2. 微任务(Microtask)

这就像是当前客人的临时加单。客人说:“我要加个荷包蛋”。厨师必须在服务下一个客人之前,先把这个客人的加单做完。不能让当前客人等着你去服务别人。

  • 常见的

  •  Promise.then  /  .catch  /  .finally 

  •  process.nextTick  (Node.js,优先级最高)

  •  MutationObserver  (监听 DOM 变化)

  •  queueMicrotask 


第三层:中学阶段 —— 完整的执行流程(必背)

这是大多数面试题的解题公式。请背诵以下流程:

  1. 执行同步代码(这其实是第一个宏任务)。

  2. 同步代码执行完毕,Call Stack(调用栈)清空

  3. 检查微任务队列

  • 如果有,依次执行所有微任务,直到队列清空。

  • 注意:如果在执行微任务时又产生了新的微任务,会插队到队尾,本轮必须全部执行完,绝不留到下一轮。

  1. 尝试渲染 UI(浏览器会根据屏幕刷新率决定是否需要渲染,通常 16ms 一次)。

  2. 取出下一个宏任务执行。

  3. 回到第 1 步,循环往复。

口诀:同步主线程 -> 清空微任务 -> (尝试渲染) -> 下一个宏任务


第四层:大学阶段 —— 常见坑点实战(初级面试题)

这时候我们来看代码,这里有两个经典坑。

坑点 1:Promise 的构造函数是同步的

面试官常考:

new Promise((resolve) => {
    console.log(1); // 同步执行!
    resolve();
}).then(() => {
    console.log(2); // 微任务
});
console.log(3);

JavaScriptCopy

解析:Promise 构造函数里的代码会立即执行。只有  .then  里面的才是微任务。 输出:  ->   ->  

坑点 2:async/await 的阻塞

async function async1() {
    console.log('A');
    await async2(); // 关键点
    console.log('B');
}
async function async2() {
    console.log('C');
}
async1();
console.log('D');

JavaScriptCopy

解析

  1.  async1  开始,打印  

  2. 执行  async2 ,打印  

  3. 关键:遇到  await ,浏览器会把  await  后面的代码( console.log('B') )放到微任务队列里,然后跳出  async1  函数,继续执行外部的同步代码。

  4. 打印  

  5. 同步结束,清空微任务,打印  输出:  ->   ->   ->  


第五层:博士阶段 —— 深入进阶(吊打面试官专用)

1. 为什么要有微任务?(设计哲学)

你可能知道微任务比宏任务快,但为什么? 本质原因:为了确保在下次渲染之前,更新应用的状态。 如果微任务是宏任务,那么 数据更新 -> 宏任务队列 -> 渲染 -> 宏任务执行 。这会导致页面先渲染一次旧数据,然后再执行逻辑更新,导致闪屏。 微任务保证了: 数据更新 -> 微任务(更新更多状态) -> 渲染 。所有的状态变更都在同一帧内完成。

2. 微任务的死循环(炸掉浏览器)

因为微任务必须清空才能进入下一个阶段。

function loop() {
    Promise.resolve().then(loop);
}
loop();

JavaScriptCopy

后果:这会阻塞主线程!浏览器页面会卡死(点击无反应),且永远不会进行 UI 渲染。 对比:如果是  setTimeout(loop, 0)  无限递归,虽然 CPU 占用高,但浏览器依然可以响应点击,依然可以渲染页面。因为宏任务之间会给浏览器“喘息”的机会。

3. 页面渲染的时机(DOM 更新是异步的吗?)

这是一个巨大的误区。JS 修改 DOM 是同步的(内存里的 DOM 树立刻变了),但视觉上的渲染是异步的。

document.body.style.background = 'red';
document.body.style.background = 'blue';
document.body.style.background = 'black';

JavaScriptCopy

浏览器很聪明,它不会画红、画蓝、再画黑。它会等 JS 执行完,发现最后是黑色,直接画黑色。

必杀技问题:如何在宏任务执行前强制渲染? 如果你想让用户看到红色,然后再变黑,普通的  setTimeout(..., 0)  是不稳定的。 标准做法是使用  requestAnimationFrame  或者 强制回流(Reflow) (比如读取  offsetHeight )。

4. 真正的深坑:事件冒泡中的微任务顺序

这是极少数人知道的细节。

场景:父子元素都绑定点击事件。

// HTML: <div id="outer"><div id="inner">Click me</div></div>


const outer = document.querySelector('#outer');
const inner = document.querySelector('#inner');


function onClick() {
    console.log('click');
    Promise.resolve().then(() => console.log('promise'));
}


outer.addEventListener('click', onClick);
inner.addEventListener('click', onClick);

JavaScriptCopy

情况 A:用户点击屏幕

  1. 触发 inner 点击 -> 打印  click  -> 微任务入队。

  2. 栈空了! (在冒泡到 outer 之前,当前回调结束了)。

  3. 检查微任务 -> 打印  promise 

  4. 冒泡到 outer -> 打印  click  -> 微任务入队。

  5. 回调结束 -> 检查微任务 -> 打印  promise 结果: click  ->  promise  ->  click  ->  promise 

情况 B:JS 代码触发  inner.click()  

  1.  inner.click()  这是一个同步函数!

  2. 触发 inner 回调 -> 打印  click  -> 微任务入队。

  3. 栈没空! (因为  inner.click()  还在栈底等着冒泡结束)。

  4. 不能执行微任务

  5. 冒泡到 outer -> 打印  click  -> 微任务入队。

  6.  inner.click()  执行完毕,栈空。

  7. 清空微任务(此时队列里有两个 promise)。 结果: click  ->  click  ->  promise  ->  promise 

面试杀招:指出用户交互触发程序触发在 Event Loop 中的堆栈状态不同,导致微任务执行时机不同。


第六层:上帝视角 —— 浏览器的一帧(The Frame)

要理解 React 为什么要搞 Concurrent Mode,首先要看懂**“一帧”**里到底发生了什么。

大多数屏幕是 60Hz,意味着浏览器只有 16.6ms 的时间来完成这一帧的所有工作。如果超过这个时间,页面就会掉帧(卡顿)。

完整的一帧流程(标准管线):

  1. Input Events: 处理阻塞的输入事件(Touch, Wheel)。

  2. JS (Macro/Micro) : 执行定时器、JS 逻辑。这里是性能瓶颈的高发区

  3. Begin Frame: 每一帧开始的信号。

  4. requestAnimationFrame (rAF) : 关键点。这是 JS 在渲染前最后修改 DOM 的机会。

  5. Layout (重排) : 计算元素位置(盒模型)。

  6. Paint (重绘) : 填充像素。

  7. Idle Period (空闲时间) : 如果上面所有事情做完还没到 16.6ms,剩下的时间就是 Idle。

关键冲突: Event Loop 的微任务(Microtasks)是在 JS 执行完立刻执行的。如果微任务队列太长,或者 JS 宏任务太久,直接把 16.6ms 撑爆了,浏览器就没机会去执行 Layout 和 Paint。 结果就是:页面卡死。


第七层:React 18 Concurrent Mode —— 时间切片(Time Slicing)

React 15(Stack Reconciler)是递归更新,一旦开始 diff 一棵大树,必须一口气做完。如果这棵树需要 100ms 计算,那这 100ms 内主线程被锁死,用户输入无响应。

React 18(Fiber 架构)引入了 可中断渲染

1. 核心原理:把“一口气”变成“喘口气”

React 把巨大的更新任务切分成一个个小的 Fiber 节点(Unit of Work)

  • 旧模式:JS 执行 100ms -> 渲染。 (卡顿)

  • 新模式 (Concurrent)

  1. 执行 5ms 任务。

  2. 问浏览器:“还有时间吗?有高优先级任务(如用户点击)插队吗?”

  3. 有插队 -> 暂停当前 React 更新,把主线程还给浏览器去处理点击/渲染。

  4. 没插队 -> 继续下一个 5ms。

2. 实现手段:如何“暂停”和“恢复”?(MessageChannel 的妙用)

React 必须要找一个宏任务来把控制权交还给浏览器。

  • 为什么不用  setTimeout(fn, 0)  

  • 因为这货有 4ms 的最小延迟(由于 HTML 标准遗留问题,嵌套层级深了会强制 4ms)。对于追求极致的 React 来说,4ms 太浪费了。

  • 为什么不用  Microtask 

  • 死穴:微任务会在页面渲染全部清空。如果你用微任务递归,主线程还是会被锁死,根本不会把控制权交给 UI 渲染。

  • 最终选择:  MessageChannel 

  • React Scheduler 内部创建了一个  MessageChannel 

  • 当需要“让出主线程”时,React 调用  port.postMessage(null) 

  • 这会产生一个宏任务

  • 因为是宏任务,浏览器有机会在两个任务之间插入 UI 渲染响应用户输入

  • 且  MessageChannel  的延迟极低(接近 0ms),优于  setTimeout 

简化的 React Scheduler 伪代码:

let isMessageLoopRunning = false;
const channel = new MessageChannel();
const port = channel.port2;


// 这是一个宏任务回调
channel.port1.onmessage = function() {
    const currentTime = performance.now();
    let hasTimeRemaining = true;


    // 执行任务,直到时间片用完(默认 5ms)
    while (workQueue.length > 0 && hasTimeRemaining) {
        performWork(); 
        // 检查是否超时(比如超过了 5ms)
        if (performance.now() - currentTime > 5) {
            hasTimeRemaining = false;
        }
    }


    if (workQueue.length > 0) {
        // 如果还有活没干完,但时间片到了,
        // 继续发消息,把剩下的活放到下一个宏任务里
        port.postMessage(null);
    } else {
        isMessageLoopRunning = false;
    }
};


function requestHostCallback() {
    if (!isMessageLoopRunning) {
        isMessageLoopRunning = true;
        port.postMessage(null); // 触发宏任务
    }
}

JavaScriptCopy


第八层:Vue 3 的策略对比 —— 为什么 Vue 不需要 Fiber?

这是一个极好的对比视角。

  • React:走的是“全量推导”路线。组件更新时,默认不知道哪里变了,需要遍历树。为了不卡顿,只能用 Event Loop 切片。

  • Vue:走的是“精确依赖”路线。响应式系统(Proxy)精确知道是哪个组件变了。更新粒度很细,通常不需要像 React 那样长时间的计算。

Vue 的 Event Loop 应用:  nextTick Vue 依然大量使用了 Event Loop,主要是为了批量更新(Batching)

count.value = 1;
count.value = 2;
count.value = 3;

JavaScriptCopy

Vue 检测到数据变化,不会渲染 3 次。它会开启一个队列,把 Watcher 推进去。然后通过  Promise.then  (微任务) 或  MutationObserver  在本轮代码执行完后,一次性 flush 队列。

应用场景:当你修改了数据,想立刻获取更新后的 DOM 高度。

msg.value = 'Hello';
console.log(div.offsetHeight); // 还是旧高度!因为 DOM 更新在微任务里
await nextTick(); // 等待微任务执行完
console.log(div.offsetHeight); // 新高度

JavaScriptCopy


第九层:实战中的“精细化调度”

除了框架内部,我们在写复杂业务代码时,如何利用 Event Loop 管线进行优化?

1.  requestAnimationFrame  (rAF) 做动画

  • 错误做法: setTimeout  做动画。

  • 原因: setTimeout  也是宏任务,但它的执行时机和屏幕刷新(VSync)不同步。可能会导致一帧里执行了两次 JS,或者掉帧。

  • 正确做法: rAF 

  • 它保证回调函数严格在下一次 Paint 之前执行。

  • 浏览器会自动优化:如果页面切到后台,rAF 会暂停,省电。

2.  requestIdleCallback  做低优先级分析

  • 场景:发送埋点数据、预加载资源、大数据的后台计算。

  • 原理:告诉浏览器,“等我不忙了(帧末尾有剩余时间)再执行这个”。

  • 注意:React 没直接用这个 API,因为它的兼容性和触发频率不稳定,React 自己实现了一套类似的(也就是上面说的 MessageChannel 机制)。

3. 大数据列表渲染(时间切片实战)

假设后端给你返回了 10 万条数据,你要渲染到页面上。

  • 直接渲染: ul.innerHTML = list  -> 页面卡死 5 秒。

  • 微任务渲染:用 Promise 包裹 -> 依然卡死!因为微任务也会阻塞渲染。

  • 宏任务分批(时间切片)

function renderList(list) {
    if (list.length === 0) return;


    // 每次取 20 条
    const chunk = list.slice(0, 20); 
    const remaining = list.slice(20);


    // 渲染这 20 条
    renderChunk(chunk);


    // 关键:用 setTimeout 把剩下的放到下一帧(或之后的宏任务)去处理
    // 这样浏览器就有机会在中间进行 UI 渲染,用户能看到列表慢慢变长,而不是卡死
    setTimeout(() => {
        renderList(remaining);
    }, 0);
}

JavaScriptCopy

  • 进阶:使用  requestAnimationFrame  替代  setTimeout ,虽然 rAF 主要是为动画服务的,但在处理 DOM 批量插入时,配合  DocumentFragment  往往比 setTimeout 更流畅,因为它紧贴渲染管线。

第十层:未来的标准 ——  scheduler.postTask 

浏览器厂商发现大家都在自己搞调度(React 有 Scheduler,Vue 有 nextTick),于是 Chrome 推出了原生的 Scheduler API

这允许你直接指定任务的优先级,而不需要玩  setTimeout  或  MessageChannel  的黑魔法。

// 只有 Chrome 目前支持较好
scheduler.postTask(doImportantWork, { priority: 'user-blocking' }); // 高优
scheduler.postTask(doAnalytics, { priority: 'background' }); // 低优

JavaScriptCopy

总结:如何回答“实际应用场景”

如果面试官问到这里,你可以这样收网:

  1. 管线视角:先说明 JS 执行、微任务、渲染、宏任务的流水线关系。

  2. React 案例:重点描述 React 18 如何利用 宏任务 (  MessageChannel  ) 实现时间切片,从而打断长任务,让出主线程给 UI 渲染

  3. 对比 Vue:解释 Vue 利用 微任务 (  Promise  ) 实现异步批量更新,避免重复计算。

  4. 业务落地

  • 高性能动画:必用  requestAnimationFrame  保持与帧率同步。

  • 海量数据渲染:手动分片,利用  setTimeout  或  rAF  分批插入 DOM,避免白屏卡顿。

  • 后台计算/埋点:利用  requestIdleCallback  在浏览器空闲时处理。

终极回答策略:从机制到架构的四维阐述

1. 核心定性(不仅是单线程)

“Event Loop 是浏览器用来协调 JS 执行DOM 渲染用户交互 以及 网络请求 的核心调度机制。它解决了 JS 单线程无法处理高并发异步任务的问题,实现了非阻塞 I/O。”

2. 标准流程(精确到微毫秒的执行顺序)

“标准的流程是:执行栈为空 -> 清空微任务队列(Microtasks) -> 尝试进行 UI 渲染 -> 取出一个宏任务(Macrotask)执行。 这里的关键点是:微任务拥有最高优先级插队权,必须全部清空才能进入下一阶段;而UI 渲染穿插在微任务之后、宏任务之前,通常由浏览器的刷新率(60Hz)决定是否执行。”

3. 进阶:与渲染管线的结合(展示物理层面的理解)

“在性能优化中,我们要关注**‘一帧’(16.6ms)**的生命周期。 如果微任务队列太长,或者宏任务执行太久,都会阻塞浏览器的 LayoutPaint,导致掉帧。

4. 降维打击:框架原理与调度实战(这是加分项!)

“深刻理解 Event Loop 是理解现代框架源码的基石:


速记核心关键词

如果面试紧张,脑子里只要记住这 4 个关键词,就能串联起整个知识网:

  1. 单线程 (起点)

  2. 微任务清空 (Promise, Vue 原理)

  3. 渲染管线 (16ms, 动画流畅度)

  4. 宏任务切片 (React Fiber, 大数据分片)

揭秘!TinyEngine低代码源码如何玩转双向转换?

2025年12月11日 10:28

本文由TinyEngine低代码源码转换功能贡献者张珈瑜原创。

背景

当前主流低代码平台普遍采用“单向出码”模式,仅支持将 DSL(Domain Specific Language,领域特定语言)转换为 Vue 或 React 源代码。一旦开发者在生成代码后手动修改了源码,平台通常无法将这些修改同步回可视化编辑器,导致代码与可视化配置割裂,严重影响开发效率与协同维护。本项目旨在构建低代码 Vue/React 源代码到 DSL 的反向转换机制,打通可视化搭建与源码开发之间的断层,实现从 UI 配置到源码编写的无缝协同。

Vue-To-DSL 方案

目标

将 Vue 单文件组件(SFC)、整包工程或 ZIP 压缩包逆向转换为 TinyEngine 所需的 DSL Schema。

核心依赖

  • @vue/compiler-sfc / @vue/compiler-dom:解析 SFC 与模板 AST
  • @babel/parser / traverse / types:脚本 AST(支持 TS/JSX)
  • jszip:ZIP 文件读取(Node 与浏览器双端支持)
  • vue:仅用于类型对齐

数据流

0.PNG

解析流程详解

1. SFC 粗分

  • 使用 @vue/compiler-sfc.parse 获取 descriptor
  • 提取 template / script / scriptSetup / styles / customBlocks
  • 保留语言类型(如 lang=&#34;ts&#34;)和 scoped 状态

2. 模板解析(Template)

  • 使用 @vue/compiler-dom.parse 构建 AST
  • 递归生成组件树节点 { componentName, props, children }
  • 指令处理
    • v-ifcondition: JSExpression
    • v-forloop: { type: 'JSExpression', value: '...' }
    • v-model / v-show / v-on / v-bind → 映射至 props 或事件
    • v-slotslot: name
  • 文本节点
    • 纯文本 → Text 组件
    • 插值表达式 → Text + JSExpression
  • 组件名归一化
    • 优先使用 componentMap
    • HTML 原生标签保留小写
    • tiny-icon-* → 统一为 Iconname 属性设为 PascalCase 名称

3. 脚本解析(Script)

  • 使用 Babel 解析 TS/JSX
  • 组合式 API(script setup)
    • reactive() / ref()state
    • computed()computed
    • 顶层函数 → methods
    • onMounted 等 → lifecycle
  • Options API
    • 识别 data / methods / computed / props / 生命周期钩子
  • 源码恢复
    • 利用 AST 节点位置切片还原函数体
    • 箭头函数转为命名函数字符串
  • 错误处理:非 strict 模式下收集错误,不中断流程

4. 样式解析(Style)

  • 合并所有 `` 块内容
  • 记录 scopedlang
  • 提供辅助工具(不直接写入 Schema):
    • parseCSSRules:抽取 CSS 规则
    • extractCSSVariables:提取 CSS 变量
    • extractMediaQueries:媒体查询识别

5. Schema 生成与归一化

  • Page Schema
    • 根节点为 Page,自动填充 fileNamemetaid
    • 行为域统一包装为 JSFunction / JSExpression
    • 深度清理多余空白字符
  • App Schema(多页面聚合):
    • 页面:src/views/**/*.vue
    • 国际化:src/i18n/{en_US,zh_CN}.json
    • 工具函数:src/utils.js(正则解析导出项)
    • 数据源:src/lowcodeConfig/dataSource.json
    • 全局状态:src/stores/*.js(轻量识别 Pinia defineStore
    • 路由元信息:从 src/router/index.js 提取 name / path / isHome

6. 转换器接口

  • convertFromString(code, fileName?)
  • convertFromFile(filePath)
  • convertMultipleFiles(files)
  • convertAppDirectory(appDir)
  • convertAppFromZip(zipBuffer)

React-To-DSL 方案

目标

将单个 React 组件(JSX/TSX)逆向转换为 TinyEngine 可消费的 DSL(IAppSchema),当前聚焦 单文件 → 单页面/区块 场景。

核心依赖

  • @babel/parser / traverse / generator:AST 解析与代码生成
  • nanoid:生成唯一 ID

转换流程

1.PNG

关键步骤说明

1. AST 解析

  • 启用 jsx + typescript 插件
  • 定位首个返回 JSX 的函数/类组件
  • 记录 useState 初始值节点、组件定义路径

2. JSX → children 树构建

  • 组件名
    • JSXIdentifier → 直接使用
    • JSXMemberExpression → 拼接如 Form.Item
    • 兜底为 Fragment
  • Props 处理
    • 字面量 → 直接值
    • 表达式 → JSExpression
    • Spread 属性 → 特殊 key '...'
  • Children
    • 文本 → 包装为 span + props.children
    • 表达式容器:
      • 若为 arr.map(item => ) → 提取 arr 作为 loop
      • 否则 → Fragment + JSExpression

3. 表达式序列化

  • 字面量(string/number/bool/null)→ 原值
  • 对象/数组 → 递归处理,Spread 元素标记为 '...'
  • 其他表达式(函数调用、三元等)→ 源码字符串 + JSExpression

4. State 与方法提取

  • State:仅首个 useState 的初始值
  • Methods
    • 函数组件:顶层函数声明或变量赋值函数
    • 类组件:非 renderClassMethod 或箭头属性
  • 生命周期:类组件中的 componentDidMount 等白名单方法

5. 组件归一化

  • 应用 defaultComponentMap(如 FormTinyForm
  • DatabaseOutlinedIcon + props.name = 'IconPanelMini'
  • style 对象 → 转为 kebab-case: value; 字符串
  • valuemodelValue(适配 Tiny 组件)

6. Schema 装配

  • PageSchema
    • componentName: 'Page''Block'
    • meta: 默认 isHome=true, router='/'
    • children: 来自 JSX 树
    • state/methods/lifeCycles: 提取结果
  • AppSchema:包裹单个 Page,其余字段初始化为空

(本项目为开源之夏活动贡献,欢迎大家体验并使用)源码可参考:

快速上手

前置条件: 已安装 Node.js (>=18)pnpm (>=8)git,本地 8090 端口可用。

启动项目

git clone -b ospp-2025/source-to-dsl https://github.com/opentiny/tiny-engine.git
cd tiny-engine
pnpm install
pnpm dev

启动成功后,访问 http://localhost:8090/?type=app&id=1&tenant=1

可视化导入 Vue 文件

进入上方地址后,点击页面右上角的“导入”。支持三种来源:

  • 单个 .vue 文件:适合导入单页或区块。
  • 项目目录:自动识别 src/views 下的页面文件。
  • ZIP 压缩包:打包后的 Vue 项目一键导入。

导入流程(任选其一):

  1. 单个 .vue 文件
  • 选择“单页上传”,挑选本地 .vue 文件。
  • 若存在同名页面,按提示“覆盖/跳过/全部覆盖”进行处理。
  • 导入完成后,在“静态页面”列表中可见,双击打开编辑。
  1. 项目目录
  • 选择“目录上传”,指向本地项目根目录。
  • 系统自动扫描 src/views 并导入页面;遇到重名同样可选择覆盖策略。
  • 完成后,页面会按目录结构展示在左侧列表。
  1. ZIP 压缩包
  • 选择“项目压缩包”,上传打包好的 Vue 项目 zip。
  • 支持批量导入与重名处理,完成后即可在列表中浏览与打开。

选择单页vue文件上传方式进行导入:

由于已经存在CreateVm页面,弹出了提示框,需要选择是否覆盖,这里点击确定:

在静态页面列表可查看到导入的页面,双击即可点开:

选择项目目录或项目压缩包上传方式进行导入:

选择目录导入则选择本地的目录进行上传:

选择项目压缩包则选择vue项目zip进行上传:

导入时有重名文件则会弹出提示框,选择是否覆盖,这里选择全选+确定:

可以看到整个项目已经被导入到可视化编辑器了

React-To-DSL 测试用例

当前 React-To-DSL 以测试用例形态展示能力,可在包内直接运行:

cd packages/react-to-dsl
pnpm test

输入样例:查看用例中的 React 组件源码(JSX)

输出结果:测试通过时会生成/断言对应的 DSL 结构,便于对照验证

(本项目为开源之夏活动贡献,欢迎大家体验并使用)源码可参考:

结语

本项目成功实现了 Vue/React 源码 DSL 的双向转换机制,有效解决了低代码平台“单向出码”导致的协同断层问题。通过模块化解析、健壮的错误处理与灵活的组件映射策略,确保了转换的准确性与实用性。

感谢导师的悉心指导,以及 OpenTiny 社区与开源之夏活动组委会的支持,让我有机会参与这一具有实际价值的开源项目!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:
OpenTiny 代码仓库:
TinyVue 源码:
TinyEngine 源码: 
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~
如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

前端HTML导出PDF分页难题:10天踩坑后的终极方案,精细到每个像素点!!!

作者 岛风风
2025年12月11日 10:23

前端HTML导出PDF分页难题:10天踩坑后的终极方案,精细到每个像素点!!!

连续折腾10天,踩遍DOM计算、canvas渲染的各种坑,终于啃下了HTML导出PDF的分页难题。分享一套经实战验证的可靠方案,帮你避开那些让人崩溃的陷阱。

问题背景:看似简单的需求,实则全是坑

导出PDF的需求很常见,但细节往往让人头大:

  • 段落、图片、表格不能从中间劈开(总不能把一句话分到两页吧?)
  • 每页必须有统一的页眉logo和页脚页码,预留合理的空间
  • 文字内容保留合理边距,段落,表格跨页的话需要在合适而精确的位置正确分割开

技术栈选了最常用的html2canvas+jspdf,本以为照葫芦画瓢就能搞定,结果一头扎进了分页的深坑——要么元素被拦腰截断,要么在奇怪的地方插入了空白行(参考了站内其他人的方案),要么DOM和canvas渲染对不上。

方案演进:从失败中找到出路

方案一:DOM高度累加(卒于间距计算)

最初想法很直接:算好每页能放多少高度,遍历元素累加高度,超了就插空白块顶到下一页。

// 伪代码:天真的初始尝试
let currentY = 0;
for (const item of items) {
  const itemHeight = item.offsetHeight;
  const itemStartPage = Math.floor(currentY / pageHeightPx);
  const itemEndPage = Math.floor((currentY + itemHeight) / pageHeightPx);
  
  if (itemEndPage > itemStartPage) {
    // 试图插入空白块顶到下一页
    insertBlankDiv(nextPageStart - currentY);
  }
  currentY += itemHeight;
}

失败原因:太理想化了!元素的margin、padding、行间距、换行符都会影响真实位置,offsetHeight累加的结果和实际渲染位置差太远,空白块插了等于白插。

方案二:getBoundingClientRect真实位置(卒于动态变化)

改用getBoundingClientRect()获取元素相对于容器的真实坐标,理论上更准确:

const contentRect = content.getBoundingClientRect();
for (const item of items) {
  const itemRect = item.getBoundingClientRect();
  const itemTop = itemRect.top - contentRect.top; // 计算相对位置
  // ...判断是否跨页
}

失败原因:DOM是动态的!插入空白块后,后续元素的位置会整体下移,但循环不会重新计算这些变化,导致后面的判断全错。

方案三:循环遍历直到稳定(卒于DOM与canvas差异)

既然插入空白块会影响位置,那就循环检测,直到没有元素跨页为止:

let hasChanges = true;
while (hasChanges) {
  hasChanges = false;
  for (const item of items) {
    if (needsBlank(item)) {
      insertBlank(item);
      hasChanges = true;
      break; // 插入后重新检查
    }
  }
}

致命问题:DOM高度和canvas高度对不上!我测试时DOM显示18223px,canvas渲染出来却是19744px,差了1500多像素。预处理时算好的分页位置,到canvas里完全是另一个地方——这是所有DOM预处理方案的死穴。

其实还有各种缩放比例啊等问题就不一一列举了

方案四:Canvas像素扫描(终于成了!)

换个思路:既然DOM和canvas天生不一致,那就跳过DOM,直接在最终渲染的canvas上找分页点。

核心逻辑:

  1. 先生成完整的canvas(拿到最终渲染结果)
  2. 扫描canvas像素,找“空白行”(全白或接近白色的行)
  3. 在理想分页位置附近,选最近的空白行作为分割点
  4. 按分割点裁剪canvas,生成每页PDF

最终实现:像素级精准分页

第一步:检测空白行

判断一行像素是否为空白(接近白色),避免切割到内容:

/**
 * 检测canvas某一行是否为空白行
 * @param {CanvasRenderingContext2D} ctx - canvas上下文
 * @param {number} y - 行的y坐标
 * @param {number} width - canvas宽度
 * @param {number} threshold - 接近白色的阈值(0-255)
 * @returns {boolean} 是否为空白行
 */
const isBlankRow = (ctx, y, width, threshold = 250) => {
  // 获取一行的像素数据(每个像素含rgba四个值)
  const imageData = ctx.getImageData(0, y, width, 1).data;
  for (let i = 0; i < imageData.length; i += 4) {
    const r = imageData[i];
    const g = imageData[i + 1];
    const b = imageData[i + 2];
    // 只要有一个通道低于阈值,就不是空白行
    if (r < threshold || g < threshold || b < threshold) {
      return false;
    }
  }
  return true;
};

第二步:寻找最佳分割点

在理想分页位置(比如第1页结束的y坐标)附近,搜索最近的空白行:

/**
 * 寻找最佳分页分割点
 * @param {CanvasRenderingContext2D} ctx - canvas上下文
 * @param {number} idealY - 理想分割点y坐标
 * @param {number} canvasWidth - canvas宽度
 * @param {number} canvasHeight - canvas高度
 * @param {number} searchRange - 搜索范围(建议设为页面高度的15%)
 * @returns {number} 实际分割点y坐标
 */
const findBestSplitPoint = (ctx, idealY, canvasWidth, canvasHeight, searchRange) => {
  // 先向上搜索(优先把内容留在当前页)
  for (let offset = 0; offset <= searchRange; offset++) {
    const y = Math.floor(idealY - offset);
    if (y < 0) break;
    if (isBlankRow(ctx, y, canvasWidth)) {
      // 找到连续空白行的起始位置(更精准)
      let blankStart = y;
      for (let j = y - 1; j >= Math.max(0, y - 50); j--) {
        if (isBlankRow(ctx, j, canvasWidth)) {
          blankStart = j;
        } else {
          break;
        }
      }
      return blankStart;
    }
  }
  // 向上没找到,再向下搜索
  for (let offset = 1; offset <= searchRange / 2; offset++) {
    const y = Math.floor(idealY + offset);
    if (y >= canvasHeight) break;
    if (isBlankRow(ctx, y, canvasWidth)) {
      return y;
    }
  }
  // 实在没找到空白行,只能用理想位置(极端情况)
  return Math.floor(idealY);
};

第三步:切割canvas生成PDF

根据分割点裁剪canvas,每页添加页眉页脚:

// 1. 先生成完整的canvas(假设已通过html2canvas生成)
const canvas = await html2canvas(content, { /* 配置项 */ });
const ctx = canvas.getContext('2d');
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const idealPageHeight = 800; // 理想每页高度(根据PDF尺寸计算)
const searchRange = Math.floor(idealPageHeight * 0.15); // 搜索范围

// 2. 计算所有分页点
const splitPoints = [0];
let currentY = 0;
while (currentY + idealPageHeight < canvasHeight) {
  const idealNextSplit = currentY + idealPageHeight;
  const actualSplit = findBestSplitPoint(ctx, idealNextSplit, canvasWidth, canvasHeight, searchRange);
  splitPoints.push(actualSplit);
  currentY = actualSplit;
}
splitPoints.push(canvasHeight);

// 3. 生成PDF并添加每页内容
const pdf = new jspdf.jsPDF({ orientation: 'portrait', unit: 'px', format: [canvasWidth, idealPageHeight] });
const totalPages = splitPoints.length - 1;

for (let i = 0; i < totalPages; i++) {
  const pageStart = splitPoints[i];
  const pageEnd = splitPoints[i + 1];
  
  // 裁剪当前页canvas
  const pageCanvas = document.createElement('canvas');
  pageCanvas.width = canvasWidth;
  pageCanvas.height = pageEnd - pageStart;
  const pageCtx = pageCanvas.getContext('2d');
  
  // 从完整canvas复制当前页内容
  pageCtx.drawImage(
    canvas,
    0, pageStart, canvasWidth, pageEnd - pageStart, // 源区域
    0, 0, canvasWidth, pageEnd - pageStart // 目标区域
  );
  
  // 添加到PDF(第1页不需要新增页面)
  if (i > 0) pdf.addPage();
  // 添加页眉(logo)
  pdf.addImage(logoDataUrl, 'PNG', 50, 20, 100, 30);
  // 添加正文
  pdf.addImage(pageCanvas.toDataURL('image/jpeg'), 'JPEG', 0, 60, canvasWidth, pageEnd - pageStart);
  // 添加页脚(页码)
  pdf.text(`第 ${i + 1}/${totalPages} 页`, canvasWidth / 2, idealPageHeight - 20, { align: 'center' });
}

// 下载PDF
pdf.save('导出文件.pdf');

为什么这个方案能成?

  1. 绕过DOM与canvas的不一致性
    之前的方案全栽在“DOM计算位置”和“canvas实际渲染”对不上的问题上,而这个方案直接操作最终渲染的canvas,从根源上避免了这个矛盾。

  2. 像素级精准判断
    空白行检测基于真实像素,比DOM计算更可靠——哪怕内容有复杂样式,只要渲染出来是空白,就不会被误判。

  3. 自适应内容布局
    不需要提前知道元素结构,无论内容是文本、图片还是表格,只要有空白行就能找到安全分割点。

实战注意事项

  1. 性能优化
    getImageData是同步操作,长内容可能卡顿。建议采样检测(比如每10行检测一次),牺牲一点精度换速度。

  2. 阈值调整
    默认threshold=250适合白色背景,若内容有浅灰/米色背景,需调低阈值(比如230),避免误判内容为空白。

  3. 搜索范围设置
    建议设为页面高度的15%(比如页面高800px,搜索120px范围):太小可能找不到空白行,太大可能导致页面内容不均。

  4. 极端情况处理
    若内容是一整块无空白的大图/长表格,只能硬切(可在切割位置加一条分割线提示)。

总结:避开DOM的坑,直接操作最终结果

HTML导出PDF的分页问题,核心矛盾是DOM渲染逻辑canvas绘制逻辑的不一致。任何试图在DOM层面预处理的方案,都绕不开这个坑。

最可靠的思路是:跳过中间层,直接在最终渲染结果(canvas)上分析和切割。虽然多了一步像素扫描,但换来了100%的分页准确性。

如果你的项目也被PDF分页折磨,不妨试试这个方案。有更好的优化思路?欢迎评论区交流!

React Native 数据同步双重奏:深度解析“页面级聚焦”与“应用级聚焦”的区别

2025年12月11日 10:17

引言

你是否遇到过这样的情况:用户在“个人中心”修改了头像,切回“首页”时头像却没变?或者用户早上打开了你的 App,下午切回来时看到的还是早上的旧新闻?

这并不是 Bug,而是 React Native 中数据同步机制缺失的表现。为了解决这些问题,我们需要手动配置 TanStack Query 的刷新时机。但最让人头疼的是:到底该监听 Screen 聚焦还是 App 聚焦?这两者有什么区别?

接下来的内容将为你彻底拆解这两个容易混淆的概念,让你根据业务需求精准控制数据刷新。

1. Refresh on Screen Focus (页面级聚焦)

代码片段:

import React from 'react'
import { useFocusEffect } from '@react-navigation/native'
import { useQueryClient } from '@tanstack/react-query'

export function useRefreshOnFocus() {
  const queryClient = useQueryClient()
  const firstTimeRef = React.useRef(true)

  useFocusEffect(
    React.useCallback(() => {
      if (firstTimeRef.current) {
        firstTimeRef.current = false
        return
      }

      // refetch all stale active queries
      queryClient.refetchQueries({
        queryKey: ['posts'],
        stale: true,
        type: 'active',
      })
    }, [queryClient]),
  )
}
  • 触发时机: 当你在 App 内部 切换页面时。

    • 例如:你有一个底部的 Tab 栏(首页、我的)。
    • 动作:用户从“我的”页面点击 Tab 切换回“首页”。
  • 依赖库: 依赖 react-navigation (或 Expo Router) 的 useFocusEffect

  • 原理:

    • React Navigation 会在页面进入视口(Visible)时触发回调。
    • 代码逻辑是手动调用 queryClient.refetchQueries 去强制刷新指定的查询(如 ['posts'])。
  • 适用场景:

    • 用户在 Tab A 改了数据,切换到 Tab B 时,你希望 Tab B 立即更新。
    • 注意:如果不加这个,在 React Navigation 中,仅仅切换 Tab 通常不会触发组件的重新挂载(Re-mount),所以 useEffectuseQuery 的默认挂载刷新不会执行。你需要这个钩子来手动触发。

2. Refetch on App Focus (应用级聚焦)

代码片段:

import { useEffect } from 'react'
import { AppState, Platform } from 'react-native'
import type { AppStateStatus } from 'react-native'
import { focusManager } from '@tanstack/react-query'

function onAppStateChange(status: AppStateStatus) {
  if (Platform.OS !== 'web') {
    focusManager.setFocused(status === 'active')
  }
}

useEffect(() => {
  const subscription = AppState.addEventListener('change', onAppStateChange)

  return () => subscription.remove()
}, [])
  • 触发时机: 当你 切换 App 或者 锁屏/解锁 时。

    • 例如:用户正在用你的 App,突然切出去回了个微信,或者接了个电话,然后又切回你的 App。
  • 依赖库: 依赖 React Native 原生的 AppState

  • 原理:

    • 监听操作系统的状态变化(Background -> Active)。
    • 调用 focusManager.setFocused(true)。这是在告诉 TanStack Query 的全局大脑:“嘿,用户切回 App 了,你可以开始工作了。”
    • 不会指定刷新某一个 Query,而是自动检查所有处于 Active 状态且已过期(Stale)的 Query 并刷新它们。
  • 适用场景:

    • 保持数据的实时性(如社交媒体的时间流、股票价格)。
    • 防止用户长时间挂起 App 后看到过期的旧数据。

核心区别对比表

特性 Refresh on Screen Focus (第一段代码) Refetch on App Focus (第二段代码)
触发动作 切换 Tab / 页面跳转 (App 没关,只是切页面) 切换 App / 锁屏 (App 到了后台又回来)
范围 局部:通常只刷新当前页面的数据 全局:影响整个 App 所有活跃的查询
控制方式 手动:代码里写死了要刷新 ['posts'] 自动:Query 引擎根据 staleTime 自动决定刷新谁
核心依赖 @react-navigation/native react-native (AppState)
必须性 选配:取决于业务逻辑是否需要切 Tab 刷新 标配:建议在根组件配置,赋予 App 原生感知力

总结

  • 如果你想让用户从微信切回来时刷新数据,用 Refetch on App Focus(写在 _layout.tsx)。
  • 如果你想让用户点底部 Tab 切换时刷新数据,用 Refresh on Screen Focus(写在具体的页面组件里)。

最佳实践是:两者通常都需要。 全局配置负责 App 级别的活跃检测,局部 Hook 负责具体的页面交互逻辑。

我踩了 72 小时的 Electron webview PDF 灰色坑,只为告诉你:别写这行代码!

作者 梦鱼
2025年12月11日 09:57

这辈子都不想再看到那个该死的灰色背景了!

症状(你绝对中过)

  • 背景是经典 PDF 灰色,但内容永远不显示
  • 连最干净的 dummy.pdf 都只给灰色
  • plugins: true、webSecurity: false、sandbox: false、contextIsolation: false 全开了也没用
  • 甚至加了 app.commandLine.appendSwitch('enable-pdf-extension') 这发核弹都不行
  • 你已经快疯了

终极元凶(就是它!)

JavaScript

// 主进程里偷偷出现过这行代码,就是它毒死了你整个应用!
session.fromPartition('nocache', { cache: false })

只要这行代码执行过一次,整个默认 session 就被永久污染,PDF Viewer 插件直接原地去世,永不翻身!

正确做法(三步永绝后患)

第一步:立刻删除这行毒代码(必须删,别注释!)

JavaScript

// 删掉!删掉!删掉!重要的事情说三遍
// session.fromPartition('nocache', { cache: false })

第二步:重启软件(必须重启!不清毒永远灰)

被污染的默认 session 只有重启才能清掉。

第三步:以后这样写就天下太平

HTML



  style=&#34;width:100%; height:100vh;&#34;
>



>

为什么加 partition 就活了?

因为 partition="persist:pdfviewer" 给 webview 开了一个全新的、从未被 { cache: false } 污染过的、持久化的独立 session,PDF 插件终于能正常加载了!

亲测有效的终极配置(直接抄)

JavaScript

// main.js(主窗口随便怎么安全都行)
new BrowserWindow({
  webPreferences: {
    webviewTag: true,
    // contextIsolation: true 全开也没事,随便玩
  }
})

HTML



验证链接(随便点都亮)

  1. 国际标准:
  2. 国内防盗链:
  3. 企业内网签名链接:随便多长都行

删掉那行毒代码 + 加好 partition → 全部秒显示!

总结(只记这三句话)

  1. 永远不要在主进程写 session.fromPartition('xxx', { cache: false })
  2. PDF webview 必须加 partition="persist:pdfviewer"
  3. 想禁用缓存的 webview 单独加 partition="nocache"

做完这三步,从此与灰色背景永别!

把这篇文章收藏起来,半年后你会含着泪点开它,然后感谢现在的自己

(已救无数后来人,包括半年后的你)

前端架构范式:意图系统构建web

作者 alanAltman
2025年12月11日 09:57

前端架构范式:意图系统构建web

当业务复杂度呈指数级增长时,传统的前端架构开始显现瓶颈。本文通过分析 xxx Jet 框架的实战代码,探讨意图系统如何成为解决复杂前端应用架构问题的银弹。

一、前端开发的架构困境

在电商这类复杂前端应用中,我们常常面临这样的挑战:

// 传统的紧耦合代码 - 难以维护的典型案例
class ProductPage {
  async addToCart(productId) {
    // 1. 调用API
    const response = await fetch('/api/cart/add', {
      method: 'POST',
      body: JSON.stringify({ productId })
    });
    
    // 2. 更新本地状态
    this.cartCount++;
    this.updateUI();
    
    // 3. 发送分析事件
    analytics.track('add_to_cart', { productId });
    
    // 4. 检查库存
    await this.checkInventory(productId);
    
    // 5. 更新推荐
    this.updateRecommendations();
    
    // 更多业务逻辑...
    // 这个函数已经超过100行,还在继续增长
  }
}

这种意大利面条式代码的问题在于:

  • 业务逻辑分散在UI组件中
  • 难以测试和复用
  • 新增功能时容易破坏现有逻辑
  • 跨团队协作困难

二、意图系统:从"如何做"到"做什么"

意图系统的核心思想是将用户意图与具体实现分离

// Jet 框架中的意图定义
interface Intent {
  type: string;          // 意图类型
  payload?: T;           // 意图数据
  metadata?: Record;
}

// 用户操作转化为意图
const intents = {
  viewProduct: { type: 'VIEW_PRODUCT', payload: { productId: 'iphone-15' } },
  addToCart: { type: 'ADD_TO_CART', payload: { productId: 'iphone-15', quantity: 1 } },
  startCheckout: { type: 'START_CHECKOUT' }
};

// 统一调度入口
class Jet {
  async dispatch<i>(intent: I) {
    return this.runtime.dispatch(intent);
  }
}

这种转变带来了思维模式的升级:

传统方式 意图系统
命令式:"调用API,更新状态,发送分析" 声明式:"用户想要加入购物车"
关注实现:如何完成操作 关注目标:要达成什么结果
紧耦合:UI与业务逻辑绑定 松耦合:UI只声明意图

三、六大核心优势

1. 业务逻辑集中化,告别散弹式修改

// 所有业务逻辑集中在意图处理器中
class AddToCartIntentHandler {
  async handle(intent, objectGraph) {
    const { cartService, analytics, inventory } = objectGraph;
    
    // 集中化的业务逻辑
    await cartService.add(intent.payload.productId);
    await analytics.track('add_to_cart', intent.payload);
    await inventory.reserve(intent.payload.productId);
    
    // 可以轻松添加新逻辑而不影响UI
    if (this.isFirstCartAdd(intent.payload.userId)) {
      await this.showWelcomeBonus();
    }
  }
}

优势:修改业务逻辑只需改动一处,不会意外破坏UI功能。

2. 天然支持A/B测试和功能开关

// 动态切换意图处理器实现
class ExperimentAwareIntentDispatcher {
  async dispatch(intent) {
    const experiment = await this.getExperiment(intent.type);
    
    if (experiment.variant === 'A') {
      return await this.handlerA.handle(intent);
    } else {
      return await this.handlerB.handle(intent); // 新实现
    }
  }
}

// 功能开关控制
if (featureFlags.enableNewCheckout) {
  dispatcher.register('START_CHECKOUT', newEnhancedCheckoutHandler);
} else {
  dispatcher.register('START_CHECKOUT', legacyCheckoutHandler);
}

优势:无需修改UI代码即可进行实验和灰度发布。

3. 统一的路由和深层链接处理

// URL 直接映射到意图
class UrlRouter {
  async route(url) {
    const intent = this.urlToIntent(url); // /product/iphone → VIEW_PRODUCT
    const result = await jet.dispatch(intent);
    return this.intentToPage(result);
  }
}

// 支持复杂的深层链接
// /product/iphone/add-to-cart?quantity=2
// 可以统一解析为 ADD_TO_CART 意图

优势:统一处理所有导航,简化了深层链接和分享功能实现。

4. 完美的跨平台一致性

class CrossPlatformIntentHandler {
  async handle(intent, objectGraph) {
    const platform = objectGraph.platform;
    
    // 同一意图在不同平台执行适当操作
    switch(platform) {
      case 'web':
        return await this.handleWeb(intent);
      case 'ios':
        return await this.handleiOS(intent);    // 调用原生API
      case 'android':
        return await this.handleAndroid(intent); // 调用Kotlin代码
      case 'ssr':
        return await this.handleSSR(intent);    // 服务端渲染逻辑
    }
  }
}

优势:一套业务逻辑,多端统一体验。

5. 增强的可测试性和可维护性

// 单元测试变得简单直接
describe('AddToCartIntentHandler', () => {
  it('应该添加商品并发送分析', async () => {
    const mockCart = { add: jest.fn() };
    const mockAnalytics = { track: jest.fn() };
    
    const handler = new AddToCartIntentHandler();
    const intent = { type: 'ADD_TO_CART', payload: { productId: '123' } };
    
    await handler.handle(intent, { cart: mockCart, analytics: mockAnalytics });
    
    expect(mockCart.add).toHaveBeenCalledWith('123');
    expect(mockAnalytics.track).toHaveBeenCalledWith('add_to_cart', { productId: '123' });
  });
});

// 集成测试验证整个流程
describe('Checkout Flow', () => {
  it('应该完成完整的结账流程', async () => {
    const result = await testWorkflow([
      { type: 'START_CHECKOUT' },
      { type: 'SELECT_SHIPPING', payload: { method: 'express' } },
      { type: 'PROCESS_PAYMENT', payload: { card: '****1234' } },
      { type: 'PLACE_ORDER' }
    ]);
    
    expect(result.order.status).toBe('CONFIRMED');
  });
});

优势:测试覆盖率高,重构信心足。

6. 强大的监控和调试能力

class InstrumentedIntentDispatcher {
  async dispatch(intent) {
    const startTime = Date.now();
    
    // 记录意图开始
    monitoring.recordIntentStart(intent.type, intent.payload);
    
    try {
      const result = await super.dispatch(intent);
      
      // 记录成功指标
      monitoring.recordIntentSuccess(intent.type, {
        duration: Date.now() - startTime,
        payload: intent.payload
      });
      
      return result;
    } catch (error) {
      // 记录失败详情
      monitoring.recordIntentFailure(intent.type, {
        error,
        payload: intent.payload,
        duration: Date.now() - startTime
      });
      throw error;
    }
  }
}

// 开发时可视化意图流
// [用户点击] → [ADD_TO_CART意图] → [处理器执行] → [结果返回]

优势:生产环境可观测性强,开发调试直观。

四、意图系统的实战价值

场景1:快速响应业务需求变化

业务需求:"在用户首次加入购物车时显示欢迎弹窗"

// 传统方式:需要修改多个组件
// ProductPage.js, SearchResults.js, Recommendation.js...

// 意图系统:只需修改意图处理器
class EnhancedAddToCartHandler {
  async handle(intent, objectGraph) {
    const { cartService, userService, ui } = objectGraph;
    
    await cartService.add(intent.payload.productId);
    
    // 新增的业务逻辑
    const isFirstAdd = await userService.isFirstCartAdd(intent.payload.userId);
    if (isFirstAdd) {
      await ui.showWelcomeDialog();
    }
    
    return { success: true };
  }
}

场景2:复杂的电商业务流程

// 订单退货流程涉及多个部门和系统
class ReturnIntentWorkflow {
  async handle(intent) {
    // 1. 验证退货资格
    await this.dispatch({ type: 'VALIDATE_RETURN_ELIGIBILITY', payload: intent.payload });
    
    // 2. 生成退货标签
    const label = await this.dispatch({ type: 'GENERATE_RETURN_LABEL', payload: intent.payload });
    
    // 3. 安排快递取件
    await this.dispatch({ type: 'SCHEDULE_PICKUP', payload: { ...intent.payload, label } });
    
    // 4. 处理退款
    await this.dispatch({ type: 'PROCESS_REFUND', payload: intent.payload });
    
    // 5. 通知用户
    await this.dispatch({ type: 'SEND_RETURN_CONFIRMATION', payload: intent.payload });
  }
}

场景3:全球化电商的本地化处理

// 同一意图在不同地区有不同实现
class RegionalCheckoutHandler {
  async handle(intent, objectGraph) {
    const region = objectGraph.region;
    
    switch(region.country) {
      case 'US':
        // 美国:信用卡支付,快速配送
        return await this.usCheckout(intent);
        
      case 'CN':
        // 中国:支持支付宝/微信,身份证验证
        return await this.chinaCheckout(intent);
        
      case 'IN':
        // 印度:UPI支付,货到付款选项
        return await this.indiaCheckout(intent);
        
      default:
        return await this.defaultCheckout(intent);
    }
  }
}

五、何时采用意图系统?

适合的场景 ✅

  • 复杂交互应用:电商、金融、企业软件
  • 多平台产品:需要Web、App、桌面端一致体验
  • 频繁业务迭代:A/B测试、功能快速上线
  • 大规模团队协作:需要清晰的架构边界

不适合的场景 ❌

  • 简单展示页面:博客、宣传网站
  • 原型验证阶段:需要快速迭代验证想法
  • 资源有限团队:有较高的学习和实现成本

迁移策略建议

// 渐进式迁移:从最复杂的业务开始
class LegacySystemAdapter {
  async migrateGradually() {
    // 阶段1:新功能使用意图系统
    this.registerNewFeatureIntents();
    
    // 阶段2:重构复杂业务逻辑
    this.refactorCheckoutToIntents();
    
    // 阶段3:逐步迁移其他功能
    this.migrateRemainingFeatures();
    
    // 阶段4:完全转向意图架构
    this.decommissionLegacyCode();
  }
}

六、意图系统的未来演进

随着前端复杂度的持续增长,意图系统正在向更智能的方向发展:

1. AI驱动的意图预测

// AI预测用户下一步意图
class PredictiveIntentSystem {
  async predictNextIntent(userBehavior) {
    const prediction = await aiModel.predict(userBehavior);
    
    // 预加载可能需要的资源
    if (prediction.confidence > 0.8) {
      await this.prefetchIntentResources(prediction.intent);
    }
    
    return prediction;
  }
}

2. 可视化意图编排

// 低代码平台上的意图编排
const workflow = visualEditor.createWorkflow([
  { intent: 'VIEW_PRODUCT', onSuccess: 'showDetails' },
  { intent: 'ADD_TO_CART', onSuccess: 'updateCart' },
  { intent: 'START_CHECKOUT', conditions: ['cartNotEmpty'] }
]);

// 生成代码
const generatedCode = workflow.generateIntentHandlers();

3. 意图级别的性能优化

// 意图优先级调度
class PriorityIntentDispatcher {
  async dispatch(intent) {
    const priority = this.getIntentPriority(intent.type);
    
    // 高优先级意图立即执行
    if (priority === 'HIGH') {
      return await this.executeImmediately(intent);
    }
    
    // 低优先级意图批量执行
    if (priority === 'LOW') {
      return await this.batchExecute(intent);
    }
  }
}

七、结语

xxx Jet 框架选择意图系统不是偶然,而是面对电商业务复杂度的必然选择。当你的应用开始出现以下症状时,就是考虑意图架构的时候了:

  1. 新增功能需要修改多个文件
  2. 业务逻辑难以测试
  3. UI组件变得臃肿
  4. 跨平台逻辑不一致
  5. 难以添加A/B测试

意图系统不仅仅是技术架构的选择,更是一种开发哲学的转变——从关注"如何实现"转向关注"用户想要什么"。这种转变让我们能够构建更健壮、更可维护、更用户为中心的前端应用。

在日益复杂的前端生态中,意图系统为我们提供了一条清晰的路径:通过抽象化用户意图,实现业务逻辑与UI的彻底解耦,从而在快速变化的需求面前保持架构的稳定性和扩展性。

最好的架构不是最复杂的,而是最能适应变化的。意图系统正是这种适应性的体现——它让我们的前端架构能够像业务一样灵活演进。

从生活实例解释什么是AST抽象语法树

作者 小胖霞
2025年12月11日 09:54

AST(Abstract Syntax Tree,抽象语法树)  听起来很高深,但其实它的核心概念非常简单:把“文本”变成“结构化的数据对象” ,方便机器理解和操作。就是把字符串形式的代码转换成机器能看懂、能操作的结构化数据—— 你可以把它理解成:代码的 “说明书”/“骨架”

机器(比如 Babel、Vue 编译器)看不懂直接的字符串代码(比如const a = 1),但能看懂 AST 这种 “键值对 + 层级结构” 的 JSON-like 数据,从而实现「修改代码、转换代码、分析代码」。

为了让你彻底明白,我们分两步走:先看生活中的例子,再看 Vue 中的实际应用。


第一部分:生活中的例子 —— “点外卖”

假设你是个复杂的客户,你给服务员说了一句很长的话(这就是源代码 Source Code):

“我要一个牛肉汉堡,不要洋葱,加双份芝士,还要一杯可乐,去冰。”

1. 为什么需要 AST?

如果你直接把这句话扔给后厨的厨师,厨师可能听懵了,或者容易漏掉细节。计算机也是一样,它看不懂这一长串字符串,它需要一个清晰的清单

2. 生成 AST(解析过程)

前台服务员(编译器/解析器)听到这句话后,会在点餐系统里输入一张结构化的单子。这张单子就是 AST

它大概长这样:

{
  &#34;类型&#34;: &#34;订单&#34;,
  &#34;内容&#34;: [
    {
      &#34;商品&#34;: &#34;牛肉汉堡&#34;,
      &#34;配料修改&#34;: [
        { &#34;操作&#34;: &#34;移除&#34;, &#34;物品&#34;: &#34;洋葱&#34; },
        { &#34;操作&#34;: &#34;添加&#34;, &#34;物品&#34;: &#34;芝士&#34;, &#34;数量&#34;: 2 }
      ]
    },
    {
      &#34;商品&#34;: &#34;可乐&#34;,
      &#34;属性&#34;: [
        { &#34;温度&#34;: &#34;去冰&#34; }
      ]
    }
  ]
}

3. 这个例子的核心点:

  • 源代码:那句口语(字符串)。
  • AST:那张结构化的单子(JSON 对象)。
  • 作用:有了这张单子,厨师(浏览器/JS引擎)不需要去分析语法,直接看字段就能精准干活;甚至如果需要把“汉堡”换成“三明治”,改单子(修改 AST)比改口语容易得多。

二、回到代码:AST 到底解决了什么问题?

场景:你写了一行代码 const msg = 'hello',想把它改成 var message = 'hello'

  • 如果你直接改字符串:需要 “找 const→替换成 var,找 msg→替换成 message”,但代码复杂时(比如嵌套函数、多文件),手动 / 字符串替换极易出错;
  • 用 AST 改:机器先把代码转成 AST(结构化数据),再精准修改节点,最后转回代码 —— 安全、精准、可批量操作。

第一步:解析(Parse)—— 代码→AST

const msg = 'hello' 对应的 AST 简化结构:

{
  &#34;type&#34;: &#34;VariableDeclaration&#34;, // 节点类型:变量声明
  &#34;kind&#34;: &#34;const&#34;, // 变量类型:const
  &#34;declarations&#34;: [
    {
      &#34;type&#34;: &#34;VariableDeclarator&#34;,
      &#34;id&#34;: { &#34;type&#34;: &#34;Identifier&#34;, &#34;name&#34;: &#34;msg&#34; }, // 变量名:msg
      &#34;init&#34;: { &#34;type&#34;: &#34;Literal&#34;, &#34;value&#34;: &#34;hello&#34; } // 变量值:hello
    }
  ]
}

此时代码不再是字符串,而是 “变量声明节点 + 变量名节点 + 值节点” 的结构化数据,每个部分都有明确标识。

第二步:转换(Transform)—— 修改 AST

机器遍历 AST,精准修改指定节点。比如我们想把const改成varmsg改成message

// 伪代码:修改 AST 节点 
ast.kind = &#34;var&#34;; // 把const换成var
ast.declarations[0].id.name = &#34;message&#34;; // 把msg换成message

修改后的 AST

{
  &#34;type&#34;: &#34;VariableDeclaration&#34;,
  &#34;kind&#34;: &#34;var&#34;, // 已修改
  &#34;declarations&#34;: [
    {
      &#34;type&#34;: &#34;VariableDeclarator&#34;,
      &#34;id&#34;: { &#34;type&#34;: &#34;Identifier&#34;, &#34;name&#34;: &#34;message&#34; }, // 已修改
      &#34;init&#34;: { &#34;type&#34;: &#34;Literal&#34;, &#34;value&#34;: &#34;hello&#34; }
    }
  ]
}

第三步:生成(Generate)——AST→代码

把修改后的 AST 转回字符串代码,核心是 “遍历 AST 树,根据节点类型拼接代码”。我们可以写一个极简的生成函数模拟这个过程:

// 迷你AST生成器:遍历节点拼接代码
function generateCode(astNode) {
  // 处理变量声明节点
  if (astNode.type === &#34;VariableDeclaration&#34;) {
    const declarations = astNode.declarations.map(decl => {
      const name = decl.id.name;
      const value = decl.init.value;
      return `${name} = '${value}'`;
    }).join(', ');
    return `${astNode.kind} ${declarations};`;
  }
}

// 执行生成
const newCode = generateCode(ast);
console.log(newCode); // 输出:var message = 'hello';

修改后的 AST 转回字符串代码:var message = 'hello'

真实场景中,Babel、Vue 编译器会用更完善的生成器(如@babel/generator),但核心逻辑都是 “节点类型→代码片段→拼接”。

Vue 中的 AST

在 Vue 中,AST 主要用于模板编译(Template Compilation)

浏览器其实只认识 HTML、CSS 和 JS。它根本不认识 Vue 的 .vue 文件,也不认识 v-if、v-for这种语法。

Vue 需要把你的 `` 变成浏览器能运行的 render 函数,中间的桥梁就是 AST

  1. 源代码(你写的 Vue 模板)
<div id="app">
  <p>你好</p>
</div>

这就好比刚才那句“我要一个汉堡...”,对浏览器来说,这只是一串普通的字符串。

2. 解析成的 AST(Vue 内部生成的树)

Vue 的编译器会把上面的 HTML 字符串“拆解”,变成下面这样的 JavaScript 对象(简化版):

const ast = {
  // 标签类型
  tag: &#34;div&#34;,
  // 属性列表
  attrs: [{ name: &#34;id&#34;, value: &#34;app&#34; }],
  // 子节点列表
  children: [
    {
      tag: &#34;p&#34;,
      // 指令被解析成了专门的属性
      if: &#34;show&#34;, 
      children: [
        {
          type: &#34;text&#34;,
          text: &#34;你好&#34;
        }
      ]
    }
  ]
};

3. 为什么要转成 AST?(Vue 拿它干什么?)

一旦变成了上面这种树形对象,Vue 就可以对代码进行**“手术”“优化”**:

  1. 识别指令:Vue 扫描这棵树,发现 p 节点有个 if: "show"。于是它知道:生成代码时,要给这行代码加个 if (show) { ... } 的判断逻辑。
  2. 静态提升(优化性能) :Vue 3 扫描 AST,发现 "你好" 是纯文本,永远不会变。Vue 就会给它打个标记:“这块不需要每次渲染都比较,直接复用”。(如果只是看字符串,很难做这种复杂的分析)。

AST 的下一步,是生成 render 函数代码(渲染函数)。

要搞懂 AST 如何转回字符串代码,核心是理解「AST 生成器(Generator)」的工作逻辑 —— 它本质是深度遍历 AST 树,根据每个节点的类型和属性,拼接出对应的代码字符串

第一阶段:AST ➡️ Render 函数代码

这就是浏览器能“认识”的第一步:因为它变成了标准的 JavaScript 代码。

浏览器虽然不懂 <p>,但它懂 JavaScript 的 if 或者三元运算符 ? :。

举个栗子

你的 Vue 模板(源代码):

<div id="app">
  <p>你好</p>
</div>

生成的 AST(中间产物,略):
(就是一个描述结构的 JSON 对象)

AST 转换后生成的 Render 函数代码(最终产物):

Vue 的编译器会根据 AST,拼接出一段 纯 JavaScript 字符串,长得像这样(为了方便阅读,我简化了 Vue 内部的函数名):

function render() {
  // _c = createElement (创建元素)
  // _v = createTextVNode (创建文本)
  // _e = createEmptyVNode (创建空节点,用于 v-if 为 false 时)

  return _c('div', { attrs: { &#34;id&#34;: &#34;app&#34; } }, [
    // 重点看这里!v-if 被变成了 JS 的三元运算符
    (show) 
      ? _c('p', [_v(&#34;你好&#34;)]) 
      : _e()
  ])
}

这里的核心变化:

  1. HTML 标签 变成了函数调用 _c('div')。
  2. v-if="show"  消失了,变成了原生的 JS 逻辑 (show) ? ... : ...。
  3. 浏览器完全认识这段代码!  这就是一段标准的 JS 函数,里面全是函数调用和逻辑判断。

第二阶段:浏览器怎么把这段代码变成画面?

你可能会问:“浏览器运行了这个函数,然后呢?屏幕上怎么就有字了?”

这里有两个步骤:生成虚拟 DOM ➡️ 转为真实 DOM

1. 运行 Render 函数,得到 虚拟 DOM (Virtual DOM)

当 Vue 运行时(Runtime)执行上面的 render 函数时,浏览器并不会立即去画界面,而是返回一个 JS 对象树,这叫做 VNode(虚拟节点)

执行 render() 后得到的返回值:

// 这是一个纯 JS 对象,不是真实的 DOM 元素
{
  tag: 'div',
  data: { attrs: { id: 'app' } },
  children: [
    {
      tag: 'p',
      children: [{ text: '你好' }]
    }
  ]
}

为什么要多这一步?
因为操作真实 DOM(网页上的元素)非常慢,而操作 JS 对象非常快。Vue 可以在这个 JS 对象上做各种计算(比如 Diff 算法),确认没问题了,再动手改网页。

2. Patch(修补/渲染)➡️ 真实 DOM

这是最后一步。Vue 的运行时系统(Runtime)会拿着上面的 VNode,调用浏览器底层的 DOM API

这时候,浏览器才真正干活:

  • Vue 看到 tag: 'div' ➡️ 调用 document.createElement('div')
  • Vue 看到 attrs: { id: 'app' } ➡️ 调用 el.setAttribute('id', 'app')
  • Vue 看到 text: '你好' ➡️ 调用 document.createTextNode('你好')
  • 最后把它们拼在一起,挂载到页面上。

总结

  • AST 是什么?
    它是代码的骨架图。它把代码从“一行行文本”变成了“层级分明的对象”。
  • Vue 里的流程:
    template (字符串) ➡️ AST (树形对象)  ➡️ render 函数 (可执行 JS) ➡️ 虚拟 DOM ➡️ 真实 DOM。

vite创建的vue项目是通过babel还是vue自己编译器编译的

在默认的 Vite + Vue 项目中,绝大多数情况下,是不需要 Babel 的,也没有用 Babel。 它的分工是这样的:

  1. .vue 文件的编译(Template -> Render函数) :完全依靠 Vue 自己的编译器(@vue/compiler-sfc)。
  2. JS/TS 语法的转译(ES6+ -> 浏览器能跑的代码) :主要依靠 Esbuild(一个用 Go 语言写的、速度极快的构建工具)。

详细拆解:谁在干活?

为了搞清楚这个问题,我们需要把你写代码时的两个“转换”动作分开看:

1. 动作一:把 Vue 模板变成 JS 代码

也就是刚才我们聊的:v-if -> render 函数。

  • 负责工头Vue Compiler (@vue/compiler-sfc)

  • 工具链:Vite 里的插件 @vitejs/plugin-vue 会调用这个 Vue 编译器。

  • AST 产生地:这里产生的 AST 是 Vue 专有的 Template AST

  • 结论:这块跟 Babel 毫无关系。哪怕你安装了 Babel,Vue 模板编译也不归 Babel 管。

    2. 动作二:把高级 JS/TS 变成浏览器能懂的 JS

比如你用了箭头函数 () => {},或者 TypeScript 的类型标注 name: string,或者最新的 ?. 语法。

  • 传统做法(Webpack 时代)
    这是 Babel 的地盘。Webpack 会用 babel-loader 把这些新语法转成老旧的 ES5 代码,为了兼容 IE 等老浏览器。

  • 现代做法(Vite 时代)
    Vite 默认认为你不需要兼容 IE(除非你专门配置)。现代浏览器(Chrome, Edge, Firefox, Safari)都已经支持 ES6 模块了。

    • 开发环境 (npm run dev)
      Vite 使用 Esbuild 来处理 JS 和 TS。
      Esbuild 比 Babel 快 10-100 倍。因为它不需要把代码转成复杂的 ES5,只需要把 TypeScript 的类型去掉,把极少数浏览器不支持的语法微调一下即可。

    • 生产打包 (npm run build)
      Vite 使用 Rollup 进行打包,同时默认使用 Esbuild 进行代码压缩和转换。

      什么时候 Vite 才会用到 Babel?

虽然 Vite 默认不用 Babel,但在一种情况下它会把 Babel 请回来:

你需要兼容“老古董”浏览器时(比如 IE11 或旧版 Chrome)。 如果你安装了 @vitejs/plugin-legacy 插件:

// vite.config.js
import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    vue(),
    legacy({
      targets: ['ie >= 11'], // 只要你需要支持这些老家伙
      additionalLegacyPolyfills: ['regenerator-runtime/runtime']
    })
  ]
}

这时候,Vite 就会在打包时自动下载并使用 Babel,把你的现代代码狠狠地转译成 ES5,以确保在老浏览器上不报错。

总结对照表

任务 Webpack (Vue CLI) Vite (现代模式)
解析 .vue 模板 Vue Loader (调用 Vue Compiler) @vitejs/plugin-vue (调用 Vue Compiler)
JS 转译 (ES6->ES5) Babel (必装,很慢) Esbuild (内置,极快,不转 ES5)
TS 转译 Babel 或 ts-loader Esbuild (毫秒级完成)
AST 类型 Vue AST + Babel AST Vue AST + Esbuild AST

Vite 项目里:

  1. Vue 编译器 负责把  里的代码变成 render 函数(利用 Vue AST)。

  2. Esbuild 负责把你的 JS/TS 变成浏览器能运行的 JS(不做过度的向下兼容)。

  3. Babel 默认是不存在的,除非你为了兼容性专门请它出山。

    所以,Vite 快的原因之一,就是把“慢吞吞”的 Babel 给优化掉了!


结论

转换成 AST 之后的代码,就是 render 函数(JavaScript 代码)。

怎么让浏览器认识?
因为那已经是纯粹的 JavaScript 了!浏览器执行这段 JS,生成虚拟节点对象,最后 Vue 内部通过 document.createElement 等原生 API 把这些对象变成了屏幕上的像素。

你的组件 API 为什么像个垃圾场?—— React 复合组件模式 (Compound Components) 实战教学

2025年12月11日 09:34

前言:一种名为“配置地狱”的组件

接上回。咱们用 React Query 把服务端状态剥离了,用 Context 把全局状态理顺了。现在你的数据流很干净。

但是,当你打开 components 文件夹,看着那个被你改了无数次的 Tabs 组件,是不是又想骂人了?

为了满足产品经理五彩斑斓的需求,你的组件 props 越加越多,最后变成了这样:

// ❌ 典型的“配置型”组件
 }, { title: '设置', content:  }]}
  activeTab={currentTab}
  onTabChange={setCurrentTab}
  tabBarClassName=&#34;bg-gray-100&#34; // 想改 Tab 栏背景?加个 prop
  tabItemClassName=&#34;text-lg&#34;    // 想改文字大小?加个 prop
  activeTabClassName=&#34;text-blue&#34; // 想改选中态颜色?再加个 prop
  renderTabBarExtraContent={新建} // 想在右边加个按钮?又要加 prop
  tabPosition=&#34;top&#34; // 想把 Tab 放左边?还得加逻辑
/>

这就叫**“配置地狱”**。 你试图通过 props 暴露出所有的 UI 细节,结果就是这个组件变得巨臃肿,且极难复用。如果我想给第二个 Tab 加个红点(Badge),你是不是还得给 items 数组的数据结构里加个字段?

src=http___image109.360doc.com_DownloadImg_2023_04_1510_264406834_10_20230415101010334.gif&refer=http___image109.360doc.gif 兄弟,别再折磨自己了。今天我们来学学 Compound Components(复合组件模式) 。看看人家 HTML 原生标签是怎么教我们做人的。

灵感来源:向 `` 致敬

你仔细想想,原生的 `` 标签是怎么用的?

  苹果
  香蕉

你并没有传一个 options 数组给 ,而是直接把 塞到了 `` 里面。

  • `` 负责管理状态(当前选了谁)。
  • 负责渲染每一项,并且告诉 “我被点了”。

这种**“父组件管状态,子组件管渲染,通过隐式契约通信”**的模式,就是复合组件模式。


实战重构:把 Tabs 拆开

我们要把那个臃肿的 Tabs 组件,拆成 Tabs, TabList, Tab, TabPanels, Panel 这一套乐高积木。

第一步:创建上下文 (Context)

父组件需要一个地方来告诉子组件:现在的 activeTab 是谁,以及提供一个 setActiveTab 的方法。


const TabsContext = createContext(null);

// 这是一个自定义 Hook,方便子组件拿数据,顺便做个错误检查
const useTabs = () => {
  const context = useContext(TabsContext);
  if (!context) throw new Error('Tabs 子组件必须包裹在  里面!');
  return context;
};

第二步:父组件 (Tabs) —— 状态的大管家

它不负责画 UI,只负责提供 Context。

  const [selectedIndex, setSelectedIndex] = useState(defaultIndex);

  return (
    
      <div>{children}</div>
    
  );
};

第三步:子组件 (Tab & Panel) —— 真正的打工人

Tab 按钮:

  const { selectedIndex, setSelectedIndex } = useTabs();
  const isActive = selectedIndex === index;

  return (
     setSelectedIndex(index)}
    >
      {children}
    
  );
};

Panel 内容区:

  const { selectedIndex } = useTabs();
  // 只有选中时才渲染
  return selectedIndex === index ? <div>{children}</div> : null;
};

见证奇迹的时刻:调用方式

重构完之后,我们在页面里怎么用呢?


  {/* 你可以在这里随便加 div,随便写样式,完全不受 props 限制 */}
  <div>
    <div>
      用户管理
      
      {/* 居然可以给单独某一个 Tab 加红点,甚至加 Tooltip,随你便! */}
      
        系统设置 <span>●</span>
      
    </div>

    {/* 想在右边加个按钮?直接写啊!不用传什么 renderExtraContent */}
    刷新
  </div>

  <div>
    
    
  </div>

对比一下之前的代码,现在的优势在哪里?

  1. UI 结构完全解耦:你想把 Tab 列表放在下面?想把 Panel 放在上面?随便你怎么排版 HTML,组件逻辑完全不需要改。
  2. 内容随心所欲:你想在 Tab 标题里加图标?加红点?加 loading 动画?直接在 children 里写 JSX 也就是了,不需要去改组件源码。
  3. 没有 Props Drilling:状态通过 Context 隐式传递,你不用手动把 activeTab 传给每一个 Tab。

进阶技巧:这就是 Headless UI 的雏形

聪明的你可能发现了,这种模式其实就是我在前几篇提到的 Headless UI 的一种实现方式。

像著名的 UI 库 Radix UI 或者 Headless UI (Tailwind) ,全是这个路子。

  • ``
  • ``
  • ``
  • ``

它们把组件拆得稀碎,把**“怎么组合”**的权力交还给了你。

当然,这种模式也有个小缺点:代码量变多了。 以前写个 `` 只要一行,现在要写十几行。

怎么解? 你可以基于这个复合组件,再封装一层“傻瓜式”组件给没特殊需求的场景用。但是底层的实现,一定要保持这种灵活性。


总结

当你发现你的组件需要接受 xxxStyle, xxxClassName, renderXxx 这种 props 的时候,请立刻停下来。

这说明你在试图控制你控制不了的事情(外部的 UI 展示)。

把控制权交出去。用 Compound Components 模式,让使用者像拼乐高一样组装你的组件。 你会发现,你再也不用因为设计稿改了一个 margin 或者加了一个 icon 而去改组件源码了。这才是真正的高内聚、低耦合

好了,我要去把那个传了 20 个 props 的 Modal 组件给拆了,祝大家的组件 API 永远性感。

lg_90841_1619336946_60851ef204362.png


下期预告:Tabs 切换是搞定了,但有个问题:用户刷新页面后,又回到了第一个 Tab,辛辛苦苦填的表单也没了。 还有,我想把“当前在第二个 Tab”这个状态分享给同事,怎么做? 下一篇,我们来聊聊 “URL 即状态 (URL as State)” 。教你如何把 React 状态同步到 URL 参数里,让你的应用拥有“记忆”。

在langchain Next 项目中使用 shadcn/ui 的记录

作者 百罹鸟
2025年12月11日 09:22

观瞻:作为一个接触ai-agent框架不久的开发者,最近在学习 Next + LangChain 技术栈时,决定做一个 AI 聊天 demo 来巩固知识。在这个过程中尝试了多个 UI 框架,最终选择了 shadcn/ui。记录下这段学习经历,希望未来能查漏补缺。

起因:为什么选择 shadcn/ui?

现如今ui框架百花齐放,不过对组件库的选择还是有点困惑:

  • Ant Design?感觉太重了,而且主要是面向后台系统的
  • MUI?React 生态里很火,但配置主题有点复杂
  • Tailwind CSS?听说很流行,但自己写组件太费时间

第一个坑:它根本不是 npm 包!

按照网上教程,我兴冲冲地运行:

bash
编辑
1pnpm add shadcn-ui

结果发现根本找不到这个包!一脸懵逼的我跑去 GitHub 看文档,才发现:

shadcn/ui 不是一个传统的组件库,而是一套可以复制到你项目里的组件代码

这完全颠覆了我对 "UI 框架" 的认知。以前用 Ant Design 或 Element UI,都是 npm install 就完事了,但 shadcn/ui 却要你把每个组件的源码直接放进自己的项目里。

一开始我觉得这很麻烦,但后来发现这其实是它的最大优势。

正确的安装姿势

第一步:初始化项目

首先要在你的 Next.js 项目根目录运行:

bash
编辑
1pnpm dlx shadcn@latest init

这个命令会问你几个问题,比如是否使用 TypeScript、Tailwind CSS 配置路径等。我的项目已经配置好了 Tailwind,所以一路回车就行。

第二步:按需添加组件

这里又踩了个小坑。我一开始以为要一个一个添加:

bash
编辑
1# 错误的想法
2pnpm dlx shadcn@latest add button
3pnpm dlx shadcn@latest add input
4pnpm dlx shadcn@latest add card
5# ... 还要输入7

后来发现其实可以一次性添加所有需要的组件:

bash
编辑
1pnpm dlx shadcn@latest add button input card scroll-area spinner toast separator label

注意:一定要用 pnpm dlx,而不是直接 pnpm shadcn,否则终端会报错找不到命令。

第三步:看看生成了什么

运行完命令后,我发现项目里多了一个 components/ui/ 目录,里面是我刚才添加的所有组件文件:

text
编辑
1components/
2└── ui/
3    ├── button.tsx
4    ├── input.tsx
5    ├── card.tsx
6    ├── scroll-area.tsx
7    ├── spinner.tsx
8    ├── toast.tsx
9    ├── separator.tsx
10    └── label.tsx

每个文件都是独立的,我可以随时打开修改,比如我想把按钮的圆角改大一点,直接编辑 button.tsx 就行,不用去查文档找怎么覆盖样式。

为什么我觉得 shadcn/ui 特别适合聊天应用?

做聊天应用有几个特殊需求:

  1. 消息气泡要区分用户和 AI
  2. 输入框要有发送按钮
  3. 聊天历史要能滚动
  4. AI 回复时要有加载状态

shadcn/ui 的这几个组件完美解决了这些问题:

消息气泡(Card 组件)

tsx
编辑
1// components/message-bubble.tsx
2import { Card, CardContent } from &#34;@/components/ui/card&#34;;
3
4export function MessageBubble({ isUser = false, content }: { isUser: boolean; content: string }) {
5  return (
6    
7      
8        <p>{content}</p>
9      
10    
11  );
12}

聊天输入区域(Input + Button + Spinner)

tsx
编辑
1// components/chat-input.tsx
2import { Input } from &#34;@/components/ui/input&#34;;
3import { Button } from &#34;@/components/ui/button&#34;;
4import { Spinner } from &#34;@/components/ui/spinner&#34;;
5
6export function ChatInput({ message, setMessage, onSubmit, isLoading }: any) {
7  return (
8    <div>
9       setMessage(e.target.value)}
12        placeholder=&#34;输入消息...&#34;
13        onKeyDown={(e) => e.key === 'Enter' && onSubmit()}
14        disabled={isLoading}
15      />
16      
17        {isLoading ?  : &#34;发送&#34;}
18      
19    </div>
20  );
21}

聊天历史滚动(ScrollArea)

tsx
编辑
1// app/page.tsx
2import { ScrollArea } from &#34;@/components/ui/scroll-area&#34;;
3
4export default function ChatPage() {
5  // ... 其他逻辑
6  
7  return (
8    <div>
9      
10        {messages.map((msg, i) => (
11          
12        ))}
13      
14      
15    </div>
16  );
17}

主题定制:比想象中简单

最让我惊喜的是主题定制。因为我的项目已经用了 Tailwind CSS,所以只需要在 tailwind.config.js 里配置颜色:

js
编辑
1// tailwind.config.js
2module.exports = {
3  theme: {
4    extend: {
5      colors: {
6        primary: {
7          500: &#34;#60a5fa&#34;, // 蓝色 - 用户消息
8        },
9        secondary: {
10          500: &#34;#22c55e&#34;, // 绿色 - AI 消息
11        }
12      }
13    }
14  }
15}

然后在组件里直接用 bg-primary-500bg-secondary-500 就行了,完全不需要额外的配置。

遇到的问题和解决方案

问题1:DevTools 小图标太烦人

Next.js 16 开发模式右下角有个小图标,特别影响调试。试了很多方法都不行,最后用 CSS 强制隐藏:

tsx
编辑
1// app/layout.tsx
2if (typeof window !== &#34;undefined&#34; && process.env.NODE_ENV === &#34;development&#34;) {
3  const hideDevtools = () => {
4    const style = document.createElement(&#34;style&#34;);
5    style.innerHTML = `
6      #devtools-indicator,
7      .nextjs-toast {
8        display: none !important;
9      }
10    `;
11    document.head.appendChild(style);
12  };
13  
14  if (document.readyState === &#34;loading&#34;) {
15    document.addEventListener(&#34;DOMContentLoaded&#34;, hideDevtools);
16  } else {
17    hideDevtools();
18  }
19}

问题2:组件太多不知道选哪些

一开始我也想把所有组件都装上,后来发现完全没必要。对于聊天应用,其实就那几个核心组件:

  • Button:发送按钮
  • Input:消息输入
  • Card:消息气泡
  • ScrollArea:聊天滚动
  • Spinner:加载状态

其他像 Toast、Separator、Label 这些是提升体验用的,可以根据需要再加。

总结:shadcn/ui 到底值不值得用?

作为一个初学者,我觉得 shadcn/ui 有这些优点:

学习成本低:基于 Tailwind CSS,样式直观易懂
高度可定制:组件代码就在你项目里,想怎么改都行
按需使用:不会引入没用的代码,包体积小
Next.js 友好:完美支持 React Server Components

当然也有缺点: ❌ 不是传统 npm 包:需要适应新的使用方式
需要手动管理:组件更新需要自己同步官方代码

但总的来说,如果你在用 Next.js + Tailwind CSS,shadcn/ui 绝对值得一试。特别是做聊天应用这种需要高度定制 UI 的场景,它能让你快速搭建出专业级的界面。

最后的建议

  1. 不要试图一次装所有组件,按需添加就好
  2. 善用 Tailwind 的主题配置,让整个应用风格统一
  3. 把组件当成自己的代码,大胆修改以适应需求
  4. 遇到问题先看官方文档,shadcn/ui 的文档写得非常清晰

Webpack vs Vite 全方位对比:原理、配置、场景一次讲透

作者 ycgg
2025年12月11日 09:05

在前端工程化领域,Webpack 和 Vite 是最主流的两大构建工具,但两者的设计理念和使用体验天差地别。本文将从 核心原理、关键差异、代码示例、选型建议 四个维度,用通俗易懂的语言帮你彻底搞懂它们的区别,避开选型和配置坑。

一、核心定位:两种完全不同的构建思路

先一句话分清两者的核心差异:

  • Webpack:「全场景打包工具」—— 不管开发还是生产,都先把所有模块(JS/TS/CSS/图片)打包成 bundle 文件,兼容所有场景但开发体验一般。
  • Vite:「现代前端构建工具」—— 开发时不打包,生产时轻量打包,主打「快到飞起的开发体验」,专为现代浏览器设计。

可以用一个比喻理解:

  • Webpack 像「自助餐提前备好所有菜」:不管你吃不吃,先把所有食材(模块)加工好(打包),优点是能满足所有人需求,缺点是准备时间长。
  • Vite 像「餐厅点单后现做」:开发时只搭好厨房(服务器),你点什么菜(请求什么模块)才现做(即时转译),优点是等待时间短,缺点是只支持“现代食客”(现代浏览器)。

二、核心原理:为什么 Vite 比 Webpack 快?

1. Webpack 原理:全量打包,一步到位

Webpack 的核心逻辑是「提前打包所有模块」,无论开发还是生产环境,流程都一致:

  1. 从入口文件(如 src/index.js)开始,递归解析所有依赖(import/require),构建「依赖关系图」;
  2. 通过 Loader 转译非 JS 模块(如 TS→JS、CSS→JS);
  3. 通过 Plugin 扩展功能(如生成 HTML、压缩代码);
  4. 把所有模块合并成一个或多个 bundle.js,输出到 dist 目录。

开发时的痛点
即使只改一行代码,Webpack 也可能需要重新解析整个依赖链、重新打包,导致「冷启动慢」「热更新(HMR)延迟」,项目越大越明显。

2. Vite 原理:非打包开发 + 轻量生产打包

Vite 拆分了「开发时」和「生产时」的逻辑,核心是「利用现代浏览器原生支持 ES 模块(ESM)」:

  • 开发时(非打包)

    1. 启动一个开发服务器,不提前打包任何模块;
    2. 浏览器请求入口文件(index.html)时,Vite 动态改写模块引用,让浏览器通过 ESM 原生加载模块;
    3. 当浏览器请求某个模块(如 src/App.tsx)时,Vite 用 ESBuild(Go 语言编写,速度是 Babel 的 10-100 倍)即时转译模块,返回给浏览器;
    4. 热更新时,仅更新修改的模块,无需重新构建整个依赖链。
  • 生产时(轻量打包): 生产环境下,为了兼容更多场景和优化性能,Vite 会切换到「打包模式」,但不自己实现打包逻辑,而是复用 Rollup(比 Webpack 打包更高效、产物更小),最终输出优化后的静态资源。

核心优势
开发时跳过全量打包,依赖 ESM 原生加载和 ESBuild 转译,冷启动和热更新速度远超 Webpack。

三、关键差异对比表(一目了然)

对比维度 Webpack Vite
构建模式(开发时) 全量打包(解析所有依赖→生成 bundle) 非打包(ESM 原生加载+即时转译)
构建模式(生产时) 自身打包引擎(支持多入口、代码分割) 基于 Rollup 打包(产物更小、Tree-shaking 更优)
冷启动速度 慢(项目越大越慢) 极快(仅启动服务器,不打包)
热更新(HMR)速度 中等→慢(需重新构建模块链) 极快(仅更新修改的模块)
转译工具 依赖 Loader(如 babel-loader、ts-loader) 内置 ESBuild(无需手动配置)
配置复杂度 高(需区分 Loader/Plugin,配置繁琐) 低(零配置启动,按需扩展)
生态特点 庞大且封闭(仅支持 Webpack 专属插件/Loader) 简洁且开放(兼容大部分 Rollup 插件)
兼容性 强(支持 IE11 等老浏览器、CJS/ESM 模块) 现代浏览器优先(开发时依赖 ESM,可通过插件兼容 IE11)
核心扩展方式 Loader(转译模块)+ Plugin(流程扩展) 单一插件体系(通过钩子覆盖转译+流程扩展)
适用场景 大型复杂项目、老项目迁移、需兼容老浏览器 新项目、中中小型应用、Vue/React 生态、追求开发效率

四、代码示例对比(核心配置+脚本)

下面通过「React + TS 项目」的核心配置,对比两者的差异,感受 Vite 的简洁。

1. 项目初始化 + 开发/打包脚本

Webpack 项目

# 1. 初始化项目
npm init -y

# 2. 安装依赖(一堆 Loader 和 Plugin)
npm install -D webpack webpack-cli babel-loader @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript html-webpack-plugin webpack-dev-server typescript ts-loader

# 3. package.json 脚本
{
  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;webpack serve&#34;,  // 开发服务
    &#34;build&#34;: &#34;webpack --mode production&#34;  // 生产打包
  }
}

Vite 项目

# 1. 初始化项目(一键生成,自带 React+TS 配置)
npm create vite@latest my-vite-app -- --template react-ts

# 2. 安装依赖(仅需安装项目依赖,无需手动装转译工具)
cd my-vite-app && npm install

# 3. package.json 脚本(自带)
{
  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;vite&#34;,  // 开发服务
    &#34;build&#34;: &#34;tsc && vite build&#34;,  // 类型检查+生产打包
    &#34;preview&#34;: &#34;vite preview&#34;  // 预览生产产物
  }
}

2. 核心配置文件对比(处理 TS/JSX/CSS)

Webpack 配置(webpack.config.js)

需要手动配置 Loader 和 Plugin,才能处理 TS/JSX/CSS:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.tsx',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[hash].js',
    clean: true
  },
  module: {
    rules: [
      // 处理 TS/JSX(需要 babel-loader + @babel/preset-typescript)
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      },
      // 处理 CSS(需要 css-loader + style-loader)
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']  // 执行顺序:css-loader → style-loader
      }
    ]
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx']
  },
  plugins: [
    // 生成 HTML(需要 html-webpack-plugin)
    new HtmlWebpackPlugin({ template: './public/index.html' })
  ],
  devServer: {
    port: 3000,
    hot: true,
    open: true
  },
  mode: process.env.NODE_ENV || 'development'
};

Vite 配置(vite.config.ts)

零配置即可处理 TS/JSX/CSS,仅需少量配置扩展功能:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';  // 仅需一个插件处理 React

export default defineConfig({
  plugins: [react()],  // 集成 React 支持(含 TS/JSX 转译)
  server: {
    port: 3000,
    open: true
  },
  build: {
    outDir: 'dist'
  }
});

3. 插件机制对比(处理 Vue 单文件组件)

Webpack 中:需要 Loader + Plugin 配合

# 安装依赖
npm install -D vue-loader vue-style-loader css-loader MiniCssExtractPlugin
// webpack.config.js
const { VueLoaderPlugin } = require('vue-loader');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: 'vue-loader'  // Loader 负责转译 Vue SFC
      },
      {
        test: /\.css$/,
        use: [process.env.NODE_ENV === 'production' ? MiniCssExtractPlugin.loader : 'vue-style-loader', 'css-loader']
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin(),  // Plugin 负责处理 Vue 相关流程
    new MiniCssExtractPlugin()  // Plugin 负责生产时提取 CSS
  ]
};

Vite 中:仅需一个插件

# 安装依赖
npm install -D @vitejs/plugin-vue
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()]  // 一个插件搞定转译+流程扩展
});

五、插件机制深度解析(核心差异)

Webpack 和 Vite 的插件机制差异,是两者配置复杂度和灵活性的关键:

1. Webpack:Loader + Plugin 分工明确

  • Loader:仅负责「模块转译」(如 TS→JS、CSS→JS),是「模块级别的工具」;
  • Plugin:仅负责「流程扩展」(如生成 HTML、压缩代码),是「流程级别的工具」;
  • 必须配合使用,比如处理 Vue 文件需要 vue-loader(转译)+ VueLoaderPlugin(流程)。

2. Vite:单一插件体系(整合 Loader+Plugin 功能)

Vite 没有 Loader 概念,插件是唯一的扩展方式,通过「钩子函数」同时覆盖转译和流程扩展功能:

  • 转译模块(对应 Webpack Loader):通过插件的 transform 钩子(如即时转译 TS/JSX);
  • 流程扩展(对应 Webpack Plugin):通过插件的其他钩子(如 configureServer 配置开发服务器、writeBundle 处理产物);
  • 兼容 Rollup 插件:生产时基于 Rollup 打包,大部分 Rollup 插件可直接在 Vite 中使用(如 rollup-plugin-terser 压缩代码)。

示例:Vite 自定义插件(处理 .txt 文件)

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    {
      name: 'transform-txt',  // 插件名称(必填)
      transform(code, id) {  // 转译钩子(对应 Webpack Loader)
        if (id.endsWith('.txt')) {
          // 将 .txt 文件内容转为 JS 模块导出
          return `export default ${JSON.stringify(code)}`;
        }
        return null;
      }
    }
  ]
});

使用时直接导入:

import content from './test.txt';
console.log(content);  // 输出 test.txt 的内容

六、选型建议:什么时候选 Webpack,什么时候选 Vite?

选 Vite 的场景(优先推荐)

  1. 新项目启动:尤其是 Vue/React + TS 的中中小型应用,追求极致开发体验;
  2. 现代浏览器优先:项目无需兼容 IE11,目标用户使用 Chrome/Firefox/Safari 等现代浏览器;
  3. 开发效率优先:团队不想花大量时间配置构建工具,希望快速上手开发;
  4. 静态站点/组件库:Vite 内置的静态资源处理和 Rollup 打包优化,适合组件库、静态博客等。

选 Webpack 的场景

  1. 大型复杂项目:比如多入口、多环境打包(浏览器+Node.js+Electron)、复杂代码分割策略;
  2. 老项目迁移:项目已基于 Webpack 开发多年,依赖大量自定义 Loader/Plugin,迁移成本高;
  3. 需要兼容老浏览器:必须支持 IE11 等老浏览器,Webpack 的兼容性处理更成熟;
  4. 特殊场景需求:比如需要集成特定工具(如 Webpack Dev Middleware)、处理特殊资源(如大型视频、WASM 模块)。

七、总结

Webpack 是「全能选手」,通过「全量打包」和「庞大生态」覆盖所有前端构建场景,但牺牲了开发体验和配置简洁性;
Vite 是「现代优等生」,通过「非打包式开发」和「ESBuild/Rollup 优化」,解决了 Webpack 的核心痛点,配置更简洁、速度更快,但聚焦于现代前端场景。

随着 Vite 生态的快速成熟,90% 的新前端项目都可以优先选择 Vite;只有在大型复杂项目、老项目迁移或特殊兼容场景下,Webpack 仍是更稳妥的选择。

希望本文能帮你彻底搞懂两者的差异,做出最适合自己项目的选型!

深度复盘: WebGL 数字孪生前端架构:如何打造高颜值、高性能的 Web 3D 可视化系统

作者 Addisonx
2025年12月11日 08:18

🚀 前言

在企业级数字孪生(Digital Twin)项目中,**“前端可视化表现”**往往决定了项目的成败。

很多项目后台数据很稳,但前端渲染卡顿、模型丑陋、交互生硬,最终导致无法交付。作为一名专注于 Web 3D 呈现与前端可视化 的开发者,我认为:让数据“好看”且“好用”,是前端的核心价值。

本文将基于我们团队最近交付的智慧园区可视化前端项目,复盘一套高内聚、低耦合的 Three.js 前端架构设计。


🏗️ 一、 前端架构设计:让 3D 只是一个“组件”

为了方便集成到任意业务系统中(无论后台是 Java、Python 还是 Go),我们将 3D 场景封装为独立的前端视图组件

1. 核心设计理念:数据驱动视图 (Data-Driven)

前端只负责两件事:渲染(Render)映射(Mapping)

  • 输入:通过 WebSocket/API 接收标准 JSON 数据(如 { id: 101, status: 'error' })。
  • 输出:3D 场景自动响应(ID为101的模型变红、闪烁)。

这种设计使得我们能以纯前端方式交付,甲方后端只需按约定推送数据即可,无需关心 3D 逻辑。

2. 代码组织结构

建议将 Three.js 逻辑封装为独立的 Class,与 Vue/React UI 层完全解耦:

// Viewer3D.js - 纯粹的渲染引擎类
export class Viewer3D {
  constructor(domElement) {
    this.renderer = new THREE.WebGLRenderer(); // 渲染器
    this.scene = new SceneManager();           // 场景管理
    this.effect = new EffectComposer();        // 后期特效(光晕/辉光)
  }

  // 暴露给业务层的 API:高亮设备
  highlightDevice(deviceId, color) {
    const mesh = this.scene.findMeshById(deviceId);
    if (mesh) {
      mesh.material.emissive.setHex(color);
      // 触发 Shader 扫光特效
      this.effect.triggerScan(mesh.position);
    }
  }
}

🛠️ 二、 前端核心技术难点解析

1. 视觉效果:Shader 编写与后期处理

普通的 Three.js 材质偏塑料感,为了达到“科技感”大屏效果,我们大量使用了自定义 Shader 和 Post-Processing(后期处理)。

技术实现

  • UnrealBloom:实现城市夜景的辉光效果(霓虹灯感)。
  • Custom Shader:不使用 GIF 贴图,而是用 GLSL 编写动态的电子围栏建筑扫描光波,清晰度无限且不耗费显存。

2. 坐标映射算法

前端开发常遇到的痛点:甲方给的是 GPS 经纬度,而 3D 场景是笛卡尔坐标。 我们封装了一套前端转换算法,支持将 GeoJSON 数据直接投射到 3D 地形上:

// 前端工具函数:经纬度转 Vector3
function latLonToVector3(lat, lon, radius = 6371) {
  const phi = (90 - lat) * (Math.PI / 180);
  const theta = (lon + 180) * (Math.PI / 180);
  const x = -(radius * Math.sin(phi) * Math.cos(theta));
  const z = (radius * Math.sin(phi) * Math.sin(theta));
  const y = (radius * Math.cos(phi));
  return new THREE.Vector3(x, y, z);
}

3. 性能优化 (FPS > 60)

在浏览器中渲染数万个物体,性能是第一指标。我们采用了纯前端的优化策略:

  • GPU Instancing:对重复的树木、路灯、机柜,合并为一次 DrawCall,CPU 开销几乎为零。
  • Draco 压缩:将几百 MB 的 OBJ 模型压缩为几 MB 的 .glb 文件,Web 端秒级加载。
  • 显存管理:自动检测不可见物体(Frustum Culling),并在组件销毁时彻底释放 Geometry 和 Material 内存。

💻 三、 系统落地效果

基于上述前端架构,我们完成了这套智慧园区/工厂可视化大屏

前端界面展示

view.gif(图注:纯前端实现的流光效果、PBR材质及 CSS3D 标签融合)

核心亮点

  • 极速加载:首屏加载时间 < 3秒。
  • 全场景漫游:支持第一人称/第三人称视角平滑切换。
  • 多端兼容:适配 Chrome、Edge 及高性能平板浏览器。

🤝 四、 技术探讨与落地

Web 3D 开发是一个深坑,从模型导出到 WebGL 渲染,每个环节都可能遇到性能瓶颈。

我们团队在踩过无数坑后,沉淀了这套成熟的前端可视化解决方案。我们非常乐意与同行或有需求的朋友进行技术交流

如果你正面临以下情况,欢迎沟通:

  1. 后端团队:你们擅长 Java/Go 业务逻辑,但缺少能搞定炫酷 3D 前端的伙伴。
  2. 项目集成:手头有智慧城市/工厂项目,需要一个稳定的前端 3D 模块来提升项目“颜值”。
  3. 技术瓶颈:现有的 3D 场景卡顿、效果不理想,需要优化方案。

在线演示环境: 👉 (注:建议使用 PC 端 Chrome 访问以获得最佳体验)

不管是技术探讨源码咨询还是项目协作,都欢迎在评论区留言或点击头像私信,交个朋友,共同进步。


声明:本文核心代码与架构思路均为原创,转载请注明出处。

跨域 iframe 内嵌的同源策略适配方案-Youtube举例

作者 三喵223
2025年12月10日 23:41

前言

因平台业务需求,需在前端基于 FFMPEG 对直播流 m3u8 格式文件执行裁剪操作。该操作要求前端环境中 window.crossOriginIsolated 状态为启用,同时需在响应头(Response Header)中配置 'Cross-Origin-Opener-Policy' 'same-origin'。然而此类配置会导致直接内嵌的 iframe 无法正常加载,因此亟需针对性的解决方案。

核心概念

在探讨具体解决方案前,先厘清以下关键技术概念:

  1. Window: credentialless property - credentialless 作为新增属性,支持 iframe 在不携带任何身份凭据(如 Cookies、HTTP 认证信息等)的前提下加载外部资源。这一特性可有效提升页面的安全性与隐私性,尤其适用于处理敏感数据的场景。
  2. Window: crossOriginIsolated property - crossOriginIsolated 属性用于检测当前运行环境是否处于跨源隔离状态。跨源隔离机制能够强化页面安全防护,避免不同源的恶意代码之间产生相互干扰。

代码示例

image.png

JavaScript

若要实现 iframe 的跨域正常加载,可参考以下代码实现:

这段代码展示了如何在 iframe 标签中设置 credentialless 属性,以实现跨域加载的需求。

Nginx 配置

在服务端层面,可通过 Nginx 配置响应头(Response Header),为跨域及 iframe 内嵌场景提供支持:

nginx

# 配置跨源相关响应头,适配iframe跨域加载需求
add_header 'Cross-Origin-Opener-Policy' 'same-origin';
add_header 'Cross-Origin-Embedder-Policy' 'credentialless';
# 配置内容安全策略,限定iframe允许加载的源
add_header Content-Security-Policy 'frame-src 'self' https://www.youtube.com https://www.your-domain.com;';

上述配置通过合理设置响应头参数,保障了 iframe 能够在跨域环境下正常加载,同时兼顾了访问的安全性。

总结

通过在前端层面为 iframe 配置 credentialless 属性,结合服务端 Nginx 对响应头的精准配置,可有效实现跨域 iframe 的正常加载。该方案既满足了平台对直播流 m3u8 裁剪操作的核心需求,又兼顾了网页的安全性与功能完整性,保障了整体业务流程的顺畅运行。

从后端模板到响应式驱动:界面开发的演进之路

作者 冻梨政哥
2025年12月10日 23:17

从后端模板到响应式驱动:界面开发的演进之路

在 Web 开发的发展历程中,界面与数据的交互方式经历了多次重要变革。从早期的后端模板渲染到如今的响应式数据驱动,每一次演进都深刻影响着开发模式和用户体验。

一、纯后端套模板时代:MVC 模式的兴起

早期的 Web 开发普遍采用 MVC(Model-View-Controller)开发模式,这种模式将应用程序分为三个核心部分:

  • Model(模型) :负责数据管理,通常与数据库交互例如通过 MySQL 等数据库进行数据抽象存储
  • View(视图) :负责数据展示,以 HTML 模板为载体
  • Controller(控制器) :处理业务逻辑,协调模型和视图

这一时期的后端代码通常直接处理 HTTP 请求并渲染模板,典型的 Node.js 示例如下:

// 简化的后端MVC示例
const http = require('http');
const mysql = require('mysql');

// 模型:数据库连接
const db = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: '',
  database: 'todoapp'
});

// 控制器:处理业务逻辑
const getTodos = (req, res) => {
  db.query('SELECT * FROM todos', (err, results) => {
    if (err) throw err;
    // 渲染视图(模板)
    const html = `
      
        
          <ul>
            ${results.map(todo => `<li>${todo.content}</li>`).join('')}
          </ul>
        
      
    `;
    res.end(html);
  });
};

// HTTP服务
http.createServer((req, res) => {
  if (req.url === '/todos') {
    getTodos(req, res);
  } else {
    res.end('Hello World');
  }
}).listen(3000);

在这种模式下,HTML 的静态部分和动态数据部分混合在一起,动态内容通过模板语法(如{{todos}})由后端数据驱动生成。

二、前后端分离:开发职责的解耦

随着 Web 应用复杂度提升,前后端分离架构逐渐成为主流。这种模式将应用拆分为两个独立部分:

  • 前端:专注于 HTML、CSS 和 JavaScript 实现,通过 Ajax 或 Fetch 主动从后端拉取数据
// 前端获取数据示例
fetch('http://localhost:3000/users')
.then(response => response.json())
.then(users => {
  // 处理并展示数据
  const userList = document.getElementById('user-list');
  userList.innerHTML = users.map(user => `<li>${user.name}</li>`).join('');
});

后端:不再返回完整 HTML,而是提供纯粹的数据接口(API)

// 后端API示例
http.createServer((req, res) => {
  if (req.url === '/users') {
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify([
      { id: 1, name: '张三' },
      { id: 2, name: '李四' }
    ]));
  }
}).listen(3000);

前后端分离带来了显著优势:

  • 前端开发者可专注于数据展示和用户体验
  • 后端开发者可专注于数据处理和系统性能
  • 双方可并行开发,通过 API 契约协作

三、Vue 响应式:数据驱动的革命

Vue 框架的出现彻底改变了前端开发模式,其核心是响应式数据驱动

1. 响应式数据(ref 实现)

Vue 通过ref将普通数据包装为响应式对象:

import { ref } from 'vue';

// 创建响应式数据
const message = ref('Hello Vue');
const todos = ref([
  { id: 1, content: '学习响应式' },
  { id: 2, content: '使用v-for' }
]);

// 修改数据(自动触发界面更新)
message.value = 'Hello Reactive';
todos.value.push({ id: 3, content: '掌握Vue' });

2. 模板语法(数据与界面绑定)

Vue 模板通过{{}}和指令实现数据与界面的自动关联:


  <div>
    
    <p>{{ message }}</p>
    
    
    <ul>
      <li>
        {{ todo.content }}
      </li>
    </ul>
  </div>

3. 完整 Vue 组件示例


  <div>
    <h2>{{ title }}</h2>
    <ul>
      <li>{{ item.name }}</li>
    </ul>
    添加项
  </div>



import { ref } from 'vue';

// 响应式数据
const title = ref('Vue响应式示例');
const list = ref([
  { id: 1, name: '第一项' },
  { id: 2, name: '第二项' }
]);

// 业务逻辑(只操作数据)
const addItem = () => {
  list.value.push({
    id: Date.now(),
    name: `新项${list.value.length + 1}`
  });
};

在 Vue 的响应式体系中,开发者只需关注数据变化,界面更新完全由框架自动处理。这种模式继承了后端模板 "数据驱动界面" 的思想,但通过前端响应式系统实现了更高效、更灵活的开发体验,彻底摆脱了手动 DOM 操作的困扰。

从后端模板到 Vue 响应式,界面开发的演进始终围绕一个核心:让开发者聚焦业务逻辑,而非界面更新细节。Vue 的响应式系统正是这一理念的完美实践。

beginWork 与 completeWork 的内部职责分工

作者 day1
2025年12月10日 22:57

一、引言

1.1 从 JSX 到 DOM 挂载

初次渲染是 “无旧 DOM 可复用” 的全新构建过程,整体流程可分为三步:

  1. 初始化 Fiber 根节点:React 从 ReactDOM.createRoot 或 ReactDOM.render 触发,创建根 Fiber 节点(FiberRoot),作为整个 Fiber 树的入口;
  2. 构建 workInProgress Fiber 树:通过 beginWork 自上而下遍历,为每个组件创建对应的 workInProgress Fiber 节点;再通过 completeWork 自下而上处理,生成 DOM 节点并绑定属性;
  3. 提交 DOM 挂载:构建完 workInProgress 树后,React 进入 “提交阶段”,将 workInProgress 树对应的 DOM 节点挂载到页面,同时将 current 树指向新树,完成渲染闭环。

1.2 beginWork 与 completeWork 的核心职责定位

beginWork 与 completeWork 是 Fiber 构建阶段的 “左右护法”,职责分工明确且互补:

  1. beginWork:负责 “向下探索”,从根 Fiber 开始,递归(迭代)为每个组件创建 workInProgress Fiber 节点,核心是 “生成 Fiber 结构、处理组件逻辑、确定子节点类型”;
  2. completeWork:负责 “向上收尾”,从叶子节点开始,逐步向上归并,核心是 “创建 DOM 节点、赋值 DOM 属性、收集副作用、关联父子 DOM”;

二者通过 “工作单元链表” 衔接:beginWork 处理完一个 Fiber 节点后,工作循环通过 performUnitOfWork 推进到下一个 Fiber(child/sibling/return),当遍历到叶子节点后,自动进入 completeWork 阶段。

二、beginWork:Fiber 树的向下构建与任务分解

beginWork 是 Fiber 构建阶段的 “前置引擎”,其核心逻辑是 “基于旧 Fiber 节点(若存在)或组件类型,创建新的 workInProgress Fiber 节点,并确定子节点的处理方式”。在初次渲染中,由于没有旧 current 树,beginWork 会直接基于组件类型初始化 Fiber 节点。

2.1 beginWork 的触发时机与入参说明

  1. 触发位置:工作循环在每个“工作单元(Fiber)”上调用 performUnitOfWork,其中第一步就是执行 beginWork(current, workInProgress, renderLanes),返回“下一个要处理的子 Fiber”。
  2. 入参与角色:
  • current:当前已提交树中的对应 Fiber(更新时存在;首屏挂载时可能为 null)。
  • workInProgress:本次渲染要构建的 Fiber 节点(WIP 树)。
  • renderLanes:当前渲染的优先级集合(决定是否跳过或继续展开)。
  1. 输出:返回子 Fiber(或 null),供工作循环继续向下深入。

2.2 不同类型 Fiber 节点的 beginWork 处理逻辑

2.2.1 重点节点速览

共性流程:根据 fiber.tag 分发到不同的更新函数,产出“下一步要渲染的子元素集合”,再调用“对比/构建子 Fiber(reconcileChildren)”。

常见类型与处理要点(简化版):

  1. FunctionComponent:renderWithHooks 执行函数组件,跑 hooks,拿到 nextChildren;随后 reconcileChildren
  2. ClassComponent:updateClassComponent 处理实例与 processUpdateQueue,计算新 state,调用 render() 得到 nextChildren;随后 reconcileChildren
  3. HostRoot(根):处理根上的更新队列(如 updateContainer 的结果),得到 nextChildren;随后 reconcileChildren
  4. HostComponent(原生节点,如 div):不在此阶段做 DOM 变更;只根据 props.children 调用 reconcileChildren
  5. HostText(文本):无子节点,通常返回 null
  6. Suspense/Offscreen:根据数据可用性与可见性策略,决定展开主内容或 fallback,再 reconcileChildren
  7. Memo/ForwardRef/SimpleMemoComponent:按其包装逻辑判断是否需要重渲染,否则可部分跳过。

简化示例(伪代码):

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case FunctionComponent:
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps; // 未解析默认值的 props
      // 解析函数组件的默认 props(若禁用默认 props 则直接使用 unresolvedProps)
      const resolvedProps =
        disableDefaultPropsExceptForClasses ||
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultPropsOnNonClassComponent(Component, unresolvedProps);
      // 处理函数组件的更新/挂载(执行函数、处理 Hooks、生成子节点)
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes
      );

    case ClassComponent:
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps; // 未解析默认值的 props
      // 解析类组件的默认 props(static defaultProps)
      const resolvedProps = resolveClassComponentProps(
        Component,
        unresolvedProps,
        workInProgress.elementType === Component
      );
      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes
      );

    case HostRoot: {
      // 处理根节点的更新/挂载(管理全局状态、初始化上下文、协调子树)
      return updateHostRoot(current, workInProgress, renderLanes);
    }

    case HostComponent: {
      // 处理 DOM 元素的更新/挂载(对比 props、生成 DOM 属性、协调子节点)
      return updateHostComponent(current, workInProgress, renderLanes);
    }

    case HostText:
      return updateHostText(current, workInProgress);

    // ... 其他类型(Suspense、Offscreen、Fragment、ContextProvider 等)
  }
}

2.2.2 updateFunctionComponent 源码精读

在函数组件的 beginWork 阶段,updateFunctionComponent 负责“调用组件函数 + 驱动 Hooks 执行 + 产出子节点 + 协调子 Fiber”。它与 renderWithHooks 搭档工作:前者决定是否需要继续向下构建子树(以及是否可以直接跳过),后者负责在一次渲染里正确地运行 Hooks 并返回最新的子节点。

执行顺序总览:

  1. 读取上下文(prepareToReadContext),为组件执行铺路;
  2. 调用 renderWithHooks,运行 Hooks 并拿到 nextChildren(JSX);
  3. 若是更新且未接收变化(current !== null && !didReceiveUpdate),走“跳过路径”,复用旧子树;

    didReceiveUpdate 来自更上层的变更判断(如 props/context 是否变化、更新优先级是否匹配)。当它为 false 时表示“本次无需对这个节点及其子树做工作,可直接重用旧 Fiber 和副作用信息”,于是会调用:

  4. 否则标记 PerformedWork 并调用 reconcileChildren,为子节点生成/更新子 Fiber。

renderWithHooks 的职责与关键点

  1. 渲染前重置当前 Fiber 的 Hooks 相关状态:memoizedStateupdateQueuelanes,并设置全局渲染环境(currentlyRenderingFiberrenderLanes)。
  2. 根据是否首屏挂载选择调度器:
  • HooksDispatcherOnMount: 首次渲染,负责初始化如 useState 的初始值。
  • HooksDispatcherOnUpdate: 更新渲染,负责读取旧状态并应用队列里的更新。
  1. 运行组件函数得到 children;如果在执行过程中触发了“渲染阶段更新”(didScheduleRenderPhaseUpdateDuringThisPass),则使用 HooksDispatcherOnRerender 进行一次或多次“立即重渲染”,直到状态稳定或达到 RE_RENDER_LIMIT 上限。
  • 这类更新的典型特征是“在函数组件执行过程中触发了自身状态的再次变更”,React 会尝试在同一渲染通道内收敛到稳定值,避免把不稳定的 UI 结果提交出去。
  1. 清理并完成 Hooks 的渲染(finishRenderingHooks),将最终的 Hooks 状态和副作用信息落到 workInProgress 上,并返回稳定的 children 给上层做协调。
// 处理函数组件的更新逻辑
function updateFunctionComponent(
  current: null | Fiber, // 旧 Fiber 节点(更新时存在,初次渲染为 null)
  workInProgress: Fiber, // 当前正在构建的 Fiber 节点
  Component: any, // 函数组件本身
  nextProps: any, // 新的 props
  renderLanes: Lanes // 当前渲染的优先级
) {
  let context;
  // 获取上下文(简化处理,省略 legacy 上下文兼容逻辑)
  if (!disableLegacyContext) {
    const unmaskedContext = getUnmaskedContext(workInProgress, Component, true);
    context = getMaskedContext(workInProgress, unmaskedContext);
  }

  let nextChildren;
  // 准备读取上下文
  prepareToReadContext(workInProgress, renderLanes);
  // 执行函数组件,通过 Hooks 处理状态和副作用,获取返回的子节点(JSX)
  nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes
  );

  // 如果是更新且组件未发生变化,直接复用旧节点(跳过子树更新)
  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  // 标记组件已执行工作
  workInProgress.flags |= PerformedWork;
  // 协调子节点(创建/更新子 Fiber 树)
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  // 返回第一个子 Fiber 节点,继续构建子树
  return workInProgress.child;
}

// 函数组件核心渲染逻辑:执行组件并处理 Hooks
export function renderWithHooks(
  current: Fiber | null, // 旧 Fiber 节点(更新时存在,初次渲染为 null)
  workInProgress: Fiber, // 当前构建中的 Fiber 节点
  Component: (p: Props, arg: SecondArg) => any, // 目标函数组件
  props: Props, // 组件接收的新 props
  secondArg: SecondArg, // 额外参数(如上下文,函数组件通常为 context)
  nextRenderLanes: Lanes // 当前渲染优先级
): any {
  // 1. 初始化渲染相关全局状态
  renderLanes = nextRenderLanes; // 记录当前渲染优先级
  currentlyRenderingFiber = workInProgress; // 标记当前正在渲染的 Fiber

  // 2. 重置当前 Fiber 的 Hooks 相关状态(清空旧状态/队列)
  workInProgress.memoizedState = null; // 清空 Hooks 状态存储
  workInProgress.updateQueue = null; // 清空 Hooks 更新队列
  workInProgress.lanes = NoLanes; // 重置优先级标记

  // 3. 选择 Hooks 调度器(区分初次渲染/更新)
  // - 初次渲染(current 为 null 或无旧 Hooks 状态):用挂载阶段调度器
  // - 更新阶段(有旧 Hooks 状态):用更新阶段调度器
  ReactSharedInternals.H =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount // 挂载时的 Hooks 实现(如 useState 初始化)
      : HooksDispatcherOnUpdate; // 更新时的 Hooks 实现(如 useState 读取旧状态)

  // 4. 执行函数组件,获取返回的子节点(JSX)
  // 此时组件内的 Hooks(如 useState、useEffect)会通过上面的调度器执行
  let children = Component(props, secondArg);

  // 5. 处理渲染阶段更新(若组件内触发了 setState 等导致状态变化)
  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    // 重新执行组件,确保状态稳定后再返回子节点
    children = renderWithHooksAgain(
      workInProgress,
      Component,
      props,
      secondArg
    );
  }

  // 6. 完成 Hooks 渲染:清理全局状态、记录最终 Hooks 信息
  finishRenderingHooks(current, workInProgress, Component);

  // 7. 返回组件渲染结果(子节点),用于后续 Fiber 子树协调
  return children;
}

2.2.3 updateClassComponent 源码精读

在 beginWork 阶段,类组件通过 updateClassComponent 完成“实例构造/更新决策 + 调用 render 产出子节点 + 协调子 Fiber”。与函数组件不同,类组件不使用 Hooks;更新是否继续由 shouldUpdate 决策,来源于 mountClassInstance/resumeMountClassInstance/updateClassInstance

执行顺序总览

  1. 上下文与错误边界预处理:
  • 如果是 legacy 上下文提供者,先 pushLegacyContextProvider,避免上下文栈失配;
  1. 实例与更新决策:
  • instance === null 首次挂载:constructClassInstancemountClassInstanceshouldUpdate = true
  • current === null 恢复挂载(如挂起后恢复):resumeMountClassInstance → 返回 shouldUpdate
  • 否则常规更新:updateClassInstance → 返回 shouldUpdate
  1. 渲染与子树协调(finishClassComponent):
  • !shouldUpdate && !didCaptureError,直接走“跳过路径”并可能失效上下文提供者;
  • 否则调用 instance.render() 产出 nextChildren,根据是否处于错误恢复决定 forceUnmountCurrentAndReconcile 或常规 reconcileChildren
  • 最后将 instance.state 挂到 memoizedState 并在提供者场景下 invalidateContextProvider
// 处理类组件的更新逻辑
function updateClassComponent(
  current: Fiber | null, // 旧 Fiber 节点(更新时存在)
  workInProgress: Fiber, // 当前构建中的 Fiber 节点
  Component: any, // 类组件本身(构造函数)
  nextProps: any, // 新的 props
  renderLanes: Lanes // 当前渲染优先级
) {
  let hasContext = false;
  // 处理上下文提供者(若为上下文提供者组件)
  if (isLegacyContextProvider(Component)) {
    hasContext = true;
    pushLegacyContextProvider(workInProgress); // 入栈上下文提供者
  }

  // 准备读取上下文
  prepareToReadContext(workInProgress, renderLanes);

  const instance = workInProgress.stateNode; // 类组件实例(this)
  let shouldUpdate; // 是否需要更新

  if (instance === null) {
    // 1. 组件未实例化(初次挂载)
    constructClassInstance(workInProgress, Component, nextProps); // 创建实例(new Component())
    mountClassInstance(workInProgress, Component, nextProps, renderLanes); // 初始化实例(调用 componentWillMount 等)
    shouldUpdate = true; // 初次挂载必更新
  } else if (current === null) {
    // 2. 恢复挂载(如从 Suspense 恢复)
    shouldUpdate = resumeMountClassInstance(
      workInProgress,
      Component,
      nextProps,
      renderLanes
    );
  } else {
    // 3. 组件已存在(更新阶段)
    // updateClassInstance通过处理新旧 props/context、执行状态更新队列、调用相关生命周期方法(如 componentWillReceiveProps、
    // shouldComponentUpdate),最终判断组件是否需要重新渲染,并更新实例的 props/state/context
    shouldUpdate = updateClassInstance(
      current,
      workInProgress,
      Component,
      nextProps,
      renderLanes
    );
  }

  // 完成类组件处理,返回下一个工作单元
  const nextUnitOfWork = finishClassComponent(
    current,
    workInProgress,
    Component,
    shouldUpdate,
    hasContext,
    renderLanes
  );

  return nextUnitOfWork;
}

// 完成类组件的渲染处理
function finishClassComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  shouldUpdate: boolean,
  hasContext: boolean,
  renderLanes: Lanes
) {
  // 处理 ref 引用
  markRef(current, workInProgress);

  // 检查是否捕获到错误
  const didCaptureError = (workInProgress.flags & DidCapture) !== NoFlags;

  // 若无需更新且无错误捕获,直接复用旧节点(跳过子树更新)
  if (!shouldUpdate && !didCaptureError) {
    if (hasContext) {
      invalidateContextProvider(workInProgress, Component, false); // 失效上下文
    }
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  const instance = workInProgress.stateNode; // 类组件实例
  let nextChildren; // 渲染的子节点(JSX)

  if (didCaptureError && !Component.getDerivedStateFromError) {
    // 错误捕获且无错误处理方法时,子节点为空
    nextChildren = null;
  } else {
    // 执行 render 方法获取子节点
    nextChildren = instance.render();
  }

  // 标记组件已执行工作
  workInProgress.flags |= PerformedWork;

  // 协调子节点(创建/更新子 Fiber 树)
  if (current !== null && didCaptureError) {
    // 错误恢复场景:强制卸载旧子树并重新协调
    forceUnmountCurrentAndReconcile(
      current,
      workInProgress,
      nextChildren,
      renderLanes
    );
  } else {
    // 正常场景:协调子节点
    reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  }

  // 保存当前状态到 Fiber 节点
  workInProgress.memoizedState = instance.state;

  if (hasContext) {
    invalidateContextProvider(workInProgress, Component, true); // 更新上下文
  }

  // 返回第一个子 Fiber 节点,继续构建子树
  return workInProgress.child;
}

2.3 子 Fiber 节点的创建与链表连接

两套对比器(核心区别在“有没有旧树可复用”):

  1. 首次挂载:mountChildFibers(从 null 构建新子 Fiber 链)。
  2. 更新:reconcileChildFibers(对比 current.child 链与新的 nextChildren:复用/插入/删除)。

链表结构与指针:

  1. fiber.child:第一个子节点。
  2. fiber.sibling:兄弟节点(单链向右)。
  3. fiber.return:父节点(便于回溯完成与向上合并副作用)。
// JSX

  
  


// Fiber 链(简化)
MyPanel (return: parent)
  └─ child → Title (return: MyPanel)
       └─ sibling → Content (return: MyPanel)

关键点:beginWork 只“产出子树的结构与下一步任务”,不做 DOM;DOM 的创建与属性设置在 completeWork,插入/更新在“提交阶段”。

2.4 工作单元的分解与优先级处理

  1. 单元分解(DFS):
  • 工作循环优先深入 childchild 完成后转向 sibling;最后回到 return
  • 每个 Fiber 的 begin/complete 构成一个“工作单元”,渲染可切片、可中断。
  1. 优先级(Lanes):
  • renderLanes 决定当前渲染的优先级集合;若该 Fiber 在此优先级下无待处理工作,可“跳过”(复用旧树)。
  • 出口条件(简化理解):没有更高优先级的变更、props/state 未变、更不需要上下文更新 → bailout,直接返回可能的 childnull
  1. 概念串联:
  • beginWork:决定“需要渲染哪些子元素”,并产生/对齐子 Fiber。
  • completeWork:在向上回溯时创建宿主实例、收集副作用。
  • 提交阶段:统一执行副作用(commitPlacement/commitUpdate 等),把渲染结果应用到 DOM。
// 例子:点击后新增一个 <li>
function List({items}) {
  return (
    <ul>
      {items.map((i) => (
        <li>{i}</li>
      ))}
    </ul>
  );
}

// 点击 setState 入队 → 渲染阶段:
// beginWork(List) → nextChildren = [..., <li>]
// reconcileChildren 对比老的 <li> 列表:复用旧的、创建新的 Fiber
// completeWork(new <li>):准备 DOM 实例与属性
// 提交阶段:commitPlacement(new <li>) 插入到真实 DOM

三、completeWork:Fiber 树的向上归并与 DOM 生成

当 beginWork 遍历到叶子节点(无 child 的 Fiber 节点)后,performUnitOfWork 会切换到 completeWork 阶段。completeWork 从叶子节点开始向上归并,核心是 “将 Fiber 节点转换为实际 DOM,并完成挂载前的准备”。

3.1 completeWork 的触发时机与执行条件

  1. 触发时机:工作循环在某个 Fiber 的子树已展开完毕、beginWork 返回 null 后,会回溯到该 Fiber 并执行 completeWork(current, workInProgress, renderLanes)。这是一段“向上归并”的过程。
  2. 作用概览:
  • 为宿主节点(HostComponent/HostText)创建或对齐实例(不插入 DOM)。
  • 为“更新”生成变更负载(payload),以便提交阶段执行。
  • 向上合并(bubble)子树的副作用标志(subtreeFlags),为提交阶段收敛出一条待执行的效果链。

示意(伪代码):

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork: Fiber = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;
    let next;

    // 执行完成工作的核心逻辑
    next = completeWork(current, completedWork, entangledRenderLanes);

    // 若生成新工作单元,切换到新工作单元并返回
    if (next !== null) {
      workInProgress = next;
      return;
    }

    // 若有兄弟节点,切换到兄弟节点并返回
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;
      return;
    }

    // 否则返回父节点继续处理
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}

3.2 不同类型 Fiber 节点的 completeWork 处理逻辑

与 beginWork 类似,completeWork 也会根据 Fiber 节点的 tag 执行差异化逻辑,核心类型如下:

  1. HostComponent(如 div):
  • 首次挂载:创建宿主实例 createInstance(type, props, rootContainer, hostContext)appendAllChildren(instance, wip) 把已创建的子实例挂到该实例上(仍在内存结构里),然后 setInitialProperties(instance, props) 设置初始属性。
  • 更新:对比新旧 props,prepareUpdate(instance, type, oldProps, newProps, rootContainer, hostContext) 返回变更负载;若非空,给 Fiber 标记 Update 并挂载负载,留给提交阶段执行。
  1. HostText(文本节点):
  • 首次挂载:createTextInstance(text, rootContainer, hostContext) 创建文本实例,挂到 fiber.stateNode
  • 更新:若文本变了,标记 Update,提交阶段会调用 commitHostTextUpdate 实际更新内容。
  1. FunctionComponent / Fragment / Context 等非宿主节点:
  • 不创建 DOM 实例;completeWork 主要做“副作用合并”和 Ref 变更判断(若 ref 变化会标记 Ref)。
  1. ClassComponent:
  • 不直接创建 DOM;常在 completeWork 阶段标记 Ref(实例 ref 变化)与合并子树效果。
  1. Suspense / Offscreen:
  • 根据可见性/回退策略在 begin 阶段已决定子树展开;complete 阶段继续合并 flags,并可能标记 Visibility 相关效果。

3.3 DOM 节点的创建、属性赋值与子节点挂载

核心要点:

  • completeWork“生成/准备”宿主实例与属性,不做真实 DOM 插入;真实插入发生在提交阶段的 commitPlacement
  • 子节点挂载通过 appendAllChildren(instance, wip) 把子实例树挂到当前实例的内部结构(即 stateNode 的子列表),为后续 commitPlacement 一次性插入打好基础。
// (挂载 vs 更新)
function completeWork(current, wip) {
  switch (wip.tag) {
    case ActivityComponent:
    case LazyComponent:
    case SimpleMemoComponent:
    case FunctionComponent:
    case ForwardRef:
    case Fragment:
    case Mode:
    case Profiler:
    case ContextConsumer:
    case MemoComponent:
      // 这些类型的组件不需要创建DOM节点
      // 冒泡当前节点的属性(如 subtreeFlags、lanes)到父节点
      bubbleProperties(workInProgress);
      return null; // 无后续处理,返回 null
    case HostComponent: {
      // ...首次客户端渲染逻辑

      // 更新宿主容器(如 DOM 容器)的状态
      updateHostContainer(current, workInProgress);
      // 冒泡属性到父节点(根节点无父节点,此处主要是处理自身 subtreeFlags)
      bubbleProperties(workInProgress);
      return;
    }

    case HostText: {
      const newText = wip.pendingProps;
      if (current && workInProgress.stateNode != null) {
        const oldText = current.memoizedProps; // 上一次渲染的文本内容
        // 调用文本节点更新逻辑(对比新旧文本,生成更新队列)
        updateHostText(current, workInProgress, oldText, newText);
      } else {
        // 客户端挂载:创建文本实例(如 DOM 中的 Text 节点)
        const rootContainerInstance = getRootHostContainer();
        const currentHostContext = getHostContext();
        markCloned(workInProgress);
        // 创建文本实例(如 DOM 中的 Text 节点)
        workInProgress.stateNode = createTextInstance(
          newText,
          rootContainerInstance,
          currentHostContext,
          workInProgress
        );
      }
      bubbleProperties(workInProgress);
      return;
    }

    // ... 其他类型以“合并副作用”为主
  }
}

3.4 副作用的收集与标记

Flags(部分常见):

  1. Placement:表示需要插入(新增节点)。
  2. Update:属性或文本变更,提交阶段读取 Fiber 的变更负载执行。
  3. Deletion:删除节点。
  4. Ref:ref 变更,提交阶段会附加/清理 ref。
  5. Visibility / Passive:可见性与副作用相关标记。 合并与传播:
  6. 每个 Fiber 在 complete 阶段会把自身与子树的 flags 合并到 subtreeFlags,以便根在提交阶段一次性遍历需要执行的效果。

合并示意:

// 功能:在 Fiber 树归并阶段,向上汇总子节点的优先级、副作用等关键信息
function bubbleProperties(completedWork: Fiber) {
  // 判断是否&#34;跳过更新&#34;(子树无变化):存在旧节点且新旧子节点引用相同
  const didBailout =
    completedWork.alternate !== null &&
    completedWork.alternate.child === completedWork.child;

  let newChildLanes: Lanes = NoLanes; // 合并后的子树优先级
  let subtreeFlags = NoFlags; // 合并后的子树副作用标记

  if (!didBailout) {
    // 【子树有更新】:全量收集子节点信息
    let child = completedWork.child;
    while (child !== null) {
      // 合并子节点自身及子树的优先级
      newChildLanes = mergeLanes(
        newChildLanes,
        mergeLanes(child.lanes, child.childLanes)
      );
      // 合并子节点自身及子树的所有副作用
      subtreeFlags |= child.subtreeFlags | child.flags;
      // 修正子节点的父引用,确保链表关系正确
      child.return = completedWork;

      child = child.sibling; // 遍历下一个兄弟节点
    }

    // 将合并的子树副作用标记到当前节点
    completedWork.subtreeFlags |= subtreeFlags;
  } else {
    // 【子树无更新】:仅收集静态信息(过滤动态副作用)
    let child = completedWork.child;
    while (child !== null) {
      // 合并子节点自身及子树的优先级(未处理的优先级仍需传递)
      newChildLanes = mergeLanes(
        newChildLanes,
        mergeLanes(child.lanes, child.childLanes)
      );
      // 仅合并静态副作用(如静态DOM属性,动态副作用无需传递)
      subtreeFlags |=
        (child.subtreeFlags & StaticMask) | (child.flags & StaticMask);
      // 修正子节点的父引用
      child.return = completedWork;

      child = child.sibling;
    }

    completedWork.subtreeFlags |= subtreeFlags;
  }

  // 保存合并后的子树优先级到当前节点
  completedWork.childLanes = newChildLanes;

  // 返回是否跳过更新的标志,供上层节点判断
  return didBailout;
}
  • 例子:文本更新的标记与提交
function TextExample() {
  const [text, setText] = useState(&#34;hello&#34;);
  useEffect(() => {
    setTimeout(() => setText(&#34;world&#34;), 100);
  }, []);
  return <div>{text}</div>;
}
// 1. 初次挂载:生成 `HostText(&#34;hello&#34;)`,提交后运行 `useEffect`(被动副作用)。
// 2. 更新调度:`setTimeout` 触发 `setText(&#34;world&#34;)`,通过 `scheduleUpdateOnFiber` 为根打上待处理的 `Lane` 并触发下一次渲染。
// 3. render 阶段:
// - `BeginWork` 进入 `HostText` 的更新路径。
// - `CompleteWork` 的 `case HostText` 中调用 `updateHostText(current, workInProgress, oldText, newText)`。
// - 若 `oldText !== newText`,执行 `markUpdate(workInProgress)`,在该 Fiber 的 `flags` 中设置 `Update`。
// - 随后 `bubbleProperties` 汇总子树标志到父节点的 `subtreeFlags`,便于提交阶段快速定位变化。
// 4. 提交阶段(mutation effects):
// - 扫描到该 `HostText` 带有 `Update`,调用 `commitHostTextUpdate(stateNode, oldText, newText)` 实际更新 DOM 文本为 &#34;world&#34;。

结论(把 complete 与提交阶段区分清楚)

  1. completeWork 是“向上归并与准备”:创建/对齐宿主实例、设置初始属性或生成变更负载、合并副作用标记。
  2. 真实 DOM 变更(插入/更新/删除)在提交阶段完成:如 commitPlacementcommitHostTextUpdate 等。
  3. 这保证渲染阶段能被时间切片与优先级打断,而提交阶段在确定的效果列表上一次性执行,提升稳定性与可控性。

四、beginWork 与 completeWork 的协同机制

beginWork 的 “向下构建” 与 completeWork 的 “向上归并” 并非独立流程,而是通过 “工作单元链表” 和 “双缓存机制” 紧密协同,共同完成初次渲染的 Fiber 树构建与 DOM 生成。

4.1 自上而下构建与自下而上归并的流程闭环

流程主线:

  1. 向下构建(beginWork):根据 fiber.tag 选择处理器,计算 nextChildren,并通过 mountChildFibers/reconcileChildFibers 生成/复用子 Fiber 链,返回 fiber.child 继续深入。
  2. 向上归并(completeWork):当子树展开完毕(beginWork 返回 null)回溯到父节点,创建/对齐宿主实例(HostComponent/HostText)、准备更新负载,并合并副作用标记(bubbleProperties)。
  3. 提交阶段(commit):在统一的效果列表上执行宿主副作用,将变更应用到 DOM(如 commitPlacementcommitHostTextUpdate)。
// 简化伪码
function performUnitOfWork(wip) {
  // 向下:展开当前节点的子树
  const next = beginWork(wip.alternate, wip, renderLanes);
  wip.memoizedProps = wip.pendingProps;

  // 有子,继续向下;无子,开始向上
  if (next !== null) return next;

  // 向上:准备实例/负载并合并副作用
  do {
    completeWork(wip.alternate, wip, renderLanes);
    bubbleProperties(wip); // 合并 flags / subtreeFlags
    if (wip.sibling !== null) return wip.sibling;
    wip = wip.return;
  } while (wip !== null);

  // 根收敛后,进入提交阶段
  return null;
}

4.2 双缓存 Fiber 树切换中的职责配合

  1. 双缓存(current ↔ workInProgress):
  • 渲染阶段构建的是 WIP 树(指针 workInProgress.alternate === current);旧树 current 仍代表“已提交”的 UI。
  • commit 阶段将 root.current 切换为本次完成的 finishedWork(WIP 树),使其成为新的“已提交”树。
  1. 职责分工:
  • beginWork:读取 current 的已有信息(如 memoizedProps/state),决定是否复用(bailout)或生成新的子 Fiber。
  • completeWork:在 WIP 节点上创建/对齐宿主实例、设置初始属性或生成更新负载,并合并副作用。
  • commit:读取 WIP 树上的 Flags/负载,进行 DOM 插入/更新/删除,并完成 ref 和 effect 的附加/清理。
  1. 插入位置明确:
  • 宿主实例的“创建与属性准备”在 complete 阶段完成;
  • 真实插入 DOM 的操作在提交阶段 commitPlacement 执行。

五、最后

Fiber 架构下的渲染,本质是通过 beginWork 与 completeWork 的双向协作,完成从组件逻辑到 DOM 实例的高效转化 —— 二者如同 “分工明确的工程队”:beginWork 负责 “规划蓝图”(构建 Fiber 树结构、分解渲染任务、处理组件逻辑),completeWork 负责 “落地施工”(创建 DOM 实例、准备属性与更新负载、收集副作用),最终通过双缓存机制与提交阶段,实现视图的稳定挂载。 理解 beginWork 与 completeWork 的核心分工后,可进一步拆解 React 渲染的关键细节,形成完整的知识体系:

  1. 子 Fiber 构建的核心:reconcileChildren 逻辑:深入 mountChildFibers(初次挂载)与 reconcileChildFibers(更新)的内部实现,理解 React 如何通过 “key 比对”“类型判断” 实现子节点的复用、插入与删除,掌握 Diff 算法的核心规则;
  2. 宿主节点的差异化处理:聚焦 HostComponent(原生 DOM 节点)与 HostText(文本节点)的完整生命周期,包括 createInstance/createTextInstance 的 DOM 创建细节、setInitialProperties 的属性初始化逻辑,以及更新时 prepareUpdate 的变更计算规则;
  3. 提交阶段的副作用执行:从 completeWork 收集的副作用标记出发,学习提交阶段如何通过 commitMutationEffects(执行 DOM 变更)、commitLayoutEffects(执行布局相关副作用)等流程,将 workInProgress 树的准备结果最终应用到页面;

谁说Java枚举只是“常量装盒”?它藏着这些骚操作

2025年12月10日 21:46

提起Java枚举(Enum),很多人第一反应是“把常量打包的语法糖”,但这货的实力远不止表面——它能写方法、实现接口、甚至搞单例,堪称Java里被低估的“全能选手”。今天扒一扒枚举的进阶玩法,告别只会用它定义常量的尴尬。

一、枚举不只是“键值对”:能写方法的特殊类

枚举本质是final修饰的类,每个枚举项都是它的实例。所以它能有成员变量、方法,甚至构造器(默认private,外界无法调用)。

public enum OrderStatus {
    // 枚举项必须放在第一行,括号传参对应构造器
    PENDING(&#34;待支付&#34;, 1),
    PAID(&#34;已支付&#34;, 2),
    SHIPPED(&#34;已发货&#34;, 3),
    COMPLETED(&#34;已完成&#34;, 4);

    // 成员变量
    private final String desc;
    private final int code;

    // 构造器(private可省略,默认私有)
    OrderStatus(String desc, int code) {
        this.desc = desc;
        this.code = code;
    }

    // 普通方法
    public String getDesc() {
        return desc;
    }

    // 静态方法:根据code查枚举
    public static OrderStatus getByCode(int code) {
        for (OrderStatus status : values()) {
            if (status.code == code) {
                return status;
            }
        }
        throw new IllegalArgumentException(&#34;无效状态码:&#34; + code);
    }
}

// 测试
public class EnumTest {
    public static void main(String[] args) {
        System.out.println(OrderStatus.PAID.getDesc()); // 输出:已支付
        System.out.println(OrderStatus.getByCode(3)); // 输出:SHIPPED
    }
}

 

二、枚举实现接口:突破“常量”局限

枚举可以实现接口,让不同枚举项有不同的行为,比switch-case更优雅。

// 定义接口
interface Operation {
    int calculate(int a, int b);
}

// 枚举实现接口
public enum Calculator implements Operation {
    ADD {
        @Override
        public int calculate(int a, int b) {
            return a + b;
        }
    },
    SUBTRACT {
        @Override
        public int calculate(int a, int b) {
            return a - b;
        }
    },
    MULTIPLY {
        @Override
        public int calculate(int a, int b) {
            return a * b;
        }
    };
}

// 测试
public class EnumInterfaceTest {
    public static void main(String[] args) {
        System.out.println(Calculator.ADD.calculate(5, 3)); // 8
        System.out.println(Calculator.SUBTRACT.calculate(5, 3)); // 2
    }
}

 

三、枚举单例:最安全的单例模式

用枚举实现单例,天然避免反射破坏、序列化问题,代码极简且万无一失(Effective Java推荐方案)。

public enum Singleton {
    // 唯一实例
    INSTANCE;

    // 单例的业务方法
    public void doSomething() {
        System.out.println(&#34;枚举单例执行操作~&#34;);
    }
}

// 测试
public class SingletonTest {
    public static void main(String[] args) {
        Singleton instance1 = Singleton.INSTANCE;
        Singleton instance2 = Singleton.INSTANCE;
        System.out.println(instance1 == instance2); // true(同一实例)
        instance1.doSomething();
    }
}

 

四、避坑点:枚举的“小脾气”

1. 枚举项必须放在第一行,否则编译报错;

2. 枚举不能继承其他类(已隐式继承Enum),但可实现多个接口;

3. values()方法是编译器自动生成的(Enum类没有),返回所有枚举项数组;

4. 枚举的equals()已重写为==,无需手动实现。

总结

别再把枚举当“高级常量”用了——它是兼具类型安全、扩展性的特殊类,不管是状态管理、规则定义还是单例实现,都能玩出花。学会这些,你的Java代码会更优雅、更健壮~

❌
❌