阅读视图

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

Vue Markdown 渲染优化实战(下):告别 DOM 操作,拥抱 AST 与函数式渲染

上回回顾:当 morphdom 遇上 Vue

上一篇文章中,我们经历了一场 Markdown 渲染的性能优化之旅。从最原始的 v-html 全量刷新,到按块更新,最终我们请出了 morphdom 这个“终极武器”。它通过直接比对和操作真实 DOM,以最小的代价更新视图,完美解决了实时渲染中的性能瓶颈和交互状态丢失问题。

然而,一个根本性问题始终存在:在 Vue 的地盘里,绕过 Vue 的虚拟 DOM (Virtual DOM) 和 Diff 算法,直接用一个第三方库去“动刀”真实 DOM,总感觉有些“旁门左道”。这就像在一个精密的自动化工厂里,引入了一个老师傅拿着锤子和扳手进行手动修补。虽然活干得漂亮,但总觉得破坏了原有的工作流,不够“Vue”。

那么,有没有一种更优雅、更“原生”的方式,让我们既能享受精准更新的快感,又能完全融入 Vue 的生态体系呢?

带着这个问题,我询问了前端群里的伙伴们。

如果就要做一个渲染器,你这个思路不是最佳实践。每次更新时,你都生成全量的虚拟 HTML,然后再对 HTML 做减法来优化性能。然而,每次更新的增量部分是明确的,为什么不直接用这部分增量去做加法?增量部分通过 markdown-it 的库无法直接获取,但更好的做法是在这一步进行改造:先解析 Markdown 的结构,再利用 Vue 的动态渲染能力生成 DOM。这样,DOM 的复用就可以借助 Vue 自身的能力来实现。—— j10c

可以用 unified 结合 remark-parse 插件,将 markdown 字符串解析为 ast,然后根据 ast 使用 render func 进行渲染即可。—— bii & nekomeowww

新思路:从“字符串转换”到“结构化渲染”

我们之前的方案,无论是 v-html 还是 morphdom,其核心思路都是:

Markdown 字符串 -> markdown-it -> HTML 字符串 -> 浏览器/morphdom -> DOM

这条链路的问题在于,从 HTML 字符串 这一步开始,我们就丢失了 Markdown 的原始结构信息。我们得到的是一堆非结构化的文本,Vue 无法理解其内在逻辑,只能将其囫囵吞下。

而新的思路则是将流程改造为:

Markdown 字符串 -> AST (抽象语法树) -> Vue VNodes (虚拟节点) -> Vue -> DOM

什么是 AST?

AST (Abstract Syntax Tree) ,即抽象语法树,是源代码或标记语言的结构化表示。它将一长串的文本,解析成一个层级分明的树状对象。对于 Markdown 来说,一个一级标题会变成一个 type: 'heading', depth: 1 的节点,一个段落会变成一个 type: 'paragraph' 的节点,而段落里的文字,则是 paragraph 节点的 children

一旦我们将 Markdown 转换成 AST,就相当于拥有了整个文档的“结构图纸”。我们不再是面对一堆模糊的 HTML 字符串,而是面对一个清晰、可编程的 JavaScript 对象。

我们的新工具:unified 与 remark

为了实现 Markdown -> AST 的转换,我们引入 unified 生态。

  • unified: 一个强大的内容处理引擎。你可以把它想象成一条流水线,原始文本是原料,通过添加不同的“插件”来对它进行解析、转换和序列化。
  • remark-parse: 一个 unified 插件,专门负责将 Markdown 文本解析成 AST(具体来说是 mdast 格式)。

第一步:将 Markdown 解析为 AST

首先,我们需要安装相关依赖:

npm install unified remark-parse

然后,我们可以轻松地将 Markdown 字符串转换为 AST:

import { unified } from 'unified'
import remarkParse from 'remark-parse'

const markdownContent = '# Hello, AST!\n\nThis is a paragraph.'

// 创建一个处理器实例
const processor = unified().use(remarkParse)

// 解析 Markdown 内容
const ast = processor.parse(markdownContent)

console.log(JSON.stringify(ast, null, 2))

运行以上代码,我们将得到一个如下所示的 JSON 对象,这就是我们梦寐以求的 AST:

{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "Hello, AST!",
          "position": { ... }
        }
      ],
      "position": { ... }
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "This is a paragraph.",
          "position": { ... }
        }
      ],
      "position": { ... }
    }
  ],
  "position": { ... }
}

第二步:从 AST 到 Vue VNodes

拿到了 AST,下一步就是将这个“结构图纸”真正地“施工”成用户可见的界面。在 Vue 的世界里,描述 UI 的蓝图就是虚拟节点 (VNode),而 h() 函数(即 hyperscript)就是创建 VNode 的画笔。

我们的任务是编写一个渲染函数,它能够递归地遍历 AST,并为每一种节点类型(heading, paragraph, text 等)生成对应的 VNode。

下面是一个简单的渲染函数实现:

function renderAst(node) {
  if (!node) return null
  switch (node.type) {
    case 'root':
      return h('div', {}, node.children.map(renderAst))
    case 'paragraph':
      return h('p', {}, node.children.map(renderAst))
    case 'text':
      return node.value
    case 'emphasis':
      return h('em', {}, node.children.map(renderAst))
    case 'strong':
      return h('strong', {}, node.children.map(renderAst))
    case 'inlineCode':
      return h('code', {}, node.value)
    case 'heading':
      return h('h' + node.depth, {}, node.children.map(renderAst))
    case 'code':
      return h('pre', {}, [h('code', {}, node.value)])
    case 'list':
      return h(node.ordered ? 'ol' : 'ul', {}, node.children.map(renderAst))
    case 'listItem':
      return h('li', {}, node.children.map(renderAst))
    case 'thematicBreak':
      return h('hr')
    case 'blockquote':
      return h('blockquote', {}, node.children.map(renderAst))
    case 'link':
      return h('a', { href: node.url, target: '_blank' }, node.children.map(renderAst))
    default:
      // 其它未实现类型
      return h('span', { }, `[${node.type}]`)
  }
}

第三步:封装 Vue 组件

整合上述逻辑,我们可以构建一个 Vue 组件。鉴于直接生成 VNode 的特性,采用函数式组件或显式 render 函数最为适宜。

<template>
  <component :is="VNodeTree" />
</template>

<script setup>
import { computed, h, shallowRef, watchEffect } from 'vue'
import { unified } from 'unified'
import remarkParse from 'remark-parse'

const props = defineProps({
  mdText: {
    type: String,
    default: ''
  }
})

const ast = shallowRef(null)
const parser = unified().use(remarkParse)

watchEffect(() => {
  ast.value = parser.parse(props.mdText)
})

// AST 渲染函数 (同上文 renderAst 函数)
function renderAst(node) { ... }

