阅读视图

发现新文章,点击刷新页面。

🚀 手动改 500 个文件?不存在的!我用 AST 撸了个 Vue 国际化神器

🚀 手动改 500 个文件?不存在的!我用 AST 撸了个 Vue 国际化神器

😱 起因:一个让人头皮发麻的需求

周一早上,产品经理笑眯眯地走过来:"小王啊,咱们这个项目要支持多语言了,你看看什么时候能搞定?"

我打开项目一看,好家伙,500+ 个 Vue 文件,里面到处都是硬编码的中文:

<h1>欢迎使用我们的系统</h1>
<button>点击提交</button>
const message = "操作成功"

按照传统做法,我需要:

  1. 打开每个文件
  2. 找到所有中文字符串
  3. 手动加上 $t()t() 包裹
  4. 把中文提取到配置文件里

粗略估算了一下,这 TM 得改到猴年马月!😭

作为一个合格的懒人程序员,我的第一反应是:

"这种重复劳动,能不能让机器来干?"

于是,我花了 n 天时间撸了个自动化工具:VueI18nify

剧透一下结果:原本预计 5 天的工作量,工具跑了 10 秒就搞定了 ✨

不过过程中也踩了不少坑,这篇文章就来聊聊我是怎么用 AST 解决这个问题的,以及那些让我抓狂的技术细节。

🎯 这玩意儿到底能干啥?

先上效果,一图胜千言!

这个工具能自动帮你:

  1. 🔍 批量扫描文件 - 递归遍历整个项目,.vue.js.ts 一个都不放过
  2. 🎨 智能包裹中文 - 自动给所有中文字符串套上 i18n 函数,该用 $t()$t(),该用 t()t()
  3. 📦 生成配置文件 - 把所有中文提取出来,整整齐齐地放进 JSON 文件

举个栗子 🌰,它会把这样的"原始代码":

<template>
  <div>
    <h1>欢迎使用</h1>
    <button @click="handleClick('点击了按钮')">点击我</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: '消息内容'
    }
  }
}
</script>

一键变身成这样的"国际化代码":

<template>
  <div>
    <h1>{{ t('欢迎使用') }}</h1>
    <button @click="handleClick(t('点击了按钮'))">{{ t('点击我') }}</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: $t('消息内容') // 注意这里用的是 $t()
    }
  }
}
</script>

同时还会贴心地生成一个 i18n-messages.json 配置文件:

{
  "欢迎使用": "欢迎使用",
  "点击了按钮": "点击了按钮",
  "点击我": "点击我",
  "消息内容": "消息内容"
}

💡 小细节:注意到了吗?模板里用的是 t(),script 里用的是 $t(),这可不是随便写的,该用啥用啥。

🤔 技术选型:正则?不,我选择 AST!

第一个想法:用正则表达式?

刚开始我也想过偷懒,直接用正则表达式匹配中文字符串,然后替换成 $t('xxx') 不就完事了?

写了两行代码后,我就放弃了...

为啥? 因为正则表达式在这个场景下就是个定时炸弹 💣:

// 这些情况正则表达式根本搞不定:

// 1. 注释里的中文不应该被替换
// 这是一个中文注释

// 2. 已经包裹过的不应该重复包裹
const msg = $t('已经包裹过了')

// 3. 字符串里的引号怎么处理?
const text = "他说:'你好'"

// 4. 模板字符串里的变量怎么办?
const greeting = `你好,${name}`

// 5. 嵌套的对象属性呢?
const obj = { title: '标题', desc: '描述' }

试了几次后,我发现用正则表达式就像拿菜刀做手术,根本不靠谱!

最终方案:AST 大法好!

既然正则不行,那就上AST(抽象语法树)!

什么是 AST?简单来说,就是把代码解析成一棵树,每个节点都有明确的类型和位置信息。就像给代码做了个 CT 扫描,啥都能看得一清二楚。

技术栈:

  • 🔧 Babel 全家桶 - 处理 JavaScript/TypeScript
    • @babel/parser - 把代码变成 AST
    • @babel/traverse - 遍历和修改 AST
    • @babel/generator - 把 AST 变回代码
  • 🎨 Vue Compiler - 处理 Vue 模板
    • @vue/compiler-dom - 解析 Vue 模板
  • 💪 TypeScript - 类型安全,写着放心

为什么 AST 这么香?

  1. 精准打击 - 只处理字符串字面量节点,注释、变量名啥的完全不会误伤
  2. 上下文感知 - 知道这个字符串是在模板里还是 script 里,该用 t() 还是 $t() 一清二楚
  3. 安全可靠 - 修改 AST 后重新生成代码,语法 100% 正确,不会出现括号不匹配之类的低级错误

🛠️ 核心实现:编译器三板斧

整个工具的架构其实很简单,就是经典的编译器三阶段:

Parser (解析) → Transformer (转换) → Generator (生成)

听起来很高大上?其实就是:读代码 → 改代码 → 写代码,就这么简单!

1️⃣ JavaScript/TypeScript 处理

对于 JS/TS 代码,主要搞定两种情况:

场景一:普通字符串
traverse(ast, {
  StringLiteral(path) {
    if (containsChinese(path.node.value)) {
      // 收集中文文本,后面要生成配置文件
      i18nCollector.add(path.node.value)

      // 检查是否已经被包裹过了,避免重复包裹
      if (isAlreadyWrappedInI18n(path)) {
        return // 已经包裹过了,跳过!
      }

      // 创建 $t() 函数调用节点
      const replaceNode = t.callExpression(t.identifier('$t'), [t.stringLiteral(path.node.value)])

      // 替换原来的节点
      path.replaceWith(replaceNode)
    }
  }
})

效果:

// 转换前
const message = '操作成功'

// 转换后
const message = $t('操作成功')
场景二:模板字符串 (这个有点坑!)

模板字符串是个大坑,因为里面可能有变量插值:

TemplateLiteral(path) {
  path.node.quasis.forEach((quasi) => {
    const text = quasi.value.raw
    if (containsChinese(text)) {
      // 将 `你好,${name}` 转换为 `${$t('你好,')}${name}`
      quasi.value.raw = `\${$t('${text.trim()}')}`
    }
  })
}

效果:

// 转换前
const greeting = `你好,${name}!欢迎使用`

// 转换后
const greeting = `${$t('你好,')}${name}${$t('!欢迎使用')}`

💡 踩坑记录:一开始我直接把整个模板字符串替换成 $t(\你好,${name}`)`,结果发现 i18n 不支持这种写法...后来才知道要把固定的文本部分提取出来单独包裹。

2️⃣ Vue 模板处理

Vue 模板比 JS 代码复杂多了,因为要处理各种节点类型。

场景一:文本节点

这个最简单,直接包裹就行:

const transformText = (node: TextNode): string => {
  const content = node.content
  if (containsChinese(content)) {
    i18nCollector.add(content.trim())

    // 检查是否已经包裹过了
    if (content.includes('t(')) {
      return content
    }

    // 包裹成 {{ t('xxx') }}
    return `{{ t('${content.trim()}') }}`
  }
  return content
}

效果:

<!-- 转换前 -->
<h1>欢迎使用</h1>

<!-- 转换后 -->
<h1>{{ t('欢迎使用') }}</h1>
场景二:属性节点

属性里的中文也要处理,而且要把普通属性改成动态绑定:

// 普通属性:placeholder="请输入"
// 转换为::placeholder="t('请输入')"

if (containsChinese(value)) {
  i18nCollector.add(value)
  return `:${attrName}="t('${value}')"` // 注意前面加了冒号!
}

效果:

<!-- 转换前 -->
<input placeholder="请输入用户名" />

<!-- 转换后 -->
<input :placeholder="t('请输入用户名')" />
场景三:事件处理器中的字符串
<!-- 转换前 -->
<button @click="handleClick('点击了按钮')">按钮</button>

<!-- 转换后 -->
<button @click="handleClick(t('点击了按钮'))">{{ t('按钮') }}</button>

这里需要解析事件处理器中的 JavaScript 表达式,找到字符串参数并替换。

实现方式:把事件处理器的表达式当成 JS 代码,用 Babel 处理一遍!

🎓 总结:

收获

这个项目虽然小,但让我对 AST 和编译原理有了更深的理解:

  1. AST 不是玄学 - 其实就是把代码变成树形结构,然后遍历修改,最后再变回代码
  2. 工具链很重要 - Babel 和 Vue Compiler 这些成熟的工具能省很多事
  3. 边界情况很多 - 看似简单的需求,实际实现起来要考虑各种边界情况
  4. 完成比完美重要 - 先做出能用的版本,再慢慢优化

项目地址: github.com/baozjj/VueI…

技术栈: TypeScript | Babel | AST | Vue Compiler

如果觉得有用,欢迎 Star ⭐️

Vue首屏加速秘籍 组件按需加载真能省一半时间

Vue首屏加速秘籍 组件按需加载真能省一半时间

做Vue开发的朋友大概都遇过这种情况,项目越做越大,组件堆得越来越多,首屏打开时白屏半天,用户早就没耐心了。其实这不是框架的问题,而是咱们加载方式不对——默认全量加载把所有组件都塞进首屏资源里,用不上的也跟着占带宽。

搞定动态组件加异步加载这套组合拳,性能立马提上来,首屏速度翻倍不是夸张。

动态组件 实现灵活切换的基础操作

Vue的component is语法是动态切换组件的基础,像标签页、表单切换这些场景都能用。点击不同按钮,就渲染对应的组件,逻辑很直观。

<template>
  <div>
    <button @click="currentComp = 'UserInfo'">用户信息</button>
    <button @click="currentComp = 'OrderList'">订单列表</button>
    <button @click="currentComp = 'Settings'">系统设置</button>
    
    <component :is="currentComp" />
  </div>
</template>

<script>
import UserInfo from './UserInfo.vue'
import OrderList from './OrderList.vue'
import Settings from './Settings.vue'

export default {
  components: { UserInfo, OrderList, Settings },
  data() {
    return {
      currentComp: 'UserInfo'
    }
  }
}
</script>

不过得注意,这种写法还是会把所有组件全量加载进来,只是实现了切换效果,没解决性能瓶颈。

异步组件 按需加载的关键杀招

真正能优化性能的是Vue3的defineAsyncComponent,它能让组件在被实际使用时才去加载,首屏不用扛着所有组件跑了。

<script>
import { defineAsyncComponent } from 'vue'

export default {
  components: {
    UserInfo: defineAsyncComponent(() => import('./UserInfo.vue')),
    OrderList: defineAsyncComponent(() => import('./OrderList.vue')),
    Settings: defineAsyncComponent(() => import('./Settings.vue'))
  },
  data() {
    return {
      currentComp: 'UserInfo'
    }
  }
}
</script>

这么一改,首屏只加载默认显示的组件,其他组件等用户点击了再加载。实测下来,首屏加载时间能减少六成以上,效果很明显。

进阶优化 加载状态和错误处理不能少

网络不好的时候,异步组件加载可能会慢或者失败,加个加载状态和错误处理,用户体验会好很多。

<script>
import { defineAsyncComponent } from 'vue'
import Loading from './Loading.vue'
import ErrorView from './ErrorView.vue'

const AsyncUserInfo = defineAsyncComponent({
  loader: () => import('./UserInfo.vue'),
  loadingComponent: Loading,
  errorComponent: ErrorView,
  delay: 200,
  timeout: 3000,
  onError(error, retry, fail) {
    console.error('加载失败', error)
    if (error.message.includes('超时')) {
      retry()
    } else {
      fail()
    }
  }
})

export default {
  components: { UserInfo: AsyncUserInfo }
}
</script>

加载时显示转圈动画,超时或出错了显示提示,网络问题还能重试,交互更顺畅。

keep-alive 缓存组件状态不丢失

动态切换组件时,默认会销毁旧组件,之前填的表单、滚到的位置都没了。用keep-alive把组件包起来,就能缓存实例,保留状态。

<template>
  <div>
    <button @click="currentComp = 'UserInfo'">用户信息</button>
    <button @click="currentComp = 'OrderList'">订单列表</button>
    
    <keep-alive>
      <component :is="currentComp" />
    </keep-alive>
  </div>
</template>

还能通过include指定要缓存的组件,避免缓存太多占内存。

<keep-alive :include="['UserInfo', 'OrderList']">
  <component :is="currentComp" />
</keep-alive>

实战避坑要点

实测过,10个组件的页面,全量加载首屏要3.5秒,用异步加载后只要1.2秒。不过有几点要注意:组件name要和文件名一致,不然keep-alive缓存会失效;必须加错误处理,不然网络差的时候会出问题;缓存别贪多,只缓存常用组件就行。

把component is、defineAsyncComponent和keep-alive结合起来,既能灵活切换组件,又能保证性能,还能保留状态,Vue动态组件的优化就到位了。

海云前端丨前端开发丨简历面试辅导丨求职陪跑

为什么Vue 3的计算属性能解决模板臃肿、性能优化和双向同步三大痛点?

一、计算属性的基本使用

计算属性(Computed)是Vue 3中用于派生状态的核心API,它能将复杂的逻辑从模板中抽离,让代码更简洁、可维护。

1.1 为什么需要计算属性?

模板表达式(如{{ author.books.length > 0 ? 'Yes' : 'No' }})适合简单逻辑,但如果逻辑复杂或需要重复使用,直接写在模板里会有两个问题:

  • 模板臃肿:大量逻辑会让模板难以阅读;
  • 代码冗余:同一逻辑重复写多次,维护成本高。

计算属性的作用就是将派生逻辑封装成“虚拟属性”,让模板只负责展示,逻辑交给计算属性处理。

1.2 Options API 中的计算属性

在Options API中,计算属性通过computed选项定义,它是一个对象,键是计算属性名,值是getter函数(用于计算值)。

示例:判断作者是否有出版书籍

export default {
  data() {
    return {
      author: {
        name: 'John Doe',
        books: ['Vue 2 - Advanced Guide', 'Vue 3 - Basic Guide', 'Vue 4 - The Mystery']
      }
    }
  },
  computed: {
    // 计算属性的getter函数
    publishedBooksMessage() {
      // this 指向组件实例,自动追踪author.books的变化
      return this.author.books.length > 0 ? 'Yes' : 'No'
    }
  }
}

模板中直接使用计算属性,和普通data属性一样:

<template>
  <p>Has published books:</p>
  <span>{{ publishedBooksMessage }}</span>
</template>

1.3 Composition API 中的计算属性(<script setup>

在Composition API中,使用computed()函数创建计算属性,返回的是computed ref(可响应的引用类型)。

示例(与上面功能一致):

<script setup>
import { reactive, computed } from 'vue'

// 响应式数据:作者信息
const author = reactive({
  name: 'John Doe',
  books: ['Vue 2 - Advanced Guide', 'Vue 3 - Basic Guide', 'Vue 4 - The Mystery']
})

// 计算属性:判断是否有书籍
const publishedBooksMessage = computed(() => {
  // 依赖author.books,自动追踪变化
  return author.books.length > 0 ? 'Yes' : 'No'
})
</script>

<template>
  <p>Has published books:</p>
  <span>{{ publishedBooksMessage }}</span> <!-- 自动解包,无需.value -->
</template>

注意

  • computed()函数接收一个getter函数,返回的publishedBooksMessageComputedRef类型;
  • 在模板中,computed ref会自动解包(不用写.value);
  • <script>中访问时,需要用.value(如publishedBooksMessage.value)。

二、计算属性的缓存机制:与方法的本质区别

计算属性和方法都能实现逻辑复用,但缓存机制是它们的核心区别。

2.1 缓存的意义:性能优化

计算属性的结果会基于依赖自动缓存——只有当依赖的响应式数据变化时,才会重新计算;否则直接返回缓存值。

而方法没有缓存,每次调用(如模板渲染、函数调用)都会重新执行逻辑。

往期文章归档
免费好用的热门在线工具

示例:对比计算属性和方法

// 计算属性:缓存结果
const expensiveComputed = computed(() => {
  console.log('计算属性执行了')
  return heavyCalculation() // 假设这是一个耗时操作
})

// 方法:无缓存
function expensiveMethod() {
  console.log('方法执行了')
  return heavyCalculation()
}
  • 当依赖不变时,多次访问expensiveComputed.value,只会打印一次“计算属性执行了”;
  • 多次调用expensiveMethod(),每次都会打印“方法执行了”。

2.2 依赖追踪:只更新必要的计算

Vue会自动追踪计算属性的依赖(即getter函数中用到的响应式数据),只有依赖变化时,计算属性才会重新计算。

反例:依赖非响应式数据

const now = computed(() => Date.now()) // Date.now()不是响应式数据

无论过多久,now.value都不会更新——因为Vue无法追踪Date.now()的变化。

三、可写计算属性:处理双向逻辑

默认情况下,计算属性是只读的(只有getter),但在某些场景下,我们需要通过计算属性反向修改源数据(如fullName同步firstNamelastName),这时可以用可写计算属性(同时定义getter和setter)。

3.1 场景需求:双向同步

比如,我们有firstNamelastName两个响应式数据,希望通过fullName(如“John Doe”)同时读取和修改它们:

  • 读取fullName时,返回firstName + ' ' + lastName
  • 修改fullName时,将新值拆分成firstNamelastName

3.2 可写计算属性的实现

可写计算属性需要同时定义get(读取逻辑)和set(修改逻辑)。

Options API 写法

export default {
  data() {
    return {
      firstName: 'John',
      lastName: 'Doe'
    }
  },
  computed: {
    fullName: {
      // getter:组合firstName和lastName
      get() {
        return this.firstName + ' ' + this.lastName
      },
      // setter:拆分新值到firstName和lastName
      set(newValue) {
        [this.firstName, this.lastName] = newValue.split(' ')
      }
    }
  }
}

Composition API 写法

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
  // getter:组合firstName和lastName
  get() {
    return firstName.value + ' ' + lastName.value
  },
  // setter:拆分新值到firstName和lastName
  set(newValue) {
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})
</script>

使用示例

// 修改fullName,会同步更新firstName和lastName
fullName.value = 'Jane Smith'
console.log(firstName.value) // Jane
console.log(lastName.value)  // Smith

四、获取计算属性的之前值

Vue 3.4新增了获取计算属性之前值的能力,通过computed()函数的previous参数实现。这在需要保留历史状态的场景中很有用(如“只能减小不能超过某个值”)。

4.1 应用场景:保留历史状态

比如,我们希望alwaysSmall始终返回count的值,但当count超过3时,保持之前的最大值(3):

<script setup>
import { ref, computed } from 'vue'

const count = ref(2)
const alwaysSmall = computed((previous) => {
  if (count.value <= 3) {
    return count.value // 当count≤3时,返回当前值
  }
  return previous // 当count>3时,返回之前的value(最大是3)
})
</script>

效果

  • count从2→3→4时,alwaysSmall.value依次是2→3→3;
  • count从4→2时,alwaysSmall.value变回2。

五、计算属性的最佳实践

为了避免踩坑,使用计算属性时需遵循以下规则:

5.1 保持 getter 无副作用

计算属性的getter应该是纯函数——只依赖输入计算输出,不修改任何外部状态(如修改其他响应式数据、发送请求、操作DOM)。

反例(错误写法):

const badComputed = computed(() => {
  this.author.name = 'Jane' // 错误:修改了其他状态
  return this.author.books.length > 0 ? 'Yes' : 'No'
})

这样会导致逻辑混乱,因为getter的职责是计算值,而不是修改状态。修改状态的操作应该放在methodswatch中。

5.2 不要直接修改计算属性的值

计算属性是派生状态(由源数据计算而来),直接修改计算属性的值是没有意义的——因为它会被下一次计算覆盖。

反例(错误写法):

// publishedBooksMessage是计算属性(无setter)
publishedBooksMessage.value = 'No' // 错误:只读属性

正确的做法是修改源数据(如author.books = []),让计算属性自动更新。

六、课后 Quiz:巩固你的理解

问题1:计算属性和方法的核心区别是什么?

答案
计算属性基于依赖缓存,只有依赖变化时才重新计算;方法无缓存,每次调用都重新执行。
解析:缓存是计算属性的核心优势,适合需要重复使用且依赖稳定的逻辑(如过滤列表);方法适合不需要缓存的场景(如事件处理)。

问题2:如何创建一个可写的计算属性?

答案
通过同时定义get(读取逻辑)和set(修改逻辑)实现:

  • Options API:在computed选项中写{ get() {}, set(newValue) {} }
  • Composition API:调用computed({ get() {}, set(newValue) {} })
    示例:参考“三、可写计算属性”中的fullName例子。

问题3:为什么计算属性中使用Date.now()不会更新?

答案
因为Date.now()不是响应式依赖,Vue无法追踪它的变化。计算属性只会在依赖的响应式数据变化时重新计算。
拓展:如果需要实时获取时间,应该用setIntervalwatch,而不是计算属性。

七、常见报错及解决方案

报错1:计算属性返回undefined

原因getter函数没有返回值(忘记写return)。
示例

const badComputed = computed(() => {
  author.books.length > 0 ? 'Yes' : 'No' // 忘记return
})

解决:确保getter函数有return语句。

报错2:无法修改只读计算属性

报错信息Set operation on key "xxx" failed: computed value is readonly
原因:尝试修改无setter的计算属性(默认是只读的)。
解决

  • 如果需要修改,给计算属性添加setter(参考“三、可写计算属性”);
  • 不要直接修改计算属性,改为修改源数据。

报错3:计算属性不随数据更新

原因

  1. 依赖了非响应式数据(如普通变量,不是ref/reactive);
  2. getter函数中没有用到响应式数据(即无依赖)。
    解决
  • 将数据转为响应式(用refreactive包裹);
  • 确保getter函数中用到了响应式数据。

参考链接

vuejs.org/guide/essen…

Playwright 中route 方法模拟测试数据(Mocking)详解

Mocking 是 route 方法最重要的应用之一,用于在测试中模拟后端 API 响应,实现测试与真实后端服务的解耦。

1. 基础 Mocking 模式

基本响应模拟

from playwright.sync_api import sync_playwright

def test_basic_mock():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        
        # 模拟用户列表 API
        def mock_users(route):
            route.fulfill(
                status=200,
                content_type="application/json",
                body='{"users": [{"id": 1, "name": "张三"}, {"id": 2, "name": "李四"}]}'
            )
        
        page.route("**/api/users", mock_users)
        page.goto("http://localhost:3000")
        
        # 此时页面调用 /api/users 将返回模拟数据
        browser.close()

2. 动态 Mocking 数据

基于请求参数的动态响应

def dynamic_mock_based_on_request(route):
    request = route.request
    
    # 根据查询参数返回不同数据
    if "user_id=1" in request.url:
        route.fulfill(
            status=200,
            body='{"id": 1, "name": "管理员", "role": "admin"}'
        )
    elif "user_id=2" in request.url:
        route.fulfill(
            status=200,
            body='{"id": 2, "name": "普通用户", "role": "user"}'
        )
    else:
        route.fulfill(status=404, body='{"error": "用户不存在"}')

page.route("**/api/user**", dynamic_mock_based_on_request)

基于请求体的动态响应

def mock_based_on_post_data(route):
    request = route.request
    
    if request.method == "POST" and request.post_data:
        post_data = request.post_data
        
        if "login" in request.url:
            # 解析登录数据
            if "admin" in post_data:
                route.fulfill(
                    status=200,
                    body='{"token": "admin-token", "user": {"role": "admin"}}'
                )
            else:
                route.fulfill(
                    status=200,
                    body='{"token": "user-token", "user": {"role": "user"}}'
                )
        else:
            route.continue_()
    else:
        route.continue_()

page.route("**/api/**", mock_based_on_post_data)

3. 复杂业务场景 Mocking

完整的 CRUD 操作模拟

class UserServiceMock:
    def __init__(self):
        self.users = [
            {"id": 1, "name": "张三", "email": "zhang@example.com"},
            {"id": 2, "name": "李四", "email": "li@example.com"}
        ]
        self.next_id = 3
    
    def handle_user_routes(self, route):
        request = route.request
        
        if request.method == "GET":
            self.handle_get(route)
        elif request.method == "POST":
            self.handle_post(route)
        elif request.method == "PUT":
            self.handle_put(route)
        elif request.method == "DELETE":
            self.handle_delete(route)
    
    def handle_get(self, route):
        if route.request.url.endswith("/api/users"):
            # 获取用户列表
            route.fulfill(
                status=200,
                body=json.dumps({"users": self.users})
            )
        else:
            # 获取单个用户
            user_id = int(route.request.url.split("/")[-1])
            user = next((u for u in self.users if u["id"] == user_id), None)
            if user:
                route.fulfill(status=200, body=json.dumps(user))
            else:
                route.fulfill(status=404, body='{"error": "用户不存在"}')
    
    def handle_post(self, route):
        # 创建用户
        post_data = json.loads(route.request.post_data)
        new_user = {
            "id": self.next_id,
            "name": post_data["name"],
            "email": post_data["email"]
        }
        self.users.append(new_user)
        self.next_id += 1
        route.fulfill(status=201, body=json.dumps(new_user))
    
    def handle_put(self, route):
        # 更新用户
        user_id = int(route.request.url.split("/")[-1])
        user_data = json.loads(route.request.post_data)
        
        for user in self.users:
            if user["id"] == user_id:
                user.update(user_data)
                route.fulfill(status=200, body=json.dumps(user))
                return
        route.fulfill(status=404, body='{"error": "用户不存在"}')
    
    def handle_delete(self, route):
        # 删除用户
        user_id = int(route.request.url.split("/")[-1])
        self.users = [u for u in self.users if u["id"] != user_id]
        route.fulfill(status=204)

# 使用模拟服务
user_mock = UserServiceMock()
page.route("**/api/users**", user_mock.handle_user_routes)

4. 错误场景模拟

HTTP 错误状态模拟

def mock_error_scenarios(route):
    # 模拟各种错误场景
    if "/api/timeout" in route.request.url:
        # 模拟超时(需要异步处理)
        route.fulfill(status=408, body='{"error": "请求超时"}')
    elif "/api/server-error" in route.request.url:
        route.fulfill(status=500, body='{"error": "服务器内部错误"}')
    elif "/api/not-found" in route.request.url:
        route.fulfill(status=404, body='{"error": "资源不存在"}')
    elif "/api/unauthorized" in route.request.url:
        route.fulfill(status=401, body='{"error": "未授权"}')
    elif "/api/forbidden" in route.request.url:
        route.fulfill(status=403, body='{"error": "禁止访问"}')
    else:
        route.continue_()

page.route("**/api/**", mock_error_scenarios)

网络异常模拟

def mock_network_issues(route):
    import random
    scenario = random.choice(["timeout", "error", "success"])
    
    if scenario == "timeout":
        # 在实际项目中可能需要异步处理
        route.fulfill(status=408, body='{"error": "请求超时"}')
    elif scenario == "error":
        route.fulfill(status=500, body='{"error": "服务器错误"}')
    else:
        route.fulfill(
            status=200,
            body='{"status": "success", "data": "正常响应"}'
        )

# 模拟不稳定的网络
page.route("**/api/unstable**", mock_network_issues)

5. 高级 Mocking 技巧

基于请求头的条件 Mocking

def mock_based_on_headers(route):
    request = route.request
    auth_header = request.headers.get("authorization", "")
    
    if "admin-token" in auth_header:
        # 管理员权限数据
        route.fulfill(
            status=200,
            body='{"data": "管理员数据", "permissions": ["read", "write", "delete"]}'
        )
    elif "user-token" in auth_header:
        # 普通用户数据
        route.fulfill(
            status=200,
            body='{"data": "用户数据", "permissions": ["read"]}'
        )
    else:
        # 未授权
        route.fulfill(status=401, body='{"error": "未授权"}')

page.route("**/api/secure-data**", mock_based_on_headers)

延迟响应模拟

import asyncio

async def mock_with_delay(route):
    # 模拟网络延迟
    await asyncio.sleep(2)
    
    await route.fulfill(
        status=200,
        body='{"message": "延迟响应", "delay": 2000}'
    )

await page.route("**/api/slow**", mock_with_delay)

6. 测试用例中的最佳实践

使用 fixture 封装 Mocking

import pytest
from playwright.sync_api import Page

@pytest.fixture
def setup_user_mock(page: Page):
    """设置用户相关的 API Mock"""
    def mock_users(route):
        route.fulfill(
            status=200,
            body=json.dumps({
                "users": [
                    {"id": 1, "name": "测试用户1", "active": True},
                    {"id": 2, "name": "测试用户2", "active": False}
                ]
            })
        )
    
    page.route("**/api/users", mock_users)
    return page

def test_user_list(setup_user_mock):
    page = setup_user_mock
    page.goto("/users")
    
    # 断言页面显示了模拟的用户数据
    assert page.text_content(".user-name") == "测试用户1"

动态修改 Mock 数据

class MockManager:
    def __init__(self, page):
        self.page = page
        self.user_data = {"users": []}
    
    def setup_user_mock(self):
        def user_handler(route):
            route.fulfill(
                status=200,
                body=json.dumps(self.user_data)
            )
        
        self.page.route("**/api/users", user_handler)
    
    def set_user_data(self, users):
        self.user_data = {"users": users}

# 在测试中使用
def test_different_user_scenarios():
    mock_manager = MockManager(page)
    mock_manager.setup_user_mock()
    
    # 测试空用户列表
    mock_manager.set_user_data([])
    page.goto("/users")
    assert page.locator(".no-users").is_visible()
    
    # 测试有用户的情况
    mock_manager.set_user_data([{"id": 1, "name": "测试用户"}])
    page.reload()
    assert page.locator(".user-item").is_visible()

7. 调试和验证

验证 Mock 是否生效

def mock_with_verification(route):
    print(f"Mocking request: {route.request.url}")
    print(f"Request method: {route.request.method}")
    print(f"Request headers: {route.request.headers}")
    
    # 记录 Mock 被调用的次数
    mock_with_verification.call_count = getattr(mock_with_verification, 'call_count', 0) + 1
    
    route.fulfill(
        status=200,
        body='{"status": "mocked"}'
    )

mock_with_verification.call_count = 0
page.route("**/api/test**", mock_with_verification)

# 测试完成后验证
assert mock_with_verification.call_count > 0, "Mock 应该被调用"

这些 Mocking 技术可以帮助你创建稳定、可靠的测试环境,确保测试不依赖外部服务,提高测试速度和可靠性。

鸿蒙Media Kit媒体服务开发快速指南

一、Media Kit概述

Media Kit是HarmonyOS提供的媒体服务框架,提供音视频播放、录制、编解码等全方位的媒体处理能力。

1.1 核心能力

能力类型 说明 主要API
音频播放 播放各种格式音频文件 AVPlayer, SoundPool, AudioRenderer
视频播放 播放各种格式视频文件 AVPlayer, Video组件
音频录制 录制音频 AVRecorder, AudioCapturer
视频录制 录制视频 AVRecorder
音视频解码 解码音视频数据 AVDemuxer, AVDecoder
音视频编码 编码音视频数据 AVMuxer, AVEncoder

1.2 选择合适的API

音频播放场景:

graph TB
    A[选择音频播放API] --> B{场景判断}
    B -->|简短音效<5s| C[SoundPool]
    B -->|完整音频文件| D[AVPlayer]
    B -->|PCM数据流| E[AudioRenderer]
    B -->|音振协同| F[AudioHaptic]

    C --> G[低时延播放]
    D --> H[支持多种格式]
    E --> I[需要预处理]
    F --> J[铃声/反馈]

视频播放场景:

  • AVPlayer: 功能完善,支持多种格式,适合专业视频播放
  • Video组件: UI组件封装,简单易用,适合快速开发

二、音频播放开发

2.1 使用AVPlayer播放音频

AVPlayer适用于播放完整的音频文件,支持mp3、m4a、flac等格式。

2.1.1 AVPlayer状态机

stateDiagram-v2
    [*] --> idle: createAVPlayer()
    idle --> initialized: url设置
    initialized --> prepared: prepare()
    prepared --> playing: play()
    playing --> paused: pause()
    paused --> playing: play()
    playing --> stopped: stop()
    stopped --> prepared: prepare()
    prepared --> released: release()
    playing --> released: release()
    released --> [*]

2.1.2 完整示例

import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';

class AudioPlayer {
  private avPlayer: media.AVPlayer | undefined = undefined;

  async init() {
    // 1. 创建AVPlayer实例
    this.avPlayer = await media.createAVPlayer();

    // 2. 设置状态变化监听
    this.avPlayer.on('stateChange', (state: string, reason: media.StateChangeReason) => {
      console.info(`AVPlayer state changed to: ${state}, reason: ${reason}`);
    });

    // 3. 设置错误监听
    this.avPlayer.on('error', (error: BusinessError) => {
      console.error(`AVPlayer error: ${error.code}, ${error.message}`);
    });

    // 4. 监听播放进度
    this.avPlayer.on('timeUpdate', (time: number) => {
      console.info(`Current time: ${time}ms`);
    });

    // 5. 监听播放完成
    this.avPlayer.on('durationUpdate', (duration: number) => {
      console.info(`Total duration: ${duration}ms`);
    });
  }

  async playAudio(url: string) {
    if (!this.avPlayer) {
      return;
    }

    try {
      // 设置音频资源
      this.avPlayer.url = url;
      // 准备播放
      await this.avPlayer.prepare();
      // 开始播放
      await this.avPlayer.play();
    } catch (err) {
      let error = err as BusinessError;
      console.error(`Play failed: ${error.code}, ${error.message}`);
    }
  }

  async pause() {
    if (this.avPlayer && this.avPlayer.state === 'playing') {
      await this.avPlayer.pause();
    }
  }

  async resume() {
    if (this.avPlayer && this.avPlayer.state === 'paused') {
      await this.avPlayer.play();
    }
  }

  async seek(time: number) {
    if (this.avPlayer) {
      await this.avPlayer.seek(time, media.SeekMode.SEEK_PREV_SYNC);
    }
  }

  async stop() {
    if (this.avPlayer) {
      await this.avPlayer.stop();
    }
  }

  async release() {
    if (this.avPlayer) {
      await this.avPlayer.release();
      this.avPlayer = undefined;
    }
  }
}

// 使用示例
let player = new AudioPlayer();
await player.init();
await player.playAudio('https://example.com/audio.mp3');

2.2 使用SoundPool播放短音

SoundPool适用于播放低时延的短音效,如按键音、提示音等。

2.2.1 工作流程

sequenceDiagram
    participant App as 应用
    participant SP as SoundPool
    participant Decoder as 解码器
    participant Audio as 音频系统

    App->>SP: createSoundPool()
    App->>SP: load(fd)
    SP->>Decoder: 解码音频
    Decoder-->>SP: 解码完成
    SP->>App: on('loadComplete')
    App->>SP: play(soundId)
    SP->>Audio: 播放音频
    Audio-->>SP: 播放完成
    SP->>App: on('playFinished')

2.2.2 代码示例

import { media } from '@kit.MediaKit';
import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';

class SoundEffectPlayer {
  private soundPool: media.SoundPool | undefined = undefined;
  private soundId: number = 0;
  private streamId: number = 0;

  async init() {
    // 1. 配置音频渲染信息
    let audioRendererInfo: audio.AudioRendererInfo = {
      usage: audio.StreamUsage.STREAM_USAGE_MUSIC,
      rendererFlags: 1
    };

    // 2. 创建SoundPool实例,最大支持14个并发流
    this.soundPool = await media.createSoundPool(14, audioRendererInfo);

    // 3. 监听资源加载完成
    this.soundPool.on('loadComplete', (soundId: number) => {
      this.soundId = soundId;
      console.info(`Sound loaded: ${soundId}`);
    });

    // 4. 监听播放完成
    this.soundPool.on('playFinishedWithStreamId', (streamId: number) => {
      console.info(`Play finished: ${streamId}`);
    });

    // 5. 监听错误
    this.soundPool.on('error', (error: BusinessError) => {
      console.error(`SoundPool error: ${error.code}`);
    });
  }

  async loadSound(fd: number, offset: number, length: number) {
    if (this.soundPool) {
      this.soundId = await this.soundPool.load(fd, offset, length);
    }
  }

  async playSound() {
    if (!this.soundPool) {
      return;
    }

    let playParameters: media.PlayParameters = {
      loop: 0,      // 不循环
      rate: 1,      // 正常速度
      leftVolume: 1.0,
      rightVolume: 1.0,
      priority: 0
    };

    this.soundPool.play(this.soundId, playParameters, (error, streamId: number) => {
      if (error) {
        console.error(`Play error: ${error.code}`);
      } else {
        this.streamId = streamId;
        console.info(`Playing stream: ${streamId}`);
      }
    });
  }

  async release() {
    if (this.soundPool) {
      await this.soundPool.off('loadComplete');
      await this.soundPool.off('playFinishedWithStreamId');
      await this.soundPool.off('error');
      await this.soundPool.release();
      this.soundPool = undefined;
    }
  }
}

// 使用示例
let soundPlayer = new SoundEffectPlayer();
await soundPlayer.init();

// 从rawfile加载音频
let context = getContext(this);
let fileDescriptor = await context.resourceManager.getRawFd('click.ogg');
await soundPlayer.loadSound(fileDescriptor.fd, fileDescriptor.offset, fileDescriptor.length);
await soundPlayer.playSound();

三、视频播放开发

3.1 AVPlayer视频播放

视频播放在音频播放基础上增加了视窗显示功能。

3.1.1 关键步骤

sequenceDiagram
    participant App as 应用
    participant AVP as AVPlayer
    participant XComp as XComponent
    participant Video as 视频系统

    App->>AVP: createAVPlayer()
    App->>AVP: 设置监听
    App->>AVP: url = videoUrl
    App->>XComp: 获取surfaceId
    XComp-->>App: surfaceId
    App->>AVP: surfaceId = id
    App->>AVP: prepare()
    AVP->>Video: 初始化视频管道
    App->>AVP: play()
    AVP->>Video: 渲染视频帧
    Video-->>XComp: 显示画面

3.1.2 完整代码示例

import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct VideoPlayer {
  private avPlayer: media.AVPlayer | undefined = undefined;
  private surfaceId: string = '';
  @State isPlaying: boolean = false;
  @State currentTime: number = 0;
  @State duration: number = 0;

  async aboutToAppear() {
    await this.initPlayer();
  }

  async initPlayer() {
    // 1. 创建AVPlayer
    this.avPlayer = await media.createAVPlayer();

    // 2. 设置状态监听
    this.avPlayer.on('stateChange', (state: string, reason: media.StateChangeReason) => {
      console.info(`State: ${state}`);
      if (state === 'playing') {
        this.isPlaying = true;
      } else if (state === 'paused' || state === 'stopped') {
        this.isPlaying = false;
      }
    });

    // 3. 设置错误监听
    this.avPlayer.on('error', (error: BusinessError) => {
      console.error(`Error: ${error.code}`);
    });

    // 4. 监听时长
    this.avPlayer.on('durationUpdate', (duration: number) => {
      this.duration = duration;
    });

    // 5. 监听进度
    this.avPlayer.on('timeUpdate', (time: number) => {
      this.currentTime = time;
    });

    // 6. 监听视频尺寸变化
    this.avPlayer.on('videoSizeChange', (width: number, height: number) => {
      console.info(`Video size: ${width}x${height}`);
    });

    // 7. 监听首帧渲染
    this.avPlayer.on('startRenderFrame', () => {
      console.info('First frame rendered');
    });
  }

  async playVideo(url: string) {
    if (!this.avPlayer || !this.surfaceId) {
      return;
    }

    try {
      // 设置视频URL
      this.avPlayer.url = url;
      // 设置窗口
      this.avPlayer.surfaceId = this.surfaceId;
      // 准备播放
      await this.avPlayer.prepare();
      // 开始播放
      await this.avPlayer.play();
    } catch (err) {
      let error = err as BusinessError;
      console.error(`Play failed: ${error.message}`);
    }
  }

  async pauseVideo() {
    if (this.avPlayer && this.isPlaying) {
      await this.avPlayer.pause();
    }
  }

  async resumeVideo() {
    if (this.avPlayer && !this.isPlaying) {
      await this.avPlayer.play();
    }
  }

  async seekTo(time: number) {
    if (this.avPlayer) {
      await this.avPlayer.seek(time);
    }
  }

  formatTime(ms: number): string {
    let seconds = Math.floor(ms / 1000);
    let minutes = Math.floor(seconds / 60);
    let remainingSeconds = seconds % 60;
    return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
  }

  build() {
    Column() {
      // 视频显示区域
      XComponent({
        id: 'video_player',
        type: XComponentType.SURFACE,
        controller: new XComponentController()
      })
        .onLoad((context) => {
          // 获取surfaceId
          this.surfaceId = context.getXComponentSurfaceId();
          // 播放视频
          this.playVideo('https://example.com/video.mp4');
        })
        .width('100%')
        .height(300)

      // 播放控制区域
      Row({ space: 20 }) {
        // 播放/暂停按钮
        Button(this.isPlaying ? '暂停' : '播放')
          .onClick(() => {
            if (this.isPlaying) {
              this.pauseVideo();
            } else {
              this.resumeVideo();
            }
          })

        // 进度显示
        Text(`${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}`)
          .fontSize(14)
      }
      .width('90%')
      .margin({ top: 20 })

      // 进度条
      Slider({
        value: this.currentTime,
        min: 0,
        max: this.duration,
        step: 1000
      })
        .width('90%')
        .onChange((value: number) => {
          this.seekTo(value);
        })
    }
    .width('100%')
    .height('100%')
  }

  aboutToDisappear() {
    if (this.avPlayer) {
      this.avPlayer.release();
    }
  }
}

四、音频录制开发

4.1 使用AudioCapturer录制音频

AudioCapturer用于采集PCM音频数据。

import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo as fs } from '@kit.CoreFileKit';