const VNodeTree = computed(() => renderAst(ast.value))

</script>

现在就可以像使用普通组件一样使用它了:

<template>
  <MarkdownRenderer :mdText="markdownContent" />
</template>

<script setup>
import { ref } from 'vue'
import MarkdownRenderer from './MarkdownRenderer.vue'

const markdownContent = ref('# Hello Vue\n\nThis is rendered via AST!')
</script>

AST 方案的巨大优势

切换到 AST 赛道后,我们获得了前所未有的超能力:

  1. 原生集成,性能卓越:我们不再需要 v-html 的暴力刷新,也不再需要 morphdom 这样的“外援”。所有更新都交由 Vue 自己的 Diff 算法处理,这不仅性能极高,而且完全符合 Vue 的设计哲学,是真正的“自己人”。
  2. 高度灵活性与可扩展性:AST 作为可编程的 JavaScript 对象,为定制化处理提供了坚实基础:
    • 元素替换:可将原生元素(如 <h2>)无缝替换为自定义 Vue 组件(如 <FancyHeading>),仅在 renderAst 函数中调整对应 case 逻辑即可。
    • 逻辑注入:可便捷地为外部链接 <a> 添加 target="_blank"rel="noopener noreferrer" 属性,或为图片 <img> 包裹懒加载组件,此类操作在 AST 层面易于实现。
    • 生态集成:充分利用 unified 丰富的插件生态(如 remark-gfm 支持 GFM 语法,remark-prism 实现代码高亮),仅需在处理器链中引入相应插件(.use(pluginName))。
  3. 关注点分离:解析逻辑(remark)、渲染逻辑(renderAst)和业务逻辑(Vue 组件)被清晰地分离开来,代码结构更清晰,维护性更强。
  4. 类型安全与可预测性:相较于操作字符串或原始 HTML,基于结构化 AST 的渲染逻辑更易于进行类型校验与逻辑推理。

结论:从功能实现到架构优化的演进

回顾优化历程:

  • v-html:实现简单,但存在性能与安全性隐患。
  • 分块更新:缓解了部分性能问题,但方案存在局限性。
  • morphdom:有效提升了性能与用户体验,但与 Vue 核心机制存在隔阂。
  • AST + 函数式渲染:回归 Vue 原生范式,提供了性能、灵活性、可维护性俱佳的终极解决方案。

通过采用 AST,我们不仅解决了具体的技术挑战,更重要的是实现了思维范式的转变——从面向结果(HTML 字符串)的编程,转向面向过程与结构(AST)的编程。这使我们能够深入内容本质,从而实现对渲染流程的精确控制。

本次从“全量刷新”到“结构化渲染”的优化实践,不仅是一次性能提升的技术过程,更是一次深入理解现代前端工程化思想的系统性探索。最终实现的 Markdown 渲染方案,在性能、功能性与架构优雅性上均达到了较高水准。

🔲 ☆

Vue Markdown 渲染优化实战(上):从暴力刷新、分块更新到 Morphdom 的华丽变身

需求背景

在最近接手的 AI 需求中,需要实现一个类似 ChatGPT 的对话交互界面。其核心流程是:后端通过 SSE(Server-Sent Events)协议,持续地将 AI 生成的 Markdown 格式文本片段推送到前端。前端负责动态接收并拼接这些 Markdown 片段,最终将拼接完成的 Markdown 文本实时渲染并显示在用户界面上。

Markdown 渲染并不是什么罕见的需求,尤其是在 LLM 相关落地产品满天飞的当下。不同于 React 生态拥有一个 14k+ star 的著名第三方库——react-markdown,Vue 这边似乎暂时还没有一个仍在活跃维护的、star 数量不低(起码得 2k+ 吧?)的 markdown 渲染库。cloudacy/vue-markdown-render 最后一次发版在一年前,但截止本文写作时间只有 103 个 star;miaolz123/vue-markdown 有 2k star,但最后一次 commit 已经是 7 年前了;zhaoxuhui1122/vue-markdown 更是 archived 状态。

第一版方案:简单粗暴的 v-html

简单调研了一圈,发现 Vue 生态里确实缺少一个能打的 Markdown 渲染库。既然没有现成的轮子,那咱就自己造一个!

根据大部分文章以及 LLM 的推荐,我们首先采用 markdown-it 这个第三方库将 markdown 转换为 html 字符串,再通过 v-html 传入。

PS: 我们这里假设 Markdown 内容是可信的(比如由我们自己的 AI 生成)。如果内容来自用户输入,一定要使用 DOMPurify 这类库来防止 XSS 攻击,避免给网站“开天窗”哦!

示例代码如下:

<template>
  <div v-html="renderedHtml"></div>
</template>

<script setup>
import { computed, onMounted, ref } from 'vue';
import MarkdownIt from 'markdown-it';

const markdownContent = ref('');
const md = new MarkdownIt();

const renderedHtml = computed(() => md.render(markdownContent.value))

onMounted(() => {
  // markdownContent.value = await fetch() ...
})
</script>

进化版:给 Markdown 分块更新

上述方案虽然能实现基础渲染,但在实时更新场景下存在明显缺陷:每次接收到新的 Markdown 片段,整个文档都会触发全量重渲染。即使只有最后一行是新增内容,整个文档的 DOM 也会被完全替换。这导致两个核心问题:

  1. **性能顶不住:**Markdown 内容增长时,markdown-it 解析和 DOM 重建的开销呈线性上升。
  2. **交互状态丢失:**全量刷新会把用户当前的操作状态冲掉。最明显的就是,如果你选中了某段文字,一刷新,选中状态就没了!

为了解决这两个问题,我们在网上找到了分块渲染的方案 —— 把 Markdown 按两个连续的换行符 (\n\n) 切成一块一块的。这样每次更新,只重新渲染最后一块新的,前面的老块直接复用缓存。好处很明显:

  • 用户如果选中了前面块里的文字,下次更新时选中状态不会丢(因为前面的块没动)。
  • 需要重新渲染的 DOM 变少了,性能自然就上来了。

代码调整后像这样:

<template>
  <div>
    <div
      v-for="(block, idx) in renderedBlocks"
      :key="idx"
      v-html="block"
      class="markdown-block"
    ></div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import MarkdownIt from 'markdown-it'

const markdownContent = ref('')
const md = new MarkdownIt()

const renderedBlocks = ref([])
const blockCache = ref([])

watch(
  markdownContent,
  (newContent, oldContent) => {
    const blocks = newContent.split(/\n{2,}/)
    // 只重新渲染最后一个块,其余用缓存
    // 处理块减少、块增多的场景
    blockCache.value.length = blocks.length
    for (let i = 0; i < blocks.length; i++) {
      // 只渲染最后一个,或新块
      if (i === blocks.length - 1 || !blockCache.value[i]) {
        blockCache.value[i] = md.render(blocks[i] || '')
      }
      // 其余块直接复用
    }
    renderedBlocks.value = blockCache.value.slice()
  },
  { immediate: true }
)

onMounted(() => {
  // markdownContent.value = await fetch() ...
})
</script>

终极武器:用 morphdom 实现精准更新

分块渲染虽然解决了大部分问题,但遇到 Markdown 列表就有点力不从心了。因为 Markdown 语法里,列表项之间通常只有一个换行符,整个列表会被当成一个大块。想象一下一个几百项的列表,哪怕只更新最后一项,整个列表块也要全部重来,前面的问题又回来了。

morphdom 是何方神圣?

morphdom 是一个仅 5KB(gzip 后)的 JavaScript 库,核心功能是:接收两个 DOM 节点(或 HTML 字符串),计算出最小化的 DOM 操作,将第一个节点 “变形” 为第二个节点,而非直接替换

其工作原理类似虚拟 DOM 的 Diff 算法,但直接操作真实 DOM

  1. 对比新旧 DOM 的标签名、属性、文本内容等;
  2. 仅对差异部分执行增 / 删 / 改操作(如修改文本、更新属性、移动节点位置);
  3. 未变化的 DOM 节点会被完整保留,包括其事件监听、滚动位置、选中状态等。

Markdown 把列表当整体,但生成的 HTML 里,每个列表项 (<li>) 都是独立的!morphdom 在更新后面的列表项时,能保证前面的列表项纹丝不动,状态自然就保住了。

这不就是我们梦寐以求的效果吗?在 Markdown 实时更新的同时,最大程度留住用户的操作状态,还能省掉一堆不必要的 DOM 操作!

示例代码

<template>
  <div ref="markdownContainer" class="markdown-container">
    <div id="md-root"></div>
  </div>
</template>

<script setup>
import { nextTick, ref, watch } from 'vue';
import MarkdownIt from 'markdown-it';
import morphdom from 'morphdom';

const markdownContent = ref('');
const markdownContainer = ref(null);
const md = new MarkdownIt();

const render = () => {
  if (!markdownContainer.value.querySelector('#md-root')) return;

  const newHtml = `<div id="md-root">` + md.render(markdownContent.value) + `</div>`

  morphdom(markdownContainer.value, newHtml, {
    childrenOnly: true
  });
}

watch(markdownContent, () => {
    render()
});

onMounted(async () => {
  // 等待 Dom 被挂载上
  await nextTick()
  render()
})
</script>

眼见为实:Demo 对比

下面这个 iframe 里放了个对比 Demo,展示了不同方案的效果差异。

小技巧: 如果你用的是 Chrome、Edge 这类 Chromium 内核的浏览器,打开开发者工具 (DevTools),找到“渲染”(Rendering) 标签页,勾选「突出显示重绘区域(Paint flashing)」。这样你就能直观看到每次更新时,哪些部分被重新绘制了——重绘区域越少,性能越好!

阶段性成果

从最开始的“暴力全量刷新”,到“聪明点的分块更新”,再到如今“精准手术刀般的 morphdom 更新”,我们一步步把那些不必要的渲染开销给砍掉了,最终搞出了一个既快又能留住用户状态的 Markdown 实时渲染方案。

不过,用 morphdom 这个第三方库来直接操作 Vue 组件里的 DOM,总觉得有点...不够“Vue”?它虽然解决了核心的性能和状态问题,但在 Vue 的世界里这么玩,多少有点旁门左道的意思。

下篇预告: 在下一篇文章里,咱们就来聊聊,在 Vue 的世界里,有没有更优雅、更“原生”的方案来搞定 Markdown 的精准更新?敬请期待!

🔲 ☆

node-sass 迁移至 dart-sass 踩坑实录

更新目标

  • node-sass -> sass ( dart-sass )
  • 减少影响面,非必要不更新其他依赖的版本
  • 在前两条基础上,看看能否提升 node.js 的版本

抛弃 node-sass 的理由

项目依赖版本现状

  • node@^12
  • vue@^2
  • webpack@^3
  • vue-loader@^14
  • sass-loader@^7.0.3
  • node-sass@^4

更新思路

node.js

webpack 官方并没有提供 webpack 3 支持的最高 node 版本,且即使 webpack 官方支持,webpack 的相关插件也未必支持。因此 node 版本能否更新就只能自己试。好在尽管这个项目的 CI/CD 跑在 node 12,但我日常都在用 node 14 开发,因此顺势将 node 版本提升至 14。

webpack、sass-loader

webpack 的版本目前处于非必要不更新的定时炸弹状态,基于现有的 webpack 3 限制,所支持的最高 sass-loader 版本就是 ^7 ( sass-loader 在 8.0.0 版本的更新日志中明确指出 8.0.0 版本需要 webpack 4.36.0)。

如果项目中 sass-loader@^7 支持使用 dart-sass 就可以不更新 sass-loader,也就不必更新 webpack 版本;反之,就需要同步更新 webpack 至 4,再视情况定下 sass-loader 的版本。

那么到底支不支持呢?我在 webpack 官方文档介绍 sass-loader 的页面找到了这样一段 package.json 片段

{
  "devDependencies": {
    "sass-loader": "^7.2.0",
    "sass": "^1.22.10"
  }
}

这证明起码在 sass-loader@7.2.0 这一版本就已经支持 dart-sass 了,因此 webpack 版本可以停留在 ^3,而 sass-loader 暂时停留在 7.0.3 版本,如果后续有问题可以更新到 ^7 版本中最新的 7.3.1 版本。

dart-sass

sass-loader@^7 所支持的最高 sass 我并没有查到,Github Copilot 信誓旦旦地告诉我

官方文档引用:

sass-loader@^7.0.0 requires node-sass >=4.0.0 or sass >=1.3.0, <=1.26.5.

建议:

  • 如果需要使用更高版本的 sass,请升级到 sass-loader 8 或更高版本。

但事实上,我并没有在互联网上找到这段文本的蛛丝马迹。并且在 sass 的 ~1.26 版本中最后一个版本是 1.26.11 而非 1.26.5,根据常见的 npm 版本号原则,major version 和 minor version 不变,只改变了 patch version 的发版一般只有 bugfix 而没有 breaking change,不至于从 1.26.5 更新到 1.26.11 就突然不支持 sass-loader 7 了,因此更可能是 AI 幻觉或者是训练数据受限。

出于谨慎考虑,最终决定采用 webpack 官方文档中提到的 sass 1.22 的最后一个版本,也就是 1.22.12。

分析完成,动手更新

第一步,卸载 node-sass,安装 sass@^1.22.12

npm uninstall node-sass
npm install sass@^1.22.12