class AudioRecorder {
  private audioCapturer: audio.AudioCapturer | undefined = undefined;
  private audioFile: fs.File | undefined = undefined;

  async init() {
    // 1. 配置音频采集参数
    let audioStreamInfo: audio.AudioStreamInfo = {
      samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100,
      channels: audio.AudioChannel.CHANNEL_2,
      sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
      encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
    };

    let audioCapturerInfo: audio.AudioCapturerInfo = {
      source: audio.SourceType.SOURCE_TYPE_MIC,
      capturerFlags: 0
    };

    let audioCapturerOptions: audio.AudioCapturerOptions = {
      streamInfo: audioStreamInfo,
      capturerInfo: audioCapturerInfo
    };

    // 2. 创建AudioCapturer
    this.audioCapturer = await audio.createAudioCapturer(audioCapturerOptions);

    // 3. 监听状态变化
    this.audioCapturer.on('stateChange', (state: audio.AudioState) => {
      console.info(`AudioCapturer state: ${state}`);
    });
  }

  async startRecording(filePath: string) {
    if (!this.audioCapturer) {
      return;
    }

    try {
      // 创建文件
      this.audioFile = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);

      // 启动录制
      await this.audioCapturer.start();

      // 读取音频数据并写入文件
      let bufferSize = await this.audioCapturer.getBufferSize();
      let buffer = await this.audioCapturer.read(bufferSize, true);
      fs.writeSync(this.audioFile.fd, buffer);
    } catch (err) {
      let error = err as BusinessError;
      console.error(`Recording failed: ${error.message}`);
    }
  }

  async stopRecording() {
    if (this.audioCapturer) {
      await this.audioCapturer.stop();
      await this.audioCapturer.release();
    }
    if (this.audioFile) {
      fs.closeSync(this.audioFile);
    }
  }
}

五、视频录制开发

5.1 使用AVRecorder录制视频

AVRecorder集成了音视频采集、编码、封装功能。

5.1.1 录制状态机

stateDiagram-v2
    [*] --> idle: createAVRecorder()
    idle --> prepared: prepare()
    prepared --> started: start()
    started --> paused: pause()
    paused --> started: resume()
    started --> stopped: stop()
    stopped --> prepared: prepare()
    prepared --> released: release()
    released --> [*]

5.1.2 代码示例

import { media } from '@kit.MediaKit';
import { camera } from '@kit.CameraKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo as fs } from '@kit.CoreFileKit';

class VideoRecorder {
  private avRecorder: media.AVRecorder | undefined = undefined;
  private videoFile: fs.File | undefined = undefined;

  async init() {
    // 1. 创建AVRecorder
    this.avRecorder = await media.createAVRecorder();

    // 2. 设置状态监听
    this.avRecorder.on('stateChange', (state: media.AVRecorderState, reason: media.StateChangeReason) => {
      console.info(`AVRecorder state: ${state}, reason: ${reason}`);
    });

    // 3. 设置错误监听
    this.avRecorder.on('error', (error: BusinessError) => {
      console.error(`AVRecorder error: ${error.code}`);
    });
  }

  async startRecording(context: Context) {
    if (!this.avRecorder) {
      return;
    }

    try {
      // 1. 创建录制文件
      let filePath = context.filesDir + '/video.mp4';
      this.videoFile = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);

      // 2. 配置录制参数
      let avProfile: media.AVRecorderProfile = {
        fileFormat: media.ContainerFormatType.CFT_MPEG_4,
        videoBitrate: 2000000,
        videoCodec: media.CodecMimeType.VIDEO_AVC,
        videoFrameWidth: 1920,
        videoFrameHeight: 1080,
        videoFrameRate: 30
      };

      let avConfig: media.AVRecorderConfig = {
        videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV,
        profile: avProfile,
        url: 'fd://' + this.videoFile.fd,
        metadata: {
          videoOrientation: '0'
        }
      };

      // 3. 准备录制
      await this.avRecorder.prepare(avConfig);

      // 4. 获取Surface用于相机输入
      let surfaceId = await this.avRecorder.getInputSurface();

      // 5. 初始化相机(省略相机初始化代码)
      // await this.initCamera(surfaceId);

      // 6. 开始录制
      await this.avRecorder.start();

    } catch (err) {
      let error = err as BusinessError;
      console.error(`Start recording failed: ${error.message}`);
    }
  }

  async pauseRecording() {
    if (this.avRecorder && this.avRecorder.state === 'started') {
      await this.avRecorder.pause();
    }
  }

  async resumeRecording() {
    if (this.avRecorder && this.avRecorder.state === 'paused') {
      await this.avRecorder.resume();
    }
  }

  async stopRecording() {
    if (this.avRecorder) {
      await this.avRecorder.stop();
      await this.avRecorder.release();
    }
    if (this.videoFile) {
      fs.closeSync(this.videoFile);
    }
  }
}

六、实战示例:多媒体播放器应用

6.1 应用架构

MediaPlayerApp/
├── entry/
│   └── src/main/
│       ├── ets/
│       │   ├── entryability/
│       │   │   └── EntryAbility.ets
│       │   ├── pages/
│       │   │   ├── Index.ets          (首页)
│       │   │   ├── AudioPlayer.ets    (音频播放器)
│       │   │   └── VideoPlayer.ets    (视频播放器)
│       │   └── model/
│       │       ├── MediaManager.ets   (媒体管理器)
│       │       └── PlaylistModel.ets  (播放列表)
│       └── resources/
│           └── rawfile/
│               ├── audio/
│               └── video/

6.2 媒体管理器实现

import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';

export interface MediaItem {
  id: string;
  title: string;
  url: string;
  duration: number;
  type: 'audio' | 'video';
}

export class MediaManager {
  private avPlayer: media.AVPlayer | undefined = undefined;
  private currentMedia: MediaItem | null = null;
  private onStateChange?: (state: string) => void;
  private onTimeUpdate?: (time: number) => void;
  private onDurationUpdate?: (duration: number) => void;

  async init() {
    this.avPlayer = await media.createAVPlayer();

    // 状态变化监听
    this.avPlayer.on('stateChange', (state: string, reason: media.StateChangeReason) => {
      console.info(`State changed to: ${state}`);
      if (this.onStateChange) {
        this.onStateChange(state);
      }
    });

    // 错误监听
    this.avPlayer.on('error', (error: BusinessError) => {
      console.error(`Player error: ${error.code}, ${error.message}`);
    });

    // 时长更新
    this.avPlayer.on('durationUpdate', (duration: number) => {
      if (this.onDurationUpdate) {
        this.onDurationUpdate(duration);
      }
    });

    // 进度更新
    this.avPlayer.on('timeUpdate', (time: number) => {
      if (this.onTimeUpdate) {
        this.onTimeUpdate(time);
      }
    });
  }

  setStateChangeListener(callback: (state: string) => void) {
    this.onStateChange = callback;
  }

  setTimeUpdateListener(callback: (time: number) => void) {
    this.onTimeUpdate = callback;
  }

  setDurationUpdateListener(callback: (duration: number) => void) {
    this.onDurationUpdate = callback;
  }