第二步,更新 webpack 配置(非必须)

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.(scss|sass)$/,
        use: [
          'style-loader',
          'css-loader',
          {
            loader: 'sass-loader',
+            options: {
+                // 事实上,这一行在大部分 sass-loader 版本中不用加,sass-loader 能自动检测本地是 sass 还是 node-sass
+                implementation: require('sass')
+              },
            },
          },
        ],
      },
    ],
  },
};

第三步,批量替换 /deep/ 语法为 ::v-deep

因为 /deep/ 写法在 2017 年被弃用 ,/deep/ 变成了不受支持的深度作用选择器,node-sass 凭借其出色的容错性能够继续提供兼容,但 dart-sass 则不支持这种写法。于是需要将 /deep/ 语法批量替换成 ::v-deep 写法,这种写法虽然在 vue 的后续 rfc 被放弃了,但直至今日依然在事实上被支持。

# 大概就是这么个意思,用 vscode 的批量替换其实也行
sed -i 's#\s*/deep/\s*# ::v-deep #g' $(grep -rl '/deep/' .)

第四步,修复其他 sass 语法错误

在迁移的过程中,我发现项目中有一些不规范的写法,node-sass 凭借出色的鲁棒性不吭一声强行解析,而 dart-sass 则干不了这粗活。因此需要根据编译时的报错手动修复一下这些语法错误,我这里一共遇到两种。

// 多打了一个冒号
.foo {
-  color:: #fff;
+  color: #fff;
}