  async playMedia(media: MediaItem, surfaceId?: string) {
    if (!this.avPlayer) {
      return;
    }

    try {
      this.currentMedia = media;
      this.avPlayer.url = media.url;

      if (media.type === 'video' && surfaceId) {
        this.avPlayer.surfaceId = surfaceId;
      }

      await this.avPlayer.prepare();
      await this.avPlayer.play();
    } catch (err) {
      let error = err as BusinessError;
      console.error(`Play media failed: ${error.message}`);
    }
  }

  async pause() {
    if (this.avPlayer && this.avPlayer.state === 'playing') {
      await this.avPlayer.pause();
    }
  }

  async resume() {
    if (this.avPlayer && this.avPlayer.state === 'paused') {
      await this.avPlayer.play();
    }
  }

  async seek(time: number) {
    if (this.avPlayer) {
      await this.avPlayer.seek(time, media.SeekMode.SEEK_PREV_SYNC);
    }
  }

  async stop() {
    if (this.avPlayer) {
      await this.avPlayer.stop();
      this.currentMedia = null;
    }
  }

  async release() {
    if (this.avPlayer) {
      await this.avPlayer.release();
      this.avPlayer = undefined;
    }
  }

  getCurrentMedia(): MediaItem | null {
    return this.currentMedia;
  }

  getState(): string {
    return this.avPlayer?.state || 'idle';
  }
}

6.3 音频播放器页面

import { MediaManager, MediaItem } from '../model/MediaManager';

@Entry
@Component
struct AudioPlayerPage {
  @State playlist: MediaItem[] = [];
  @State currentIndex: number = 0;
  @State isPlaying: boolean = false;
  @State currentTime: number = 0;
  @State duration: number = 0;
  private mediaManager: MediaManager = new MediaManager();

  async aboutToAppear() {
    // 初始化媒体管理器
    await this.mediaManager.init();

    // 设置监听回调
    this.mediaManager.setStateChangeListener((state: string) => {
      this.isPlaying = (state === 'playing');
    });

    this.mediaManager.setTimeUpdateListener((time: number) => {
      this.currentTime = time;
    });

    this.mediaManager.setDurationUpdateListener((duration: number) => {
      this.duration = duration;
    });

    // 加载播放列表
    this.loadPlaylist();
  }

  loadPlaylist() {
    this.playlist = [
      {
        id: '1',
        title: '音乐1',
        url: 'https://example.com/music1.mp3',
        duration: 0,
        type: 'audio'
      },
      {
        id: '2',
        title: '音乐2',
        url: 'https://example.com/music2.mp3',
        duration: 0,
        type: 'audio'
      }
    ];
  }

  async playAudio(index: number) {
    if (index >= 0 && index < this.playlist.length) {
      this.currentIndex = index;
      await this.mediaManager.playMedia(this.playlist[index]);
    }
  }

  async togglePlayPause() {
    if (this.isPlaying) {
      await this.mediaManager.pause();
    } else {
      await this.mediaManager.resume();
    }
  }

  async playNext() {
    let nextIndex = (this.currentIndex + 1) % this.playlist.length;
    await this.playAudio(nextIndex);
  }

  async playPrevious() {
    let prevIndex = (this.currentIndex - 1 + this.playlist.length) % this.playlist.length;
    await this.playAudio(prevIndex);
  }

  formatTime(ms: number): string {
    let totalSeconds = Math.floor(ms / 1000);
    let minutes = Math.floor(totalSeconds / 60);
    let seconds = totalSeconds % 60;
    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
  }