// :nth-last-child 没指定数字
.bar {
-  &:nth-last-child() {
+  &:nth-last-child(1) {
      margin-bottom: 0;
  }
}

踩坑

::v-deep 样式不生效

依赖更新完后看了两眼好像是没问题,就推测试环境了。结果一天没到就被同事 call 了,::v-deep 这种深度作用选择器居然没有生效?

抱着试一试的态度,GPT 给了如下回答

Vue 2 + vue-loader + Sass 的组合下,这种写法是正确的前提是你的构建工具链支持 ::v-deep 语法(如 vue-loader@15 及以上版本 + sass-loader)。

虽说我依然没有查证到为什么更新 vue-loader@15 才能使用 ::v-deep 语法,但对 vue-loader 进行更新后,::v-deep 语法确实生效了。在撰写本文时,我找到了些许蛛丝马迹,可能能解释这一问题。

  1. vue-loader 在 14 版本的官方文档就是没有 ::v-deep 写法的示例,这一示例一直在 vue-loader 15.7.0 版本发布后才被加入

  2. vue-cli 的 Github Issue 评论区中有人提到

    ::v-deep implemented in @vue/component-compiler-utils v2.6.0, should work after you reinstall the deps.

    而 vue-loader 在 15.0.0-beta.1 版本才将 @vue/component-compiler-utils 加入到自己的 dependencies 中,并直到 vue-loader 15.7.1 中才将其 @vue/component-compiler-utils 的版本号更新到满足要求的 ^3.0.0

那能否升级到 vue-loader 16 甚至 17 版本呢?不行,在 vue-loader v16.1.2 的更新日志中明确写道

Note: vue-loader v16 is for Vue 3 only.

vue-loader 14 -> 15 breaking change

vue-loader 从 14 往上迁移时,不修改 webpack 配置直接跑会遇到 vue 语法不识别的问题。具体表现为 .vue 文件命名都是正确有效的语法,但构建开发时编译器就是不认,报语法错误。vue-loader 官方有一份迁移文档,需要注意一下。

ERROR in ./src/......
Module parse failed: Unexpected token(1:0)
You may need an appropriate loader to handle this file type.
// ...
import path from 'path'
+const VueLoaderPlugin = require('vue-loader/lib/plugin')

// ...

  plugins: [
+    new VueLoaderPlugin()
    // ...
  ]

除此之外,在我这个项目中需要额外移除 webpack 配置中针对 .vue 文件的 babel-loader

{
  test: /\.vue$/,
  use: [
-    {
-      loader: 'babel-loader'
-    },
    {
      loader: 'vue-loader',
    }
  ]
}

最终更新情况

  • node@^12 -> node@^14
  • vue-loader@^14 -> vue-loader@^15
  • node-sass@^4 -> sass@^1.22.12

其余依赖版本维持不变

参见

🔲 ☆

el-image 和 el-table 怎么就打架了?Stacking Context 是什么?

这是精弘内部的图床开发时遇到的事情,大一的小朋友反馈说 el-image 和 el-table 打架了。

截图

demo 的 iframe 引入

看到后面的表格透出 el-image 的预览层,我的第一反应是叫小朋友去检查 z-index 是否正确,el-image 的 mask 遮罩的 z-index 是否大于表格。

经过我本地调试,发现 z-index 的设置确实没问题,但后面的元素为什么会透出来?谷歌搜索一番,找到了这篇文章

给 el-table 加一行如下代码即可

.el-table__cell {
    position: static !important;
}

经本地调试确认,这一方案确实能解决问题,但为什么呢?这就涉及到 Stacking Context (层叠上下文)了。

Stacking Context(层叠上下文)究竟是什么?

简单来说,Stacking Context 可以被类比成画布。在同一块画布上,z-index 值越高的元素就处于越上方,会覆盖掉 z-index 较低的元素,这也是为什么我最开始让检查 z-index 的设置是否有问题。但问题出在 Stacking Context 也是有上下顺序之分的。

现在假设我们有 A、B 两块画布,在 A 上有一个设置了 z-index 为 1145141919810 的元素。那这个元素具备非常高的优先级,理应出现在浏览器窗口的最上方。但如果 B 画布的优先级高于 A 画布,那么 B 元素上的所有元素都会优先显示(当了躺赢狗)。那么画布靠什么来决定优先级呢?

  • 处于同级的 Stacking Context 之间靠 z-index 值来区分优先级
  • 对于 z-index 值相同的 Stacking Context,在 html 文档中位置靠后的元素拥有更高的优先级

第二条规则也能解释为什么在上面的 demo 中,只有在表格中位置排在图片元素后面的元素出现了透出来的情况。

所以为什么 el-image 和 el-table 打架了?

这次的冲突主要是下面两个因素引起的

  1. el-table 给每个 cell 都设置了 position: relative 的 css 属性,而 position 被设为 relative 时,当前元素就会生成一个 Stacking Context。

    image-20250531013029154

    所以我们这么一个有十个格子的表格,其实就生成了十个画布。而这其中每个画布 z-index 都为 1。根据刚才的规则,在图片格子后面的那些格子对应的 html 代码片段在整体的 html 文档中更靠后,所以他们的优先级都高于图片格子。

  2. el-image 的预览功能所展开的遮罩层处于 el-image 标签内部

    上图中橙色部分是 el-image 在预览时提供的遮罩,可以看到 element-plus 组件的 image 预览的默认行为是将预览时所需要的遮罩层直接放在 <el-image> </el-image> 标签内部,这导致 el-image 的遮罩层被困在一个低优先级的 Stacking Context 中,后面的格子里的内容就是能凭借高优先级透过来。

所以解决方案是什么?

更改 position 值在这里确实是可行的

上面我谷歌搜到的将 el-table 中 cell 的 position 值强制设为 static 确实是有效的,因为 static 不会创建新的 Stacking Context,这样就不会有现在的问题。

将需要出现在最顶层的代码放置在优先级最大的位置是更常见的方案

但别的组件库在处理这个需求时,一般会将预览时提供的遮罩的 html 代码片段直接插入到 body 标签内部的最尾部,并设置一个相对比较大的 z-index 值,以确保这个遮罩层能够获得最高的优先级,以此能出现在屏幕的最上方。(像一些 dialog 对话框、popover 悬浮框也都是这个原理)。

事实上,element-plus 组件库也提供了这个功能

preview-teleported: image-viewer 是否插入至 body 元素上。嵌套的父元素属性会发生修改时应该将此属性设置为 true

所以在使用 el-image 时传入一个 :preview-teleported="true" 是一个更普适的方案,因为我们并不能确保 el-image 的父元素除了 el-table 的 cell 以外还有什么其他的父元素会创建新的 Stacking Context。

参见

🔲 ⭐

vuejs、php、caddy 与 docker —— web 期末大作业上云部署

这学期修了一门叫《用HTML5 和 PHP编写JavaScript,jQuery 和 AJAX脚本》的 web 课(对,听起来很奇怪的名字)。期末大作业是写一个影评系统,前端允许使用框架,后端仅允许使用 php,具体的作业要求如下

作业要求

(源码会在验收结束以后开源)

大作业写了得要有三个礼拜,工作时长加起来得有 30 个小时,想着验收之前上线一段时间积累一些评论数据,验收的时候也会更加顺利一些,于是就开始尝试在服务器上部署。部署的过程还是比较复杂的,所以写下这篇博客记录一下。

后端部分

早前有《PicUploader使用系列(一)——在Archlinux上使用Caddy部署PicUploader》的经验,便觉得使用 Caddy + php-fpm 部署的方式多少有点麻烦了,这次便尝试了使用 Docker 部署、Caddy 反代的方式。

Dockerfile 如下:

FROM php:8-apache
RUN docker-php-ext-install mysqli
RUN a2enmod rewrite
COPY . /var/www/html
EXPOSE 80

在后端的根目录下有一个 .htaccess 文件,将所有的请求都交给 index.php 来处理,这样就可以根据我的上一篇博客中所提到的方式去构建不使用任何 php 框架实现的简易 router 效果

RewriteEngine On
RewriteRule ^(.*) index.php [QSA,L]

构建 Docker 镜像时使用 docker build . -t mrs-php 命令,运行 docker 容器时使用命令

docker run -d \
    -p 7788:80 \
    --name mrs-php \
    -v /path/to/uploads:/var/www/html/uploads \
    --restart unless-stopped \
    mrs-php

这样,后端就在 7788 端口上开起来了,后续 Caddy 只要将打到 /api/*/uploads/* 的请求转发到 7788 端口即可,避免了使用 php-fpm 时需要的配置。uploads 目录是用来存放图片的,我将这个路径挂在在宿主机的目录下,方便备份导入等操作。

mysql 连接时的小插曲

需要注意的是,在 Docker 容器中运行的 php 如果想要访问宿主机上的 mysql,需要注意修改 mysql 服务器的 ip 地址,并允许 mysql 接收来自非本机的请求。

在宿主机中运行 ip -br a 命令可以看到 docker 所采用的虚拟网卡的 ip 地址

docker0          UP             172.17.0.1/16 fe80::42:eff:febf:b26c/64

我这边得到的 ip 地址是 172.17.0.1,所以在 php 那边访问的数据库 ip 地址就应该是 172.17.0.1,而非 localhost 或者 127.0.0.1

此外,需要允许宿主机的 mysql 接收来自 Docker 容器的请求

使用 docker network inspect bridge 命令可以查到 docker 容器的 ip 地址,接着需要去允许来自这个 ip 的请求。建议去网上自行搜索,因为 mysql 语句我自己也不熟悉。我使用的 mysql 版本是 8,语句似乎和以前的版本不兼容?我使用下面三个命令轮着输就好了(有时候报错,有时侯又不报错),有大佬懂的话评论区讲讲。

use mysql;
GRANT ALL ON *.* TO 'root'@'%';
update user set host='%' where user='root';
GRANT ALL ON *.* TO 'root'@'%';

前端部分

前端部分部署起来没什么难度

我使用的是 vite 开发的 vuejs 项目,直接使用 pnpm build 构建出静态文件,然后放入了 /var/www/mrs 目录,这部分没什么可说的

Caddy 配置

Caddy 配置如下

example.com {
    handle /api/* {
        reverse_proxy localhost:7788
    }

    handle /uploads/* {
        reverse_proxy localhost:7788
    }

    handle /* {
        root * /var/www/mrs
        file_server
        try_files {path} /
    }
}

将打到 /api/*/uploads/* 都交给 7788 端口的后端进行处理,前端部分要使用 try_files 将请求都指向 //index.html 交由 vue-router 处理,否则 caddy 就找不到对应的文件了。这里我尝试过使用 route 关键词代替 handle,但 try_files 的功能没有生效,这两者的区别官方文档中有提到,但我没看懂,等我以后看看有没有机会去折腾了。

参考:

使用Caddy配置同一域名下的前后分离

Caddy 2

🔲 ☆

Vue.js 中 nextTick | 笔记

𠄗𠳵

𤶙𤒪𣃚𨲜𣏅𤒪𣏳𧂿𮫠𢨢(𦈠𩊺𭵰𦈠𯒉𤒪𠪵𯞊𫟍)𪶴𥼃𩓴𫿲𩊼𢔧𤄨𪐏𦐻𡎱𤒪𫪏𦛥𥶝𤒪𤸳。
𠄉𪐏,𣃚𨲜𣏅𤒪𧭉𬂨𥼃𪻴𤒪𫪏𦛥𥶝。
𠑪𢋄𠴃𯊐𡢉𤒪Vue.nextTick()𤒪𠪵𤒪vm.$nextTick()𤒪𮶻𮫠𤈢𥍊𤒪𣃚𨲜𣏅𤒪𥼃𪻴𤒪𫪏𦛥𥶝𤒪𪶴𣲐𥢖。
𤈇𮯱𨏍𥻢𦙛𩭙𣿽𦤳𠱳𮶻𮫠𪶴𩰮𢫋𫉙𮈋。

🔲 ☆

写一个炫酷的个人名片页✨✨

这篇文章主要介绍名片页的路由过渡是如何去做的

介绍

在19年,我就写了一个较为炫酷的个人名片页。当时的我热衷于使用各种过渡效果,当然,也尝试了很多新鲜的 css 特性,例如为了实现多种主题色使用了 css 变量(好像还是我首次使用flex布局呢)

但当时的我显然还尚未深谙前端布局之道🤯,许多页面元素在当时的浏览器渲染是正常的,现在却有些崩坏了,很多细节处理不完善,遂准备将这个名片页进行重制

不过还是有挺多小巧思在里面的。比如为了防止滚动导致卡片被切开,给容器加了一个伪元素实现的阴影。实现很简单,效果却非常不错。

现在重制也基本上完成了(还剩几个页面没写完,不过无伤大雅),可以先看看效果 im.daidr.me

不难看出整体的页面风格和以前非常相似,不过确实很符合我对「炫酷」的想象

技术栈

Vue3 + WindiCSS + SCSS + Nuxt

这个名片页其实在去年12月就开始写了,刚开始没做SSR,最近尝试迁移到了Nuxt,路由动效之类兼容也很折磨,不过这不是这篇文章的重点,就不多说啦~

文章页是前几个星期才刚加上的,目前是把旧WordPress博客当CMS用,但是有些太重了——给文章页加上了Redis swr缓存,才能勉强保证流畅访问。最近在食用基于 Crossbell 链的 xLog,感觉非常不错,希望研究明白之后能把文章页接入 xLog😗。

分析

路由结构

首先是路由结构(关系到之后页面如何进行变换)。

目前,这个名片页有5套页面 /me(别名 /),/friends/projects/blog/:slug/404。他们的结构是这样的:

├── `/`
│   ├── `/me`
│   └── `/friends`
├── `/projects`
├── `/blog/:slug`
└── `/404`

因为 /me 和 /friends 页面的容器大小一致,翻牌的效果不太合适,所以互相切换时使用另外一套过渡效果。

(现在看来这种实现并不合适,维护和自定义都会比较困难。Nuxt有自带的路由过渡配置选项,不需要依赖子路由来实现。)

容器定位

能够将一个容器固定在页面正中心的方法其实蛮少的,我使用的是 fixed 绝对定位。先给元素设置 top: 50%; left: 50%;,但这时候的元素并不在页面正中心(而是其左上角在页面中心)

所以需要接着设置 transform: translate(-50%, -50%);,将元素向左/向上偏移。

翻牌过渡

来讲讲图片中的翻牌过渡是如何实现的。(仅考虑翻牌元素本身)

下面的代码各位 Vuer 一定不陌生,这能让 vue-router 在切换页面时应用过渡效果

<router-view v-slot="{ Component }">
  <transition name="fade">
    <component :is="Component" />
  </transition>
</router-view>

而实际上,Transition 组件可不仅仅能传递 name,还能够通过 JavaScript 具体控制过渡的每个环节。将过渡动效的相关逻辑封装到 RouterTransition 中:

<!-- App.vue -->
<RouterView v-slot="{ Component }">
    <RouterTransition>
        <component :is="Component" />
    </RouterTransition>
</RouterView>

<!-- RouterView.vue -->
<Transition 
     ref="SlotRef" :css="false"
     @before-enter="onBeforeEnter" @enter="onEnter" @after-enter="onAfterEnter"
     @before-leave="onBeforeLeave" @leave="onLeave"
>
    <slot />
</Transition>

分析

使用 JavaScript 去控制过渡,我们就需要知道过渡前后元素的尺寸以及位置,拿到元素倒是好办,但是这里有一个问题:需要应用过渡的元素并不一定是页面根元素

比方说 /projects 页面,只有顶部的菜单栏应用了过渡。所以需要有一个手段去标识这些元素。我使用的方法是为需要过渡的元素加上类名 transition-page-wrapper

写一个工具函数,传入页面根元素,返回需要过渡的元素

const getTransitionContainer = (el) => {
    const containerClass = 'transition-page-wrapper';
    // 如果el不是元素,直接返回
    if (!el || !el.classList) {
        return el;
    }
    // 如果el是目标元素,直接返回
    if (el.classList.contains(containerClass)) {
        return el;
    }
    // 否则,遍历el的所有层级的子元素,找到目标元素
    for (let i = 0; i < el.children.length; i++) {
        const child = el.children[i];
        if (child.classList && child.classList.contains(containerClass)) {
            return child;
        } else {
            const _child = getTransitionContainer(child);
            if (_child != child) {
                return _child;
            }
        }
    }
    return el;
}

过渡开始前

之后,路由切换前的页面元素会被称为 fromEl,路由切换后的页面元素会被称为 toEl

首先,我们来搞定 before-leave 事件。在这个函数中,我们需要将 fromEl 的位置、尺寸信息记录下来,为了保证过渡顺滑,我还准备额外记录 border-radius 属性。

const fromWrapperStyle = {
    x: 0,
    y: 0,
    w: 0, // 宽度
    h: 0, // 高度
    br: 0, // border-radius 属性
    t: "", // transform 属性
};

其中,xywh 的值可以使用 getBoundingClientRect 方法取到。而 b(border-radius) t(transform) 是 css 样式属性,元素的 style 属性只能拿到其内联样式,为了拿到浏览器计算之后元素的所有准确 css 样式,需要使用 getComputedStyle 方法。封装成 writeCfgObj 工具函数方便之后使用:

const writeCfgObj = (el, cfgObj) => {
    const elRect = el.getBoundingClientRect();
    cfgObj.x = elRect.x;
    cfgObj.y = elRect.y;
    cfgObj.w = elRect.width;
    cfgObj.h = elRect.height;
    const _style = getComputedStyle(el);
    cfgObj.br = parseFloat(_style.borderRadius.replace("px", ""));
    cfgObj.t = _style.transform;
}

transition 组件的 before-leave 事件有一个参数,该参数会传递将在过渡中消失的元素(即 fromEl

const onBeforeLeave = (fromEl) => {
    // 根据根元素,获取实际需要过渡的元素
    let _fromWrapper = getTransitionContainer(fromEl);
    
    // 之前写的工具方法,用于存入元素位置/尺寸/部分样式
    writeCfgObj(_fromWrapper, fromWrapperStyle);
}

有了 fromEl 的位置/尺寸,接下来就是 toEl 的位置尺寸了,可以通过 before-enter 事件拿到

需要注意和 before-leave 不同的是:此时的 toEl 实际上还没有被插入到 dom 树中 (都插入进去了还过渡什么),此时元素的位置和尺寸都没法直接获取,我们需要一些额外的步骤。

const onBeforeEnter = (toEl) => {
    // 复制一份 toEl
    let toWrapper = toEl.cloneNode(true);
    // 禁用过渡,防止元素自带 transition 的情况下,之后设置 opacity 出现不必要的穿帮
    toWrapper.style.transitionDuration = '0s'
    // 设置 opacity 为 0
    toWrapper.style.opacity = 0;
    // 插入到 body
    document.body.appendChild(toWrapper);

    // 取得克隆后容器内的过渡元素
    let _toWrapper = getTransitionContainer(toWrapper);
    writeCfgObj(_toWrapper, toWrapperStyle);
    
    // 移除
    toWrapper.remove();
}

实际上就是将 toEl 克隆一份插入到 dom 中,获取完位置立刻删掉。因为 opacity 被我们设置成了 0,此时元素不可见,用户其实不太会感知到。

TransitionGroup 的实现其实差不多

过渡进行中!

拿到了 toElfromEl 的这些属性,过渡就可以开始啦!过渡主要会使用到 tranform 元素

不过先别急😜,在开始过渡之前,我们需要算出 toElfromEl 的位置和尺寸差值,这样我们才方便使用 translatescale 对元素应用变换。

这里需要注意的是:我们对元素应用变换使用了 transform 属性,而元素本身可能就有位移。过渡的过程中,我们会对其进行覆盖,所以计算时千万别忘了把元素本身的位移考虑进去。

const calcDelta = (prevCfg, nextCfg, nextMatrix3dStr) => {
    const matrix3d = nextMatrix3dStr.replace(/matrix3d\(|\)/g, "").split(",").map((v) => parseFloat(v));
    // 转换为 translate
    const nextTranslateX = matrix3d[12];
    const nextTranslateY = matrix3d[13];

    // 计算 scale
    const scaleX = prevCfg.w / nextCfg.w;
    const scaleY = prevCfg.h / nextCfg.h;

    // 计算 delta
    let deltaX = prevCfg.x - prevCfg.x + nextTranslateX;
    let deltaY = prevCfg.y - prevCfg.y + nextTranslateY;

    // 因为进行了 scale,所以需要根据 scale 修正 delta
    deltaX -= (1 - scaleX) * nextCfg.w / 2;
    deltaY -= (1 - scaleY) * nextCfg.h / 2;

    return {
        deltaX,
        deltaY,
        scaleX,
        scaleY,
    };
}

看到上面的代码可能会有些懵,matrix3d 是什么?什么时候冒出来的?

还记得之前取元素 transform 属性时使用的 getComputedStyle 么?浏览器会返回计算后的样式。我们拿到的,并不是形似 translate(-50%, -50%) 的字符串,而是一个 matrix3d 函数所代表的变换矩阵。为了拿到元素的位移,我们只需要第13个参数 a4 和第14个参数 b4 就够了

scaleX/Y – 通过 toElfromEl 的尺寸算出应该缩放的比例

deltaX/Y – 通过 toElfromEl 的位置和位移算出应该移动的距离,由于需要进行缩放,还需要使用缩放比例对这个差值进行修正

接下来,就可以正式来处理 toEl 的离开了,需要使用到 transition 组件的 leave 事件

const onLeave = (el, done) => {
    // 获取应该过渡的元素
    el = getTransitionContainer(el);

    // 强制赋予一个过渡效果
    el.style.transitionProperty = 'all';
    el.style.transitionDuration = '1300ms';
    
    // 让浏览器缓一缓 ε=ε=ε=┏(゜ロ゜;)┛
    requestAnimationFrame(() => {
        const d = calcDelta(toWrapperStyle, fromWrapperStyle, fromWrapperStyle.t);
        
        // 因为使用了 windicss,所以这里采用了覆盖 css 变量的方式
        // 也可直接使用 transform
        
        // 翻转
        // 可以看到这里进行了一个x轴和z轴的反转,这样正好把容器反过来
        el.style.setProperty("--tw-rotate-x", "180deg");
        el.style.setProperty("--tw-rotate-z", "-180deg");
        
        // 将容器(fromEl)移到新位置(toEl的位置)
        el.style.setProperty("--tw-translate-x", `${d.deltaX}px`);
        el.style.setProperty("--tw-translate-y", `${d.deltaY}px`);
        el.style.setProperty("--tw-scale-x", `${d.scaleX}`);
        el.style.setProperty("--tw-scale-y", `${d.scaleY}`);
        
        // 改变容器的圆角
        const scale = (d.scaleX + d.scaleY) / 2;
        el.style.borderRadius = toWrapperStyle.br / scale + "px";
        
        // 渐隐
        el.style.opacity = "0";
    })
    
    // 监听过渡结束事件,在 tranform 过渡完成之后,告知 transition 组件过渡已结束
    let _event = null;
    el.addEventListener('transitionend', _event = (ev) => {
        if (ev.target === el && ev.propertyName === 'transform') {
            el.removeEventListener('transitionend', _event);
            done();
        }
    })
}

调用 leave 事件传递进来的 done() 回调函数之后, fromEl 就会被 transition 组件删除,不需要我们自己删除。

现在,fromEl 已经完成过渡并且被清除了,最后一件事,就是要将 toEl 显示出来,正好和 fromEl 相反。fromEl 旋转 180°,toEl 就旋转 -180°。

const onEnter = (el, done) => {
    el.style.transitionDuration = '0s'
    const d = calcDelta(fromWrapperStyle, toWrapperStyle, toWrapperStyle.t);
    el.style.setProperty("--tw-rotate-x", "-180deg");
    el.style.setProperty("--tw-rotate-z", "-180deg");
    el.style.setProperty("--tw-translate-x", `${d.deltaX}px`);
    el.style.setProperty("--tw-translate-y", `${d.deltaY}px`);
    el.style.setProperty("--tw-scale-x", `${d.scaleX}`);
    el.style.setProperty("--tw-scale-y", `${d.scaleY}`);
    el.style.opacity = "0";
    const scale = (d.scaleX + d.scaleY) / 2;
    el.style.borderRadius = fromWrapperStyle.br / scale + "px";

    document.body.offsetHeight;

    requestAnimationFrame(() => {
        el.style.transitionProperty = 'all';
        el.style.transitionDuration = '1300ms';

        // 重置全部属性
        el.style.borderRadius = "";
        el.style.opacity = "";
        el.style.setProperty("--tw-rotate-x", "");
        el.style.setProperty("--tw-rotate-z", "");
        el.style.setProperty("--tw-translate-x", "");
        el.style.setProperty("--tw-translate-y", "");
        el.style.setProperty("--tw-scale-x", "");
        el.style.setProperty("--tw-scale-y", "");
    })
    
    let _event = null;
    el.addEventListener('transitionend', _event = (ev) => {
        if (ev.target === el && ev.propertyName === 'transform') {
            el.removeEventListener('transitionend', _event);
            done();
        }
    })
}

这个 onEnter 函数看起来和之前的 onLeave 差不多,但仔细一看又差很多🤯。这是因为两者的原理是不一样的。

onLeave 事件用于处理 fromElfromEl 在过渡完成后就要被删掉的,谁管它会不会残留什么乱七八糟的内联样式呢。所以,我们选择先给 fromEl 一个 transition 属性,然后给他赋予位移,使其慢慢过渡到新元素的位置。

onEnter 事件用于处理 toEl,这里的 toEl 在过渡完成后是要留在页面上的,我们不能因为过渡,就往上面写一堆内联样式,写了至少也要在过渡完成后删掉。

所以,这里的逻辑是:先禁用 transition,然后通过内联的 transformtoEl 放置到 fromEl 的位置上。这时候,开启 transition,然后删除之前设置的 transform 属性,toEl 就会过渡回来啦!而且过渡完成后,transform 属性不会残留在元素上,棒!

过渡完成后

我们给 toEl 设置了 transition 属性,所以需要 after-enter 事件来「擦擦屁股」

const onAfterEnter = (el) => {
    el = getTransitionContainer(el);
    el.style.transitionProperty = '';
    el.style.transitionDuration = '';
}

现在,整个翻牌过渡就完成啦

你可能会发现在某些情况下,会出现另外一种过渡(加载超过100ms时,先转变到loading)

这个动画通过路由守卫实现,原理也差不多,只是将 toEl/fromEl 替换成 Loading 元素。不过在 Workbox 和 Nuxt Prefetch 双重加持下,这个动画已经没有什么意义了。

🔲 ⭐

!important导致TransitionGroup失效

大家如果曾经接触过 Vue, 那么大抵会对其自带的组件 TransitionGroup 有所了解。这篇文章便记录了 TransitionGroup 中「移动动画」的一些使用细节。

或许你对 TransitionGroup 的「移动动画」还不太了解,那么我在这里浅浅地介绍一下。正常使用时,你需要为 .[name]-move 类提供一个过渡样式,例如 transition: all 0.5s ease;,这样,当 TransitionGroup 内的元素位置变更后, Vue 会尝试让变动了位置的元素从老位置平滑过渡到新位置。当然,Vue 也支持新增元素和删除元素的过渡效果,只需要为 [name]-enter-from[name]-leave-to 类名提供样式,这不是本文的重点,故不再详细介绍。

曾经的我,也像大部分人那样按部就班把样式写完,没出过问题。

直到群里有人告诉我:「试试给元素增加一个常驻的、带有 !importanttransition 样式,会让过渡失效」

我当场愣住 😀 ,这在当时的我看来是一件很难理解的事情:本身过渡时 Vue 就会通过 [name]-move 为元素加上 transition 属性,为什么提前给元素加上一个优先级最高的 transition 属性,过渡反而没法生效了呢?

从源码入手

我们可以在 TransitionGroup.ts 阅读与 TransitionGroup 相关的代码内容

初始化阶段

不难发现,Vue 在渲染函数内,将子元素数组 children 赋值给 setup 函数作用域下的变量 prevChildren (L113)。同时,通过一个 for 循环遍历 prevChildren (L135),将每个子元素的位置信息储存于 positionMap 中(旧位置)。

该阶段中与我们讨论内容相关的,便是这两处内容。prevChildren 的赋值,使得 Vue 能够在之后的 updated 生命周期中,得以取得子元素引用,方便进行相关操作。而 positionMap,让 Vue 有能力在之后的操作中,得到元素的原始位置。

此处的 positionMap 是一个 WeakMap,Vue 使用元素对象作为 key 值,能够保证在元素被销毁后,positionMap 中对应元素的位置信息被适时自动回收。

Updated 生命周期

当 TransitionGroup 内的子元素发生变动后,会调用 onUpdated 注册的回调函数,同时也是在这里,Vue 完成了过渡所需要的大部分操作。

首先,Vue 通过一个 forEach,再次遍历获取了各个元素的位置信息,储存到 newPositionMap 中(L72)。此时,一个元素的新旧位置分别储存在 newPositionMappositionMap 中,我们需要做的,就是让元素从旧位置平滑过渡到新位置。

接下来就是关键所在:既然是 updated 生命周期,此时元素们应该已经在新位置呆着了,又谈何过渡?所以,我们要做的,并不是单纯让元素从旧位置过渡到新位置。而是将已经位于新位置的元素,重新放回旧位置,再让其平滑返回到新位置,完成整个过渡过程。那么 Vue 是怎么完成的呢?

L73的代码是这样的:

const movedChildren = prevChildren.filter(applyTranslation)

Vue 使用 applyTranslation 方法过滤出了需要移动的子元素数组 movedChildren。通过观察函数和变量的命名,我们几乎可以肯定地说,在过滤的同时,Vue 还对元素进行了一些操作,而实际上也确实如此。

function applyTranslation(c: VNode): VNode | undefined {
    const oldPos = positionMap.get(c)!
    const newPos = newPositionMap.get(c)!
    const dx = oldPos.left - newPos.left
    const dy = oldPos.top - newPos.top
    if (dx || dy) {
        const s = (c.el as HTMLElement).style
        s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
        s.transitionDuration = '0s'
        return c
    }
}

对于需要移动的元素,Vue 计算了新旧位置差值,使用 css 属性 Transform,将元素放回旧位置。特别关注一下 s.transitionDuration = '0s'之后要考

但是实际上,此时的元素仍然没有回到旧位置。浏览器会将样式变动加入渲染队列中,而不是立刻渲染。这里涉及浏览器重排(reflow)的相关知识,可以搜索相关文章来进行阅读。

为了保证元素立刻被放置到旧位置,在L73得到 movedPosition 后,Vue 执行了 forceReflow 方法(L76),强制触发重排。而 forceReflow 方法内容也很简单(Transition.ts#L461)

function forceReflow() {
  return document.body.offsetHeight
}

这里也是个前端小知识 😛 ,读取文档的 offsetHeightoffsetWidth,也能触发文档重排。

可以这么理解:渲染队列中存在改动而不进行重排直接获取文档宽度或高度,会导致拿到的元素宽高是过时的,所以浏览器在读取前对文档进行了重排。

之后的工作就很简单了,只需要给元素加上 [name]-move 类名(L81),然后去除之前添加的 transitionDurationTransform 属性,元素自然就能平滑返回到新位置啦~监听 transitionend 事件(L83-L93),做一些收尾工作(去除过渡相关类名等)

读到这里,我们已经能够解决文章开头的那个问题了。实现过渡效果,需要确保元素正位于旧位置。在 Vue 中,为了确保文档重排后元素通过 Transform 放到了旧位置,Vue 将元素的过渡时间设置为 0s 并进行了一次强制重排。但是人为添加的高优先级 transition 属性导致重排时元素没法第一时间回到旧位置,也就没有过渡效果了。

我也写了一个小 demo,简化了 TransitionGroup 中的无关代码,感兴趣可以看看 https://codepen.io/DaiDR/pen/VwdMRxa

❌