  build() {
    Column() {
      // 顶部导航
      Row() {
        Text('音频播放器')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
      }
      .width('100%')
      .padding(20)
      .backgroundColor('#f0f0f0')

      // 播放列表
      List({ space: 10 }) {
        ForEach(this.playlist, (item: MediaItem, index: number) => {
          ListItem() {
            Row() {
              Text(item.title)
                .fontSize(16)
                .layoutWeight(1)
                .fontColor(index === this.currentIndex ? '#007DFF' : '#000')

              if (index === this.currentIndex) {
                Text(this.isPlaying ? '播放中' : '已暂停')
                  .fontSize(12)
                  .fontColor('#666')
              }
            }
            .width('100%')
            .padding(15)
            .backgroundColor(index === this.currentIndex ? '#e6f2ff' : '#fff')
            .borderRadius(8)
          }
          .onClick(() => {
            this.playAudio(index);
          })
        }, (item: MediaItem) => item.id)
      }
      .width('90%')
      .layoutWeight(1)
      .margin({ top: 20 })

      // 播放控制区域
      Column({ space: 20 }) {
        // 当前播放信息
        Text(this.currentIndex >= 0 ? this.playlist[this.currentIndex].title : '未选择')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)

        // 进度条
        Column({ space: 10 }) {
          Slider({
            value: this.currentTime,
            min: 0,
            max: this.duration,
            step: 1000
          })
            .width('100%')
            .onChange((value: number) => {
              this.mediaManager.seek(value);
            })

          Row() {
            Text(this.formatTime(this.currentTime))
              .fontSize(12)
            Blank()
            Text(this.formatTime(this.duration))
              .fontSize(12)
          }
          .width('100%')
        }
        .width('100%')

        // 控制按钮
        Row({ space: 30 }) {
          Button('上一首')
            .onClick(() => this.playPrevious())

          Button(this.isPlaying ? '暂停' : '播放')
            .onClick(() => this.togglePlayPause())
            .width(80)

          Button('下一首')
            .onClick(() => this.playNext())
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('90%')
      .padding(20)
      .backgroundColor('#f8f8f8')
      .borderRadius(10)
      .margin({ bottom: 20 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#fff')
  }

  aboutToDisappear() {
    this.mediaManager.release();
  }
}

七、后台播放与媒体会话

7.1 实现后台播放

要实现后台播放或熄屏播放,需要:

  1. 注册AVSession(媒体会话)
  2. 申请长时任务
import { AVSessionManager } from '@kit.AVSessionKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit';

class BackgroundAudioPlayer {
  private session: AVSessionManager.AVSession | undefined = undefined;

  async setupBackgroundPlayback() {
    try {
      // 1. 创建媒体会话
      this.session = await AVSessionManager.createAVSession(
        getContext(this),
        'audio',
        'com.example.mediaplayer'
      );

      // 2. 激活会话
      await this.session.activate();

      // 3. 设置会话元数据
      let metadata: AVSessionManager.AVMetadata = {
        assetId: '001',
        title: '歌曲标题',
        artist: '艺术家',
        album: '专辑名称',
        duration: 240000
      };
      await this.session.setAVMetadata(metadata);

      // 4. 设置播放状态
      let playbackState: AVSessionManager.AVPlaybackState = {
        state: AVSessionManager.PlaybackState.PLAYBACK_STATE_PLAY,
        speed: 1.0,
        position: { elapsedTime: 0, updateTime: Date.now() }
      };
      await this.session.setAVPlaybackState(playbackState);

      // 5. 申请长时任务
      let bgMode = backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK;
      backgroundTaskManager.startBackgroundRunning(getContext(this), bgMode);

    } catch (err) {
      let error = err as BusinessError;
      console.error(`Setup background playback failed: ${error.message}`);
    }
  }

  async stopBackgroundPlayback() {
    // 停止长时任务
    backgroundTaskManager.stopBackgroundRunning(getContext(this));

    // 销毁会话
    if (this.session) {
      await this.session.deactivate();
      await this.session.destroy();
    }
  }
}

八、最佳实践

8.1 性能优化

1. 资源预加载

class MediaResourceManager {
  private soundPool: media.SoundPool | undefined = undefined;
  private soundCache: Map<string, number> = new Map();

  async preloadSounds(sounds: string[]) {
    for (let sound of sounds) {
      let fd = await this.getSoundFd(sound);
      let soundId = await this.soundPool!.load(fd.fd, fd.offset, fd.length);
      this.soundCache.set(sound, soundId);
    }
  }

  async playPreloadedSound(soundName: string) {
    let soundId = this.soundCache.get(soundName);
    if (soundId) {
      await this.soundPool!.play(soundId, {} as media.PlayParameters, () => {});
    }
  }

  private async getSoundFd(name: string) {
    let context = getContext(this);
    return await context.resourceManager.getRawFd(name);
  }
}

2. 内存管理

class PlayerLifecycleManager {
  private player: media.AVPlayer | undefined = undefined;

  async onPageShow() {
    // 页面显示时创建播放器
    if (!this.player) {
      this.player = await media.createAVPlayer();
    }
  }

  async onPageHide() {
    // 页面隐藏时释放播放器资源
    if (this.player) {
      await this.player.stop();
      await this.player.release();
      this.player = undefined;
    }
  }
}

8.2 错误处理

class RobustMediaPlayer {
  private avPlayer: media.AVPlayer | undefined = undefined;
  private retryCount: number = 0;
  private maxRetries: number = 3;

  async playWithRetry(url: string) {
    try {
      await this.play(url);
      this.retryCount = 0;
    } catch (err) {
      let error = err as BusinessError;
      console.error(`Play failed: ${error.code}`);

      if (this.retryCount < this.maxRetries) {
        this.retryCount++;
        console.info(`Retry ${this.retryCount}/${this.maxRetries}`);
        setTimeout(() => {
          this.playWithRetry(url);
        }, 1000 * this.retryCount);
      } else {
        console.error('Max retries reached');
        // 通知用户播放失败
      }
    }
  }

  private async play(url: string) {
    if (!this.avPlayer) {
      this.avPlayer = await media.createAVPlayer();
    }
    this.avPlayer.url = url;
    await this.avPlayer.prepare();
    await this.avPlayer.play();
  }
}

8.3 音频焦点管理

import { audio } from '@kit.AudioKit';

class AudioFocusManager {
  private avPlayer: media.AVPlayer | undefined = undefined;

  async setupAudioFocus() {
    if (!this.avPlayer) {
      return;
    }

    // 设置音频中断模式
    this.avPlayer.audioInterruptMode = audio.InterruptMode.INDEPENDENT_MODE;

    // 监听音频焦点变化
    this.avPlayer.on('audioInterrupt', (info: audio.InterruptEvent) => {
      console.info(`Audio interrupt: ${info.hintType}`);

      switch (info.hintType) {
        case audio.InterruptHint.INTERRUPT_HINT_PAUSE:
          // 暂停播放
          this.avPlayer?.pause();
          break;
        case audio.InterruptHint.INTERRUPT_HINT_RESUME:
          // 恢复播放
          this.avPlayer?.play();
          break;
        case audio.InterruptHint.INTERRUPT_HINT_STOP:
          // 停止播放
          this.avPlayer?.stop();
          break;
        case audio.InterruptHint.INTERRUPT_HINT_DUCK:
          // 降低音量
          this.avPlayer?.setVolume(0.3);
          break;
        case audio.InterruptHint.INTERRUPT_HINT_UNDUCK:
          // 恢复音量
          this.avPlayer?.setVolume(1.0);
          break;
      }
    });
  }
}

九、支持的格式

9.1 音频格式

格式 编解码器 封装格式
AAC AAC-LC, HE-AAC MP4, M4A, AAC
MP3 MP3 MP3
FLAC FLAC FLAC
Vorbis Vorbis OGG
Opus Opus OGG

9.2 视频格式

格式 视频编解码器 音频编解码器 封装格式
H.264 AVC AAC, MP3 MP4, MKV
H.265 HEVC AAC, MP3 MP4, MKV

十、总结

本文全面介绍了HarmonyOS Media Kit的核心能力:

功能模块 主要API 适用场景
音频播放 AVPlayer 播放完整音频文件
短音播放 SoundPool 低时延音效播放
视频播放 AVPlayer 播放视频文件
音频录制 AudioCapturer, AVRecorder 录制音频
视频录制 AVRecorder + Camera 录制视频
后台播放 AVSession + 长时任务 后台音乐播放

开发要点:

  1. ✅ 根据场景选择合适的API
  2. ✅ 正确处理播放器状态转换
  3. ✅ 做好错误处理和重试机制
  4. ✅ 及时释放资源避免内存泄漏
  5. ✅ 后台播放需注册AVSession
  6. ✅ 正确处理音频焦点事件

通过本文学习,您应该能够:

  • 熟练使用AVPlayer播放音视频
  • 使用SoundPool实现低时延音效
  • 实现音视频录制功能
  • 开发完整的多媒体播放器应用
  • 实现后台播放和媒体会话管理

从「[1,2,3].map (parseInt)」踩坑,吃透 JS 数组 map 与包装类核心逻辑

你有没有遇到过这样的诡异场景:明明以为 [1,2,3].map(parseInt) 会返回 [1,2,3],实际运行却得到 [1, NaN, NaN]

这行看似简单的代码,藏着 JS 数组方法、函数传参、包装类等多个核心知识点的关联。今天我们就从这个经典坑点切入,一步步拆解 map 方法的底层逻辑,顺带理清 NaN、包装类、字符串处理等容易混淆的知识点。

一、先踩坑:为什么 [1,2,3].map (parseInt) 不是 [1,2,3]?

要搞懂这个问题,我们得先明确两个关键:map 方法的参数传递规则,以及 parseInt 的工作原理。

1. map 方法的真正传参逻辑

MDN 明确说明:map 方法会遍历原数组,对每个元素调用回调函数,并将三个参数依次传入回调:

  • 当前遍历的元素(item)
  • 元素的索引(index)
  • 原数组本身(arr)

也就是说,[1,2,3].map(parseInt) 等价于:

javascript

运行

[1,2,3].map((item, index, arr) => {
  return parseInt(item, index, arr);
});

这里的关键是:map 会强制传递三个参数给回调,而不是只传我们以为的 “元素本身”。

2. parseInt 的参数陷阱

parseInt 的语法是 parseInt(string, radix),它只接收两个有效参数:

  • 第一个参数:要转换的字符串(非字符串会先转字符串)
  • 第二个参数:基数(进制,范围 2-36,0 或省略则默认 10 进制)
  • 第三个参数会被直接忽略

结合 map 的传参,我们逐次分析遍历过程:

  • 第一次遍历:item=1,index=0 → parseInt (1, 0)。基数 0 等价于 10 进制,结果 1。
  • 第二次遍历:item=2,index=1 → parseInt (2, 1)。基数 1 无效(必须≥2),结果 NaN。
  • 第三次遍历:item=3,index=2 → parseInt (3, 2)。2 进制中只有 0 和 1,3 无效,结果 NaN。

这就是为什么最终结果是 [1, NaN, NaN] —— 不是 map 或 parseInt 本身有问题,而是参数传递的 “错位匹配” 导致的。

3. 正确写法是什么?

如果想通过 map 实现 “数组元素转数字”,正确做法是明确回调函数的参数,只给 parseInt 传需要的值:

javascript

运行

// 方法1:手动控制参数
[1,2,3].map(item => parseInt(item)); 
// 方法2:使用Number简化
[1,2,3].map(Number); 
// 两种写法结果都是 [1,2,3]

二、吃透 map 方法:不止是 “遍历 + 返回”

解决了坑点,我们再深入理解 map 的核心特性 —— 它是 ES6 数组新增的纯函数(不改变原数组,返回新数组),这也是它和 forEach 的核心区别。

1. map 的核心规则(必记)

  • 不改变原数组:无论回调函数做什么操作,原数组的元素都不会被修改。
  • 返回新数组:新数组长度与原数组一致,每个元素是回调函数的返回值。
  • 跳过空元素:map 会忽略数组中的 empty 空位(forEach 也会),但不会忽略 undefined 和 null。

示例验证:

javascript

运行

const arr = [1, 2, 3, , 5]; // 第4位是empty
const newArr = arr.map(item => item * 2);
console.log(newArr); // [2,4,6, ,10](保留空位)
console.log(arr); // [1,2,3, ,5](原数组不变)

2. 实用场景:从基础到进阶

map 的核心价值是 “数据转换”,日常开发中高频使用:

  • 基础转换:数组元素的统一处理(如平方、转格式)

    javascript

    运行

    const arr = [1,2,3,4,5,6];
    const squares = arr.map(item => item * item); // [1,4,9,16,25,36]
    
  • 复杂转换:提取对象数组的特定属性

    javascript

    运行

    const users = [{name: '张三'}, {name: '李四'}, {name: '王五'}];
    const names = users.map(user => user.name); // ['张三', '李四', '王五']
    

三、延伸知识点:NaN 与包装类,JS 的 “隐式魔法”

在分析 map 和 parseInt 的过程中,我们遇到了 NaN,而 JS 中字符串能调用length方法的特性,又涉及到 “包装类” 的隐式逻辑 —— 这两个知识点是理解 JS “面向对象特性” 的关键。

1. NaN:不是数字的 “数字”

NaN 的全称是 “Not a Number”,但 typeof 检测结果是number,这是它的第一个反直觉点。

什么时候会出现 NaN?

  • 无效的数学运算:0/0Math.sqrt(-1)"abc"-10
  • 类型转换失败:parseInt("hello")Number(undefined)
  • 注意:Infinity(6/0)和-Infinity(-6/0)不是 NaN,它们是有效的 “无穷大” 数值。

如何正确判断 NaN?

因为NaN === NaN的结果是false(NaN 不等于任何值,包括它自己),所以必须用专门的方法:

javascript

运行

// 推荐:ES6新增的Number.isNaN(只检测NaN)
Number.isNaN(parseInt("hello")); // true

// 不推荐:window.isNaN(会先转换类型,误判情况多)
isNaN("hello"); // true("hello"转数字是NaN)
isNaN(123); // false

2. 包装类:JS 让 “简单类型” 拥有对象能力

JS 是完全面向对象的语言,但我们平时写的"hello".length520.1314.toFixed(2),看起来是 “简单数据类型调用对象方法”—— 这背后就是包装类的隐式操作。

包装类的工作流程

当你对字符串、数字、布尔值这些简单类型调用方法时,JS 会自动做三件事:

  1. 用对应的构造函数(String、Number、Boolean)创建一个临时对象(包装对象);
  2. 通过这个临时对象调用方法(如 length、toFixed);
  3. 方法调用结束后,立即销毁临时对象,释放内存。

用代码还原这个过程:

javascript

运行

let str = "hello";
console.log(str.length); // 实际执行过程:
const tempObj = new String(str); // 1. 创建包装对象
console.log(tempObj.length); // 2. 调用方法
tempObj = null; // 3. 销毁对象

关键区别:简单类型 vs 包装对象

javascript

运行

let str1 = "hello"; // 简单类型(string)
let str2 = new String("hello"); // 包装对象(object)

console.log(typeof str1); // "string"
console.log(typeof str2); // "object"
console.log(str1.length === str2.length); // true(方法调用结果一致)

四、拓展:字符串处理的常见误区(length、slice、substring)

包装类让字符串拥有了对象方法,但字符串处理中也有不少容易踩坑的点,结合笔记中的案例总结:

1. length 的 “坑”:emoji 占几个字符?

JS 的字符串用 UTF-16 编码存储,常规字符(如 a、中)占 1 个 16 位单位,emoji 和生僻字占 2 个及以上。length 属性统计的是 “16 位单位个数”,而非视觉上的 “字符个数”:

javascript

运行

console.log('a'.length); // 1(常规字符)
console.log('中'.length); // 1(常规字符)
console.log("𝄞".length); // 2(emoji占2个单位)
console.log("👋".length); // 2(emoji占2个单位)

2. slice vs substring:负数索引与起始位置

两者都用于截取字符串,但处理负数索引和起始位置的逻辑不同:

  • 负数索引:slice 支持从后往前截取(-1 是最后一位),substring 会把负数转为 0;
  • 起始位置:slice 严格按 “前参为起点,后参为终点”,substring 会自动交换大小值(小的当起点)。

示例对比:

javascript

运行

const str = "hello";
console.log(str.slice(-3, -1)); // "ll"(从后数第3位到第1位)
console.log(str.substring(-3, -1)); // ""(负数转00>0无结果)
console.log(str.slice(3, 1)); // ""3>1无结果)
console.log(str.substring(3, 1)); // "el"(自动交换为1-3

五、总结:从坑点到体系化知识

回到最初的[1,2,3].map(parseInt),这个坑的本质是 “对 API 参数传递规则的理解不透彻”。但顺着这个坑,我们串联起了:

  • map 方法的参数传递、纯函数特性;
  • parseInt 的基数规则、类型转换逻辑;
  • NaN 的特性与判断方法;
  • 包装类的隐式工作流程;
  • 字符串处理的常见误区。

JS 的很多 “诡异现象”,本质都是对底层逻辑的不了解。掌握这些核心知识点后,再遇到类似问题时,就能快速定位根源 —— 这也是我们从 “踩坑” 到 “成长” 的关键。

最后留一个小思考:["10","20","30"].map(parseI

结合400行mini-react代码,图文解说React原理

引言: 在我学习React原理的时候,一上来看的非常全而细节的书/博客(头大),或者是看的教你实现一个简单mini-react(还是一知半解),最终学的痛苦又效果不好。所以,写了这篇博客,希望能帮助您入门React原理。此外,在我看来,这篇文章帮助你应付面试完全足够了。

说明:

  1. 本文章主要围绕Zachary Lee的 400行实现mini-react 项目进行分析,结合图文详细分析,带你弄懂React原理。
  2. 这个项目的一些API命名会和React有些出入,我会尽量对齐这些命名概念,同时本项目为了减少代码量会弱化很多细节,我根据实际React的实现做补充。
  3. 本文很多图都出自7kms大佬的 图解React,对理解React非常有帮助,强烈推荐大家学习。(P.S. 7kms大佬是基于React17进行分析的,有些地方(比如EffectList)和最新的React 18/19是有出入的,所以另外再推荐一本书:卡颂的《React设计原理》)

通过本文你能收获什么:

  1. 理解并实现Zachary Lee的mini-react。
  2. 更深刻理解React的原理,包括渲染流程、Diff算法、bailout策略和hooks等。

1、React基本概念

a.常用对象:ReactElement,FiberNode,虚拟DOM

1.JSX编译:

我们都知道React支持JSX语法,类似html标签的写法,如下:

<div id="root">
  <div>
  <h1>hello</h1>
  world
  </div>
</div>

那么实际上它会被转换为JS代码来创建ReactElement,每一个标签和文本都可以视为ReactElement

当我们import React from 'react'时就引入了React.createElementReact.render这些API,然后代码会被babel编译如下:

React.render(React.createElement('div', {}, 
React.createElement('h1', {}, 'hello'), 
'world'), 
document.getElementById('root'));

P.S.为什么早期react项目要引入import React from 'react'这句话,就是因为编译后需要引入React.createElement

2.介绍ReactElement:

通过React.createElement创建出来的一个普通JS对象就是ReactElement类型(在本项目代码中使用的VirtualElement,可认为是同一个东西)

// Class Component组件和Function组件组合定义
interface ComponentFunction {
  new (props: Record<string, unknown>): Component; //能new出Component实例
  (props: Record<string, unknown>): VirtualElement | string; //直接调用返回虚拟DOM VirtualElement
}
type VirtualElementType = ComponentFunction | string;

interface VirtualElementProps {
  children?: VirtualElement[];
  [propName: string]: unknown;
}
interface VirtualElement {
  type: VirtualElementType;
  props: VirtualElementProps;
}

// 判断是否是VirtualElement(即ReactElement)
const isVirtualElement = (e: unknown): e is VirtualElement =>
  typeof e === 'object';

// Text elements require special handling.
const createTextElement = (text: string): VirtualElement => ({
  type: 'TEXT',
  props: {
    nodeValue: text,
  },
});

// 创建一个VirtualElement(即ReactElement)
const createElement = (
  type: VirtualElementType,
  props: Record<string, unknown> = {},
  ...child: (unknown | VirtualElement)[]
): VirtualElement => {
  const children = child.map((c) =>
    isVirtualElement(c) ? c : createTextElement(String(c)),
  );

  return {
    type,
    props: {
      ...props,
      children,
    },
  };
};

// Component组件定义 
//(class MyComp extends Component, 自定义class组件都要继承这个Component)
abstract class Component {
  props: Record<string, unknown>;
  abstract state: unknown;
  abstract setState: (value: unknown) => void;
  abstract render: () => VirtualElement;

  constructor(props: Record<string, unknown>) {
    this.props = props;
  }

  // Identify Component.
  static REACT_COMPONENT = true;
}

简单来看,VirtualElementReactElement)他包含了

  • type:实际React中type指ClassComponent/FunctionComponent/HostComponent(div/span/a这些原生标签)/HostText/HostRoot(FiberTree根节点)等,本项目代码的type做了简化,并把ClassComponentFunctionComponent定义在一起了成ComponentFunction,然后按REACT_COMPONENT来区分。
  • props:就是使用React时传入的props,包括children.

P.S.下文我提到ReactElement,你可以认为就是VirtualElement

3.介绍FiberNode:

// 真实DOM
type FiberNodeDOM = Element | Text | null | undefined;

// 定义FiberNode(Fiber节点)
interface FiberNode<S = any> extends VirtualElement {
  alternate: FiberNode<S> | null;  //指向当前Fiber节点的旧版本,用于Diff算法比较
  dom?: FiberNodeDOM; //指向真实DOM节点
  effectTag?: string; //用于标记Fiber节点的副作用,如添加、删除、更新等,实际react中是flags
  child?: FiberNode; //指向第一个孩子Fiber节点
  return?: FiberNode; //指向父Fiber节点
  sibling?: FiberNode; //指向兄弟Fiber节点
  hooks?: {  //hooks数组(实际React是hooks链表)
    state: S;
    queue: S[];
  }[];
}
  • 一定程度上你可以认为ReactElement是虚拟DOM, 也可以认为FiberNode是虚拟DOM。FiberNode是在ReactElement基础上进一步封装,补充描述了状态、dom、副作用标记、节点关系等等。
  • alternate:我们在下面的双缓存-离屏FiberTree的概念中进一步说明作用,它指向对应的old FiberNode。
  • effectTag:(实际React是flags)在下面的「渲染流程」会进一步分析,它标记了这个Fiber是否存在副作用要执行。
  • dom:真实DOM。(实际React的FiberNode上有stateNode属性 👈 与该 Fiber 对应的“实例”或 DOM 节点,本项目代码这里简单用dom替代了。)
  • hooks:用来表示组件的hooks状态。(实际React中Fiber用 memoizedState 属性表示,这个属性用来是用来保存组件的局部状态的。memoizedState对于FunctionComponent来说是一个hooks链表,对于ClassComponent则是普通对象{}

下表展示了stateNode对应的内容:

Fiber tag stateNode 内容 示例
HostComponent 对应的 DOM 节点 `` → stateNode 指向 HTMLDivElement
ClassComponent 对应的 类组件实例 new MyComponent()
FunctionComponent null 因为函数组件没有实例
HostRoot 对应的 root 容器实例 ReactRoot(如 ReactDOM.createRoot(container)
HostText 对应的 文本节点 TextNode

下图展示了ReactElement和FiberTree分别在内存中的样子:

(图片来自图解Reactimage.png

b.双缓存-离屏FiberTree

(图片来自图解React

image.png

  1. React中会同时存在两棵FiberTree,如图,中间的是内存中的FiberTree,也叫离屏FiberTree,是通过workInProgress指针(简称wip)进行移动来构建的;右边的是页面FiberTree,代表实际页面(表示不会再发生变化,对应实际页面DOM结构)。
  2. 为什么要两棵呢?页面FiberTree代表旧Fibers, 离屏FiberTree代表新Fibers,需要根据ReactElement结构来创建新Fibers。创建过程中需要比较(Diff)新旧FiberTree进行「打标签」来表示需要做哪些dom更新操作。 当我遍历离屏FiberTree时,通过alternate指针找到旧Fiber,然后对它们的孩子节点进行Diff。
  3. FiberRoot是React应用的辅助节点,用来管理FiberTree。它的current指针指向的那个FiberTree代表页面FiberTree当内存中的FiberTree构建完成后,FiberRoot.current切换到内存的FiberTree,表示新旧页面切换,完成更新
  4. HostRootFiber就是FiberTree的根。挂载/更新都是从HostRootFiber开始DFS的。

c.宏观理解React 运行原理

(图片来自图解React

image.png

1.这个workLoop就是一个函数,会被反复执行的一个函数,在React渲染流程中「render阶段」和「commit阶段」会做不同的事情。

2.当一次渲染任务开始(由renderRootSyncrenderRootConcurrent触发):

  • render阶段,从上到下DFS,递归时调用beginWork函数,回溯时调用completeWork。这阶段核心工作是创建Fiber节点和打副作用标签。(副作用的简单理解:修改实际DOM就是副作用)
  • commit阶段,从上到下DFS,根据Fiber上的副作用标签(flags)和父Fiber上的deletions标记进行实际DOM操作:新增/移动、修改和删除。

3.如何让workLoop反复不断执行呢?,本项目代码使用了requestIdleCallback(React很早期也是这个)来调用workLoop,当浏览器空闲时,就会分配一个时间片给workLoop执行。

4.当requestIdleCallback存在下面的问题:

  • 不可预测、触发频率太低。页面在保持高帧率的时候(如动画、滚动)时,浏览器几乎没有空闲时间,导致 requestIdleCallback 回调迟迟不能执行。
  • 优先级机制太弱。只提供了一个 “空闲时执行” 的概念,没有优先级控制。
  • 这个API的兼容性

由于上述原因,React 自己实现了任务调度的算法(Scheduler):

  • MessageChannel(微任务方式,能精确控制调用时机);
  • setTimeout(作为后备机制);
  • 可控的时间切片(每个任务执行 5ms 左右后中断);
  • 多级优先级(Immediate、UserBlocking、Normal、Low、Idle);
workLoop

当引入min-react时( import React from './mini-react';),就开始workLoop了,就开始工作循环了,按调度不断执行workLoop这个函数

void (function main() { 
  window.requestIdleCallback(workLoop);
})();

workLoop内,通过deadline.timeRemaining()判断剩余时间来决定是否继续执行「任务单元」。 一个任务单元(unitOfWork)是一个执行时间比较短的JS任务,一次requestIdleCallback给的空闲时间内一般能执行多个「任务单元」,即使超过时间了也影响不大,因为单个「任务单元」很短。这就是时间分片。

const workLoop: IdleRequestCallback = (deadline) => {
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }

  window.requestIdleCallback(workLoop);
};

2、performUnitOfWork对应 「render阶段」

「render阶段」会从离屏FiberTree的根节点(HostRootFiber)开始向下DFS,当然这种DFS是基于迭代方式的(而不是递归),这样才能做到前面提到的时间分配,一个个执行短的「任务单元」。

这个阶段的主要任务就是Diff比较和对Fiber打flags标签。每次执行performUnitOfWork(wip:FiberNode)就是处理一个Fiber节点(这里的wip指 离屏FiberTree中的工作指针workInProgress,代表当前要处理的Fiber)一开始wip指向HostRootFiber

在实际React中,「render」阶段的流程可以分成两种流程来看,一种是初次渲染流程,另一种是更新渲染流程

a.初次渲染流程

如下面这组图片所示,此时页面FiberTree是空的,会根据一步步根据render函数产生的ReactElement来构建离屏FiberTree。 (下图1,2,3,4 来自 图解React

image.pngimage.pngimage.pngimage.png

1.向下递归beginWork的流程:

  • 图1、此时wip指向HostRootFiber,执行performUnitOfWork(wip)时会调用updateHostRoot,拿HostRootFiber的children和HostRootFiber.alternate的children进行Diff比较,然后创建内存中的Fiber(App)HostRootFiber的children只有一个,就是React.render(App, container)时传入的App)。最后wip指向子Fibers的第一个,即Fiber(App)
  • 图2、此时wip指向Fiber(App),执行App组件的render方法,产生ReactElement('div')(这个就是App的children),执行performUnitOfWork(wip)时会那App的children和App.alternate的children进行Diff比较,然后创建内存中的Fiber(div)。最后wip指向子Fibers的第一个,即Fiber(div)
  • 图3、此时wip指向Fiber(div),执行performUnitOfWork(wip)时拿Fiber(div)的children和Fiber(div).alternate的children进行Diff比较(初次渲染实际上不会比较,更新渲染才会真正的比较),然创建内存中的Fiber(header)Fiber(Content)。最后wip指向子Fibers的第一个,即Fiber(header)

2.向上回溯completeWork的流程:

  • 构建dom节点,挂在Fiber上,同时把子dom节点通过appendChild关联上(注意:这个添加dom节点是从下往上的,所以此时回溯构建的真实DOM并没有真的挂载到Document)

当挂载完成后,再经过「commit阶段」后就变成下面这样,此时原本离屏的FiberTree变成页面FiberTree,而原来的页面的FiberTree被清理成一棵空树。

b.更新渲染流程

React有三种发起主动更新的方式:

  1. Class组件中调用setState.
  2. Function组件中调用hook对象暴露出的dispatchAction(就是setState).
  3. container节点上重复调用render

更新渲染初次挂载的区别:

  1. 有一个预处理的步骤——markUpdateLaneFromFiberToRoot(fiber, lane),标记优先级,等进入「render」阶段DFS时通过bailout策略可能跳过一些子树的协调。
  2. 更新渲染的beginWork是需要打副作用标签flags的,completeWork是需要收集子Fiber的flags到父Fiber.subtreeFlags的,这是为了下一个阶段「commit阶段」准备的,这样在commit时DFS可以跳过一些子树。
markUpdateLaneFromFiberToRoot

初次挂载是React.render触发,直接从根节点向下DFS了。而更新渲染流程中则有所不同,有一个预处理的步骤——markUpdateLaneFromFiberToRoot(fiber, lane),名字记不记无所谓,关键理解这一步做了什么。

image.png

如上图所示,在App组件内发生更新,先执行markUpdateLaneFromFiberToRoot动作:给Fiber(App)设置lanes,然后不断回溯,父Fiber会收集所有子Fiber的lanes并入childLanesmarkUpdateLaneFromFiberToRoot结束后再安排一个调度任务(就是进入workLoop),等待执行。

  • childLanes在渲染流程的「render阶段」的优化起到作用。在命中优化条件情况下,如果Fiber的childLanes不包含了当前更新优先级,将跳过Fiber和它的整个子树的协调/Diff(这个将在下面的bailout优化策略中介绍)
  • 注意:childLanes是挂在old Fiber上的,在比较new FiberTree和old FiberTree后,发现某个Fiber命中优化才会去检查对应old Fiber的childLanes
bailout策略

bailout策略 讲的是如何在beginWork中比较新旧Fiber的优化问题(命中bailout策略能减少render工作量)。如何命中:

  1. oldProps全等于newProps(但对于Memo纯组件,条件会变宽松,只需要浅比较oldProps和newprops)
  2. legacy context不变(对于新版Context,只要所处的context的value变化就意味着更新,那么就不会命中bailout)
  3. 没有更新操作(哪怕状态不变,但只要做了更新动作,比如state:{}->setState({})也不会命中bailout)
  4. FiberNode.type不变

这里必须贴一下React的源码帮我们理解这个逻辑:

function updateMemoComponent(wip: FiberNode, renderLane: Lane) {
// bailout四要素
// props浅比较
const current = wip.alternate;
const nextProps = wip.pendingProps;
const Component = wip.type.type;

if (current !== null) {
const prevProps = current.memoizedProps;

// state context
if (!checkScheduledUpdateOrContext(current, renderLane)) {
// 浅比较props
if (shallowEqual(prevProps, nextProps) && current.ref === wip.ref) {
didReceiveUpdate = false;
wip.pendingProps = prevProps;

// 满足四要素
wip.lanes = current.lanes;
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
}
}
return updateFunctionComponent(wip, Component, renderLane);
}

当执行bailoutOnAlreadyFinishedWork时,你就会发现childLanes起作用了,childLanes决定了命中bailout后的优化程度。

function bailoutOnAlreadyFinishedWork(wip: FiberNode, renderLane: Lane) {
// 高程度优化,跳过子树协调
if (!includeSomeLanes(wip.childLanes, renderLane)) {
return null;
}
//低程度优化,复用Fiber,继续子树协调
cloneChildFibers(wip);
return wip.child;
}

(图片来自react性能优化|bailout策略

image.png

  • 如果childLanes数组中包含本次更新优先级,则复用Fiber,继续子树的DFS/协调。
  • 如果不包含,则跳过子树DFS/协调。
完整更新流程

递归打flags,回溯收集flags 递归执行beginWork的顺序和「初次渲染」流程一样,不过不同的是:

  1. 做新旧Fiber的比较,打flags
  2. 会遇到bailout策略,可能跳过子树协调/Diff。

回溯执行completeWork的顺序和「初次渲染」流程也一样,不同的是:

  1. 收集子Fiber flags,并入父Fiber的subtreeFlags

beginWork

(下图1,2,3,4 来自 图解React

image.pngimage.pngimage.pngimage.png

其实这组图已经很好说明了流程,我就不每一步说明了。重点关注:当wip指向Fiber(Header)时:

  • <Header>组件是PureComponent,满足四要素(props浅比较相同,没有更新,没有context变化,type没变),命中bailout
  • 然后Fiber(Header)上childLanes没有包含本次更新优先级,所以高程度优化,直接跳过了子树的比较,返回的wip就指向了兄弟节点Fiber(button)

completeWork: 7kms大佬的绘的图关于completeWork是使用EffectList(React v18已经不用了)收集副作用,下面是7kms大佬的图:

image.png

然后我自己画了个图,表示收集subtreeFlags,一些细节就没画出来,重点关注subtreeFlagsdeletions数组(希望我的画功没让你失望~)

image.png

这个subtreeFlags是收集子flags和子subtreeFlags合并得来的,在React中实际是一个二进制的数,但为了理解理论,这里你可以把它当做是一个数组好了。

  • wip指向Fiber(div),遍历所有子Fiber,收集有 subtreeFlags=[Placement, Deletion],继续冒泡。
  • wip指向Fiber(App),遍历所有子Fiber,因为Fiber(App),Fiber(button)没有flags和subtreeFlags,只有Fiber(div)有subtreeFlags,故收集有subtreeFlags=[Placement, Deletion],继续冒泡。
  • wip指向Fiber(HostRootFiber),收集有subtreeFlags=[Placement, Deletion]
结合项目代码分析

项目代码的performUnitOfWork比较简化,没有明显区分初次挂载时和更新时两个流程,都按更新流程来写的,同时省略了completeWork回溯时该做的事(这个不影响功能,回溯收集subtreeFlags是为了跳过一些子Fiber的Diff,用于优化)。

// 执行「任务单元」,处理fiberNode(React中会把wip传给fiberNode)
// 比较wip的props和oldFiberNode的props,记录差异到effectTag
const performUnitOfWork = (fiberNode: FiberNode): FiberNode | null => {
  const { type } = fiberNode;
  switch (typeof type) {
    // 1.处理函数组件和类组件
    case 'function': {
      wipFiber = fiberNode; 
      wipFiber.hooks = [];
      hookIndex = 0;
      let children: ReturnType<ComponentFunction>;
      // 区分函数组件和类组件(实际React中是分成ClassComponent和FunctionComponent种类型)
      // 这里通过REACT_COMPONENT(Component上的静态变量来区分)
      if (Object.getPrototypeOf(type).REACT_COMPONENT) {
        const C = type;
        const component = new C(fiberNode.props);
        const [state, setState] = useState(component.state);
        component.props = fiberNode.props;
        component.state = state;
        component.setState = setState;
        children = component.render.bind(component)(); //对于类组件,调用render方法获取children
      } else {
        children = type(fiberNode.props); //对于函数组件,直接调用函数组件并传入props获取children
      }
      reconcileChildren(fiberNode, [
        isVirtualElement(children)
          ? children
          : createTextElement(String(children)),
      ]);
      break;
    }
    // 2.处理文本节点和Fragment
    case 'number':
    case 'string':
      if (!fiberNode.dom) {
        fiberNode.dom = createDOM(fiberNode);
      }
      reconcileChildren(fiberNode, fiberNode.props.children);
      break;
    case 'symbol':
      if (type === Fragment) {
        reconcileChildren(fiberNode, fiberNode.props.children);
      }
      break;
    default:
      if (typeof fiberNode.props !== 'undefined') {
        reconcileChildren(fiberNode, fiberNode.props.children);
      }
      break;
  }

  // 处理完成当前节点(wip)后,返回下一个要处理的节点(nextUnitOfWork)
  // 找下一个待处理节点nextUnitOfWork,遵循DFS的顺序
  if (fiberNode.child) {
    return fiberNode.child;
  }

  let nextFiberNode: FiberNode | undefined = fiberNode;

  while (typeof nextFiberNode !== 'undefined') {
    if (nextFiberNode.sibling) {
      return nextFiberNode.sibling;
    }

    nextFiberNode = nextFiberNode.return;
  }

  return null;  //null表示节点处理完毕
};

render阶段又叫reconcile阶段,原因就是因为这个阶段的核心在于reconcileChildren函数,即Diff过程。下面就开始介绍React的Diff算法。

c.调和/Diff算法

Diff算法原理介绍

在React中Fiber节点的比较只做同层级的比较,按FiberTree从上到下的顺序一级级比较。分为单节点和多节点比较。

  • 单节点比较(ReactElement序列只有1个或0个)
  • 多节点比较(ReactElement序列大于1个,构成数组)

单节点比较

image.png

多节点比较 初始的(新Fibers)ReactElement序列和 oldFibers序列如下:

image.png

第一次循环先遍历公共序列,即新旧Fiber是一一对应,key和type相同,一旦key或type不同就中断循环。前面这段公共序列的oldFibers是可以复用的(即复用dom)

image.png

第二次循环从第一次断开的地方开始。

1) 先设置一个lastPlaceIndex的索引,为什么叫lastPlaceIndex,因为它和最终的dom移动(是否打上Placement副作用标签相关)。 初始设置lastPlaceIndex=0

2) 把oldFibers剩余的节点放入Map,方便后续通过key找oldFiber。即Fiber(C)Fiber(D)Fiber(E)会被放入Map。

3) 遍历ReactElement序列的剩余序列

  • 如果用当前ReactElement key能在Map中找到oldFiber,就复用Fiber(复用dom),oldFiber从Map中移除。
  • 复用后,还要比较lastPlaceIndex和oldFiber的index,如果index比lastPlaceIndex大or相等则只需要更新lastPlaceIndex=index,否则仅标记该新Fiber flags=PlacementlastPlaceIndex不动。
  • 如果用当前ReactElement找不到oldFiber,则标记flags=Placement (此时表示的是新增)

关于3)的第2点,以下图为例说明: key=e找到Fiber(E)(oldFiber E的索引为4),此时lastPlaceIndex是0,只需更新lastPlaceIndex=4;: key=c找到Fiber(C)(oldFiber C的索引为2),此时对新Fiber C标记Placement(表示移动)lastPlaceIndex不变。 关于3)的第3点,就是 key=xkey=y找不到oldFiber,然后标记为Placement(表示新增)。

image.png

Diff算法思维导图

image.png

结合项目代码分析

这个项目代码,没有考虑key的设计(简化了),主要考虑type的比较,也没有区分开「单节点比较」和「多节点比较」,仅仅是简单进行了「多节点比较」的粗略版比较。我们来一起看看吧,重点关注其中是如何打副作用标签的(项目里是effectTag字段,实际React中是flags字段)。

const reconcileChildren = (
  fiberNode: FiberNode,
  elements: VirtualElement[] = [],
) => {
  let index = 0;
  let oldFiberNode: FiberNode | undefined = void 0;
  let prevSibling: FiberNode | undefined = void 0;
  const virtualElements = elements.flat(Infinity);

  //这里的fiberNode是内存中FiberTree的「父Fiber」 alternate指针指向它对应的old FiberNode(即离屏FiberTree上的)
  if (fiberNode.alternate?.child) {
    oldFiberNode = fiberNode.alternate.child; //  oldFiberNode表示了oldFibers序列
  }

  while (
    index < virtualElements.length ||
    typeof oldFiberNode !== 'undefined'
  ) {
    // ReactElement通过index自增移动来获取,而oldFiberNode通过sibling移动来获取
    const virtualElement = virtualElements[index];
    let newFiber: FiberNode | undefined = void 0;

    const isSameType = Boolean(
      oldFiberNode &&
        virtualElement &&
        oldFiberNode.type === virtualElement.type,
    );
    // 1.type相同,标记为UPDATE(复用真实dom,仅修改dom的属性)
    if (isSameType && oldFiberNode) {
      newFiber = {
        type: oldFiberNode.type,
        dom: oldFiberNode.dom,
        alternate: oldFiberNode,
        props: virtualElement.props,
        return: fiberNode,
        effectTag: 'UPDATE',
      };
    }
    // 2.type不同并且有新的ReactElement,标记为REPLACEMENT,表示新增或移动dom
    // 其实这里可分为两种情况:1)oldFiberNode不存在——新增,2)oldFiberNode存在但type不同——移动
    if (!isSameType && Boolean(virtualElement)) {
      newFiber = {
        type: virtualElement.type,
        dom: null,
        alternate: null,
        props: virtualElement.props,
        return: fiberNode,
        effectTag: 'REPLACEMENT',
      };
    }
    // 3.type不同并且oldFiberNode存在(隐藏条件:ReactElement不存在),父FiberNode标记为DELETION,表示删除dom
    // 除了标记为DELETION,还会把oldFiber放到deletions数组中,用于后续commitRoot时删除dom  (实际React中这个deletions是挂在父fiberNode上的)
    if (!isSameType && oldFiberNode) {
      deletions.push(oldFiberNode);
    }

    if (oldFiberNode) { // oldFiberNode通过sibling移动来获取
      oldFiberNode = oldFiberNode.sibling;
    }

    // 构建新的fiber树(即内存中的Fiber Tree)
    if (index === 0) {
      fiberNode.child = newFiber;
    } else if (typeof prevSibling !== 'undefined') {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index += 1; // ReactElement通过index自增移动来获取
  }
};

3、commitRoot对应 「commit阶段」

1.笼统的说,「commit阶段」要做两件事:

  • 负责DOM节点的插入/移动、更新属性和删除,注意这里说的是DOM,是对真实DOM操作。
  • 执行副作用,useEffect/useLayoutEffect的 destory和create函数。

2.「commit阶段」的流程如下图所示

(图来自《React设计原理》)
image.png 这个“判断是否有副作用”指判断subtreeFlags是否有标记(不是noFlags)。有副作用标记,则DFS时对于每个Fiber节点,需要执行BeforeMutationMutationLayout阶段。

  • BeforeMutation:执行ClassComponent的getSnapshotBeforeUpdate方法,异步调用flushPassiveEffects(和useEffect有关).
  • Mutation:插入/移动、更新属性或删除DOM。
  • Layout:执行componentDidMount/UpdateuseLayoutEffect钩子。

3.等上述的同步代码执行完成后,我们看到的页面就更新了! 到这里我相信你也理解了为什么 useLayout能获取到更新的dom并且能在页面绘制前操作dom了。

  • 这里有个有意思的问题:大家普遍的理解是react(v18版本后)的setState是异步的。那么为什么useLayoutEffect中setState可以在页面绘制前完成状态更新呢?
  • 解释:如果 layout effect 里有 setState,React 立即标记这是一个 同步更新(SyncLane);并且立刻重新 render + commit
useLayoutEffect(() => {
    setXxState(); // 引起的更新优先级是同步的,优先级最高,更新在页面绘制前执行。
  }, []);

其实这里会涉及到一个经典面试题“setState是同步还是异步?”,这个问题留到下一篇博客(React八股和场景题)讨论吧,欢迎大家关注和订阅专栏(doge)。

4.这里你是不是好奇useEffect的执行时机又是怎么样的呢? 别急,后面的「hooks原理」小节会讲到这个问题。

5.补充: Fiber早期架构(v16)中还没有subtreeFlags,使用的是Effects List,如下图,HostRootFiber中保存了effectsList,通过遍历这个链表来更新Fiber,就不用重新DFS整棵Fiber Tree了。

image.png

最新的Fiber架构,则采用了subtreeFlags(v17过渡版本就开始有这个字段了,需要开启并发模式才会用到),大概原因是为了Suspense这个API的一些特性,采用了收集flags的方式。这样就需要DFS 整棵Fiber Tree,通过subtreeFlags判断是否需要继续向下DFS。

结合项目代码分析

本项目代码没有考虑副作用的处理了,重点关注DOM的更新。

// 根据Fiber节点的effectTag更新真实DOM
// 在commitRoot之前,已经完成了所有Fiber节点的比较
// 之前的Fiber比较流程是可以中断的,但commitRoot不能中断
const commitRoot = () => {
  // 找到带dom的父Fiber
  const findParentFiber = (fiberNode?: FiberNode) => {
    if (fiberNode) {
      let parentFiber = fiberNode.return;
      while (parentFiber && !parentFiber.dom) {
        parentFiber = parentFiber.return;
      }
      return parentFiber;
    }

    return null;
  };

  const commitDeletion = (
    parentDOM: FiberNodeDOM,
    DOM: NonNullable<FiberNodeDOM>,
  ) => {
    if (isDef(parentDOM)) {
      parentDOM.removeChild(DOM);
    }
  };

  const commitReplacement = (
    parentDOM: FiberNodeDOM,
    DOM: NonNullable<FiberNodeDOM>,
  ) => {
    if (isDef(parentDOM)) {
      parentDOM.appendChild(DOM);
    }
  };

  const commitWork = (fiberNode?: FiberNode) => {
    if (fiberNode) {
      if (fiberNode.dom) {
        const parentFiber = findParentFiber(fiberNode);
        const parentDOM = parentFiber?.dom;
        //根据副作用标签,更新真实DOM(注意:这里的effectTag和实际React的flags有差异,表示方式不同罢了)
        switch (fiberNode.effectTag) {
          case 'REPLACEMENT':
            commitReplacement(parentDOM, fiberNode.dom);
            break;
          case 'UPDATE':
            updateDOM(
              fiberNode.dom,
              fiberNode.alternate ? fiberNode.alternate.props : {},
              fiberNode.props,
            );
            break;
          default:
            break;
        }
      }
      //递归,先第一个孩子,再处理兄弟节点
      commitWork(fiberNode.child);
      commitWork(fiberNode.sibling);
    }
  };

  // 这里处理了所有的删除工作。(实际React中是在commitWork(fiber)DFS时,遍历父节点的deletions数组做删除的)
  for (const deletion of deletions) {
    if (deletion.dom) {
      const parentFiber = findParentFiber(deletion);
      commitDeletion(parentFiber?.dom, deletion.dom);
    }
  }
  // 执行插入/移动、更新工作。
  if (wipRoot !== null) {
    commitWork(wipRoot.child);
    currentRoot = wipRoot;
  }

  wipRoot = null;
};

更新DOM和创建DOM代码多一点,单独写成函数,如下:

// 更新DOM(属性)
// 简单起见,这里是删除之前所有属性,添加新属性
const updateDOM = (
  DOM: NonNullable<FiberNodeDOM>,
  prevProps: VirtualElementProps,
  nextProps: VirtualElementProps,
) => {
  const defaultPropKeys = 'children';

  for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
    if (removePropKey.startsWith('on')) {
      DOM.removeEventListener(
        removePropKey.slice(2).toLowerCase(),
        removePropValue as EventListener,
      );
    } else if (removePropKey !== defaultPropKeys) {
      // @ts-expect-error: Unreachable code error
      DOM[removePropKey] = '';
    }
  }

  for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
    if (addPropKey.startsWith('on')) {
      DOM.addEventListener(
        addPropKey.slice(2).toLowerCase(),
        addPropValue as EventListener,
      );
    } else if (addPropKey !== defaultPropKeys) {
      // @ts-expect-error: Unreachable code error
      DOM[addPropKey] = addPropValue;
    }
  }
};
// 基于Fiber的type创建 dom 
const createDOM = (fiberNode: FiberNode): FiberNodeDOM => {
  const { type, props } = fiberNode;
  let DOM: FiberNodeDOM = null;

  if (type === 'TEXT') {
    DOM = document.createTextNode('');
  } else if (typeof type === 'string') {
    DOM = document.createElement(type);
  }

  // Update properties based on props after creation.
  if (DOM !== null) {
    updateDOM(DOM, {}, props);
  }

  return DOM;
};

4、Hooks原理

React官方将hooks分为两类,一类是状态hooks,另一类是副作用hooks。

  1. 状态hooks: useState, useReducer, (广义上还有)useContext, useRef, useCallback, useMemo
  2. 副作用hooks: useEffect, useLayoutEffect

a.状态hook

1.下面我们通过一个Fiber节点来看看hooks是如何工作的。 以Fiber(App)节点为例,对应的JSX代码如下:

function App() {
  const [count, setCount] = useState(0);
  useEffect(()=>{
    console.log('didMount')
  },[])
  const [show, setShow] = useState(true)
  useEffect(()=>{
    console.log(show, count)
  },[show, count])
  return (
    <div>
      <p>You clicked {show ? count : '*'} times</p>
      <button onClick={() => setCount(count + 1)}>increase</button>
      <button onClick={() => setCount(count - 1)}>decrease</button>
    </div>
  );
}

Fiber(App)上用了4个hook,那么Fiber的结构如下,Fiber上的属性memoizedState保存了一个链表结构:

image.png

注意:你写的hooks顺序和memoizedState保存的顺序是一致的。当App Function的hooks按顺序执行的同时,会通过一个全局变量currentHook移动来指向当前的hook。如果hooks是条件里执行的话,那么hooks链表节点的查找实际是不可预测的,这也是为什么hooks不能条件里执行

2.下面我们来分析下hook节点(链表节点)上的queuememoizedState

  • 当我们调用setState方法,实际会生成一个Update对象放入queue队列,如下图所示。
  • 当到了「Render阶段」处理对应新Fiber时,会从旧Fiber把hooks链表copy一份过来,然后一个个执行hook,执行currentHook时会从queue队列中遍历所有Update计算出最终的状态,这个状态是放在新Fiber的currentHookmemoizedState
  • 这个memoizedState就是 const [state, setState] = useState()中的state。

image.png

结合项目代码分析

本项目中的变量命名和数据结构有些差异,下面我先说明“映射”关系。(本项目->实际React):

  • Fiber的hook数组 -> Fiber的memoizedState链表
  • hook节点的state -> hook节点的memoizedState
  • hook节点的queue数组 -> hook节点的queue链表
  • 全局变量hookIndex -> 全局变量currentHook指针

本项目代码如下:

// hooks: 找到当前hook节点,计算状态。
function useState<S>(initState: S): [S, (value: S) => void] {
  const fiberNode: FiberNode<S> = wipFiber;
  // 每次更新需要重新构建Fiber, 运行useState时需要从alternate(old Fiber)中获取上一次的hook
  const hook: {
    state: S;
    queue: S[];
  } = fiberNode?.alternate?.hooks
    // 从hooks数组(实际是React是hooks链表)中获取当前的hook节点! 这解释了为什么要按顺序执行hooks
    ? fiberNode.alternate.hooks[hookIndex] 
    : {
        state: initState,
        queue: [],
      };

  // 从更新队列(实际React中更新队列是一个链表,这里简化为数组)中取出所有更新,合并到state中
  while (hook.queue.length) {
    let newState = hook.queue.shift();
    if (isPlainObject(hook.state) && isPlainObject(newState)) {
      newState = { ...hook.state, ...newState };
    }
    if (isDef(newState)) {
      hook.state = newState; //这就是该hook的新状态,根据这个新状态渲染UI
    }
  }

  if (typeof fiberNode.hooks === 'undefined') {
    fiberNode.hooks = [];  
  }

  // 组件内可能多次调用useState,每个useState对应一个hook节点(实际React中就是一个链表节点)
  fiberNode.hooks.push(hook);
  hookIndex += 1; //使用索引保证能按顺序处理hooks数组

  // setState就是一个闭包,里面访问了当前hook节点。
  const setState = (value: S) => {
    hook.queue.push(value);
    if (currentRoot) { //注意这个currentRoot 指向旧Fiber Tree的根节点(即实际React中的FiberRoot.current)
      // 创建新Fiber Tree 的 HostRootFiber
      wipRoot = { 
        type: currentRoot.type,
        dom: currentRoot.dom,
        props: currentRoot.props,
        alternate: currentRoot,
      };
      // Fiber工作指针有值了意味着新的render任务,requestIdleCallback会调用workLoop时会处理nextUnitOfWork
      // 接下来就会进入performUnitOfWork,从根HostRootFiber往下DFS,构建新的Fiber Tree
      nextUnitOfWork = wipRoot;  
      deletions = [];
      currentRoot = null;
    }
  };

  return [hook.state, setState];
}

b.副作用hook

我们先记住下面这个Effect对象定义

export type Effect = {
  tag: HookFlags, // 副作用hook的类型
  create: () => (() => void) | void, //useXxxEffect的创建,即第一个参数。
  destroy: (() => void) | void, //useXxxEffect的销毁函数,第一个参数的返回函数
  deps: Array<mixed> | null, //useXxxEffect的依赖
  next: Effect, //下一个useXxxEffect保存的Effect对象
};

一图胜千言,对于副作用hook而言,hook节点上memoizedState保存的是Effect对象

image.png

初次调用

1.如上图所示,初次调用useEffect,会创建Effect对象并形成effects链表。Effect.tag标记了Effect是Layout还是Passive(对于useLayoutEffect的就标记Layout,对于useEffect的就标记Passive

2.在Commit的「BeforeMutation子阶段」, 异步调用了flushPassiveEffects(宏任务)。在这期间带有Passive标记的effect已经被添加到全局数组中。 接下来flushPassiveEffects就可以脱离fiber节点,遍历全局数组,直接访问effect,先执行effect.destroy,后执行effect.create函数。

3.解释异步调用了flushPassiveEffects:这里的异步调用,是React调度时给了NormalSchedulerPriority优先级,此时flushPassiveEffects被当做一个宏任务来执行。(到这里咱就明白了useEffect和useLayoutEffect的区别:useEffect是一个宏任务,在页面绘制后执行;useLayoutEffect是微任务,在页面绘制前执行

更新调用

image.png

1.当组件更新,就会重新执行useEffect/useLayoutEffect,创建新hook节点,然后对新hook会和旧hook的effect依赖deps比较:

  • 如果有依赖项引用变化,创建新Effect并打上tag |= HasEffect标记
  • 如果没有变化,仅创建新Effect(没有HasEffect标记)

2.新的hook以及新的effect创建完成之后, 余下逻辑与初次渲染完全一致。处理 Effect 回调时也会根据effect.tag进行判断: 只有effect.tag包含HookHasEffect时才会调用effect.destroyeffect.create()

3.此时,你应该明白了如果useXxEffect没有正确依赖,会导致Effect回调不会被触发

4.这部分,本项目没有代码哦~

5、总结

学数学我们讲究数形结合,那么学框架原理,我们也要「码形结合」,这个“码”就是指代码,本文很好的码形结合解释了React原理。

渲染阶段-思维导图

image.png

更新流程-思维导图

image.pngimage.png

Diff算法-思维导图

image.png

参考

mini-react github仓库
图解React
《React设计原理》- big-react github仓库
react性能优化|bailout策略

🚀 从 GPT-5 流式输出看现代前端的流式请求机制(Koa 实现版)

一、前言:为什么要“流式输出”?

传统 HTTP 请求是「一次性返回完整结果」,而大模型(如 GPT-5)生成内容的过程往往比较慢。
如果要让用户看到“边生成边显示”的效果(像 ChatGPT 打字机一样),
就必须使用 流式响应(Streaming Response)


二、流式响应不是新协议

很多人会以为流式请求是某种新的 HTTP 方法,其实不是。

✅ 流式是 响应体的行为,不是请求方式的变化。
服务端仍然用普通的 POST,只是不会一次性 res.end(),而是持续往里写数据。

因此 GPT-5 的流式接口看起来是这样的:

POST /v1/chat/completions
Content-Type: application/json
Accept: text/event-stream

服务器边生成边 write(),客户端边读边显示。


三、为什么必须用 POST?

因为 GPT-5 请求通常包含复杂 JSON:

{
  "model": "gpt-5",
  "messages": [
    { "role": "user", "content": "解释量子纠缠" }
  ],
  "temperature": 0.7,
  "stream": true
}

只有 POST 请求 才能合法地携带请求体(body)。
GET 请求虽然也能带 query 参数,但长度有限、结构不适合复杂 JSON。

🧠 所以:“流式”是响应的特征,而“POST”是为了传递参数。


四、Koa 实现:服务端流式输出示例

下面是最小可运行的 Koa 流式响应示例

import Koa from "koa";
import Router from "@koa/router";
import bodyParser from "koa-bodyparser";

const app = new Koa();
const router = new Router();

router.post("/stream", async (ctx) => {
  const { prompt } = ctx.request.body;

  ctx.set("Content-Type", "text/event-stream");
  ctx.set("Cache-Control", "no-cache");
  ctx.set("Connection", "keep-alive");

  // 模拟 GPT-5 边生成边输出
  const text = `你发送的提示词是:${prompt}。\n下面是流式输出示例:`;
  for (const ch of text) {
    ctx.res.write(`data: ${ch}\n\n`);
    await new Promise((r) => setTimeout(r, 50)); // 模拟生成延迟
  }

  ctx.res.write("data: [DONE]\n\n");
  ctx.res.end();
});

app.use(bodyParser());
app.use(router.routes());
app.listen(3000, () => console.log("🚀 Server on http://localhost:3000/stream"));

🧠 说明:

  1. ctx.set("Content-Type", "text/event-stream") 告诉浏览器使用 SSE 流式响应
  2. 每个 ctx.res.write() 会立即发送一部分内容;
  3. 客户端不需要多次请求,而是持续读取同一个响应流。

五、前端如何接收流

前端用原生 fetch() 即可实现流式读取:

const res = await fetch("http://localhost:3000/stream", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ prompt: "你好,GPT-5!" }),
});

const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value);
  console.log("部分响应:", chunk);
}

控制台会实时打印字符流。
在真实应用中,你可以把这些内容插入编辑器或页面。


六、EventSource 可以替代吗?

不行(至少不完全行)。
EventSource 是浏览器内置的 SSE 客户端,但它:

  • ❌ 只支持 GET 请求;
  • ❌ 不能带请求体;
  • ✅ 适合广播、通知,而不是 GPT 请求。

所以如果你只要订阅服务器事件(例如“系统状态更新”),可以用 EventSource
但要给模型发 prompt、传 JSON,就必须用 fetch + ReadableStream


七、能不能用 ?query 传?

可以,但不推荐。
例如:

const source = new EventSource(`/stream?prompt=${encodeURIComponent("你好")}`);

这种方式仅适合短文本、教学演示。
如果 prompt 很长,或者要传多条 messages,URL 会溢出或被记录到日志中。
所以 GPT-5 这类接口建议还是:

POST 携带 JSON body
✅ 服务端用 text/event-stream 返回流式内容


八、对比三种实时通信方式

技术 请求体 响应方式 实时性 双向通信 适用场景
fetch + ReadableStream ✅ 支持 SSE / chunk ✅ 高 ❌ 单向 GPT-5 / AI 输出
EventSource ❌ 不支持 SSE ✅ 高 ❌ 单向 系统日志、状态广播
WebSocket ✅ 支持 Binary/Text ✅ 最高 ✅ 双向 聊天、协作、游戏

💡 GPT-5 这类接口的最佳实践:
POST + fetch + ReadableStream


九、结语

流式请求的核心并不在于“请求方式”,
而在于响应体的 分块(chunked transfer) 与浏览器对流的解析能力。

现代浏览器对流的支持越来越好,
这让我们能在前端直接用标准 API 实现 ChatGPT 那样的实时输出体验。

所以——
流式输出依然是 POST 请求,
只是响应体变成了连续的数据流。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

深入解析 Vue 3 SSR 编译管线:ssrCodegenTransform 源码全解

一、背景与概念

在 Vue 3 的服务端渲染(SSR)体系中,模板编译器的职责是将 .vue 模板转化为服务端可执行的渲染函数代码。与客户端渲染(CSR)不同,SSR 输出的不是虚拟 DOM,而是完整 HTML 字符串,因此编译产物的结构和生成逻辑完全不同。

这篇文章将分析 Vue 源码中 ssrCodegenTransform.ts 文件的实现,它是SSR 编译阶段的核心转换器,主要负责:

  • 将模板 AST 转换成 JS AST;
  • 插入 SSR 特定的 helper 调用;
  • 组织字符串拼接逻辑;
  • 管理作用域与 CSS 变量。

二、核心函数:ssrCodegenTransform

📜 源码片段

export function ssrCodegenTransform(ast: RootNode, options: CompilerOptions): void {
  const context = createSSRTransformContext(ast, options)

  if (options.ssrCssVars) {
    const cssContext = createTransformContext(createRoot([]), options)
    const varsExp = processExpression(
      createSimpleExpression(options.ssrCssVars, false),
      cssContext,
    )
    context.body.push(
      createCompoundExpression([`const _cssVars = { style: `, varsExp, `}`]),
    )
    Array.from(cssContext.helpers.keys()).forEach(helper => {
      ast.helpers.add(helper)
    })
  }

  const isFragment =
    ast.children.length > 1 && ast.children.some(c => !isText(c))
  processChildren(ast, context, isFragment)
  ast.codegenNode = createBlockStatement(context.body)

  ast.ssrHelpers = Array.from(
    new Set([
      ...Array.from(ast.helpers).filter(h => h in ssrHelpers),
      ...context.helpers,
    ]),
  )
  ast.helpers = new Set(Array.from(ast.helpers).filter(h => !(h in ssrHelpers)))
}

🧩 逐行解析

  1. 创建上下文:

    const context = createSSRTransformContext(ast, options)
    

    创建一个 SSR 专用的转换上下文,用于存储编译状态(包括 helper、body、错误回调等)。

  2. 注入 CSS 变量:

    if (options.ssrCssVars) { ... }
    

    当模板中使用了 SFC <style> 中定义的 CSS 变量时,需要生成 _cssVars 常量,确保 SSR 渲染时能正确解析。

  3. 判断是否为 Fragment:

    const isFragment = ast.children.length > 1 && ast.children.some(c => !isText(c))
    

    若根节点包含多个子节点(或非纯文本节点),需将其包裹为 <!--[--><!--]--> 片段标记。

  4. 核心递归处理:

    processChildren(ast, context, isFragment)
    

    将 AST 中的所有子节点转换为 JS 表达式或字符串片段。

  5. 生成最终代码块:

    ast.codegenNode = createBlockStatement(context.body)
    

    以 BlockStatement(JS 语法树节点)形式输出整个 SSR 渲染函数体。

  6. 区分 SSR 与 Vue 内置 helper:

    ast.ssrHelpers = ...
    

    将属于 SSR 渲染器的 helper(如 _push, _interpolate)从普通 Vue helper 中分离,供 @vue/server-renderer 使用。


三、上下文系统:createSSRTransformContext

📜 源码片段

function createSSRTransformContext(
  root: RootNode,
  options: CompilerOptions,
  helpers: Set<symbol> = new Set(),
  withSlotScopeId = false,
): SSRTransformContext {
  const body: BlockStatement['body'] = []
  let currentString: TemplateLiteral | null = null

  return {
    root,
    options,
    body,
    helpers,
    withSlotScopeId,
    onError: options.onError || (e => { throw e }),
    helper<T extends symbol>(name: T): T {
      helpers.add(name)
      return name
    },
    pushStringPart(part) {
      if (!currentString) {
        const currentCall = createCallExpression(`_push`)
        body.push(currentCall)
        currentString = createTemplateLiteral([])
        currentCall.arguments.push(currentString)
      }
      const bufferedElements = currentString.elements
      const lastItem = bufferedElements[bufferedElements.length - 1]
      if (isString(part) && isString(lastItem)) {
        bufferedElements[bufferedElements.length - 1] += part
      } else {
        bufferedElements.push(part)
      }
    },
    pushStatement(statement) {
      currentString = null
      body.push(statement)
    },
  }
}

🧠 原理讲解

SSRTransformContext 是整个编译流程的“状态容器”。
它的职责相当于一个“编译游标”:

  • 跟踪生成的语句(body);
  • 合并字符串模板;
  • 注册需要导入的 helper;
  • 捕获编译错误。

⚙️ 字符串缓冲机制

SSR 渲染主要是拼接字符串,因此引入了 pushStringPart() 方法,将所有连续的字符串合并在一个模板字面量(TemplateLiteral)中,减少 _push 调用开销。


四、核心节点遍历:processChildren

📜 源码片段

export function processChildren(
  parent: Container,
  context: SSRTransformContext,
  asFragment = false,
  disableNestedFragments = false,
  disableComment = false,
): void {
  if (asFragment) context.pushStringPart(`<!--[-->`)
  const { children } = parent
  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    switch (child.type) {
      case NodeTypes.ELEMENT:
        switch (child.tagType) {
          case ElementTypes.ELEMENT:
            ssrProcessElement(child, context)
            break
          case ElementTypes.COMPONENT:
            ssrProcessComponent(child, context, parent)
            break
          case ElementTypes.SLOT:
            ssrProcessSlotOutlet(child, context)
            break
        }
        break
      case NodeTypes.TEXT:
        context.pushStringPart(escapeHtml(child.content))
        break
      case NodeTypes.INTERPOLATION:
        context.pushStringPart(
          createCallExpression(context.helper(SSR_INTERPOLATE), [child.content]),
        )
        break
      case NodeTypes.IF:
        ssrProcessIf(child, context, disableNestedFragments, disableComment)
        break
      case NodeTypes.FOR:
        ssrProcessFor(child, context, disableNestedFragments)
        break
      case NodeTypes.COMMENT:
        if (!disableComment) {
          context.pushStringPart(`<!--${child.content}-->`)
        }
        break
    }
  }
  if (asFragment) context.pushStringPart(`<!--]-->`)
}

🧩 核心逻辑分解

  1. Fragment 包裹:
    使用注释节点标记多节点片段,便于客户端 hydration 对齐。

  2. 节点类型分派:

    • 元素节点:委托给 ssrProcessElement()
    • 组件节点:调用 ssrProcessComponent()
    • 插槽节点:使用 ssrProcessSlotOutlet()
    • 文本节点:HTML 转义后直接拼接。
    • 插值表达式:调用 _interpolate helper。
    • 条件 / 循环节点:分别交由 ssrProcessIfssrProcessFor 处理。
  3. 注释节点:
    默认输出到最终 HTML 中,但可以通过 disableComment 抑制。


五、子上下文机制:createChildContext

function createChildContext(
  parent: SSRTransformContext,
  withSlotScopeId = parent.withSlotScopeId,
): SSRTransformContext {
  return createSSRTransformContext(
    parent.root,
    parent.options,
    parent.helpers,
    withSlotScopeId,
  )
}

每个子作用域(如插槽或循环体)都会派生一个新的 context,但共享相同的 helpers 集合。
这样既能独立管理作用域,又能复用 helper 注册,保证生成代码一致。


六、实践与拓展

✅ 实践意义

理解 ssrCodegenTransform 有助于:

  • 编写自定义 SSR 指令;
  • 调试模板编译输出;
  • 改进服务端渲染性能;
  • 探索 @vue/compiler-ssr 的扩展。

🧩 拓展方向

  • 字符串合并优化:可尝试 AST 层级的批量合并;
  • 流式 SSR:在 _push 调用处加入流写入逻辑;
  • 定制 helper 注入:通过 context.helper() 动态扩展 SSR runtime。

⚠️ 潜在问题

  • CSS 变量注入只在顶层执行,若嵌套作用域需手动传递;
  • 片段注释标记若被破坏,会导致客户端 hydration 错位;
  • 对于复杂组件(如异步组件、Teleport)还需额外的 transform 支持。

七、总结

ssrCodegenTransform 是 Vue 3 SSR 编译管线的关键环节,它将模板 AST 转化为服务端渲染可执行的 JS AST。其设计充分体现了 分层抽象 + 上下文状态隔离 + 字符串缓冲优化 的思想。

理解此模块能让开发者深入掌握 Vue SSR 的底层工作原理,也为二次开发和性能优化提供了坚实基础。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue SSR 运行时辅助工具注册机制源码详解

本文将深入解析 Vue 在服务端渲染(SSR)过程中对运行时辅助工具(Runtime Helpers)的注册机制。我们将从概念出发,结合源码剖析其设计原理、用途及扩展性。


一、概念层:SSR 与运行时辅助工具(Runtime Helpers)

在 Vue 的 服务端渲染 (Server-Side Rendering, SSR) 体系中,编译器会将模板转化为可执行的渲染函数。然而,为了简化代码生成和运行时逻辑复用,Vue 将常见的渲染任务抽象为一系列 运行时辅助函数(runtime helpers)

这些 helpers 负责处理:

  • 插值(ssrInterpolate
  • 元素渲染(ssrRenderVNode
  • 组件渲染(ssrRenderComponent
  • 样式与属性处理(ssrRenderStyle, ssrRenderAttrs
  • 动态模型、插槽、Teleport、Suspense 等复杂结构

这些函数被标识为 独立的 Symbol 常量,并在运行时注册到编译器中,以便生成模板代码时可以正确引用。


二、原理层:Symbol 注册与映射机制

核心源码如下:

import { registerRuntimeHelpers } from '@vue/compiler-dom'

export const SSR_INTERPOLATE: unique symbol = Symbol(`ssrInterpolate`)
export const SSR_RENDER_VNODE: unique symbol = Symbol(`ssrRenderVNode`)
export const SSR_RENDER_COMPONENT: unique symbol = Symbol(`ssrRenderComponent`)
...
export const ssrHelpers: Record<symbol, string> = {
  [SSR_INTERPOLATE]: `ssrInterpolate`,
  [SSR_RENDER_VNODE]: `ssrRenderVNode`,
  ...
}

// 注册所有 SSR helpers
registerRuntimeHelpers(ssrHelpers)

核心逻辑解析:

  1. Symbol 定义

    export const SSR_INTERPOLATE: unique symbol = Symbol(`ssrInterpolate`)
    
    • 每个 helper 使用 Symbol() 生成独一无二的标识,防止命名冲突。
    • TypeScript 的 unique symbol 类型确保类型系统能静态识别这些常量。
  2. 映射表(ssrHelpers)

    export const ssrHelpers: Record<symbol, string> = { ... }
    
    • 将 Symbol 映射到对应字符串名称。
    • 这些名称必须与 @vue/server-renderer 中的 helper 函数名严格一致,否则在 SSR 构建阶段会发生运行时错误。
  3. 运行时注册

    registerRuntimeHelpers(ssrHelpers)
    
    • 调用 @vue/compiler-dom 提供的注册函数,将所有 helpers 注册到编译器内部的 helper 表。
    • 编译模板时,如果模板使用了某个 SSR 功能(如 <Suspense> 或插槽),编译器会自动注入相应 helper 的引用。

三、对比层:SSR 与 CSR(客户端渲染)Helper 的区别

对比维度 客户端渲染 (CSR) Helpers 服务端渲染 (SSR) Helpers
执行环境 浏览器(DOM 操作) Node.js 或 Render Context
输出目标 虚拟 DOM (VNode) 字符串 HTML
注册方式 registerRuntimeHelpers (在编译器层注册) 同样机制,但 helper 名称不同
典型函数 createVNode, renderList ssrRenderComponent, ssrInterpolate
编译结果 JS runtime 生成 DOM 直接生成 HTML 字符串流

因此 SSR helpers 是一组“无副作用”的函数,专门用于在服务端渲染阶段拼接 HTML,而非操作浏览器 DOM。


四、实践层:自定义 SSR Helper 示例

你可以通过相同机制扩展 SSR helper,例如添加自定义格式化输出函数:

export const SSR_FORMAT_DATE: unique symbol = Symbol('ssrFormatDate')

const customHelpers = {
  [SSR_FORMAT_DATE]: 'ssrFormatDate'
}

registerRuntimeHelpers(customHelpers)

并在 @vue/server-renderer 中实现对应函数:

export function ssrFormatDate(value) {
  return new Date(value).toLocaleDateString()
}

使用效果:

<p>{{ ssrFormatDate(user.createdAt) }}</p>

在 SSR 输出阶段,该模板会调用注册的 ssrFormatDate() 方法直接生成字符串。


五、拓展层:SSR Helper 注册的作用链路

完整流程如下:

模板编译阶段 (compiler-dom)
     ↓
AST 转换阶段 (transform)
     ↓
检测所需 helpers
     ↓
调用 registerRuntimeHelpers 注册
     ↓
生成 render 函数时注入 import 语句
     ↓
@vue/server-renderer 提供对应函数实现
     ↓
最终 HTML 字符串输出

这种分层架构使得 Vue SSR 模块具备:

  • 解耦性强(编译器与运行时分离)
  • 可扩展性高(支持自定义 helper)
  • 类型安全性(TypeScript 的 unique symbol

六、潜在问题与注意事项

  1. 命名必须与运行时实现严格对应
    ssrHelpers 中的字符串与 @vue/server-renderer 实现不一致,会导致运行时抛出 undefined is not a function
  2. SSR 与 CSR Helper 不可混用
    SSR helper 仅在服务端使用,客户端 hydration 阶段应依赖客户端 helper。
  3. 不应在模板中直接引用未注册 helper
    模板编译器只识别已注册的 helper,否则编译器无法生成合法代码。

七、总结

本文展示了 Vue SSR 运行时辅助函数的注册机制:

  • 通过 unique symbol 保证唯一性;
  • 通过 registerRuntimeHelpers 注册至编译器;
  • 在编译阶段根据模板特性自动注入相应 helper;
  • 最终在服务端渲染时调用具体的字符串生成函数。

这种设计实现了 SSR 模块的模块化与灵活性,是 Vue 3 编译器与运行时架构分离思想的典型体现。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

深入解析 Vue SSR 编译器的核心函数:compile

Vue 3 的服务端渲染(SSR, Server-Side Rendering)体系中,compile() 是一个关键函数。它负责将模板字符串或 AST 抽象语法树转化为 可在服务端执行的渲染函数,以生成最终的 HTML 字符串。

本文我们将深入解析 compile 的完整实现,剖析其底层机制与设计哲学。


一、概念:compile 是做什么的?

compile 是 Vue SSR 编译流程的入口函数,功能上类似于前端版本的 @vue/compiler-domcompile,但专为服务端渲染优化。
它主要完成以下几个任务:

  1. 解析模板(Parsing)→ 生成 AST;
  2. 执行转换(Transform)→ 为 SSR 注入特定逻辑;
  3. 生成代码(Codegen)→ 输出服务端可执行的渲染函数代码。

核心目标:把 .vue 模板转化为服务端可运行的渲染函数,使得同一模板在 Node.js 环境下可以生成 HTML 字符串。


二、原理:从模板到渲染函数的编译流程

来看完整源码:

export function compile(
  source: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  options = {
    ...options,
    ...parserOptions,
    ssr: true,
    inSSR: true,
    scopeId: options.mode === 'function' ? null : options.scopeId,
    prefixIdentifiers: true,
    cacheHandlers: false,
    hoistStatic: false,
  }

  const ast = typeof source === 'string' ? baseParse(source, options) : source
  rawOptionsMap.set(ast, options)

  transform(ast, {
    ...options,
    hoistStatic: false,
    nodeTransforms: [
      transformVBindShorthand,
      ssrTransformIf,
      ssrTransformFor,
      trackVForSlotScopes,
      transformExpression,
      ssrTransformSlotOutlet,
      ssrInjectFallthroughAttrs,
      ssrInjectCssVars,
      ssrTransformElement,
      ssrTransformComponent,
      trackSlotScopes,
      transformStyle,
      ...(options.nodeTransforms || []),
    ],
    directiveTransforms: {
      bind: transformBind,
      on: transformOn,
      model: ssrTransformModel,
      show: ssrTransformShow,
      cloak: noopDirectiveTransform,
      once: noopDirectiveTransform,
      memo: noopDirectiveTransform,
      ...(options.directiveTransforms || {}),
    },
  })

  ssrCodegenTransform(ast, options)

  return generate(ast, options)
}

🔍 步骤 1:配置编译选项

options = {
  ...options,
  ...parserOptions,
  ssr: true,
  inSSR: true,
  scopeId: options.mode === 'function' ? null : options.scopeId,
  prefixIdentifiers: true,
  cacheHandlers: false,
  hoistStatic: false,
}

说明:

  • ssr: trueinSSR: true → 明确告诉编译器处于服务端渲染模式;
  • prefixIdentifiers: true → 在 SSR 模式下启用变量前缀(如 _ctx.),避免作用域冲突;
  • cacheHandlershoistStatic 被禁用,因为 SSR 没有客户端 diff 的性能需求。

注释:

// SSR 模式下需要明确开启服务端标志
// 并关闭前端优化(如事件缓存、静态提升)

🔍 步骤 2:生成或使用已有 AST

const ast = typeof source === 'string' ? baseParse(source, options) : source
rawOptionsMap.set(ast, options)

说明:

  • 若传入字符串模板,则调用 baseParse() 将其解析为 AST;
  • 若已是 RootNode(抽象语法树),则直接使用;
  • rawOptionsMap.set() 保存编译配置,用于后续子树的 SSR 转换(尤其是 <slot>)。

🔍 步骤 3:执行 AST 转换(Transform 阶段)

transform(ast, {
  ...options,
  nodeTransforms: [...],
  directiveTransforms: {...},
})

关键:

这一阶段将模板 AST 转化为 SSR 友好的中间表示(IR)

核心 Node Transforms:

转换函数 作用
transformVBindShorthand 处理 :prop 的简写绑定
ssrTransformIf v-if 转化为条件渲染表达式
ssrTransformFor v-for 转化为循环渲染
trackVForSlotScopes 跟踪 v-for 中的插槽作用域
ssrTransformSlotOutlet 改写 <slot> 为 SSR 输出函数
ssrInjectFallthroughAttrs 处理组件透传属性
ssrInjectCssVars 注入 SSR 版本的 CSS 变量
ssrTransformElement 核心:将普通元素节点转化为 SSR 可渲染字符串
ssrTransformComponent 组件级别的 SSR 转换
transformStyle 处理样式绑定(v-bind:style

指令转换(Directive Transforms):

指令 转换函数 说明
v-bind transformBind 保留 DOM 编译逻辑
v-on transformOn 保留事件逻辑(部分忽略)
v-model ssrTransformModel SSR 特殊处理双向绑定
v-show ssrTransformShow 转化为服务端条件渲染
v-cloak/once/memo noopDirectiveTransform 在 SSR 阶段被忽略

🔍 步骤 4:SSR 专用代码生成阶段

ssrCodegenTransform(ast, options)

这一阶段会扫描并修改 ast.codegenNode,将其替换为 SSR 代码生成树

这一步是 SSR 的“魔法”所在,它将模板结构转化为字符串拼接逻辑,例如:

<div>{{ msg }}</div>

会被编译成:

push(`<div>${_ctx.msg}</div>`)

🔍 步骤 5:生成最终渲染函数

return generate(ast, options)

最终输出的 CodegenResult 包含:

  • 渲染函数字符串;
  • 依赖导入信息;
  • SSR 上下文管理代码。

三、对比:SSR 编译 vs. DOM 编译

特性 DOM 编译(客户端) SSR 编译(服务端)
输出 渲染函数(VNode 树) 渲染函数(HTML 字符串)
优化 静态提升、事件缓存 字符串拼接优化
指令处理 运行时 patch 编译期生成逻辑
样式作用域 动态添加 编译时注入
运行环境 浏览器 Node.js

可以看出 SSR 编译器去掉了许多“前端运行时优化”,换取 编译期确定性执行速度


四、实践:如何使用 compile

以下是一个最小示例:

import { compile } from '@vue/compiler-ssr'

const result = compile(`<div>Hello {{ name }}</div>`)
console.log(result.code)

输出示例(简化):

function ssrRender(_ctx, _push, _parent, _attrs) {
  _push(`<div>Hello ${_ctx.name}</div>`)
}

这段代码可直接在 Node 环境中执行,用于服务端输出 HTML。


五、拓展:SSR 的子编译流程

SSR 编译器还支持:

  • 插槽内容(slot branches)进行独立编译;
  • 支持 CSS 变量注入;
  • 支持自定义 directiveTransforms,用于扩展 SSR 指令。

开发者可通过 options.nodeTransformsoptions.directiveTransforms 注入自定义逻辑,实现个性化的 SSR 编译管线。


六、潜在问题与注意事项

  1. 与 hydration 不兼容的行为
    某些指令如 v-oncev-memo 无法在 SSR 端使用,会在 hydration 时失效。
  2. CSS 变量同步问题
    ssrInjectCssVars 仅编译注入变量,但客户端需同步以避免闪烁。
  3. 性能陷阱
    若模板过大,generate() 阶段可能生成极长字符串;可考虑分块渲染。

总结

compile() 是 Vue SSR 编译器的核心接口,它将模板编译为可执行的字符串生成函数,是从模板到 HTML 的桥梁。
通过多层 transform 管线与 SSR 专用 codegen,Vue 实现了优雅的模板到字符串编译机制。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue SSR 错误系统源码解析:createSSRCompilerError 与 SSRErrorCodes 的设计原理

在 Vue 3 的服务端渲染(Server-Side Rendering, SSR)编译器实现中,错误系统是一个极其关键的组成部分。本文将深入剖析 createSSRCompilerErrorSSRErrorCodes 以及相关机制,展示 Vue SSR 编译阶段如何优雅地捕获、标识并提示错误。


一、概念层:SSR 编译错误系统概述

在 Vue 的编译体系中,错误处理的目标是让开发者清晰地知道编译器在哪一步、为何失败。SSR 编译器与普通 DOM 编译器相比,有一些特殊的逻辑和语义,因此需要额外定义 SSR 专属错误码(SSRErrorCodes错误创建函数(createSSRCompilerError

简而言之,createSSRCompilerError 是 SSR 版的 createCompilerError,两者共享底层逻辑,但拥有独立的错误编号与错误信息表。


二、原理层:源码逐行拆解与设计逻辑

核心导入部分

import {
  type CompilerError,
  DOMErrorCodes,
  type SourceLocation,
  createCompilerError,
} from '@vue/compiler-dom'
  • CompilerError:Vue 编译器通用的错误类型定义。
  • DOMErrorCodes:DOM 编译器的错误编号集合。
  • createCompilerError:标准错误构造函数,用于生成带位置信息的错误对象。
  • SourceLocation:用于标注源码中错误位置的结构体(包括行、列、偏移量等)。

这些导入为 SSR 错误模块提供了类型约束与基类能力。


定义 SSR 特化错误类型

export interface SSRCompilerError extends CompilerError {
  code: SSRErrorCodes
}

这里通过 接口扩展(interface extends)CompilerError 泛化为 SSRCompilerError
区别在于:code 字段不再引用 DOMErrorCodes,而是使用 SSR 自己定义的 SSRErrorCodes

✅ 目的:防止 SSR 与 DOM 错误码空间冲突,同时让 TypeScript 能识别不同来源的错误类型。


创建 SSR 编译错误函数

export function createSSRCompilerError(
  code: SSRErrorCodes,
  loc?: SourceLocation,
) {
  return createCompilerError(code, loc, SSRErrorMessages) as SSRCompilerError
}

这一函数是整个模块的核心逻辑。

  • 参数 code:指定错误编号。
  • 参数 loc:错误在源文件中的位置。
  • 第三个参数 SSRErrorMessages:错误码到错误消息的映射表。

最终返回值是通过类型断言强制转换为 SSRCompilerError 的对象。

🔍 底层机制说明:
createCompilerError 会创建形如 { code, loc, message } 的错误对象。SSR 版本只是传入自己的 message 映射表,因此整个系统能自动输出 SSR 特有的提示。


定义 SSR 错误枚举

export enum SSRErrorCodes {
  X_SSR_UNSAFE_ATTR_NAME = 65 /* DOMErrorCodes.__EXTEND_POINT__ */,
  X_SSR_NO_TELEPORT_TARGET,
  X_SSR_INVALID_AST_NODE,
}
  • X_SSR_UNSAFE_ATTR_NAME = 65:以 DOM 错误系统的扩展点为基准。
  • 后续枚举项自增:6667
  • 注释中的 __EXTEND_POINT__ 是一个 同步锚点,用于保证不同编译模块之间错误码不重叠。

⚙️ 机制解读:
DOMErrorCodes.__EXTEND_POINT__ 是 DOM 模块预留的扩展区起点。
SSR 模块在此之后定义自己的错误码,从而保证系统内的唯一性。


测试保护逻辑

if (__TEST__) {
  if (SSRErrorCodes.X_SSR_UNSAFE_ATTR_NAME < DOMErrorCodes.__EXTEND_POINT__) {
    throw new Error(
      `SSRErrorCodes need to be updated to ${
        DOMErrorCodes.__EXTEND_POINT__
      } to match extension point from core DOMErrorCodes.`,
    )
  }
}

该块代码只在单元测试环境中执行,用于防止 SSR 错误码与 DOM 错误码冲突

🧠 思路解读:
由于 enum 自增逻辑依赖前值,而不同模块可能被拆分编译,因此测试阶段通过此断言确保 SSR 错误编号始终 ≥ DOM 错误扩展点。


定义错误消息映射表

export const SSRErrorMessages: { [code: number]: string } = {
  [SSRErrorCodes.X_SSR_UNSAFE_ATTR_NAME]: `Unsafe attribute name for SSR.`,
  [SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET]: `Missing the 'to' prop on teleport element.`,
  [SSRErrorCodes.X_SSR_INVALID_AST_NODE]: `Invalid AST node during SSR transform.`,
}

这部分是错误码到错误消息的映射表:

错误码 错误信息 含义
X_SSR_UNSAFE_ATTR_NAME Unsafe attribute name for SSR. 属性名在 SSR 环境中不安全(如事件绑定)。
X_SSR_NO_TELEPORT_TARGET Missing the 'to' prop on teleport element. teleport 组件缺少 to 属性。
X_SSR_INVALID_AST_NODE Invalid AST node during SSR transform. 在 SSR 转换阶段检测到无效的 AST 节点。

这类表驱动映射能让编译器在运行时快速定位错误,而无需条件分支判断。


三、对比层:SSR 与 DOM 错误系统的差异

对比维度 DOM 错误系统 SSR 错误系统
错误枚举 DOMErrorCodes SSRErrorCodes
消息表 DOMErrorMessages SSRErrorMessages
扩展策略 内部定义 + 保留扩展点 从 DOM 扩展点继续定义
主要用途 客户端模板编译 服务器端渲染转换
检查机制 DOM 专属 AST 校验 SSR AST 与运行时安全性

四、实践层:错误捕获与调试示例

try {
  throw createSSRCompilerError(
    SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET,
    { start: 12, end: 24 } as any
  )
} catch (err) {
  console.error(err.message)
}

输出:

Missing the 'to' prop on teleport element.

分步解释:

  1. 创建错误对象:调用 createSSRCompilerError 并传入错误码。
  2. 附加位置信息:在 loc 中指定源码范围。
  3. 错误输出:内部根据 SSRErrorMessages 查表生成友好的消息文本。

五、拓展层:Vue 编译器的错误生态体系

SSR 错误系统只是 Vue 编译器错误体系的一环,其他模块包括:

  • DOMErrorCodes:处理模板语法与指令问题。
  • CompilerError:统一错误接口。
  • transform 与 parser:通过上下文(context)传播错误对象。
  • onError 回调机制:允许上层(如 Vue Loader)捕获错误并友好提示。

通过这种模块化设计,Vue 编译器能在不同阶段复用通用错误逻辑,并保持类型安全。


六、潜在问题与改进思路

  1. 枚举值手动同步风险
    若 DOM 扩展点更新但 SSR 未调整,将导致测试失败。可考虑使用自动脚本同步。
  2. 错误码分布不连续
    若未来引入更多 SSR 模块,可能需要重新规划错误码段。
  3. 国际化支持不足
    当前错误信息仅英文,未来可引入多语言映射表。

总结

createSSRCompilerError 模块体现了 Vue 编译器体系中严谨的模块分层与扩展策略。
通过枚举、映射表与类型系统的协作,Vue 能在 SSR 编译阶段精准定位错误,保证开发者在调试复杂渲染逻辑时得到清晰反馈。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue SSR 编译器源码深析:ssrTransformShow 的实现原理与设计哲学

一、概念理解:v-show 在 SSR 环境中的本质问题

在 Vue 的客户端渲染(CSR)中,v-show 是通过动态修改元素的 display 样式属性来控制显隐的。
但在 SSR(Server-Side Rendering) 环境下,没有真实的 DOM 操作,因此必须在 编译阶段 将其转换为合适的“样式表达式”,以便在渲染 HTML 时即决定元素的显示与否。

这段源码正是 Vue SSR 编译阶段对 v-show 指令的专用转换函数:ssrTransformShow


二、原理剖析:从 AST 转换到可执行的 SSR 表达式

源码如下:

import {
  DOMErrorCodes,
  type DirectiveTransform,
  createConditionalExpression,
  createDOMCompilerError,
  createObjectExpression,
  createObjectProperty,
  createSimpleExpression,
} from '@vue/compiler-dom'

export const ssrTransformShow: DirectiveTransform = (dir, node, context) => {
  if (!dir.exp) {
    context.onError(
      createDOMCompilerError(DOMErrorCodes.X_V_SHOW_NO_EXPRESSION),
    )
  }
  return {
    props: [
      createObjectProperty(
        `style`,
        createConditionalExpression(
          dir.exp!,
          createSimpleExpression(`null`, false),
          createObjectExpression([
            createObjectProperty(
              `display`,
              createSimpleExpression(`none`, true),
            ),
          ]),
          false /* no newline */,
        ),
      ),
    ],
  }
}

🔍 逐行注释讲解

1. 导入编译器工具函数

import {
  DOMErrorCodes,                // DOM 编译器错误码枚举
  type DirectiveTransform,       // 指令转换器类型定义
  createConditionalExpression,   // 创建条件(三元)表达式节点
  createDOMCompilerError,        // 创建编译错误对象
  createObjectExpression,        // 创建对象字面量表达式节点
  createObjectProperty,          // 创建对象属性节点
  createSimpleExpression,        // 创建简单表达式节点
} from '@vue/compiler-dom'

👉 这些函数用于生成 Vue 编译器 AST(抽象语法树)节点,使模板语法转换为 JavaScript 渲染函数表达式。


2. 定义 ssrTransformShow

export const ssrTransformShow: DirectiveTransform = (dir, node, context) => {
  • dir:指令对象,包含 nameexpmodifiers 等信息。
  • node:当前处理的 AST 节点(如 <div v-show="visible">)。
  • context:编译上下文,包含错误处理、代码生成状态等。

3. 错误处理:未传入表达式时报错

if (!dir.exp) {
  context.onError(
    createDOMCompilerError(DOMErrorCodes.X_V_SHOW_NO_EXPRESSION),
  )
}

🧩 解释:

  • v-show 未绑定表达式(如 <div v-show>),在编译阶段抛出 X_V_SHOW_NO_EXPRESSION 错误。
  • 这是语义层检查,确保 SSR 输出逻辑正确。

4. 返回转换结果:生成 SSR 样式表达式

return {
  props: [
    createObjectProperty(
      `style`,
      createConditionalExpression(
        dir.exp!,                      // 条件:若表达式为真
        createSimpleExpression(`null`, false),   // 则样式为 null(保持默认)
        createObjectExpression([       // 否则强制设置 display:none
          createObjectProperty(
            `display`,
            createSimpleExpression(`none`, true),
          ),
        ]),
        false /* no newline */,
      ),
    ),
  ],
}

🧠 逻辑转换效果如下:

模板:

<div v-show="visible"></div>

编译成 SSR 渲染表达式:

{
  style: visible ? null : { display: "none" }
}

此结构在服务端渲染时即能决定元素是否隐藏,而不依赖浏览器执行逻辑。


三、对比分析:v-show vs v-if 在 SSR 中的差异

指令 渲染机制 SSR 表现 性能特征
v-if 条件性渲染(创建/销毁 DOM) 服务端直接生成或省略对应 HTML 轻量但有重绘代价
v-show 样式控制(display: none 服务端生成元素但隐藏 保持结构完整,适用于频繁切换显示

📌 结论:ssrTransformShow 的存在,使得 v-show 也能在 SSR 输出阶段保留 DOM 结构一致性,有利于 hydration(客户端激活) 一致性。


四、实践部分:自定义 SSR 指令转换示例

假设我们要实现一个类似 v-visible 的 SSR 指令,其逻辑与 v-show 类似但控制 visibility

export const ssrTransformVisible: DirectiveTransform = (dir, node, context) => {
  if (!dir.exp) {
    context.onError(createDOMCompilerError(DOMErrorCodes.X_V_SHOW_NO_EXPRESSION))
  }
  return {
    props: [
      createObjectProperty(
        `style`,
        createConditionalExpression(
          dir.exp!,
          createSimpleExpression(`null`, false),
          createObjectExpression([
            createObjectProperty(
              `visibility`,
              createSimpleExpression(`hidden`, true),
            ),
          ]),
          false,
        ),
      ),
    ],
  }
}

🔧 编译结果:

<div v-visible="isVisible"></div>

➡ SSR 渲染后:

{
  style: isVisible ? null : { visibility: "hidden" }
}

五、拓展思考:SSR 编译器的可扩展性机制

Vue 编译器的设计高度模块化。
所有指令都通过类似的 DirectiveTransform 接口实现:

interface DirectiveTransform {
  (dir: DirectiveNode, node: ElementNode, context: TransformContext): TransformResult
}

因此开发者可自由扩展:

  • 新增自定义指令;
  • 为 SSR 定义独立转换逻辑;
  • 拓展 AST 节点结构;
  • 实现插件式编译中间层。

这也是 Vue 编译器能支持多平台(Web、SSR、Custom Renderer)的关键机制。


六、潜在问题与注意事项

  1. v-show 的动态性缺失
    SSR 仅处理初始渲染,后续仍需在客户端由 runtime 更新样式。
    若表达式依赖异步数据,则 SSR 阶段无法精确控制。
  2. 样式覆盖冲突
    若模板或 CSS 已设置 display 样式,SSR 输出的内联样式可能被覆盖。
  3. hydration 不一致风险
    若 SSR 阶段与客户端初始数据不一致,会造成 hydration mismatch。

总结

ssrTransformShow 是 Vue SSR 编译管线中的一个小而精的组件,其核心使命是:

将“显示控制指令”转译为“样式表达式”,确保 SSR 输出结构完整且可预测。

通过 createConditionalExpressioncreateObjectExpression 等函数的组合,Vue 实现了“模板 → AST → SSR 表达式”的全链条自动化编译。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue SSR 源码解析:ssrTransformModel 深度剖析

一、概念

在 Vue 的 SSR(服务端渲染)编译阶段中,v-model 指令的处理逻辑与客户端渲染存在显著差异。
客户端的 v-model 依赖运行时双向绑定机制,而 SSR 需要在编译时就生成静态字符串输出,因此必须提供一个对应的 SSR 版本 transformssrTransformModel

它的任务是:

  • v-model 指令在服务端编译阶段转化为适用于服务端的渲染表达式;
  • 自动为不同类型的表单控件(inputtextareaselect 等)注入正确的 SSR 绑定逻辑;
  • 确保在模板被渲染成字符串时,v-model 的值反映在 DOM 属性中(例如选中状态或输入值)。

二、原理

ssrTransformModel 是一个 DirectiveTransform,即用于指令编译的转换函数。它通过匹配 v-model 指令节点,根据节点类型(HTML 元素类型)生成对应的 SSR 代码片段。

核心思路如下:

  1. 判断节点类型

    • 普通元素(inputtextareaselect) → 服务端静态输出;
    • 组件节点(<MyInput v-model="x" />) → 委托给 transformModel()
  2. 针对不同元素的处理逻辑

    • input[type=text]:输出 value 属性;
    • input[type=radio]:根据 v-model 值是否匹配当前选项生成 checked
    • input[type=checkbox]:根据数组/布尔判断生成 checked
    • textarea:将内部内容替换为插值;
    • select:递归处理所有 option,为选中项添加 selected 属性。
  3. 错误与校验

    • 检查不合法用法,如:

      • v-modelvalue 同时存在;
      • v-model 用于 <input type="file">
      • v-model 用于非表单元素。

三、源码与逐行注释

1. 引入与类型声明

import {
  DOMErrorCodes,
  type DirectiveTransform,
  ElementTypes,
  type ExpressionNode,
  NodeTypes,
  type PlainElementNode,
  type TemplateChildNode,
  createCallExpression,
  createConditionalExpression,
  createDOMCompilerError,
  createInterpolation,
  createObjectProperty,
  createSimpleExpression,
  findProp,
  hasDynamicKeyVBind,
  transformModel,
} from '@vue/compiler-dom'
import {
  SSR_INCLUDE_BOOLEAN_ATTR,
  SSR_LOOSE_CONTAIN,
  SSR_LOOSE_EQUAL,
  SSR_RENDER_DYNAMIC_MODEL,
} from '../runtimeHelpers'
import type { DirectiveTransformResult } from 'packages/compiler-core/src/transform'

说明:

  • 前半部分导入 AST 处理相关工具函数(如 createCallExpressioncreateConditionalExpression 等)。
  • 后半部分导入 SSR 渲染辅助函数(如 SSR_LOOSE_EQUALSSR_RENDER_DYNAMIC_MODEL 等),这些是 SSR 运行时在字符串拼接中使用的 helper。

2. 核心 transform 函数定义

export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
  const model = dir.exp!

说明:

  • dir:表示 v-model 指令节点;
  • node:当前绑定的元素节点;
  • context:编译上下文,用于错误报告、helper 注册等;
  • modelv-model 的表达式节点(如 foo.bar)。

3. 检查重复绑定 value

  function checkDuplicatedValue() {
    const value = findProp(node, 'value')
    if (value) {
      context.onError(
        createDOMCompilerError(
          DOMErrorCodes.X_V_MODEL_UNNECESSARY_VALUE,
          value.loc,
        ),
      )
    }
  }

逻辑说明:
SSR 不需要再显式设置 value,因为 v-model 已自动生成它;若模板中多写一个 value,会报错提示用户。


4. 递归处理 <select> 子节点

  const processSelectChildren = (children: TemplateChildNode[]) => {
    children.forEach(child => {
      if (child.type === NodeTypes.ELEMENT) {
        processOption(child as PlainElementNode)
      } else if (child.type === NodeTypes.FOR) {
        processSelectChildren(child.children)
      } else if (child.type === NodeTypes.IF) {
        child.branches.forEach(b => processSelectChildren(b.children))
      }
    })
  }

逻辑说明:
递归遍历 <select> 的所有层级子节点,包括被 v-forv-if 包裹的选项,确保每个 <option> 都能正确标注 selected 状态。


5. 处理 <option> 元素

  function processOption(plainNode: PlainElementNode) {
    if (plainNode.tag === 'option') {
      if (plainNode.props.findIndex(p => p.name === 'selected') === -1) {
        const value = findValueBinding(plainNode)
        plainNode.ssrCodegenNode!.elements.push(
          createConditionalExpression(
            createCallExpression(context.helper(SSR_INCLUDE_BOOLEAN_ATTR), [              createConditionalExpression(                createCallExpression(`Array.isArray`, [model]),
                createCallExpression(context.helper(SSR_LOOSE_CONTAIN), [                  model,                  value,                ]),
                createCallExpression(context.helper(SSR_LOOSE_EQUAL), [                  model,                  value,                ]),
              ),
            ]),
            createSimpleExpression(' selected', true),
            createSimpleExpression('', true),
            false,
          ),
        )
      }
    } else if (plainNode.tag === 'optgroup') {
      processSelectChildren(plainNode.children)
    }
  }

逐步说明:

  1. <option> 没有显式 selected,则判断它是否应被选中;

  2. 判断逻辑:

    • 如果 v-model 是数组,则调用 SSR_LOOSE_CONTAIN(model, value)
    • 否则用 SSR_LOOSE_EQUAL(model, value)
  3. 若判断为真,则拼接字符串 " selected" 到最终 SSR 输出。


6. 主逻辑分支(根据元素类型)

  if (node.tagType === ElementTypes.ELEMENT) {
    const res: DirectiveTransformResult = { props: [] }
    const defaultProps = [
      createObjectProperty(`value`, model),
    ]

创建 transform 结果对象 res,用于存放编译后生成的 props(最终转为 SSR 属性字符串)。


6.1 输入框 <input>

    if (node.tag === 'input') {
      const type = findProp(node, 'type')
      if (type) {
        const value = findValueBinding(node)
(1) 动态类型输入框
        if (type.type === NodeTypes.DIRECTIVE) {
          res.ssrTagParts = [
            createCallExpression(context.helper(SSR_RENDER_DYNAMIC_MODEL), [
              type.exp!,
              model,
              value,
            ]),
          ]

SSR_RENDER_DYNAMIC_MODEL 处理,例如:<input :type="inputType" v-model="x">
SSR 阶段会生成动态 type 分支逻辑。

(2) 静态类型输入框

根据 type 值不同:

  • radio
case 'radio':
  res.props = [
    createObjectProperty(
      `checked`,
      createCallExpression(context.helper(SSR_LOOSE_EQUAL), [model, value]),
    ),
  ]
  • checkbox
case 'checkbox':
  const trueValueBinding = findProp(node, 'true-value')
  ...
  res.props = [
    createObjectProperty(
      `checked`,
      createConditionalExpression(
        createCallExpression(`Array.isArray`, [model]),
        createCallExpression(context.helper(SSR_LOOSE_CONTAIN), [model, value]),
        model,
      ),
    ),
  ]
  • file
case 'file':
  context.onError(
    createDOMCompilerError(
      DOMErrorCodes.X_V_MODEL_ON_FILE_INPUT_ELEMENT,
      dir.loc,
    ),
  )
  • default(text/password等)
default:
  checkDuplicatedValue()
  res.props = defaultProps

6.2 文本域 <textarea>

    } else if (node.tag === 'textarea') {
      checkDuplicatedValue()
      node.children = [createInterpolation(model, model.loc)]

替换内部内容为插值表达式,使得 SSR 输出 <textarea>内容</textarea>


6.3 下拉框 <select>

    } else if (node.tag === 'select') {
      processSelectChildren(node.children)

调用前文定义的递归函数,为 <option> 自动添加 selected


6.4 非法用法处理

    } else {
      context.onError(
        createDOMCompilerError(
          DOMErrorCodes.X_V_MODEL_ON_INVALID_ELEMENT,
          dir.loc,
        ),
      )
    }

例如在 <div><span> 上使用 v-model


6.5 返回结果

    return res
  } else {
    // component v-model
    return transformModel(dir, node, context)
  }

对组件节点交由通用的 transformModel 处理。


7. 辅助函数:提取 value 绑定

function findValueBinding(node: PlainElementNode): ExpressionNode {
  const valueBinding = findProp(node, 'value')
  return valueBinding
    ? valueBinding.type === NodeTypes.DIRECTIVE
      ? valueBinding.exp!
      : createSimpleExpression(valueBinding.value!.content, true)
    : createSimpleExpression(`null`, false)
}

若节点有 v-bind:value 或静态 value 属性,返回其表达式;否则返回一个空表达式 null


四、对比分析

类型 客户端编译 (transformModel) SSR 编译 (ssrTransformModel)
输出形式 动态绑定 + 事件 静态字符串生成
目标 运行时 DOM 更新 初始 HTML 渲染
处理时机 运行时 编译时
表单类型支持 同步运行时 DOM 预渲染选中与值

SSR 版本的核心区别是:它必须在没有 DOM 的环境下模拟“选中/输入”状态


五、实践应用

假设模板如下:

<select v-model="lang">
  <option value="en">English</option>
  <option value="jp">Japanese</option>
</select>

SSR 编译后会生成:

<select>
  <option value="en" selected>English</option>
  <option value="jp">Japanese</option>
</select>

如果 lang === 'en',则 selected 会在服务器端直接输出。
客户端 hydration 阶段无需再额外处理选中逻辑。


六、拓展:与 v-bindv-if 的协同

在 SSR 编译中,v-modelv-bindv-ifv-for 可交织存在。
ssrTransformModel 特意支持递归遍历 v-ifv-for 子节点,确保所有动态渲染的选项都被正确判断和输出。


七、潜在问题与设计思考

  1. 动态类型的复杂性
    <input :type="inputType" v-model="x"> 在 SSR 中必须生成多分支渲染逻辑,否则无法确定 checked/value 的生成规则。
  2. 数组绑定的兼容性
    通过 Array.isArray() 判断是否是多选绑定,这种动态检测在 SSR 环境中需谨慎处理性能开销。
  3. hydration 对齐问题
    SSR 输出的初始状态必须与客户端初始 data 完全一致,否则会出现 mismatch 警告。

八、总结

ssrTransformModel 是 Vue SSR 编译体系中最关键的指令处理逻辑之一。
它在编译阶段将 v-model 的双向绑定语义转化为静态属性字符串,从而实现初始状态可还原、无运行时依赖的 HTML 输出

这段代码充分体现了 Vue 在 编译期与运行期分层设计 的思想:

编译期静态生成 + 运行时动态绑定 = 高性能与高一致性并存。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue SSR 源码解析:ssrProcessIf 条件渲染的服务端转换逻辑

在 Vue 的服务端渲染(SSR)编译阶段,v-if / v-else-if / v-else 指令需要被转换为可在服务器端执行的渲染逻辑,以生成正确的 HTML 输出。
本文将深入解析 ssrProcessIf 的源码结构、原理设计、与编译端差异,并逐步讲解如何生成对应的 SSR 代码节点。


一、概念层:ssrProcessIf 的职责定位

在 Vue 的编译流程中:

  • 第一阶段(AST 构建) :通过 createStructuralDirectiveTransform 注册结构性指令(如 v-ifv-for)。
  • 第二阶段(SSR 转换) :根据 AST 节点生成服务端可执行代码段。

ssrProcessIf 属于第二阶段函数,其主要任务是:

将编译器 DOM 层的 IfNode(即 v-ifv-else-ifv-else)转换为 SSR 环境下可执行的 JavaScript 条件语句(if / else if / else),并构建对应的渲染逻辑块(BlockStatement)。


二、原理层:源码解构与逻辑流程

完整源码如下(附详细注释):

import {
  type BlockStatement,
  type IfBranchNode,
  type IfNode,
  type NodeTransform,
  NodeTypes,
  createBlockStatement,
  createCallExpression,
  createIfStatement,
  createStructuralDirectiveTransform,
  processIf,
} from '@vue/compiler-dom'
import {
  type SSRTransformContext,
  processChildrenAsStatement,
} from '../ssrCodegenTransform'

// (1) 注册指令 transform,用于第一阶段 AST 构建
export const ssrTransformIf: NodeTransform = createStructuralDirectiveTransform(
  /^(?:if|else|else-if)$/, // 匹配三种 v-if 指令
  processIf, // 使用 compiler-dom 中的基础逻辑
)

// (2) SSR 阶段:根据 AST 生成服务端代码结构
export function ssrProcessIf(
  node: IfNode,
  context: SSRTransformContext,
  disableNestedFragments = false,
  disableComment = false,
): void {
  const [rootBranch] = node.branches

  // 2.1 生成第一个 if 语句
  const ifStatement = createIfStatement(
    rootBranch.condition!,
    processIfBranch(rootBranch, context, disableNestedFragments),
  )
  context.pushStatement(ifStatement)

  // 2.2 遍历后续的 else-if / else 分支
  let currentIf = ifStatement
  for (let i = 1; i < node.branches.length; i++) {
    const branch = node.branches[i]
    const branchBlockStatement = processIfBranch(
      branch,
      context,
      disableNestedFragments,
    )
    if (branch.condition) {
      // else-if 分支
      currentIf = currentIf.alternate = createIfStatement(
        branch.condition,
        branchBlockStatement,
      )
    } else {
      // else 分支
      currentIf.alternate = branchBlockStatement
    }
  }

  // 2.3 无 else 分支时插入空注释节点
  if (!currentIf.alternate && !disableComment) {
    currentIf.alternate = createBlockStatement([
      createCallExpression(`_push`, ['`<!---->`']), // 输出空注释占位
    ])
  }
}

// (3) 处理单个分支的内部 children
function processIfBranch(
  branch: IfBranchNode,
  context: SSRTransformContext,
  disableNestedFragments = false,
): BlockStatement {
  const { children } = branch
  const needFragmentWrapper =
    !disableNestedFragments &&
    (children.length !== 1 || children[0].type !== NodeTypes.ELEMENT) &&
    // 优化:单一子节点为 ForNode 时可跳过 Fragment 包裹
    !(children.length === 1 && children[0].type === NodeTypes.FOR)
  return processChildrenAsStatement(branch, context, needFragmentWrapper)
}

🧩 核心逻辑分层解析

层次 功能说明
ssrTransformIf 注册 AST 转换插件,识别所有 v-if 指令。
ssrProcessIf 将 AST 的 IfNode 转换为 SSR 语句树(JavaScript 逻辑块)。
processIfBranch 将分支的子节点转换为渲染语句体,自动判断是否需要 <template> Fragment 包裹。
_push('<!-- -->') 无匹配分支时,输出 SSR 空注释(与客户端渲染一致)。

三、对比层:SSR vs Client 编译逻辑

项目 客户端编译(compiler-dom 服务端编译(compiler-ssr
输出目标 渲染函数(_createVNode 等) 字符串拼接输出(_push() 调用)
条件控制 通过 createConditionalExpression 生成三元表达式 通过 createIfStatement 生成实际 JS if/else 语句
空分支处理 输出 null 输出 HTML 注释 <!---->
Fragment 包裹 依赖 runtime 渲染优化 在编译阶段判断是否合并 Fragment

总结
客户端编译偏向运行时动态决策(虚拟 DOM diff),
而 SSR 编译是静态化、预展开的逻辑树,追求“可直接输出字符串”的高效性。


四、实践层:示例推演

假设我们有如下模板:

<div>
  <div v-if="ok">A</div>
  <div v-else-if="maybe">B</div>
  <div v-else>C</div>
</div>

经过 ssrProcessIf 转换后,内部会生成伪代码结构如下:

if (ok) {
  _push(`<div>A</div>`)
} else if (maybe) {
  _push(`<div>B</div>`)
} else {
  _push(`<div>C</div>`)
}

若缺省 v-else,则输出:

if (ok) {
  _push(`<div>A</div>`)
} else {
  _push(`<!---->`)
}

→ 这种方式保证了 SSR 输出的 HTML 结构与客户端渲染保持一致。


五、拓展层:相关模块联动

  • processChildrenAsStatement
    负责将模板的子节点编译为 BlockStatement,以便在 SSR 环境中可按顺序 _push()

  • SSRTransformContext
    上下文对象,包含:

    • _push() 输出流管理;
    • 静态/动态片段收集;
    • 嵌套片段(Fragment)处理开关;
  • createIfStatement
    内部封装为标准的 AST IfStatement,确保输出 JS 可被后续 Codegen 阶段直接序列化为字符串。


六、潜在问题与优化方向

  1. 多层嵌套条件的代码量膨胀

    • SSR 输出的是纯 JS 控制流,会导致分支较多时体积增长。
    • 可考虑后续阶段引入“条件预计算”或“短路优化”。
  2. Fragment 包裹判断逻辑复杂

    • 当前通过节点类型和数量判断,边界情况(如 v-if 内包裹 template)可能出现过包或漏包。
  3. 可调试性弱

    • SSR 阶段生成的 _push 调用在调试时可读性差,未来可考虑引入 Source Map 或结构化渲染树调试器。

总结

ssrProcessIf 是 Vue SSR 编译器中处理条件渲染的关键模块。
它通过静态化展开条件逻辑、生成 JS 控制流语句,实现了与客户端一致的输出结果。
这一设计体现了 “运行时动态性 → 编译期确定性” 的 Vue SSR 架构哲学。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

深入理解 Vue SSR 中的 v-for 编译逻辑:ssrProcessFor 源码解析

一、概念背景

在 Vue 3 的服务端渲染(SSR)编译阶段,v-for 指令的处理过程被拆分为两个阶段:

  1. 第一阶段(结构化转换) :通过 createStructuralDirectiveTransform 捕获模板中的 v-for 语法,并生成语法树(AST)。
  2. 第二阶段(SSR 专用代码生成) :使用 ssrProcessFor 将语法树节点转化为 SSR 运行时可执行的渲染函数片段。

换句话说,ssrProcessFor 是 SSR 编译中 负责把 v-for 从 AST 转换为字符串拼接逻辑 的核心函数。


二、源码结构总览

import {
  type ForNode,
  type NodeTransform,
  NodeTypes,
  createCallExpression,
  createForLoopParams,
  createFunctionExpression,
  createStructuralDirectiveTransform,
  processFor,
} from '@vue/compiler-dom'
import {
  type SSRTransformContext,
  processChildrenAsStatement,
} from '../ssrCodegenTransform'
import { SSR_RENDER_LIST } from '../runtimeHelpers'

// 第一阶段:v-for 的结构化指令转换
export const ssrTransformFor: NodeTransform =
  createStructuralDirectiveTransform('for', processFor)

// 第二阶段:v-for 的 SSR 代码生成逻辑
export function ssrProcessFor(
  node: ForNode,
  context: SSRTransformContext,
  disableNestedFragments = false,
): void {
  const needFragmentWrapper =
    !disableNestedFragments &&
    (node.children.length !== 1 || node.children[0].type !== NodeTypes.ELEMENT)
  const renderLoop = createFunctionExpression(
    createForLoopParams(node.parseResult),
  )
  renderLoop.body = processChildrenAsStatement(
    node,
    context,
    needFragmentWrapper,
  )

  if (!disableNestedFragments) {
    context.pushStringPart(`<!--[-->`)
  }
  context.pushStatement(
    createCallExpression(context.helper(SSR_RENDER_LIST), [
      node.source,
      renderLoop,
    ]),
  )
  if (!disableNestedFragments) {
    context.pushStringPart(`<!--]-->`)
  }
}

三、原理拆解

1. 第一阶段:结构化指令转换

export const ssrTransformFor: NodeTransform =
  createStructuralDirectiveTransform('for', processFor)

这段代码调用了 Vue 编译器的核心工具 createStructuralDirectiveTransform
其作用是:

  • 捕获模板中所有带 v-for 的节点
  • 调用 processFor 将其转换为一个 ForNode AST 节点。

这一阶段仅构建静态结构,不关心 SSR 逻辑。


2. 第二阶段:SSR 渲染逻辑生成

接下来,ssrProcessFor 会在 SSR transform pass 中被调用,将 ForNode 转化为最终的服务端字符串拼接逻辑。

(1) 判断是否需要 Fragment 包裹

const needFragmentWrapper =
  !disableNestedFragments &&
  (node.children.length !== 1 || node.children[0].type !== NodeTypes.ELEMENT)
  • v-for 内有多个子节点,或子节点不是单个元素时,需要用注释标记的 fragment 包裹,以在 SSR 输出中维持正确的层级结构。
  • 例如:
<div v-for="i in list">
  <span>{{ i }}</span>
  <p>text</p>
</div>

会被包裹为:

<!--[--><span>...</span><p>...</p><!--]-->

(2) 构造循环函数表达式

const renderLoop = createFunctionExpression(
  createForLoopParams(node.parseResult),
)
  • createForLoopParamsv-for="(item, index) in list" 中提取参数:(item, index)

  • createFunctionExpression 则生成类似:

    (item, index) => { /* 渲染逻辑 */ }
    

(3) 生成循环体逻辑

renderLoop.body = processChildrenAsStatement(
  node,
  context,
  needFragmentWrapper,
)
  • processChildrenAsStatement 会把 v-for 的子节点转化为 SSR 输出语句;
  • 其中会递归调用 SSR 版本的 processElementprocessText 等;
  • 如果 needFragmentWrapper = true,则会在输出中插入注释节点包裹子节点。

(4) 生成最终 SSR 调用表达式

context.pushStatement(
  createCallExpression(context.helper(SSR_RENDER_LIST), [
    node.source,
    renderLoop,
  ]),
)

生成的代码逻辑大致等价于:

_ssrRenderList(list, (item, index) => {
  // renderLoop.body 逻辑
})

SSR_RENDER_LIST 是 Vue SSR 运行时的辅助函数,作用与客户端渲染中的 renderList 相同,用于在 SSR 阶段执行循环渲染。


(5) 处理 Fragment 包裹标记

if (!disableNestedFragments) {
  context.pushStringPart(`<!--[-->`)
}
// ...loop...
if (!disableNestedFragments) {
  context.pushStringPart(`<!--]-->`)
}
  • 这两行分别在循环输出前后插入特殊注释标记;

  • 这些标记帮助客户端 hydration 过程正确定位 Fragment 边界;

  • 例如 SSR 输出:

    <!--[--><div>...</div><div>...</div><!--]-->
    

四、核心逻辑流程图

v-for AST 节点
    ↓
ssrProcessFor()
    ↓
判断是否需要 Fragment
    ↓
构造 renderLoop 函数表达式
    ↓
将子节点转换为可执行语句块
    ↓
包装 SSR_RENDER_LIST 调用
    ↓
输出字符串标记 + 渲染结果

五、与客户端编译对比

维度 客户端编译 SSR 编译
输出目标 Virtual DOM 渲染函数 字符串拼接逻辑
运行时 Helper renderList SSR_RENDER_LIST
Fragment 用 VNode 包裹 用注释节点包裹
Hydration 需求 不存在 必须维持 DOM 边界一致性
子节点处理 生成虚拟节点数组 转换为字符串输出逻辑

可以看出,SSR 的 v-for 处理逻辑重点在于字符串输出正确性与 Hydration 边界维护,而非虚拟节点构造。


六、实践案例

示例模板

<ul>
  <li v-for="(item, i) in list">{{ item }}</li>
</ul>

SSR 编译结果(简化后)

_ssrRenderList(_ctx.list, (item, i) => {
  return `<li>${_ssrInterpolate(item)}</li>`
})

在服务端渲染时,该函数返回拼接好的字符串数组,最终生成 HTML。


七、拓展思考

  1. disableNestedFragments 参数的用途

    • 用于嵌套结构中(如 v-for 内部的 v-if),避免重复包裹。
    • 例如模板层已经添加了 Fragment 注释,就可以禁用内部 fragment。
  2. SSR_RENDER_LIST 的执行机制

    • 在运行时执行循环,拼接字符串;
    • 同时维持顺序和索引一致,确保 hydration 对应关系。
  3. 性能优化方向

    • 可针对静态 list 进行编译期展开;
    • 在长列表中通过分块渲染减少内存占用。

八、潜在问题与调试要点

问题 可能原因 解决思路
SSR 输出结构不匹配 缺少 Fragment 注释边界 检查 needFragmentWrapper 判断逻辑
Hydration 错位 子节点顺序或注释标识错误 确认 <!--[--><!--]--> 对应位置
性能下降 动态表达式复杂 可使用 key 优化 diff 逻辑
嵌套循环出错 参数作用域冲突 检查 createForLoopParams 是否正确生成参数

九、总结

ssrProcessFor 是 Vue SSR 编译管线中将模板循环指令转换为字符串渲染逻辑的关键模块。
其核心工作包括:

  • 生成循环函数表达式;
  • 处理子节点渲染;
  • 管理 Fragment 包裹与注释边界;
  • 最终生成基于 SSR_RENDER_LIST 的可执行输出。

它是 Vue SSR 保证模板在客户端复水时结构精确对应的重要机制之一。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

深度解析:Vue 3 中 ssrTransformTransitionGroup 的实现原理与机制

在 Vue 3 的服务端渲染(SSR)编译体系中,TransitionGroup 是一个非常特殊的组件。
它既是一个过渡容器,又需要在服务端生成结构化 HTML,并在客户端保持可 hydration 的一致性。
为了实现这一目标,Vue 编译器对它进行了专门的两阶段处理——
也就是本文要深入解析的 ssrTransformTransitionGroupssrProcessTransitionGroup


一、背景与整体概念

Vue 的 SSR 编译过程与普通编译(即 DOM 渲染编译)不同。
在 SSR 模式下,模板需要被转换为可直接输出 HTML 字符串的渲染函数

对于一般组件来说,SSR 编译器会自动将 props、children 等信息编译为字符串。
但对于 <TransitionGroup> 这种动态包装容器组件,则必须特殊处理:

  • 它允许开发者通过 tag:tag 自定义包裹元素;
  • 它会在运行时将子节点展平(flatten)
  • 它会在运行时过滤注释节点
  • 同时还要保证 SSR 与 Hydration 阶段 DOM 结构对齐。

于是 Vue 内部引入了一个双阶段设计:

Phase 1:构建阶段(Transform)
Phase 2:输出阶段(Process)

这就是本文件实现的核心逻辑。


二、源码全貌

以下是 Vue 源码中的关键实现(简化版):

export function ssrTransformTransitionGroup(node, context) {
  return (): void => {
    const tag = findProp(node, 'tag')
    if (tag) {
      const otherProps = node.props.filter(p => p !== tag)
      const { props, directives } = buildProps(node, context, otherProps, true, false, true)
      let propsExp = null
      if (props || directives.length) {
        propsExp = createCallExpression(context.helper(SSR_RENDER_ATTRS), [
          buildSSRProps(props, directives, context),
        ])
      }
      wipMap.set(node, {
        tag,
        propsExp,
        scopeId: context.scopeId || null,
      })
    }
  }
}

export function ssrProcessTransitionGroup(node, context) {
  const entry = wipMap.get(node)
  if (entry) {
    const { tag, propsExp, scopeId } = entry
    if (tag.type === NodeTypes.DIRECTIVE) {
      // 动态 tag
      context.pushStringPart(`<`)
      context.pushStringPart(tag.exp!)
      if (propsExp) context.pushStringPart(propsExp)
      if (scopeId) context.pushStringPart(` ${scopeId}`)
      context.pushStringPart(`>`)

      processChildren(node, context, false, true, true)

      context.pushStringPart(`</`)
      context.pushStringPart(tag.exp!)
      context.pushStringPart(`>`)
    } else {
      // 静态 tag
      context.pushStringPart(`<${tag.value!.content}`)
      if (propsExp) context.pushStringPart(propsExp)
      if (scopeId) context.pushStringPart(` ${scopeId}`)
      context.pushStringPart(`>`)

      processChildren(node, context, false, true, true)
      context.pushStringPart(`</${tag.value!.content}>`)
    }
  } else {
    // 无 tag 情况
    processChildren(node, context, true, true, true)
  }
}

三、原理剖析:SSR 编译的两阶段逻辑

1. Transform 阶段(ssrTransformTransitionGroup

在 AST 转换阶段,Vue 编译器会遍历模板中的每一个节点。
对于 <TransitionGroup>,这一步主要做的是信息提取

核心流程

const tag = findProp(node, 'tag') // 找出 <TransitionGroup tag="ul"> 的 tag 属性
const otherProps = node.props.filter(p => p !== tag) // 过滤掉 tag
const { props, directives } = buildProps(...) // 构建 SSR props

逻辑说明

  • findProp:查找 <TransitionGroup> 上的 tag 属性;

  • buildProps:构建 SSR 需要的 props;

    • 这里传入的 true /* ssr (skip event listeners) */ 表示跳过事件监听,因为 SSR 不绑定事件;
  • createCallExpression:生成 _ssrRenderAttrs() 调用表达式,用于在后续拼接字符串时插入属性字符串;

  • 最终结果存入一个 WeakMap 缓存,以便第二阶段使用。

生成的中间数据结构

{
  tag: AttributeNode | DirectiveNode,
  propsExp: CallExpression | null,
  scopeId: string | null
}

2. Process 阶段(ssrProcessTransitionGroup

这一阶段真正执行“渲染字符串输出”。

动态标签(:tag="expr")逻辑

if (tag.type === NodeTypes.DIRECTIVE) {
  context.pushStringPart(`<`)
  context.pushStringPart(tag.exp!)
  if (propsExp) context.pushStringPart(propsExp)
  if (scopeId) context.pushStringPart(` ${scopeId}`)
  context.pushStringPart(`>`)
  processChildren(node, context, false, true, true)
  context.pushStringPart(`</`)
  context.pushStringPart(tag.exp!)
  context.pushStringPart(`>`)
}

在这里,tag.exp! 是一个动态表达式(例如 "listTag"),
SSR 输出会变成类似:

<${listTag} ...attrs>...</${listTag}>

静态标签(tag="ul")逻辑

context.pushStringPart(`<${tag.value!.content}`)
if (propsExp) context.pushStringPart(propsExp)
if (scopeId) context.pushStringPart(` ${scopeId}`)
context.pushStringPart(`>`)
processChildren(node, context, false, true, true)
context.pushStringPart(`</${tag.value!.content}>`)

输出类似:

<ul class="fade-list"> ... </ul>

特殊参数说明

processChildren(node, context, false, true, true) 的最后两个布尔参数非常关键:

  • flattenFragments = true:表示将所有子节点展开为单层 fragment;
  • ignoreComments = true:TransitionGroup 会在运行时过滤掉注释节点,因此 SSR 也必须同步。

四、对比与特性分析

特性项 普通组件 Transition TransitionGroup
输出结构 固定 DOM 或 Fragment 包裹子元素 动态 tag 可配置
Props 处理 正常 正常 跳过事件监听
子节点渲染 保持层级 单层 强制展平 (flatten)
注释节点 保留 保留 过滤掉注释节点
SSR 输出 静态 动态包裹 动态拼接标签结构

这一表格揭示了为什么 Vue 需要专门的 SSR transform 逻辑来处理它:
TransitionGroup 同时具备“容器”与“动态结构”的特性。


五、拓展理解:SSR Transform 的模式化设计

Vue 的 SSR Transform 系统普遍遵循一个模板化的模式:

阶段 函数命名 作用
Transform 阶段 ssrTransformXxx 只负责收集静态信息,记录到 WeakMap
Process 阶段 ssrProcessXxx 读取缓存信息,生成最终字符串输出

同类文件包括:

  • ssrTransformComponent
  • ssrTransformTeleport
  • ssrTransformSlotOutlet

这种设计具有以下优势:

  1. 逻辑解耦:Transform 与输出生成分离;
  2. 性能优化:避免重复属性分析;
  3. 递归安全:支持嵌套结构(如 TransitionGroup 内再嵌套组件);
  4. Hydration 一致性:保证 SSR 输出与客户端渲染结构对齐。

六、潜在问题与实现注意事项

  1. WeakMap 生命周期
    编译阶段缓存仅存于内存,若存在并行编译(如 Vite 多线程 SSR),需避免交叉访问。

  2. 属性拼接安全性
    由于 SSR 拼接字符串直接输出 HTML,必须保证 tag.exp 安全,防止 XSS 注入。Vue 内部对该表达式有 AST 级验证。

  3. 作用域 ID 拼接细节
    注意 context.pushStringPart( ${scopeId}) 前的空格,
    若省略,可能导致属性粘连错误,如:

    <ulclass="fade">  // ❌ 错误输出
    
  4. 无 tag 情况
    若开发者未指定 tag,Vue 会退回 fragment 模式(即不包裹任何标签),
    此时调用 processChildren(..., true, true, true)


七、总结与启示

ssrTransformTransitionGroup 是 Vue 3 SSR 架构中一个小而精妙的片段。
它的实现体现出 Vue 对编译阶段与运行时一致性的极高要求:

  • 利用 两阶段编译策略 解决动态结构问题;
  • AST Transform 层 提前抽取信息,避免生成期重复运算;
  • 通过 processChildren 的参数控制,完美模拟了运行时行为。

这种精细化设计让 Vue 的 SSR 输出既高效又精准,保证了前后端渲染结构一致性。


结尾说明:
本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue SSR 源码解读:ssrTransformTransition 与 ssrProcessTransition 的实现逻辑

在 Vue 3 的服务端渲染(SSR)体系中,Transition 组件虽然在客户端负责动画过渡,但在服务端它并不执行动画,而是仅作为一种逻辑容器。本文将深入分析 Vue SSR 编译阶段如何处理 <transition> 节点,重点讲解 ssrTransformTransitionssrProcessTransition 两个核心函数的作用和设计。


一、概念

1. 背景

当我们在模板中使用:

<transition appear>
  <div>Hello</div>
</transition>

客户端渲染时,Vue 会为其绑定动画钩子。但 SSR 中没有动画执行环境,因此需要一种方式“识别并简化” <transition> 的渲染逻辑,以便最终输出稳定的 HTML 字符串。

2. 目标

SSR 的编译阶段要做到两点:

  • 保留 <transition> 的语义结构;
  • 过滤掉与动画无关的运行时逻辑,只保留必要的内容。

二、原理解析

Vue 的 SSR 编译管线中,针对组件会调用一系列 transformprocess 阶段函数。

  • ssrTransform* 系列函数:负责“分析”组件节点的属性与结构;
  • ssrProcess* 系列函数:负责“生成”最终的 SSR 输出。

对于 <transition>,Vue 分别定义了:

  1. ssrTransformTransition:在 AST 转换阶段执行,用于标记 appear 属性;
  2. ssrProcessTransition:在生成 SSR 代码阶段执行,用于输出最终 HTML。

三、源码拆解与逐行注释

源码整体

import {
  type ComponentNode,
  NodeTypes,
  type TransformContext,
  findProp,
} from '@vue/compiler-dom'
import {
  type SSRTransformContext,
  processChildren,
} from '../ssrCodegenTransform'

const wipMap = new WeakMap<ComponentNode, Boolean>()

export function ssrTransformTransition(
  node: ComponentNode,
  context: TransformContext,
) {
  return (): void => {
    const appear = findProp(node, 'appear', false, true)
    wipMap.set(node, !!appear)
  }
}

export function ssrProcessTransition(
  node: ComponentNode,
  context: SSRTransformContext,
): void {
  // #5351: filter out comment children inside transition
  node.children = node.children.filter(c => c.type !== NodeTypes.COMMENT)

  const appear = wipMap.get(node)
  if (appear) {
    context.pushStringPart(`<template>`)
    processChildren(node, context, false, true)
    context.pushStringPart(`</template>`)
  } else {
    processChildren(node, context, false, true)
  }
}

(1)wipMap:工作中间态存储

const wipMap = new WeakMap<ComponentNode, Boolean>()

解释:
这是一个“弱映射表”,用来在 transformprocess 阶段之间共享节点状态。
在编译管线中,同一个节点会多次被访问,WeakMap 能安全地存储它的中间状态,不影响垃圾回收。


(2)ssrTransformTransition:标记阶段

export function ssrTransformTransition(
  node: ComponentNode,
  context: TransformContext,
) {
  return (): void => {
    const appear = findProp(node, 'appear', false, true)
    wipMap.set(node, !!appear)
  }
}

逐行解释:

  • findProp(node, 'appear', false, true):在当前组件节点中查找 appear 属性;
  • 若存在,则表示该 <transition> 有初次动画逻辑;
  • wipMap.set(node, !!appear):将布尔结果记录到 wipMap 中,供后续处理阶段使用。

核心作用:
这一步不生成任何输出,而是为节点打上“是否含 appear” 的标记


(3)ssrProcessTransition:生成阶段

export function ssrProcessTransition(
  node: ComponentNode,
  context: SSRTransformContext,
): void {
  node.children = node.children.filter(c => c.type !== NodeTypes.COMMENT)
  const appear = wipMap.get(node)

  if (appear) {
    context.pushStringPart(`<template>`)
    processChildren(node, context, false, true)
    context.pushStringPart(`</template>`)
  } else {
    processChildren(node, context, false, true)
  }
}

逐行解释:

  1. 过滤注释节点:

    node.children = node.children.filter(c => c.type !== NodeTypes.COMMENT)
    

    避免无意义的注释干扰 SSR 输出。

  2. 读取 transform 阶段标记:

    const appear = wipMap.get(node)
    

    获取 <transition> 是否含有 appear 属性。

  3. 按条件输出:

    • 如果存在 appear:包裹一层 <template> 标签(表示这是一个初始渲染的容器);
    • 否则,直接输出子节点。
    if (appear) {
      context.pushStringPart(`<template>`)
      processChildren(node, context, false, true)
      context.pushStringPart(`</template>`)
    } else {
      processChildren(node, context, false, true)
    }
    

总结逻辑:
SSR 版本的 <transition> 实际不会生成 <transition> 标签,而是根据是否“初始出现动画”来选择是否包裹 <template>


四、对比:客户端 vs 服务端

环境 Transition 行为 输出结构
客户端 (DOM) 生成真实 <transition> 节点;控制过渡动画 <transition><div>...</div></transition>
服务端 (SSR) 仅输出子内容,不涉及动画逻辑 <div>...</div><template>...</template>

SSR 的 Transition 更像是“语义占位符”,保证编译一致性,而非真实 DOM 控制器。


五、实践案例

示例模板

<transition appear>
  <p>Hello SSR</p>
</transition>

SSR 编译输出(简化后)

<template><p>Hello SSR</p></template>

若去掉 appear 属性:

<transition>
  <p>Hello SSR</p>
</transition>

输出则变为:

<p>Hello SSR</p>

可见,<template> 的出现与 appear 属性一一对应。


六、拓展:WeakMap 的作用与替代方案

使用 WeakMap 而非普通 Map 的原因:

  • 避免内存泄漏(节点销毁后自动释放);
  • 符合“编译阶段数据暂存”的设计原则;
  • 多阶段共享同一引用对象。

若改用普通 Map,节点对象可能被持久引用,导致无法被垃圾回收。


七、潜在问题与思考

  1. SSR 与 Hydration 差异
    SSR 输出 <template> 可能与客户端虚拟 DOM 结构不同,需确保 hydration 阶段 DOM 匹配。
  2. TransitionGroup 的特殊性
    目前逻辑仅覆盖 <transition>,而 <transition-group> 会有更复杂的子节点管理逻辑。
  3. 进一步优化
    若未来引入 v-showv-if 动画 SSR 兼容,可考虑在 transform 阶段做更细粒度 AST 分析。

八、总结

本文从编译管线视角,完整分析了 Vue SSR 如何处理 <transition> 节点:

  • ssrTransformTransition 负责标记;
  • ssrProcessTransition 负责输出;
  • WeakMap 作为中间态桥梁;
  • 最终目标是输出干净、语义一致的 HTML。

这种分阶段设计兼顾了 编译阶段清晰性运行时性能,体现了 Vue SSR 的工程化思路。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue SSR 深度解析:ssrProcessTeleport 的源码机制与实现原理

在 Vue 3 的服务端渲染(SSR)编译阶段中,ssrProcessTeleport 是一个二次编译(second-pass)阶段的代码生成转换函数,用于处理 <teleport> 组件的服务端输出逻辑。
本文将深入剖析其设计目的、实现原理与编译链中的位置,并通过逐行注释展示源码的运行流程。


一、概念背景:SSR 与 Teleport 的特殊性

Teleport 的核心作用是在客户端渲染时允许开发者将某些内容渲染到 DOM 树的其他位置,例如:

<teleport to="#modal">
  <div>Modal content</div>
</teleport>

而在 SSR(Server-Side Rendering) 模式中,Vue 必须在生成 HTML 字符串时保留这种结构的逻辑信息,以便在客户端 hydrate 时仍能正确关联目标节点。
因此,SSR 编译器必须捕获 teleport 的目标 (to 属性)、内容及禁用状态 (disabled),并生成可在运行时执行的渲染函数调用。


二、原理剖析:函数结构与核心流程

我们先看完整函数结构:

export function ssrProcessTeleport(
  node: ComponentNode,
  context: SSRTransformContext,
): void {
  // 1. 提取 to 属性
  const targetProp = findProp(node, 'to')
  if (!targetProp) {
    context.onError(
      createSSRCompilerError(SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET, node.loc),
    )
    return
  }

  // 2. 解析 teleport 的目标表达式
  let target: ExpressionNode | undefined
  if (targetProp.type === NodeTypes.ATTRIBUTE) {
    target =
      targetProp.value && createSimpleExpression(targetProp.value.content, true)
  } else {
    target = targetProp.exp
  }
  if (!target) {
    context.onError(
      createSSRCompilerError(
        SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET,
        targetProp.loc,
      ),
    )
    return
  }

  // 3. 检查 disabled 属性
  const disabledProp = findProp(node, 'disabled', false, true)
  const disabled = disabledProp
    ? disabledProp.type === NodeTypes.ATTRIBUTE
      ? `true`
      : disabledProp.exp || `false`
    : `false`

  // 4. 生成内容渲染函数
  const contentRenderFn = createFunctionExpression(
    [`_push`],
    undefined,
    true,
    false,
    node.loc,
  )
  contentRenderFn.body = processChildrenAsStatement(node, context)

  // 5. 调用 SSR_RENDER_TELEPORT helper 输出最终代码
  context.pushStatement(
    createCallExpression(context.helper(SSR_RENDER_TELEPORT), [
      `_push`,
      contentRenderFn,
      target,
      disabled,
      `_parent`,
    ]),
  )
}

三、逐行解析与代码注释

1. 依赖导入部分

import {
  type ComponentNode,
  type ExpressionNode,
  NodeTypes,
  createCallExpression,
  createFunctionExpression,
  createSimpleExpression,
  findProp,
} from '@vue/compiler-dom'
  • 这些来自 @vue/compiler-dom 的工具帮助我们在 AST 层面分析节点结构。
  • findProp 用于查找节点上的属性。
  • createSimpleExpression 用于包装字面量。
  • createFunctionExpressioncreateCallExpression 用于生成可序列化的函数调用表达式。
import {
  type SSRTransformContext,
  processChildrenAsStatement,
} from '../ssrCodegenTransform'
  • SSRTransformContext 记录当前的编译状态(例如 helper 函数、输出缓冲区等)。
  • processChildrenAsStatement 会将组件的子节点转换为 _push 调用序列(即生成 HTML 的部分)。

2. 目标属性提取与校验

const targetProp = findProp(node, 'to')
if (!targetProp) {
  context.onError(
    createSSRCompilerError(SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET, node.loc),
  )
  return
}

🔍 Teleport 没有 to 属性时直接报错,因为无法确定渲染目标。


3. 生成 Teleport 目标表达式

let target: ExpressionNode | undefined
if (targetProp.type === NodeTypes.ATTRIBUTE) {
  target =
    targetProp.value && createSimpleExpression(targetProp.value.content, true)
} else {
  target = targetProp.exp
}
  • to 是静态字符串时(如 "body"),会转换成简单表达式;
  • 若为动态绑定(如 :to="dynamicTarget"),则直接使用已存在的表达式。
if (!target) {
  context.onError(
    createSSRCompilerError(
      SSRErrorCodes.X_SSR_NO_TELEPORT_TARGET,
      targetProp.loc,
    ),
  )
  return
}

再次进行容错检查,确保目标有效。


4. 解析 Teleport 的 disabled 属性

const disabledProp = findProp(node, 'disabled', false, true)
const disabled = disabledProp
  ? disabledProp.type === NodeTypes.ATTRIBUTE
    ? `true`
    : disabledProp.exp || `false`
  : `false`

这里实现了对 <teleport disabled>:disabled="isOff" 等多种写法的兼容。
若完全未声明则默认为 "false"


5. 生成内容渲染函数

const contentRenderFn = createFunctionExpression(
  [`_push`],
  undefined,
  true,
  false,
  node.loc,
)
contentRenderFn.body = processChildrenAsStatement(node, context)
  • 这里定义了一个函数 ( _push ) => { ... },其中 _push 是 SSR 生成字符串的累积器。
  • 通过 processChildrenAsStatement 将子节点转换为 _push('<div>...</div>') 的序列。

6. 生成最终的 Teleport 渲染调用

context.pushStatement(
  createCallExpression(context.helper(SSR_RENDER_TELEPORT), [
    `_push`,
    contentRenderFn,
    target,
    disabled,
    `_parent`,
  ]),
)

这一步实际上会生成类似如下的 SSR 代码:

_ssrRenderTeleport(_push, (_push) => {
  _push(`<div>Modal content</div>`)
}, "#modal", false, _parent)

SSR_RENDER_TELEPORT 是运行时 helper,用来在服务器渲染时输出占位结构并记录 Teleport 的上下文。


四、与客户端编译逻辑的对比

模式 渲染位置 主要职责
客户端编译 (compiler-dom) <teleport> 转换为运行时组件调用 负责 DOM 操作与挂载目标
SSR 编译 (compiler-ssr) 生成 _ssrRenderTeleport 调用 负责输出 HTML 字符串结构

SSR 编译器的目标不是运行组件逻辑,而是预先生成字符串模板,因此它会将 Teleport 的运行逻辑“降级”为字符串拼接函数调用。


五、实践:如何调试与扩展

如果你想在自定义 SSR 环境中注入额外逻辑(例如记录 Teleport 使用次数),可以在 ssrCodegenTransform 阶段拦截:

context.registerHelper(SSR_RENDER_TELEPORT)

并在运行时自定义 _ssrRenderTeleport

export function ssrRenderTeleport(push, renderContent, target, disabled, parent) {
  console.log(`Teleport to: ${target}`)
  if (!disabled) {
    renderContent(push)
  }
}

六、拓展思考

  • 可插拔性设计:Vue SSR 的 transform 阶段是模块化的,可针对组件类型注册不同的二次处理函数。
  • AST 级编译复用:此逻辑复用 compiler-dom 的节点定义体系,使得 SSR 与 DOM 编译器高度兼容。
  • 运行时与编译时解耦:SSR 编译器不会直接生成 HTML,而是生成运行时 helper 调用,使得服务器端逻辑更灵活。

七、潜在问题与注意事项

  1. 动态目标表达式的求值:SSR 不会实际解析 :to 绑定的值,必须在运行时环境中确定;
  2. disabled 的字符串化陷阱:在 SSR 生成的代码中 "false" 是字符串,不是真布尔;
  3. Hydration 差异:服务端输出必须与客户端 Teleport 的挂载位置一致,否则 hydration 失败;
  4. 嵌套 Teleport 场景:需要谨慎处理多层 Teleport,否则会引发输出顺序不一致。

八、结语

ssrProcessTeleport 展示了 Vue SSR 编译器的强大与优雅设计:
它以最小的代价在编译阶段保留 Teleport 的运行语义,同时通过抽象层(helper + context)确保代码可维护性与扩展性。

一句话总结:它是将“客户端结构指令”转译为“服务端字符串指令”的桥梁。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

❌