普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月13日技术

《实时渲染》第3章-图形处理单元-3.4可编程着色和及其API的演变

作者 charlee44
2026年3月13日 22:19

实时渲染

3. 图形处理单元

3.4 可编程着色和及其API的演变

可编程着色框架的想法可以追溯到1984年Cook的《shade trees》[287]。图3.4显示了一个简单的着色器及其相应的着色树。RenderMan着色语言[63, 1804]是在1980年代后期从这个想法发展而来的。它今天仍然用于电影制作渲染,以及其他不断发展的规范,例如开放着色语言(OSL)项目[608]。

图3.4. 一个简单的铜材质着色器的着色树,及其相应的着色器语言程序。(在Cook [287] 之后。)

消费级图形硬件于1996年10月1日由3dfx In-teractive首次成功推出。有关今年的时间表,请参见图3.5。他们的 Voodoo显卡能够以高品质和高性能渲染游戏Quake,因此很快被采用。该硬件自始至终都实现了一个固定功能的流水线。在GPU原生支持可编程着色器之前,曾多次尝试通过多个渲染通道实时实现可编程着色操作。Quake III:Arena脚本语言是1999年该领域第一个广泛的商业成功。正如本章开头提到的,NVIDIA的GeForce256是第一个被称为GPU的硬件,但它不可编程。但是,它是可配置的。

图3.5. 一些API和图形硬件发布的时间表。

2001年初,NVIDIA的GeForce 3是第一个支持可编程顶点着色器的GPU[1049],通过DirectX8.0和对OpenGL的扩展来开放接口。这些着色器是用类似汇编的语言编程的,驱动程序可以将其即时转换为微代码。像素着色器也包含在DirectX 8.0中,但像素着色器缺乏实际的可编程性——支持的有限“程序”被驱动程序转换为纹理混合状态,驱动程序又将硬件“寄存组合器”连接在一起。这些“程序”不仅长度有限(12条指令或更少),而且缺乏重要的功能。Peercy等人从他们对RenderMan的研究[1363]中发现,依赖纹理读取和浮点数据对真正的可编程性至关重要。

此时的着色器不允许流控制(分支),因此必须通过计算两个条件分支并在结果之间选择或插值来模拟条件。DirectX定义了着色器模型 (SM) 的概念,以区分具有不同着色器功能的硬件。2002年,DirectX9.0发布,包括Shader Model 2.0,它具有真正可编程的顶点和像素着色器。使用各种扩展在OpenGL下也公开了类似的功能。添加了对任意依赖纹理读取和16位浮点值存储的支持,最终完成了Peercy等人确定的一组要求。对着色器资源(例如指令、纹理和寄存器的限制有所增加,因此着色器能够实现更复杂的效果。还增加了对流控制的支持。着色器的长度和复杂性不断增加,使得汇编编程模型变得越来越繁琐。幸运的是,DirectX 9.0还包含 HLSL。这种着色语言是由Microsoft与NVIDIA合作开发的。大约在同一时间,OpenGL ARB(架构审查委员会)发布了GLSL,这是一种与OpenGL非常相似的语言 [885]。这些语言深受 C编程语言的语法和设计理念的影响,并包含来自RenderMan着色语言的元素。

Shader Model 3.0于2004年推出,增加了动态流控制,使着色器更加强大。它还将可选功能变成了要求,进一步增加了资源限制,并增加了对顶点着色器中纹理读取的有限支持。2005年末(微软的Xbox 360)和2006年末(索尼电脑娱乐的PLAYSTATION 3系统)推出新一代游戏机时,都配备了Shader Model 3.0级别的GPU。任天堂的Wii游戏机是最后一批著名的固定功能GPU之一,最初于 2006 年底发货。纯粹的固定功能管道在在之后已不复存在。着色器语言已经发展到可以使用各种工具来创建和管理它们的地步。图3.6显示了使用Cook着色树概念的此类工具的屏幕截图。

图3.6. 用于着色器设计的视觉着色器图形系统。各种操作都封装在功能框中,在左侧可选择。选中后,每个功能框都有可调参数,如右图所示。每个功能框的输入和输出相互链接以形成最终结果,显示在中心框的右下方。(截图来自“mental mill”,mental images inc。)

可编程性的下一个重要步骤也在2006年底附近发生。包含在DirectX 10.0[175]中的着色器模型4.0引入了几个主要功能,例如几何着色器和流输出。Shader Model 4.0包括一个适用于所有着色器(顶点、像素和几何)的统一编程模型,即前面描述的统一着色器设计。进一步增加了资源限制,并添加了对整数数据类型(包括按位运算)的支持。OpenGL 3.3中GLSL 3.30的引入提供了类似的着色器模型。

2009年DirectX 11和Shader Model 5.0发布,添加了曲面细分阶段着色器和计算着色器,也称为DirectCompute。该版本还专注于更有效地支持CPU多处理,这是第18.5节中讨论的主题。OpenGL在4.0版中添加了曲面细分,在4.3版中添加了计算着色器。DirectX和OpenGL的发展方式不同。两者都设置了特定版本发布所需的一定级别的硬件支持。Microsoft控制DirectX API,因此直接与AMD、NVIDIA和Intel等独立硬件供应商(IHV)以及游戏开发商和计算机辅助设计软件公司合作,以确定要公开的功能。OpenGL由硬件和软件供应商联盟开发,由非营利组织Khronos Group管理。由于涉及的公司数量众多,API功能通常在DirectX中引入后的某个时间出现在OpenGL的发行版中。但是,OpenGL允许特定于供应商或更通用的扩展,允许在发布正式支持之前使用最新的GPU功能。

API的下一个重大变化是AMD于2013年推出的Mantle API。与视频游戏开发商DICE合作开发,Mantle的想法是剥离大部分图形驱动程序的开销,并将此控制直接交给开发人员。除了这种重构之外,还进一步支持了有效的CPU多处理。这类新的API侧重于大大减少CPU在驱动程序中花费的时间,以及更高效的CPU多处理器支持(第18章)。 Mantle中首创的想法被微软采纳并于2015年作为DirectX 12发布。请注意,DirectX 12并不专注于展示新的GPU功能——DirectX 11.3展示了相同的硬件功能。这两个API都可用于将图形发送到虚拟现实系统,例如Oculus Rift和HTC Vive。然而,DirectX 12是对API的彻底重新设计,更好地映射到现代GPU架构。低开销驱动程序对于CPU驱动程序成本导致瓶颈的应用程序很有用,或者使用更多的CPU处理器来处理图形可以提高性能[946]。从早期的API移植可能很困难,而且一个不成熟的实现会导致较低的性能[249, 699, 1438]。

Apple于2014年发布了自己的低开销API,称为Metal。Metal最初可用于iPhone 5S和iPad Air等移动设备,一年后更新的Macintosh可通过 OS X El Capitan访问。除了效率之外,降低CPU使用率还可以节省电量,这是移动设备的一个重要因素。这个API有自己的着色语言,适用于图形和GPU计算程序。

AMD将其Mantle工作捐赠给了Khronos Group,后者于2016年初发布了自己的新API,称为Vulkan。与OpenGL一样,Vulkan可在多个操作系统上运行。Vulkan使用一种称为SPIRV的新高级中间语言,它用于着色器表示和通用GPU计算。预编译着色器是可移植的,因此可以在支持所需功能的任何GPU上使用[885]。Vulkan也可用于非图形GPU计算,因为它不需要显示窗口[946]。Vulkan与其他低开销驱动程序的一个显着区别在于,它旨在与从工作站到移动设备的各种系统一起使用。

在移动设备上,标准是使用OpenGL ES。“ES”代表嵌入式系统,因为此API是为移动设备开发的。当时的标准OpenGL在其某些调用结构中相当庞大和缓慢,并且需要支持很少使用的功能。OpenGL ES 1.0于2003年发布,是OpenGL 1.3的精简版本,描述了一个固定功能的管道。虽然DirectX的发布与支持它们的图形硬件的发布同步,但为移动设备开发图形支持并没有以同样的方式进行。例如,2010年发布的第一款iPad实现了 OpenGL ES 1.1。2007年,OpenGL ES 2.0规范发布,提供可编程着色。它基于OpenGL 2.0,但没有固定功能组件,因此不向后兼容 OpenGL ES 1.1。OpenGL ES 3.0 于2012 年发布,提供了多个渲染目标、纹理压缩、变换反馈、实例化以及更广泛的纹理格式和模式以及着色器语言改进等功能。OpenGL ES 3.1添加了计算着色器,3.2添加了几何和曲面细分着色器等功能。第23章讨论更详细的移动设备架构。

OpenGL ES的一个分支是基于浏览器的 API WebGL,通过JavaScript调用。该API的第一个版本于2011年发布,可用于大多数移动设备,因为它在功能上等同于OpenGL ES 2.0。与OpenGL一样,扩展可以访问更高级的GPU功能。WebGL 2假定支持OpenGL ES 3.0。

WebGL特别适合在课堂上试验特性或使用:

  • 它是跨平台的,适用于所有个人计算机和几乎所有移动设备。
  • 驱动程序批准由浏览器处理。即使一个浏览器不支持特定的GPU或扩展,通常另一个浏览器会支持。
  • 代码是解释性的,而不是编译性的,开发时只需要一个文本编辑器。 • 大多数浏览器都内置了调试器,可以检查在任何网站上运行的代码。 • 例如,可以通过将程序上传到网站或Github来部署程序。

更高级别的场景图和效果库(例如three.js [218])可以轻松访问各种更复杂的效果的代码,例如阴影算法、后处理效果、基于物理的着色和延迟渲染。

TodoList应用:SPA应用首屏性能优化实践

2026年3月13日 18:19

hello,大家好,我是唐叔,今天想介绍的是 SPA - 单页应用的首屏性能问题优化的一些实践,也是我理解的 SPA 应用最大的技术难点吧。

SPA 的优势很明显:一次加载,无缝交互,适合多端复用。但代价也很大——首屏需要加载所有资源,一旦代码庞大,白屏时间就会变长。这就是我们常说的“首屏性能问题”。

下面就以唐叔最近一直在开发的 TodoList 应用为例,分享几种优化手段。

优化方式 1:骨架屏

严格说,骨架屏并不是优化性能,而是优化用户体验。

在数据加载完成前,先展示一个灰色的“页面轮廓”,避免用户面对白屏。

第一步,前端 html 页面,在 HTML 中预留骨架屏结构,具体可以基于你原本页面的框架去处理:

第二步,在编写 JS 初始化操作时,先显示骨架屏,然后加载数据,加载完数据再隐藏骨架屏。

大体编码是这样的:

class App {
   // 初始化应用
    async init() {
        try {
          // 显示加载状态
 Utils.setLoading(true);
            // 加载数据
        } catch (error) {
            // 异常报错
        } finally {
            // 隐藏加载状态
            Utils.setLoading(false);

            // 隐藏骨架屏
            const skeletonScreen = document.getElementById('skeleton-screen');
            if (skeletonScreen) {
                skeletonScreen.style.display = 'none';
            }
        }
    }
}

document.addEventListener('DOMContentLoaded', () => {
    // 显示骨架屏
    const skeletonScreen = document.getElementById('skeleton-screen');
    if (skeletonScreen) {
        skeletonScreen.style.display = 'flex';
    }

    // 延迟初始化,确保所有资源加载完成
    setTimeout(() => {
        app.init();
    }, 100);
});

额外建议:现在的开发更多是“思路驱动实现”,你可以用 AI 快速生成骨架屏代码,前提是你清楚它的原理。

优化方式 2:前端缓存

缓存虽然对首次加载无效,但对二次打开提升巨大。

常见前端存储方式主要有下述几类:

存储方式 容量 持久性 异步/同步 数据结构 同源限制
Cookie ~4KB 可设置 同步 字符串
localStorage 5-10MB 永久 同步 字符串
sessionStorage 5-10MB 会话级 同步 字符串
IndexedDB >250MB 永久 异步 结构化
Cache API 不定 永久 异步 Request/Response
FileSystem 不定 永久 异步 文件

TodoList 中,我们使用 localStorage 存储用户偏好(如主题、语言)。如果你开发的是轻量化应用,完全可以用 IndexedDB 作为整个应用的数据存储层。

💡 不过要补充说明的是,上述各类存储方式,可能存在浏览器兼容性问题。像 TodoList 用的后端是 pywebview,开启 localStorage 通过配置 private_mode

优化方式 3:路由懒加载

SPA 首屏并不需要加载所有页面模块时,可以把部分组件延迟到用户访问时再加载。

TodoList 移动端中,我正在做将「左侧抽屉弹窗」改为懒加载,等用户点击时再加载对应数据,减少首屏负担。

优化方式 N:其他策略

上述几种方式,是目前 TodoList 应用主用的优化方式,当然还有其他的方式,这里以我了解到的做展开介绍,当前不是专业前端可能了解的不多,其他同学知道的其他方式也可以在评论区补充说明。

  • 静态资源懒加载

    和路由懒加载类似,类似图片等高占用体积的资源,可以考虑使用这种方式,不过像 TodoList 应用,前端和后端都是写在一个包里面的,没有纯粹的后端服务,貌似用不了。

    <img src="placeholder.jpg" data-src="real-image.jpg" loading="lazy" />
    
  • Gzip 压缩

    现代构建工具(如 Webpack、Vite)会自动生成压缩后的代码包,服务端开启 Gzip 后即可生效,显著减少传输体积。不过 TodoList 是纯 HTML/CSS/JS 页面,估计还真的不好用这种方式。


以上是 TodoList 项目中正在实践的首屏优化策略。作为本专栏的开篇,希望能帮你少踩一些坑,也欢迎大家在评论区补充你常用的优化方式。

🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”

作者 Lee川
2026年3月13日 18:07

🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”

第一章:舞台搭建 —— 内存的三大分区

在代码运行之前,JavaScript 引擎先画好了三块地皮。请看这张图,这是所有故事发生的物理地基

c2e28f0b62e932380333c67696ea1599.jpg

  1. 🟠 代码空间 (Code Space):存放我们的剧本(源代码)。
  2. 🔴 栈空间 (Stack)“临时更衣室”
    • 特点:进出极快,空间小,自动整理。
    • 住谁?函数执行的上下文基本数据类型(数字、布尔值等)。
    • 规则:后进先出(LIFO),函数执行完,里面的东西立马被清空。
  3. 🔵 堆空间 (Heap)“大型仓库”
    • 特点:空间大,存取稍慢,需要保洁员(垃圾回收器 GC)定期打扫。
    • 住谁?对象、数组、函数等复杂的大件物品。

💡 核心隐喻

  • 是演员手里的提词卡(写着简单的数字或地址)。
  • 是后台巨大的道具库(放着复杂的布景和道具)。
  • 演员(变量)手里通常只拿着一张写有道具编号的卡片(引用地址),而不是直接把道具扛在肩上。

第二章:基本类型的“独立副本” —— 深度解析 1.js

让我们先看 1.js 的代码,看看它在栈空间里是怎么“变魔术”的。

📜 代码剧本 (1.js)

function foo() {
    var a = 1;      // 步骤 A
    var b = a;      // 步骤 B
    a = 2;          // 步骤 C
    console.log(a); // 输出 2
    console.log(b); // 输出 1  <-- 为什么 b 没变?
}
foo();

🎬 内存现场直播

步骤 A:var a = 1;

引擎在栈空间开辟了一个格子,贴上标签 a,里面直接放入数字 1

  • 栈状态[ a: 1 ]
  • 堆状态:空(基本类型不住堆)
步骤 B:var b = a; (关键瞬间!)

这是新手最容易误解的地方。

  • 错误理解ba 绑定了,ab 也变。
  • 真相:引擎在栈空间又开辟了一个全新的格子,贴上标签 b。它读取 a 格子里的值(也就是 1),然后复制了一份放到 b 的格子里。
  • 栈状态
    [ a: 1 ]
    [ b: 1 ]  <-- 这是一个独立的副本!
    
  • 此时,a 和 b 毫无关系,只是数值碰巧相同。
步骤 C:a = 2;

引擎找到标签 a 的格子,把里面的 1 擦掉,写上 2

  • 栈状态
    [ a: 2 ]  <-- 只有这里变了
    [ b: 1 ]  <-- b 毫发无损,因为它存的是独立的副本
    
🏁 结局
  • console.log(a) -> 读到 2
  • console.log(b) -> 读到 1

🧠 记忆口诀基本类型是“复印机”。 b = a 是把 a 的内容复印了一份给 b。以后 a 怎么改,跟 b 手里的复印件没关系。


第三章:引用类型的“共享地址” —— 深度解析 2.js

现在难度升级,看看 2.js 中的对象。这时候,堆空间登场了。

📜 代码剧本 (2.js)

function foo() {
    var a = {name: "极客时间"};  // 步骤 A
    var b = a;                    // 步骤 B
    a.name = '极客邦';            // 步骤 C
    console.log(a); 
    console.log(b);               // 输出什么?居然也变了?
}
foo();

🎬 内存现场直播

步骤 A:var a = {name: "极客时间"};
  1. 堆空间行动:引擎发现是个对象(大件物品),不能在栈里直接放。于是它在堆空间申请了一块地盘(假设地址是 1001),把 {name: "极客时间"} 这个对象存进去。
  2. 栈空间行动:在栈里创建变量 a。但是 a 里面不存对象本身,而是存那个对象的门牌号(地址) 1001
  • 栈状态[ a: 1001 (地址) ]
  • 堆状态地址 1001 -> { name: "极客时间" }
步骤 B:var b = a; (最关键的时刻!)
  • 动作:引擎在栈里创建变量 b。它读取 a 里的内容。

  • 注意a 里的内容是 1001(地址)。所以,引擎把 1001 复制给了 b

  • 结果ab 现在都拿着同一张写着 1001 的纸条。它们指向同一个堆内存地址。

  • 栈状态

    [ a: 1001 ]  \
                  +--> 指向堆里的同一个对象
    [ b: 1001 ]  /
    
  • 堆状态地址 1001 -> { name: "极客时间" }

步骤 C:a.name = '极客邦';
  • 动作:引擎通过 a 找到地址 1001,冲进堆空间,把那个对象里的 name 属性改成了 '极客邦'

  • 关键点:它修改的是堆里的实物,而不是栈里的地址。

  • 堆状态更新地址 1001 -> { name: "极客邦" } (实物被改了!)

🏁 结局
  • console.log(a):拿着地址 1001 去堆里看 -> 看到 { name: "极客邦" }
  • console.log(b):拿着地址 1001 去堆里看 -> 还是看到 { name: "极客邦" }

🧠 记忆口诀引用类型是“遥控器”。

  • ab 是两个不同的遥控器(栈里的变量)。
  • 但它们都对着同一台电视机(堆里的对象)。
  • 你用 a 遥控器换了台(修改属性),b 遥控器看到的画面自然也跟着变了。

第四章:闭包的“时空胶囊” —— 结合图片深度拆解

为什么函数执行完了,里面的变量还能被记住?这就是闭包的魔法。我们结合您提供的后三张图来还原这个过程。

场景设定

function foo() {
    var myName = "极客时间";
    var test1 = 1;
    
    function inner() {
        var test2 = 2;
        console.log(myName); // 这里的 myName 从哪来?
    }
    
    return inner; // 把内部函数扔出去
}

var bar = foo(); // foo 执行完了,按理说它的变量该消失了
bar();           // 但这里依然能打印 "极客时间"

第一阶段:函数执行中

foo() 正在运行时:

  1. 调用栈 (Call Stack) 压入了一个 foo 的执行上下文。
  2. 变量环境里记录了:
    • myName: "极客时间"
    • test1: 1
    • inner: 函数定义(包含了一个秘密武器:对外部作用域的引用
  3. 此时一切正常,myName 就安稳地待在 foo 的栈帧里。

第二阶段:返回与引用的建立

这是最神奇的一步!

  1. foo 函数执行结束,按常理,它的执行上下文应该从调用栈弹出,里面的 myName 应该被销毁。
  2. 但是! 因为 inner 函数(现在赋值给了全局变量 bar)在定义时,偷偷通过作用域链抓住了 foo 的变量环境。
  3. 内存迁移
    • 原本应该在栈里随函数结束而消失的 myNametest1,因为被 inner 引用了,引擎被迫将它们从栈空间“转移”或“保留”在堆空间中(或者说,包含这些变量的整个作用域对象被移到了堆上持久化)。
    • 如上图所示,clourse(foo) (即 inner) 在栈里,但它手里紧紧攥着一个地址 1003
    • 地址 1003 指向堆空间里的一个对象,里面赫然躺着 { myName: "极客时间", test1: 1 }

第三阶段:调用闭包

当我们调用 bar() (即 inner) 时:

  1. 引擎创建 inner 的执行上下文。
  2. 代码遇到 console.log(myName)
  3. 引擎在当前上下文没找到 myName
  4. 它顺着作用域链(那个秘密武器),找到了堆里地址 1003 对应的环境。
  5. 成功读取:"极客时间"。

🧠 闭包本质总结: 闭包不是某种特殊的语法,而是函数与其词法环境的组合

  • 普通函数:用完即走,栈帧清空,数据消失。
  • 闭包函数:因为“有人”(外部引用)还需要它内部的变量,所以引擎不敢清空栈帧,而是把这些变量打包扔到堆里长期保存,直到没人再需要这个函数为止。
  • 代价:这些变量会一直占用内存,直到 bar = null 断开引用,垃圾回收器才会来清理。

第五章:一图胜千言 —— 总结对比

为了让您彻底清晰,我们把刚才的分析浓缩成一张对比表:

特性 基本类型 (1.js) 引用类型 (2.js) 闭包 (5.html/6.html)
存储位置 只在栈 栈存地址,堆存实体 变量被强行保留在堆
赋值行为 值拷贝 (复印文件) 引用拷贝 (复制遥控器) 作用域捕获 (带走整个房间)
修改影响 互不影响 互相影响 (改的是同一份数据) 内部函数可读写外部私有变量
生命周期 函数结束即销毁 对象无引用时被 GC 回收 比定义它的函数活得更久
形象比喻 两个独立的苹果 两个人看同一个投影 把家里的家具搬到了公共仓库

💡 给开发者的终极建议

  1. 处理基本类型:放心大胆地赋值,不用担心改了一个影响另一个。
  2. 处理对象/数组:小心!b = a 之后,你以为你在操作 b,其实你可能在修改 a 的数据。如果需要独立副本,请使用扩展运算符 [...a]Object.assign 进行深拷贝/浅拷贝
  3. 使用闭包
    • 好处:创造私有变量,模拟类,函数柯里化。
    • 风险:如果不小心在闭包里引用了巨大的 DOM 节点或大对象,且长期不释放,会导致内存泄漏
    • 解决:不需要时,手动将引用置为 null (bar = null),告诉垃圾回收器“可以打扫了”。

希望这次结合内存动态流转生活化比喻的讲解,能让您对 JavaScript 的内存机制和闭包有透彻的理解!如果还有哪个环节觉得不够直观,请随时告诉我,我们可以针对那个点继续深挖。

低代码可视化平台的前端架构设计:从渲染引擎到插件系统

作者 可视之道
2026年3月13日 18:04

今天分享一个做内部的可视化搭建工具经验,从零开始设计前端架构,希望对同样在做类似事情的同学有帮助。

先说结论:分层是第一原则

做低代码可视化平台,最怕的就是"一锅粥"——渲染逻辑、交互逻辑、数据逻辑全搅在一起。等到要加新功能的时候,改一个地方崩三个地方。

经过几轮重构,我最终沉淀出一个四层架构:

┌─────────────────────────────────────┐
│           交互层 (Interaction)       │  拖拽、选中、缩放、快捷键
├─────────────────────────────────────┤
│           渲染层 (Renderer)          │  Canvas/SVG/DOM 渲染引擎
├─────────────────────────────────────┤
│           模型层 (Model)             │  组件树、Schema、状态管理
├─────────────────────────────────────┤
│           插件层 (Plugin)            │  扩展能力、生命周期钩子
└─────────────────────────────────────┘

每一层只关心自己的事,通过标准接口通信。下面逐层拆解。

一、模型层:一切的基础是 Schema

低代码平台的核心数据结构是一棵组件树,用 JSON Schema 描述。这棵树决定了画布上渲染什么、怎么渲染、数据怎么流转。

// 组件节点的核心数据结构
interface ComponentNode {
  id: string;                    // 唯一标识
  type: string;                  // 组件类型,如 'Button', 'Chart', 'Container'
  props: Record<string, any>;    // 组件属性
  style: CSSProperties;          // 样式
  children?: ComponentNode[];    // 子节点
  events?: EventBinding[];       // 事件绑定
  dataSource?: DataBinding;      // 数据源绑定
}

// 组件元数据:描述组件"能做什么"
interface ComponentMeta {
  name: string;
  category: string;              // 分类:基础组件、图表、容器...
  propsSchema: JSONSchema;       // 属性的 JSON Schema,用于自动生成配置面板
  slots?: string[];              // 插槽定义
  events?: string[];             // 可触发的事件
  thumbnail?: string;            // 缩略图
}

这里有个关键设计决策:Schema 是"单一事实来源"(Single Source of Truth) 。画布渲染、属性面板、代码生成、数据绑定,全部从这棵树派生。不要搞多份数据互相同步,那是噩梦的开始。

状态管理:不可变数据 + 命令模式

组件树的每次修改都通过命令(Command)执行,而不是直接 mutate:

class EditorStore {
  private state: EditorState;
  private history: Command[] = [];
  private cursor: number = -1;

  execute(command: Command) {
    // 执行命令
    this.state = command.execute(this.state);
    // 记录历史(支持撤销/重做)
    this.history = this.history.slice(0, this.cursor + 1);
    this.history.push(command);
    this.cursor++;
    // 通知订阅者
    this.notify();
  }

  undo() {
    if (this.cursor < 0) return;
    this.state = this.history[this.cursor].undo(this.state);
    this.cursor--;
    this.notify();
  }

  redo() {
    if (this.cursor >= this.history.length - 1) return;
    this.cursor++;
    this.state = this.history[this.cursor].execute(this.state);
    this.notify();
  }
}

用不可变数据(Immutable)的好处是:状态可追溯、撤销重做天然支持、脏检查高效。代价是每次修改都要创建新对象,但配合结构共享(Structural Sharing),性能完全可以接受。

二、渲染层:Canvas 还是 DOM?

这是做可视化平台绕不开的选择题。我的经验是:看场景

维度 DOM 渲染 Canvas 渲染 SVG 渲染
节点数上限 ~500 10000+ ~2000
交互复杂度 天然支持 需要自己实现事件系统 天然支持
文本排版 原生支持 痛苦 一般
动画性能 一般 优秀 一般
适用场景 表单搭建、页面搭建 工业组态、大屏、拓扑图 流程图、简单图形

如果你做的是类似"页面搭建器"(表单、后台页面),DOM 渲染就够了,React/Vue 的虚拟 DOM 已经帮你处理了大部分事情。

但如果是工业组态、SCADA、数据大屏这类场景,节点动辄上万,还有大量动画和实时数据刷新,Canvas 几乎是唯一选择。

Canvas 渲染引擎的核心循环

一个 Canvas 渲染引擎的骨架其实不复杂:

class RenderEngine {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private sceneGraph: SceneNode[];  // 场景图
  private dirty: boolean = true;
  private rafId: number = 0;

  // 渲染主循环
  private loop = () => {
    if (this.dirty) {
      this.clear();
      this.render(this.sceneGraph);
      this.dirty = false;
    }
    this.rafId = requestAnimationFrame(this.loop);
  };

  private render(nodes: SceneNode[]) {
    for (const node of nodes) {
      this.ctx.save();
      // 应用变换矩阵(位移、旋转、缩放)
      this.applyTransform(node.transform);
      // 调用节点自身的绘制方法
      node.draw(this.ctx);
      // 递归渲染子节点
      if (node.children) {
        this.render(node.children);
      }
      this.ctx.restore();
    }
  }

  // 标记脏区域,触发重绘
  markDirty() {
    this.dirty = true;
  }
}

但真正的难点在于:

  1. 事件系统:Canvas 没有 DOM 事件冒泡,你得自己实现 hitTest(点击检测)。常见方案是离屏 Canvas 颜色拾取,或者基于包围盒的空间索引(R-Tree / 四叉树)。
  2. 脏区域渲染:全量重绘在节点多的时候很浪费。记录哪些区域变了,只重绘变化的部分,能大幅提升性能。
  3. 分层渲染:把静态元素和动态元素放在不同的 Canvas 层上。静态层不需要频繁重绘,动态层(如动画、实时数据)独立刷新。
// 分层渲染示意
class LayeredRenderer {
  private staticCanvas: HTMLCanvasElement;   // 静态层:背景、固定元素
  private dynamicCanvas: HTMLCanvasElement;  // 动态层:动画、实时数据
  private interactCanvas: HTMLCanvasElement; // 交互层:选中框、拖拽辅助线

  renderStatic() {
    // 只在布局变化时重绘
    this.drawNodes(this.staticCanvas, this.staticNodes);
  }

  renderDynamic() {
    // 每帧或数据更新时重绘
    this.drawNodes(this.dynamicCanvas, this.dynamicNodes);
  }

  renderInteraction() {
    // 鼠标移动时重绘
    this.drawSelectionBox(this.interactCanvas);
    this.drawAlignGuides(this.interactCanvas);
  }
}

这个分层策略在实际项目中效果非常明显——我们的场景有 8000+ 节点,分层后帧率从 15fps 稳定到了 50fps 以上。

三、交互层:拖拽不只是 mousedown + mousemove

可视化编辑器的交互比想象中复杂得多。拖拽组件到画布、拖拽调整位置、拖拽调整大小、框选、对齐辅助线、吸附……每一个都是独立的交互状态。

我推荐用有限状态机(FSM) 来管理交互状态:

type InteractionState = 
  | 'idle'           // 空闲
  | 'dragging'       // 拖拽移动
  | 'resizing'       // 调整大小
  | 'selecting'      // 框选
  | 'connecting'     // 连线
  | 'panning';       // 画布平移

class InteractionFSM {
  private state: InteractionState = 'idle';

  transition(event: MouseEvent | KeyboardEvent) {
    switch (this.state) {
      case 'idle':
        if (isMouseDownOnNode(event)) this.state = 'dragging';
        else if (isMouseDownOnHandle(event)) this.state = 'resizing';
        else if (isMouseDownOnCanvas(event)) this.state = 'selecting';
        else if (isSpacePressed(event)) this.state = 'panning';
        break;
      case 'dragging':
        if (isMouseUp(event)) {
          this.commitDrag();
          this.state = 'idle';
        }
        break;
      // ... 其他状态转换
    }
  }
}

状态机的好处是:交互逻辑清晰、不会出现状态混乱(比如拖拽的时候突然触发了框选)、容易扩展新的交互模式。

四、插件层:微内核是终极答案

这是我认为整个架构中最重要的一层。

一个可视化平台要支持的功能太多了:不同类型的组件、不同的数据源、不同的导出格式、不同的交互工具……如果全部写在核心代码里,代码量会爆炸,而且每加一个功能都要改核心。

微内核 + 插件化是解决这个问题的经典模式。核心思路:

  • 内核只做三件事:插件管理、事件总线、服务注册
  • 所有业务功能都是插件:组件库是插件、数据源适配器是插件、导出器是插件、工具栏按钮也是插件
// 微内核定义
class EditorKernel {
  private plugins: Map<string, Plugin> = new Map();
  private hooks: Map<string, Function[]> = new Map();
  private services: Map<string, any> = new Map();

  // 注册插件
  use(plugin: Plugin) {
    plugin.install(this);
    this.plugins.set(plugin.name, plugin);
    return this;
  }

  // 注册钩子(类似 Webpack 的 tapable)
  hook(name: string, fn: Function) {
    if (!this.hooks.has(name)) this.hooks.set(name, []);
    this.hooks.get(name)!.push(fn);
  }

  // 触发钩子
  async callHook(name: string, ...args: any[]) {
    const fns = this.hooks.get(name) || [];
    for (const fn of fns) {
      await fn(...args);
    }
  }

  // 注册/获取服务
  provide(name: string, service: any) { this.services.set(name, service); }
  inject(name: string) { return this.services.get(name); }
}

// 插件接口
interface Plugin {
  name: string;
  dependencies?: string[];
  install(kernel: EditorKernel): void;
  activate?(): void;
  deactivate?(): void;
}

举个实际例子——一个"ECharts 图表组件"插件:

const echartsPlugin: Plugin = {
  name: 'echarts-components',
  dependencies: ['component-registry'],

  install(kernel) {
    const registry = kernel.inject('component-registry');

    // 注册一批 ECharts 组件
    registry.register('LineChart', {
      category: '图表',
      propsSchema: { /* ... */ },
      render: (props, bindData) => {
        const chart = echarts.init(container);
        chart.setOption(bindData ? mergeData(props, bindData) : props);
        return chart;
      }
    });

    registry.register('BarChart', { /* ... */ });
    registry.register('PieChart', { /* ... */ });

    // 监听数据更新事件,刷新图表
    kernel.hook('data:update', (nodeId, data) => {
      const chart = chartInstances.get(nodeId);
      if (chart) chart.setOption(data, { notMerge: false });
    });
  }
};

// 使用
const editor = new EditorKernel();
editor
  .use(corePlugin)           // 核心功能
  .use(componentRegistry)    // 组件注册中心
  .use(echartsPlugin)        // ECharts 图表
  .use(mqttDataSource)       // MQTT 数据源
  .use(exportHtmlPlugin)     // 导出 HTML
  .use(alignPlugin);         // 对齐辅助线

这个设计参考了 VS Code 和 Webpack 的插件体系。VS Code 的成功很大程度上归功于它的插件架构——核心编辑器很轻,语言支持、主题、调试器全是插件。Webpack 的 tapable 钩子系统也是同样的思路,整个构建流程都是通过钩子串起来的。

插件间通信:事件总线 vs 服务注入

插件之间不应该直接引用,而是通过两种方式通信:

  1. 事件总线:松耦合,适合"通知型"通信。比如"节点被选中了"、"数据更新了"。
  2. 服务注入:适合"能力型"通信。比如插件 A 需要用到插件 B 提供的"导出 PDF"能力。
// 事件总线:发布-订阅
kernel.hook('node:selected', (nodeId) => {
  // 属性面板插件监听,更新面板内容
  propertyPanel.update(nodeId);
});

// 服务注入:依赖查找
const exporter = kernel.inject('pdf-exporter');
await exporter.export(currentScene);

五、代码生成:从 Schema 到可运行代码

低代码平台的最终产物通常是可部署的代码。代码生成器的设计也很适合用插件化:

// 代码生成器也是插件
const vueCodegenPlugin: Plugin = {
  name: 'vue-codegen',
  install(kernel) {
    kernel.provide('codegen:vue', {
      generate(schema: ComponentNode): string {
        return `
<template>
  ${generateTemplate(schema)}
</template>

<script setup>
${generateScript(schema)}
</script>

<style scoped>
${generateStyle(schema)}
</style>`;
      }
    });
  }
};

不同的目标框架(Vue/React/原生 HTML)对应不同的代码生成插件,核心 Schema 不变,输出随意切换。

总结

回顾整个架构,核心思路就三个:

  1. Schema 驱动:用一棵 JSON 树描述一切,所有功能从这棵树派生
  2. 分层解耦:模型、渲染、交互、插件各司其职,通过接口通信
  3. 微内核 + 插件化:核心最小化,功能全部插件化,用钩子和服务注入串联

这套架构不是一开始就设计出来的,是经过三轮重构才稳定下来的。第一版是"能跑就行"的原型,第二版把渲染层抽出来了,第三版才引入了微内核。如果你也在做类似的项目,建议一开始就把 Schema 设计好,这是地基,后面怎么改都不怕。

渲染引擎和插件系统的选型,取决于你的业务场景。做页面搭建器,DOM + React/Vue 就够了;做工业组态/SCADA,Canvas 渲染引擎 + 微内核插件系统是更好的选择。

下一篇打算聊聊 SCADA Web 化的具体架构,特别是前端渲染层和数据采集层怎么解耦的问题。如果你也在做工业可视化相关的项目,欢迎留言交流。


我是一个专注前端可视化的技术人,分享可视化、Canvas、工业互联网相关的技术实践。关注我,一起在可视化的世界里折腾。

别再滥用 iframe 了!这些场景下它其实是最优解

作者 kyriewen
2026年3月13日 18:02

引言

“这个页面要嵌入另一个系统的报表,怎么办?”

“用 iframe 啊!”

“我想在咱们站里放个B站视频,但不想跳转。”

“用 iframe 啊!”

“微前端怎么搞?”

“用 iframe 啊!”——等等,微前端真的适合用 iframe 吗?

作为前端开发者,我们几乎每天都在和 iframe 打交道。它像一个“万能容器”,能轻松地把另一个页面塞进当前页面。但你真的了解它的能力边界吗?为什么有时候 iframe 会让页面卡顿?为什么有些网站死活不让你嵌入?为什么安全报告总提醒你注意 iframe 风险?

今天,我们抛开浅层用法,深入 iframe 的每一个毛孔,看看这个 25 岁的 HTML 元素,在现代前端开发中究竟扮演什么角色。

一、初见 iframe:不只是“页面里的页面”

1.1 基础语法

<iframe src="https://example.com" width="600" height="400"></iframe>

就这么简单,一个外部页面就被嵌入了。但它背后的行为远比看到的复杂:

  • 它创建了一个独立的浏览上下文(browsing context),有自己的历史记录、DOM 树、全局对象。
  • 可能与父页面同源(同协议、域名、端口),也可能跨域。
  • 它的加载、渲染、脚本执行几乎完全独立,但资源(如网络连接、线程)又共享自浏览器。

1.2 常用属性

除了 srcwidthheight,还有几个现代属性至关重要:

属性 作用
sandbox 对 iframe 内容施加额外限制(后文详述)
allow 控制特性权限,如麦克风、摄像头、全屏
allowfullscreen 是否允许全屏
loading 懒加载(lazy / eager
referrerpolicy 控制 Referer 头的发送策略

二、核心应用场景:什么时候非它不可?

2.1 嵌入第三方内容

视频(YouTube、B站)、地图(Google Maps)、社交帖子(Twitter、Instagram),这些平台提供的嵌入代码几乎都是 iframe。为什么?

  • 安全隔离:第三方脚本不能直接访问你的页面 DOM,防止恶意操作。
  • 样式独立:不会被你的 CSS 意外污染,也不用担心污染你的页面。
  • 功能完整:播放器、地图交互等复杂功能可以直接用对方提供的代码,不用自己实现。

2.2 广告系统

广告往往是跨域的,且需要沙盒化运行。iframe 天生适合:广告脚本在独立环境运行,无法窃取主站数据,同时又可以通过 postMessage 进行必要的通信(如上报尺寸变化)。

2.3 微前端架构的“兜底方案”

现代微前端框架(single-spa、qiankun)大多采用 JS 沙箱 + 路由分发的方式,但遇到老旧的、必须用全局变量或修改原型链的子应用时,iframe 成了最后的防线。虽然它有通信成本高、加载慢、UI 不同步等缺点,但胜在隔离彻底。

2.4 保持页面状态的“快照”

例如在线代码编辑器(CodePen、JSFiddle)的预览区域,用 iframe 执行用户代码,即使代码崩溃也不会影响主页面。

三、深入原理:iframe 与父页面的爱恨情仇

3.1 独立王国的边界

iframe 内部的所有内容(包括 JS 变量、定时器、事件监听)都局限在自己窗口内。但以下几个资源是跨上下文共享的:

  • 浏览器缓存src 里的资源会被正常缓存。
  • 网络连接:TCP 连接数限制是全局的,过多的 iframe 可能耗尽连接池。
  • localStorage / sessionStorage:同源 iframe 可以读写父页面的存储,跨域则不能(会抛出安全错误)。

3.2 通信:同源 vs 跨域

同源 iframe

父页面可以像操作自己的 DOM 一样操作 iframe 内部:

// 父页面
const iframe = document.getElementById('my-iframe');
iframe.contentDocument.getElementById('btn').click(); // 直接访问内部元素
iframe.contentWindow.someGlobalFunction(); // 调用内部全局函数

但要注意,必须等待 iframe 加载完成,否则 contentDocument 可能为空。

跨域 iframe

浏览器强制的同源策略会阻止父页面访问跨域 iframe 的 DOM。这时唯一安全的通信方式是 window.postMessage

父页面发送消息:

iframe.contentWindow.postMessage({
  type: 'UPDATE_USER',
  payload: { id: 123, name: 'Alice' }
}, 'https://iframe-domain.com'); // 目标源,必须指定

iframe 内监听消息:

window.addEventListener('message', (event) => {
  // 务必验证来源!
  if (event.origin !== 'https://parent-domain.com') return;
  
  if (event.data.type === 'UPDATE_USER') {
    // 更新界面
  }
});

安全原则: 永远检查 event.originevent.source,防止恶意页面冒充。

3.3 嵌套与层级

iframe 可以多层嵌套,形成“子→孙”结构。每一层都有独立的 window,但可以通过 window.parentwindow.top 访问父窗口和顶层窗口。跨域时访问这些属性也会被安全策略限制,只能通过 postMessage 向上传递。

四、安全性与沙盒:给 iframe 戴上枷锁

4.1 sandbox 属性

sandbox 是 iframe 最强大的安全工具,它可以启用一系列限制:

<iframe src="https://untrusted.com" sandbox></iframe>
<!-- 完全沙盒化:不允许脚本、表单、弹窗、导航等 -->

可以选择性放宽限制:

<iframe src="https://example.com" sandbox="allow-scripts allow-same-origin"></iframe>

常见 sandbox 值:

含义
allow-scripts 允许执行脚本
allow-same-origin 允许视为同源(如果不加,即使 URL 同源也会被当作跨域处理)
allow-forms 允许提交表单
allow-popups 允许弹窗(window.open
allow-modals 允许调用 alert() 等模态框
allow-orientation-lock 允许锁定屏幕方向
allow-pointer-lock 允许指针锁定
allow-top-navigation 允许导航到顶层窗口(危险)
allow-presentation 允许启动演示模式

重要: 不加 allow-same-origin 时,iframe 会被分配一个独特的来源(null),即使 URL 看起来同源。这是为了防止恶意脚本利用 iframe 绕过同源策略。

4.2 allow 属性(功能策略)

allow 属性用于控制更精细的权限,如摄像头、麦克风:

<iframe src="https://meet.example.com" 
        allow="camera; microphone; fullscreen">
</iframe>

这些权限需要配合 Feature Policy(现称 Permissions Policy)使用,浏览器会向用户请求授权。

4.3 防止页面被嵌入(X-Frame-Options 与 CSP)

如果你的页面不想被别人的 iframe 嵌入(比如防止点击劫持),可以设置响应头:

  • X-Frame-Options: DENY(完全禁止)或 SAMEORIGIN(只允许同源页面嵌入)
  • Content-Security-Policy: frame-ancestors 'self' https://example.com(更精细的控制)
X-Frame-Options: DENY
# 或
Content-Security-Policy: frame-ancestors 'none';

五、性能影响:看不见的代价

5.1 加载阻塞

<iframe> 的加载会阻塞主页面 onload 事件。即使使用 loading="lazy" 懒加载,也依然需要额外的连接开销。

5.2 内存占用

每个 iframe 都是一个独立的文档环境,会占用大量内存(尤其是包含复杂交互时)。过多 iframe 可能导致页面卡顿甚至崩溃。

5.3 最佳实践

  • 延迟加载:对不可见的 iframe(如下方广告)设置 loading="lazy"
  • 动态创建:只在需要时创建 iframe,用完及时销毁(iframe.remove())。
  • 限制数量:不要超过 2~3 个活跃 iframe。
  • 预先连接:如果知道 iframe 来源,可以用 <link rel="preconnect"> 提前建立连接。

六、现代替代方案:iframe 不是唯一解

场景 iframe 的问题 替代方案
嵌入外部页面 样式隔离但交互受限 Web Components(Shadow DOM)可以隔离样式,但不能隔离 JS
微前端 通信复杂、加载慢 single-spa、qiankun 等基于 JS 沙箱的微前端框架
显示富文本内容 需要安全展示用户生成内容 使用专门的 HTML 渲染库(如 DOMPurify)搭配 Shadow DOM
显示 PDF iframe 会接管整个窗口 <object><embed>,或使用 PDF.js 自建渲染
跨域通信 只能 postMessage 如果只是获取数据,可以用 Fetch API + CORS

但 iframe 在完全隔离不可信代码的场景中,依然不可替代。例如在线代码编辑器、广告系统。

七、常见问题与解决方案

Q1:如何让 iframe 高度自适应内容?

跨域 iframe 无法直接读取内部高度,但可以通过 postMessage 由内部通知父页面:

iframe 内:

const height = document.documentElement.scrollHeight;
window.parent.postMessage({ type: 'resize', height }, 'https://parent.com');

父页面:

window.addEventListener('message', (e) => {
  if (e.data.type === 'resize') {
    document.getElementById('my-iframe').style.height = e.data.height + 'px';
  }
});

Q2:为什么 iframe 内的 localStorage 无法共享?

跨域 iframe 的 localStorage 是与 iframe 的源绑定的,无法访问父页面源的数据。如果需要共享,可以考虑 postMessage 让父页面代为存储。

Q3:如何检测 iframe 是否加载完成?

iframe.onload = () => {
  console.log('iframe loaded');
};
// 或者用 addEventListener
iframe.addEventListener('load', () => {});

对于跨域 iframe,onload 仍然可触发,但不能访问内部文档。

Q4:如何防止 iframe 内的链接跳转导致父页面变化?

给 iframe 设置 sandbox(不加 allow-top-navigation)即可阻止链接导航到顶层。

Q5:如何让 iframe 内的 PDF 自动打印?

这无法直接控制,因为 PDF 插件是浏览器内置行为。但可以提示用户使用插件菜单。

八、总结:iframe 仍是工具箱里的重要角色

iframe 就像前端开发的“瑞士军刀”——看似笨重,但在需要强隔离的场景里,它依然是唯一可靠的选择。了解它的工作原理、性能影响和安全策略,你就能在合适的场景下让它发挥最大价值,而不是盲目滥用。

下一次当你想用 iframe 时,不妨问自己三个问题:

  1. 真的需要完全隔离吗?能否用 Web Components 替代?
  2. 如果非用不可,是否设置了合适的 sandboxallow 属性?
  3. 如何优化它的加载,避免拖慢主页面?

思考题:假设你想在自己的博客里嵌入一个来自“example.com”的页面,但对方设置了 X-Frame-Options: SAMEORIGIN,你有什么办法让它强制显示吗?为什么?

(答案下期揭晓,也欢迎留言讨论)


每日一问:你在实际开发中遇到过哪些 iframe 的坑?是怎么解决的?欢迎在评论区分享你的经验。

AI+Shopify 前端开发:实战一年后,聊聊 AI Agent 和前端的生死局

作者 sniper
2026年3月12日 14:36

作为一个折腾 Shopify 前端快 5 年的开发者,去年开始全面把 AI 工具融入工作流 —— 从最开始用 ChatGPT 写 Liquid 模板,到现在尝试 AI Agent 自动处理适配问题,这一年的变化让我既兴奋又焦虑。经常有同行问:“AI 都能自动生成代码、调试兼容了,前端是不是快死了?” 结合我在 Shopify 开发中的真实经历,今天就聊聊这个扎心的话题,再说说对 AI Agent 未来的思考。

先说说 AI 给 Shopify 前端开发带来的 “真实改变”

做过 Shopify 定制的都知道,Liquid 模板虽然不难,但重复工作是真的多 —— 比如每个店铺都要改的商品卡片、结账页面适配、移动端弹窗样式,以前一个项目光这些基础活就要占 30% 时间。现在我基本是 “自然语言甩需求”,比如跟 Copilot 说 “生成一个 Shopify 商品卡片,要显示变体选择,移动端点击图片放大,折扣标签用渐变红色,符合 Dawn 主题风格”,几十秒就能拿到可用的代码,改改变量名就能直接用。

更惊喜的是 AI 解决兼容性问题的能力。Shopify 店铺要适配不同浏览器、不同设备,以前调试移动端样式要反复刷新测试,现在用 AI 工具直接上传页面链接,它能自动识别出 iOS 端按钮错位、Chrome 浏览器字体不兼容的问题,还能给出修改后的 CSS 代码。上周帮客户做跨境店铺,AI 甚至提醒我 “Shopify 在欧洲地区的 Cookie 弹窗需要多语言支持”,连合规细节都考虑到了,这是以前自己做开发容易忽略的。

但这里必须说句大实话:AI 生成的代码 “能用但不精”。比如它会生成大量冗余的 CSS 样式,或者 Liquid 语法虽然正确,但没有考虑到 Shopify 的性能优化 —— 比如图片没有用 image_url 过滤尺寸,导致页面加载变慢;或者 AJAX 请求没有做错误处理,用户加入购物车失败时没有提示。这些细节还是需要开发者手动优化,AI 目前只能帮我们 “搭架子”,没法做到 “精装修”。

聊聊 AI Agent:前端开发的下一个风口,还是 “新瓶装旧酒”?

最近 AI Agent 的概念很火,简单说就是 “能自主完成复杂任务的 AI 助手”,而不是像现在这样需要我们一步步喂需求。我上个月测试了一个专门针对 Shopify 的 AI Agent 工具,体验下来既有惊喜也有失望。

惊喜的地方在于:它能自主理解复杂需求。比如我跟它说 “帮我优化这个 Shopify 店铺的前端性能,目标是 Google PageSpeed 得分超过 90 分”,它会自动分析页面 —— 识别出未压缩的 JS 文件、没有懒加载的图片、渲染阻塞的 CSS,然后生成一套完整的优化方案,甚至能自动修改 Liquid 模板里的代码,不需要我手动操作。更厉害的是,它还能联动 Shopify 的 API,比如自动获取商品图片的 URL,批量优化图片尺寸,这已经不是简单的 “代码生成”,而是 “全流程自动化” 了。

但失望的地方也很明显:AI Agent 的 “自主决策能力” 还很有限。比如我让它 “根据店铺的产品类型(美妆类目)设计一套新的首页 Banner 样式”,它生成的设计虽然美观,但完全没有考虑到美妆用户的浏览习惯 —— 比如没有突出产品的成分、功效,而是堆砌了太多装饰元素。还有一次,它自动修改了购物车页面的布局,结果导致结账按钮的位置超出了移动端的可视区域,反而影响了转化率。后来我发现,AI Agent 目前还没法理解 “业务逻辑” 和 “用户体验” 的深层关联,它只能基于已有的数据和规则做决策,缺乏对具体行业、具体用户的洞察。

不过我很看好 AI Agent 的发展趋势。现在的 AI 是 “工具”,而未来的 AI Agent 会是 “合作伙伴”—— 比如它能自主监控 Shopify 店铺的前端状态,发现问题后自动提醒开发者,甚至在开发者授权的情况下自主修复小问题;或者根据店铺的销售数据,自动调整前端布局,比如把热销商品的展示位置提前,优化用户的购买路径。但这个过程需要时间,目前的 AI Agent 还处于 “初级阶段”,离真正替代开发者还有很长的路要走。

核心疑问:AI 这么猛,前端真的会 “死” 吗?

这是我最近被问得最多的问题,我的答案很明确:前端不会死,但 “只会写代码的前端” 会被淘汰。

为什么这么说?结合 Shopify 开发的场景举几个例子:

  1. AI 能生成 Liquid 代码,但它没法理解 “品牌调性”—— 比如客户要做一个极简风格的小众品牌店铺,AI 生成的代码可能功能齐全,但缺乏设计感和独特性,这时候需要前端开发者结合品牌理念,优化界面的留白、字体、色彩搭配,让店铺有 “灵魂”;
  2. AI 能解决简单的兼容性问题,但它没法处理 “复杂的业务逻辑适配”—— 比如跨境电商店铺需要对接不同国家的支付接口、物流系统,前端需要根据用户的地理位置显示不同的支付方式、配送信息,这需要开发者理解业务流程,设计合理的交互逻辑,AI 目前还做不到;
  3. AI 能优化前端性能,但它没法平衡 “性能” 和 “用户体验”—— 比如压缩图片能提升加载速度,但过度压缩会影响图片清晰度,导致用户无法看清产品细节,这需要开发者根据实际情况做取舍,找到最佳平衡点。

再往深了想,前端开发的核心是什么?不是 “写代码”,而是 “通过技术解决用户需求,提升用户体验”。代码只是实现目标的工具,而 AI 正在把我们从 “工具人” 的角色中解放出来,让我们有更多时间去思考 “用户真正需要什么”“如何让店铺更有竞争力”。比如以前我要花大量时间写模板代码,现在可以把这些时间用在用户调研、交互设计上,甚至可以学习 Shopify 的后端 API、数据分析,成为一个 “全栈型” 的电商技术专家。

未来的前端开发者,更像是 “技术 + 业务 + 设计” 的复合型人才 —— 既要懂 AI 工具的使用,能用它高效完成基础工作;又要懂业务逻辑,能理解电商行业的需求和痛点;还要懂用户体验,能设计出符合用户习惯的界面。而那些只会机械写代码、不懂思考的开发者,确实会被 AI 替代。

最后总结一下

AI 给 Shopify 前端开发带来了巨大的效率提升,AI Agent 的发展更是让我们看到了全流程自动化的可能,但这并不意味着前端开发会消失。相反,AI 正在推动前端开发行业的 “升级”,淘汰落后的产能,让真正有价值的开发者脱颖而出。

作为开发者,我们不用害怕 AI,而是要拥抱它 —— 学习如何使用 AI 工具,把它变成自己的 “左膀右臂”;同时也要不断提升自己的核心竞争力,比如加深对业务的理解、提升设计能力、学习跨领域知识。只有这样,才能在 AI 时代立于不败之地。

最后,想问大家一个问题:你们在使用 AI 做 Shopify 开发时,遇到过哪些坑?或者对 AI Agent 的发展有什么不同的看法?欢迎在评论区交流~

前端工程化进阶:Monorepos 架构简析(水文)

作者 墨渊君
2026年3月12日 12:19

引言

对于 Monorepos 架构一直早有耳闻, 但是一直用不上也就懒得去了解, 刚好最近想要基于 prosemirror 写一个自己的富文本编辑器, 基本上是就是参考 tiptap 源码来一步步开发, 一为折腾二为学习。刚好 tiptap 其实就是 Monorepos 架构所以顺便简单研究研究, 故而有了这篇文章。

一、基本概念了解

再开始前先了解几种常见的架构

1.1 单体架构(Monolith)

所谓 Monolith 这个词字面意思是 单块的、整体的, 在软件架构里常用来指 单体架构 模式。

该模式其实就是将所有功能都打包在一个整体里, 作为一个应用进行部署和运行, 它和微服务应该是一个完全相反的一种模式。

  1. 主要特点就是:
  • 单一代码库: 所有功能模块(用户、订单、支付、库存…)都在同一个代码仓库、同一个项目里。
  • 单体部署: 打包后只产出一个可执行文件 / war 包 / docker 镜像, 直接上线。
  • 共享内存与数据库: 所有功能都是部署在一个机器上, 通常共享内存、共用一个数据库。
  1. 优点
  • 开发简单: 没有复杂的服务间通信。
  • 部署简单: 一个包就能跑起来, 不用维护一堆微服务。
  • 调试方便: 本地起一个服务就能跑全流程。
  • 性能好: 模块间是进程内调用, 不用走网络。
  1. 缺点
  • 耦合高: 改一处可能影响全局,难以模块化管理。
  • 扩展性差: 无法按模块独立扩容。
  • 技术栈受限: 所有功能只能用同一技术栈。
  • 发布成本高: 改一行代码也要重新打包发布整个应用。

当然 Monolith 其实和我们要聊的 Monorepos 没啥太大关系, 这里只是顺带了解了解。后面要聊的 multirepo / monorepo 讨论的更多是 代码仓库管理策略

1.2 多仓库模式(multirepos)

所谓 multireposMulti-repos 也就是多仓库模式, 说白了就是每个「项目/服务/模块/应用」都是单独放在一个代码仓库里。

当然这些代码仓库如果没有任何关联, 其实也没啥好说点。但是相反有些它们之间可能是有关联的, 甚至有很多业务逻辑都是相通的。比如我们一个项目有 PC 端、后台管理、移动端、小程序、APP 端等等, 甚至有些还有共用的组件库、工具库之类的。

再比如我们上面提到的 prosemirror 其实就是使用 multirepos 架构, 每个功能模块都是以独立仓库的形式存在, 如下图所示:

image

  1. 那么 multirepos 架构有舍特点呢?
  • 一仓一项目: 每个服务、库、UI 组件、工具包都有自己独立的 Git 仓库。
  • 独立版本管理: 每个仓库有自己的版本号、分支、发布流程。
  • 强解耦: 一个仓库的变更不直接影响其他仓库。
  1. multirepos 架构优点:
  • 边界清晰: 不同模块之间独立管理, 没有太多耦合。
  • 独立发布: 可以单独更新、发布某个仓库, 而不必影响其他仓库。
  • 权限控制简单: 敏感项目可以设置私有仓库, 权限隔离好做。
  • 适合多团队合作: 不同仓库(项目)由各自的团队负责, 团队之间互不干扰。
  1. multirepos 架构缺点
  • 协作成本高: 当某个需求改动需要涉及多个仓库时, 需要多次提 PR、多次发布、如果之间还存在耦合就容易出错。
  • 依赖管理麻烦: 仓库之间如果相互依赖, 则比较麻烦没次都需要手动发布版本、升级依赖。
  • 工具链碎片化: 每个仓库可能有不同的 lint / build / test 配置, 配置之间要做到一致性就比较麻烦。只要有一点调整, 就需要动所有仓库。

1.3 单仓库模式(Monorepos)

Monorepo 则是单仓库模式, 顾名思义就是直接将多个项目/服务/模块/包放到同一个仓库进行统一管理, 就好比如我有一个网站, 有前端项目有服务项目, 服务端也是用 JS(Node) 写的, 那我其实就可以将这两个项目放到同一个仓库进行一个管理, 这样的话很多工具函数、脚本、项目配置都是可以复用的。

Monorepos 模式其实就特别一些开源的项目, 上文说到的 tiptap 就是该模式, 一个完整的项目包含了基础的核心模块、还有各种功能扩展模块、同时还需要针对 RectVue、原生 HTML 提供对应的功能包... 同时这些模块之间很多

image

  1. Monorepos 架构特点
  • 单个代码仓库: 所有相关项目、包、模块都在一个 Git 仓库中进行统一管理。
  • 多包结构: 一般使用 packages/apps/ 等目录存放多个子项目, 每个子项目可以发布为单独的依赖包。
  • 共享依赖与工具链: 可统一使用同一套 lintbuildtestCI 等配置。
  • 快捷引用: 不同模块之间不需要手动执行 npm link 或者发布为 npm 包, 相互之间就可以直接快速引用。
  • 自动化构建与发布: 通常配合工具如 pnpm workspacelernanxturborepo 可以方便快捷的实现依赖管理、构建缓存、按需发布等等。
  1. multirepos 架构优点
  • 统一管理、提升协作效率: 所有模块在一个仓库内, 统一管理版本、代码规范、CI/CD 流程, 不需要在多个仓库中反复提 PR、切换分支。
  • 依赖共享, 减少重复安装: 可以通过 workspace(如 pnpm/yarn)共享依赖包, 减少磁盘占用、加快安装速度。
  • 模块间联动开发方便: 当多个模块有关联时, 可以直接联调, 无需手动发布中间版本, 改动立即生效。
  • 一致性更高: 所有子项目共用同一套配置(eslinttsconfigprettier 等) 风格统一、维护简单。
  • 自动化工具生态完善: 结合 pnpm workspacelernanxturborepo 等工具可轻松实现增量构建、缓存优化、按包发布、版本追踪等高级能力。
  1. multirepos 架构缺点
  • 仓库体积庞大: 所有模块都在一个仓库中, 代码量和依赖文件如果较多, 仓库就会变得庞大, 首次 clone、安装依赖的成本较高。
  • 权限与访问控制困难: 如果不同模块由不同团队进行维护, 那么将这些模块放在一个仓库中在权限划分上就比较麻烦(需用借助额外工具来实现)。
  • 构建复杂度提升: 需要配置高效的构建缓存与任务调度系统,否则容易导致全量构建慢。
  • Git 历史和分支管理复杂: 所有改动都在同一仓库中,提交记录庞大,版本回溯或分支策略需要严格规范。

二、Npm Workspaces

npm workspacenpm 7 引入的一个新功能, 它允许我们直接在一个单项目中同时管理多个独立的子项目(依赖包), 可以方便的集中管理它们之间的依赖, 减少重复并提升可维护性。

Npm 项目中, 我们也正是通过 Npm Workspaces 来实现 Monorepos 架构, 下面我们就简单介绍下 Npm Workspaces 的使用。

2.1 项目初始化

  1. 首先我们先初始化一个 Npm 项目:
npm init -y

初始化完成后, 会在项目跟目录下创建一个 package.json 文件

image

  1. 创建子包: 下面我们来创建两个子包 packages/a 以及 app/b
npm init -y -w packages/a
npm init -y -w app/b

执行上面代码将会:

  • 分别在 apppackages 目录下初始化两个子包(npm 项目)
  • 同时会在根 package.json 中, workspaces 配置中添加新的子包
  • 同时还会在根目录中, 将子包以软连接的形式安装到 node_modules

image

  1. 手动创建子包: 我们知道了调用 npm init -y -w app/b 会做哪些处理, 那么自然, 我们完全也可以自动手动添加子包, 下面我们来新增一个子包 packages/c
  • 新增目录 packages/c
  • 在目录 packages/c 下, 通过 npm init -y 初始化一个 npm 项目
  • package.json 中, workspaces 配置新增配置项 packages/c
  • 最后还需要在根目录下执行 npm install, 目的是为了在 node_modules 中为每个子包创建新的软连接

image

2.2 为子包安装依赖包

在上文我们使用 npm init -y -w app/b 添加来子包:

  • 该命令中 -w 参数用于指定子包, 而 -w app/b 其实是 --workspace=app/b 的缩写
  • npm init -y 则是我们要在子包中进行的操作

同理, 如果我们要为子包 app/b 安装依赖 dayjs 就可以直接在项目根目录执行 npm install dayjs -w app/b 即可

npm install dayjs -w app/b

而执行 npm install dayjs -w app/b 会自动完成:

  • 在顶层, 也就是根目录安装依赖 dayjs
  • 同时会更新子包 app/bpackage.json, 新增依赖包配置

image

2.3 使用子包

正如上文所说, 其实当我们执行 npm install 或者通过 npm init -y -w [子包] 初始化子包时, 都会在项目根目录 node_modules 中, 为每个子包创建一个软链接, 如下图所示最右边的箭头表示该依赖包是个软链接

image

而有了这个软链接, 就可以帮助我们在不同子包中进行相互引用: 如下图所示, 我们在子包 packages/c 中, 直接引用了子包 packages/bapp/b

image

当然这边每个子包的目录名不重要, 重点是每个子包中 package.json 中定义的 name 值, 也就是包名称, 这个包名称我们是可以随意修改的, 只要不重复继续, 包名修改后重新 npm install 即可(更新软链接)

image

对了, 项目根 package.json 中, workspaces 配置的是子包的路径, 所以只是改子包的名称, 这边是不需要动的。同时这边其实也可以直接使用通配符 *, 如下所示, packagesapp 目录下的所有项目都将会被作为子包进行加载

{
  ...
+ "workspaces": [
+   "packages/*",
+   "app/*"
+ ]
}

三、pnpm workspaces

除了使用官方 npm workspaces, 我们还可以使用第三方包管理工具, 比如 yarn 或者 pnpm 它们都实现了各种的一套 workspaces 协议, 下面我们简单介绍下 pnpm workspaces 至于 yarn 就不展开了

3.1 初始化项目

pnpm 中初始化相对来说会比较麻烦点, 一切都需要手动操作

  1. 初始化根项目: 在项目根目录初始化一个 npm 项目
pnpm init
  1. 创建 pnpm workspace 配置文件: 根目录创建配置文件 pnpm-workspace.yaml 所有和 workspace 相关的配置都在这边定义

image

  1. 手动创建 & 初始化子包: 是的这边子包需要我们手动在对应子包目录下进行创建、初始化(npm init y)

image

3.2 添加依赖

  1. 根目录安装依赖: 对于公用的依赖可以直接在项目根目录中进行安装, 这边可以直接在根目录执行 pnpm add 来进行安装, 或者在任意位置(子包、根目录)通过 -w 参数来安装根依赖包, 这里的 -w = workspace root
pnpm add react # 根目录执行
pnpm add react -w # 任意位置执行都行, 会在根目录安装依赖
  1. 为子包安装依赖: 除了直接在子包内通过执行 pnpm add 来安装项目依赖外, 其实我们还可以使用 --filter 来为子包安装依赖, 通过 --filter 就不限制目录了
pnpm add react # 子包内执行
pnpm add react --filter a # 任意位置执行都行, 通过「--filter a」来为子包「a」安装依赖

3.3 使用子包

在开始前我们需要了解下 workspace: 协议, 该协议是 pnpmmonorepo 中用于引用本地 workspace 子包的特殊语法。当我们在 monorepo 中, 一个子包依赖另一个包时, 就可以使用 workspace: 前缀来声明这是一个内部依赖。在 本地开发pnpm 会自动帮我们软链接到本地对应的子包, 而在 发布时 则会自动替换为实际的子依赖包。

如下所示:

  • 通过 --filter 来为某个子包安装依赖
  • 'c@workspace:*' 表示安装 workspace: 协议的子包 c, 需要注意的是这边要加引号 ''
pnpm add 'c@workspace:*' --filter a 

image

上文用的是 workspace:* 表示使用任意版本, 也就是最新的子包, 自然这边我们也可以限制子包的版本, 规则其实和 npm 依赖包版本号的规则差不多

# 不同的 workspace 版本协议
pnpm --filter pkg-b add 'pkg-a@workspace:*'   # 任意版本
pnpm --filter pkg-b add 'pkg-a@workspace:^'   # 匹配主版本
pnpm --filter pkg-b add 'pkg-a@workspace:~'   # 匹配次版本
pnpm --filter pkg-b add 'pkg-a@workspace:^1.0.0'  # 指定版本范围

四、参考

从“截图大法”到真实交互:B站专栏视频卡的技术革命

2026年3月12日 12:02

背景:从“伪造”卡片到真实交互

回望 B 站富文本编辑器的演进史,我们经历了一个从“无”到“有”,再从“有”到“优”的过程。在 UEditor 时代,我们解决了基本的文本编辑需求;在 Quill 时代,我们引入了 Delta 数据模型。

然而,在 Quill 时期,面对视频卡等复杂卡片,受限于 Quill 对 BlockNode 缺乏完善的支持,被迫采用“ Canvas 绘图伪造卡片” 的障眼法。今天,拥抱 ProseMirror 生态,这套“ 截图大法” 终于画上句号,取而代之的是支持真实交互的卡片渲染系统。

这场从“伪造”到“真实”的革命,不仅是一次技术栈的迁移,更是一次对技术债的降维打击。今天就带大家深入代码底层,看看我们是如何填平这个深坑的。

第一章:旧世界——那些年,我们用 Canvas “画”出来的视频卡

1.1 用户视角的“灵异”体验

你可能经历过这样的场景:在专栏里粘贴了一个视频链接,然后看着 Loading 转圈圈,心里默数两秒,“啪”的一下,编辑器里出现了一个视频卡片。

看起来很美?别急着夸。当你试图点击播放时,发现它毫无反应;当你试图修改标题时,发现根本选不中文字。这哪里是视频卡片,这分明就是一张死图!

是的,这就是我们不得不采用的 “Canvas 截图大法”

1.2 技术黑幕:Canvas 的“障眼法”

为了在 Quill 这个不支持复杂 Block Node 的编辑器里塞进一个视频卡,我们当年可是绞尽脑汁,最终设计了一套后续发现极其痛苦的 html2canvas 截图链路:

1. 隐式渲染: 在浏览器可视区域外(看不见的地方),用 HTML 偷偷画一个临时的卡片 DOM。

2. Canvas 截图: 调用 html2canvas 咔嚓一下,把这个 DOM 变成 Canvas。为了保证清晰度,通常需要设置 scale: 4

3. 图片生成: 将 Canvas 导出为 Base64 图片。

4. 上传替换: 把图片上传到 CDN,最后在编辑器里插一个静态的 <img> 标签。

图片

1.3 无法回避的四大痛点

说实话,每次写这段代码时,我的内心都是崩溃的。这种做法虽然暂时解决了跨平台兼容问题,但代价是沉重的:

  • 交互性丧失(Interactive Loss): 这仅仅是一张死图。所谓“所见即所得”其实是“所见即图片”。

  • 性能黑洞: 整个“API请求 → 绘制 → 截图 → 上传”的链路平均耗时 2秒 以上。严重打断写作心流。

  • 数据死锁: 卡片上的播放量、弹幕数永远停留在插入的那一刻。如果视频后续爆火,卡片信息也不会更新,甚至误导读者。

  • 存储浪费: 每一张生成的卡片图片都需要占用 CDN 空间,随着文章数量增长,这是巨大的隐形资源浪费。

第二章:病根诊断——当 Quill 遇上视频卡

为什么 Quill 做不好视频卡?这得从它的底层基因说起。

2.1 Delta 像“收银小票”,ProseMirror 像“乐高积木”

Quill 使用的是 Delta 数据模型。Delta 本质上是一个线性的操作记录,就像一张长长的收银小票🧾。

截屏2026-03-11 16.36.42.png

你想在这张薄薄的小票中间塞进一个立体、复杂的“视频播放器盒子”?太难了!Delta 天生就是扁平的,它很难描述复杂的嵌套结构。我们被迫使用的“截图大法”,其实就是在小票上画了个电视机的图案,而不是真的放了个电视机。

而 ProseMirror 使用的是 Document Model(文档树),它就像是乐高积木

// ProseMirror Tree: 结构化的树形数据
{  
  "type": "doc",  
  "content": [ 
    { "type": "paragraph", "content": [{ "type": "text", "text": "Hello" }] },
    {  
      "type": "videoCard", // 独立的块级节点   
      "attrs": { "bvid": "BV1xx..." },    
      "content": [] // 可以继续嵌套其他节点  
    }
  ]
}

你可以搭建一个名为"视频卡"的积木块,然后在里面随意嵌套"标题积木"、"封面积木"甚至"播放器积木"。这种树状结构天然就支持复杂的 Block Node(块级节点)

图片

2.2 技术对比表:为什么我们要换枪?

图片

2.3 选型博弈: 为什么是TipTap+ProseMirror?

在决定彻底抛弃 Quill 之前,我们对市面上的富文本技术方案进行了一次深度摸底。从底层技术演进来看,Web 富文本编辑器主要经历了三个维度的跃迁:

  • Level 0(强依赖 DOM):完全基于原生的 contenteditable,典型如 UEditor。技术门槛低,但跨端表现极其不可控。
  • Level 1(视图即数据):拥有自身抽象的数据模型,但依然依赖原生 DOM 渲染。典型如 Quill、Slate、Draft.js 及 ProseMirror。
  • Level 2(自排版自渲染):彻底抛弃 contenteditable,利用 Canvas/SVG 自研排版引擎,典型如 Google Docs。

从 B 站图文生态(专栏、动态)的实际业务诉求出发,L2 方案属于严重的性能与研发成本过剩,而 L0 方案早已无法满足现代组件的交互需求。因此,我们的主战场锁定在了 L1 级别的抽象数据模型方案。

在 L1 的终极对决中,面对生态优秀的 Lexical 和老牌的 Draft.js(往往强绑定 React),以及底层极其强大但 API 学习曲线陡峭的 ProseMirror,我们最终选择了 Tiptap + ProseMirror 的组合拳。

Tiptap 作为基于 ProseMirror 构建的 Headless(无头)框架,完美继承了其强大的文档树(Document Tree)和 Schema 规范,同时提供了一层极其优雅的 API 封装。这套“底层稳健兜底,上层开发丝滑”的设计,斩断了特定 UI 框架的强依赖,成为我们完成这次降维打击的最优解。

第三章:ProseMirror 核心实战——架构重组

既然痛点找准了,那就开干。我们设计了全新的 “编辑器-组件分离” 架构,利用 ProseMirror 强大的 NodeView 机制,彻底重构了卡片系统。

图片

3.1 架构革新:编辑器与组件的“分家”

在这个架构中,编辑器不再负责具体的 UI 渲染,而是专注于文档结构的管理。NodeView 充当了“桥接”的角色。

3.2 核心设计 I:Schema 定义(给积木定规矩)

首先,我们需要告诉编辑器,“视频卡”这个积木长什么样,有什么属性。

// schema/video-card.ts
const VideoCard = Node.create({
  name'videoCard',  
  group'block',     // 声明我是块级节点  
  atomtrue,         // 💡 关键点:原子化  
  draggabletrue,    // 可拖拽    
  
  // 定义数据属性  
  addAttributes() {
    return { 
      card_style: { defaultCardStyle.NORMAL },   // 卡片风格 
      info: { default: {} },             // 业务数据   
      status: { default'loading' }          // loading | loaded | error  
    }
  },    
  
  // 解析规则:怎么从 HTML 读出来
  parseHTML() { 
    return [{ tag'div[data-type="video-card"]' }] 
  },
  
  // 渲染规则:怎么存成 HTML  
  renderHTML({ node }) {  
    return ['div', { 'data-type''video-card''data-bvid': node.attrs.bvid }, 0]  
  }
})

🧐 Code Review:

  • atom: true 是这里的神来之笔。它告诉 ProseMirror:“这个节点是一个整体,光标不能跑进去,要么选中整个卡片,要么不选”。这完美符合卡片的交互逻辑,避免了光标在卡片内部乱窜的尴尬。
  • addAttributes 定义了卡片的数据模型,这些数据会直接映射到 UI 组件的 Props 中。

3.3 核心设计 II:NodeView(连接两个世界的桥梁)

接下来是重头戏 —— NodeView。它是连接 ProseMirror 数据层和 UI 渲染层的桥梁。我们要在这里把 UI组件挂载上去。

截屏2026-03-11 16.43.54.png

🧐 Code Review:

  • 这段代码实现了真正的“所编写即所得”。你在编辑器里看到的组件,就是发布后读者看到的组件,连代码都是同一份!
  • 通过事件监听,组件内部的操作(如点击删除、重试加载)可以反向控制编辑器的数据状态。

第四章:硬核填坑——从“能用”到“好用”

重构之路从不平坦,为了让这个系统真正“好用”,我们解决了不少棘手的工程问题。

4.1 隐秘的代价:插入极速,但运行态呢?

技术世界没有银弹。当我们为“极速插入”和“真实交互”欢呼时,隐秘的代价也随之而来——展示态(运行时)的性能崩盘风险。旧方案虽然插入慢,但在运行时只是一张死图,文章里塞入 50 个卡片依然能丝滑滚动。但新方案的每一个视频卡,都是一个包含了复杂 DOM 树、状态机、播放器的真实组件。如果放任不管,十几个播放器同时驻留内存,浏览器会直接崩溃 。

为了兜住这层底线,我们在架构上设计了两大“降落伞”:

4.2 把播放器“装”进编辑器(CardPlayer 管理器)

我们引入了双视图自由切换模式与 CardPlayer 实例池 :

  • NORMAL 模式:普通小卡,仅展示封面和元信息,不播放视频 。

  • ADVANCED 模式:点击后直接展开内嵌播放器,通过改变 card_style 属性无缝切换,受 CardPlayer 管理器控制 。

截屏2026-03-11 16.45.20.png

图片

4.2 极致性能优化(批量解析 + 三级缓存)

如果用户一次性粘贴 50 个链接怎么办?发 50 个 API 请求?服务器会报警的 !我们重构了链接解析层,引入了批量验证和共享缓存 。

截屏2026-03-11 16.46.03.png

🧐 Code Review:

  • 这里基于防抖,100ms 内的粘贴操作会被合并为一个请求(Batch API)。
  • 缓存是全局共享的。当用户在编辑器内反复撤销、重做或拖拽卡片时,直接命中缓存,实现 0 延迟渲染。

图片图片

第五章:核心创新点总结

5.1 智能链接解析与双向转换

我们不仅支持从“链接”变“卡片”,还支持完美的逆向转换。通过 resource_url 字段保存用户原始输入信息,确保数据 100% 完整。

图片

截屏2026-03-11 16.47.11.png

5.2 模板策略模式

我们抽象了 BaseCard 基类,利用策略模式处理不同类型的卡片渲染。无论是视频卡、专栏卡还是投票卡,都复用了同一套生命周期管理逻辑(mount → load → update → destroy),代码复用率提升了 60%

截屏2026-03-11 16.48.34.png

5.3 历史包袱的优雅着陆:旧专栏兼容

新架构固然强大,但对于一个拥有海量存量数据的平台来说,绝不能以牺牲历史数据为代价。同时,新编辑器生产的内容也必须完美融入现有的内容分发基建。为此,我们围绕 Opus 协议(B站图文统一发布协议) 设计了一套向下兼容历史、向上打通分发的全局策略:

  • 战略锚点:基于 Opus 图文统一发布协议的链路闭环

Opus 是我们内部定义的图文统一发布协议。为了无缝接入现有的动态分发渠道,确保高质量图文能够高效流转,新版编辑器在最终发布时,会将所有文档树数据全量转换为 Opus 格式。这不仅统一了底层标准,也让生产端到分发端的链路彻底打通。

  • 首选路径:历史专栏优先转出 Opus 无损还原

针对过去沉淀的千万级历史专栏,我们已经在服务端优先尝试将其向 Opus 格式进行转出与迁移。由于 Opus 是我们的标准协议,当这些转换成功的数据进入新版编辑器时,能够通过 Schema 的精准映射,100% 无损还原为内部的 Document Tree,让老文章直接享受最纯粹的组件化编辑体验。

  • 柔性兜底:不支持迁移场景的 H5 动态解析

然而,总有一些极其古老(例如夹杂着 UEditor 时代“野生标签”)且无法安全迁移为 Opus 格式的富文本黑盒。面对这些“硬骨头”,我们并没有采用高风险的“强洗数据”,而是让新版编辑器利用加载 H5 内容的方式进行动态兜底。通过触发节点中预设的 parseHTML 规则,在浏览器端实时将陈旧的 HTML 代码“翻译”成全新的规范化 Block Node,确保再老的专栏也能在新编辑器中顺利“复活”并进行二次编辑。

第六章:效果实测与总结

通过这次架构升级,我们将“插卡”这一高频操作的体验提升到了新的维度 。但在亮眼的数据背后,我们也完成了一次经典的工程性能博弈。来看一组真实的对比数据 :

图片图片

结论:走向“应用级”文档

从 Quill 到 ProseMirror 的迁移,不仅仅是更换了一个编辑器内核,更是我们对文档理解的一次升级。

文档不再只是静态内容的载体,而是动态应用的容器。

通过 Tiptap + ProseMirror 的现代化技术栈,我们成功将“低保真”的绘图式卡片,进化为具备完整生命周期、状态管理和复杂交互的“应用级”组件。这不仅解决了当下的性能痛点,更为未来引入投票、互动游戏等更复杂的业务卡片奠定了坚实的基础。

我们终于可以说:在 B 站的专栏编辑器里,你看到的,就是真实的 🎉(WYSIWYG)

-End-

作者丨泯泷

从 V8 引擎看 JS 代码是如何一步步变成机器指令的

作者 天涯学馆
2026年3月12日 11:15

今天咱们要深入 V8 引擎的“心脏”,看看一行 JavaScript 代码(比如 function add(a, b) { return a + b })是如何被“翻译”成 CPU 能懂的机器指令的。

这个过程涉及 解析(Parsing)、抽象语法树(AST)、字节码生成、JIT 优化编译 等核心环节。我会用 “代码示例+流程拆解+关键组件讲解” 的方式,带你从“输入代码”到“机器执行”全程跟踪,彻底搞懂 V8 的工作原理。

前置知识:V8 引擎的核心组件

在开始前,先明确 V8 引擎的几个关键“角色”(简化版):

组件 职责 关键产出物
解析器(Parser) 将 JS 代码文本转换为结构化的抽象语法树(AST) AST(抽象语法树)
解释器(Ignition) 读取 AST 并生成字节码(Bytecode),快速启动执行 字节码(轻量级中间代码)
优化编译器(TurboFan) 监控字节码执行,对“热点代码”(频繁执行的代码)生成优化的机器码 优化的机器码(高性能二进制指令)
执行引擎 执行字节码或机器码,操作内存、调用栈等底层资源 最终计算结果

一句话总结
JS 代码 → 解析器 → AST → 解释器(字节码)→ 优化编译器(机器码)→ 执行引擎(运行结果)。

第一步:解析(Parsing)——从代码文本到 AST

什么是 AST?

AST(Abstract Syntax Tree,抽象语法树)是一种 用树状结构表示代码语法结构 的数据。每个节点代表代码中的一个语法元素(如变量、函数、表达式)。

举个栗子
对于代码 function add(a, b) { return a + b },它的 AST 结构大致如下(用文字描述):

Program(根节点)
└── FunctionDeclaration(函数声明)
    ├── id: Identifier(函数名 "add")
    ├── params: FormalParameters(参数列表)
    │   ├── Identifier(参数 "a")
    │   └── Identifier(参数 "b")
    └── body: BlockStatement(函数体)
        └── ReturnStatement(返回语句)
            └── BinaryExpression(加法表达式)
                ├── left: Identifier(变量 "a")
                └── right: Identifier(变量 "b")

解析器如何生成 AST?

解析器的工作分为两步:词法分析(Lexical Analysis)语法分析(Syntactic Analysis)

(1)词法分析:将代码拆分为“词法单元”(Tokens)

词法分析器(Tokenizer)会将代码文本按“语法规则”切割成最小的有意义单元(Tokens)。例如:

function add(a, b) { return a + b }

会被拆分为以下 Tokens(简化版):

[ 'function', 'add', '(', 'a', ',', 'b', ')', '{', 'return', 'a', '+', 'b', '}' ]

(2)语法分析:将 Tokens 转换为 AST

语法分析器(Parser)根据 JS 语法规则(如 ECMAScript 标准),将 Tokens 组织成树状结构的 AST。如果代码语法错误(如少括号),这一步会抛出错误。

代码演示:用 V8 的解析器生成 AST
实际开发中,可以用 Chrome DevTools 的 ConsoleSources 面板查看 AST(需开启“Enable AST visualization”)。例如,输入以下代码并调试:

function add(a, b) { return a + b; }

DevTools 会显示类似以下的 AST 结构(简化):

▸ FunctionDeclaration {
  id: Identifier { name: 'add' },
  params: [
    Identifier { name: 'a' },
    Identifier { name: 'b' }
  ],
  body: BlockStatement {
    body: [
      ReturnStatement {
        argument: BinaryExpression {
          operator: '+',
          left: Identifier { name: 'a' },
          right: Identifier { name: 'b' }
        }
      }
    ]
  }
}

第二步:解释执行(Ignition)——从 AST 到字节码

为什么需要字节码?

直接将 AST 转换为机器码效率太低(需要处理平台差异、优化成本高)。因此,V8 选择先由解释器(Ignition)将 AST 转换为 字节码(一种轻量级的中间代码),再执行字节码。

字节码的特点:

  • 跨平台:不依赖具体 CPU 架构(如 x64、ARM);
  • 体积小:比机器码更紧凑,减少内存占用;
  • 快速生成:解释器可以快速启动,避免长时间编译等待。

解释器如何生成字节码?

Ignition 解释器会遍历 AST,并根据 V8 内置的“字节码指令集”(类似 CPU 的汇编指令,但更抽象)生成字节码。

举个栗子
对于 add(1, 2) 的调用,AST 中的 CallExpression 节点会被 Ignition 转换为以下字节码(简化):

PushNumber 1       // 将数字 1 压入栈
PushNumber 2       // 将数字 2 压入栈
CallFunction add   // 调用函数 add
Return             // 返回结果

字节码的执行流程

解释器执行字节码时,会维护一个 执行上下文栈(Call Stack),每个上下文包含变量环境、作用域链等信息。例如,调用 add(1, 2) 时:

  1. 压入全局执行上下文;
  2. 压入 add 函数执行上下文;
  3. 执行加法操作,将结果(3)压入栈;
  4. 弹出 add 上下文,返回结果到全局上下文。

第三步:JIT 优化(TurboFan)——从字节码到机器码

1. 为什么需要优化?

解释器执行字节码的速度较慢(相比机器码)。对于“热点代码”(如被重复调用多次的函数),V8 会用优化编译器(TurboFan)将其转换为 优化的机器码,大幅提升执行效率。

2. TurboFan 如何优化?

TurboFan 的核心是 类型反馈(Type Feedback):通过监控字节码的执行,收集变量的类型信息(如 a 总是数字),然后基于这些信息生成高度优化的机器码。

举个栗子
假设有一个函数 function sum(a, b) { return a + b },如果它被多次调用且 ab 总是数字:

  • 初始执行时,Ignition 生成通用字节码(处理所有可能的类型,如数字、字符串);
  • TurboFan 监控到 ab 始终是数字,生成优化的机器码(直接使用 CPU 的加法指令 ADD);
  • 后续调用该函数时,直接执行优化的机器码,跳过解释器的字节码步骤。

3. 优化的条件与限制

TurboFan 的优化需要满足 类型稳定(变量的类型不会意外变化)。如果类型发生变化(如 a 有时是数字,有时是字符串),V8 会触发 去优化(Deoptimization)

  • 停止使用优化的机器码;
  • 回退到解释器执行,并重新收集类型信息。

代码演示:类型稳定与去优化

function add(a, b) {
  return a + b;
}

// 第一次调用:类型稳定(数字)
add(1, 2);  // TurboFan 可能优化为机器码

// 第二次调用:类型变化(字符串)
add('1', '2');  // 触发去优化,回退到解释器

第四步:执行机器码——CPU 如何“理解”指令

1. 机器码的本质

机器码是 CPU 能直接执行的二进制指令(如 10001011),对应 CPU 的底层操作(如加减乘除、内存读写)。

2. 从字节码到机器码的转换

TurboFan 优化编译器会将字节码转换为与 CPU 架构匹配的机器码。例如,x64 架构的 CPU 执行 ADD 指令时,机器码可能是 01000000(具体二进制由 CPU 指令集决定)。

3. 执行流程示例

add(1, 2) 的优化机器码为例,CPU 会依次执行以下步骤:

  1. 从内存中读取 a(值为 1)和 b(值为 2);
  2. 执行 ADD 指令,将两个数相加(结果为 3);
  3. 将结果存入寄存器或内存;
  4. 返回结果。

完整流程总结

我们用一个完整的例子,串联所有步骤:

代码输入

function add(a, b) {
  return a + b;
}
console.log(add(1, 2));  // 输出 3

步骤 1:解析器生成 AST

Program
└── FunctionDeclaration (add)
    ├── id: "add"
    ├── params: [a, b]
    └── body: ReturnStatement (a + b)

步骤 2:Ignition 生成字节码

PushNumber 1       // 压入 1
PushNumber 2       // 压入 2
Add                // 执行加法(a + b)
Return             // 返回结果

步骤 3:TurboFan 优化为机器码(x64 示例)

; 假设 a 在寄存器 rax,b 在寄存器 rbx
mov rax, 1         ; 将 1 存入 rax
mov rbx, 2         ; 将 2 存入 rbx
add rax, rbx       ; rax = rax + rbx(结果 3)
ret                ; 返回 rax 的值

步骤 4:CPU 执行机器码

CPU 按顺序执行上述机器指令,最终将结果 3 写入内存,并输出到控制台。

V8 引擎执行流程全景图(Mermaid 架构图)

graph TD
    A[JS 代码文本] --> B[解析器 Parser]
    B --> C[抽象语法树 AST]
    C --> D[解释器 Ignition]
    D --> E[字节码 Bytecode]
    E --> F{是否热点代码?}
    F -->|是| G[优化编译器 TurboFan]
    F -->|否| H[执行引擎]
    G --> I[优化的机器码 Machine Code]
    I --> H
    H --> J[CPU 执行]
    E --> K[执行上下文栈]
    K --> H
    G --> L[类型反馈 Type Feedback]
    L --> G
    H --> M[去优化 Deoptimization]
    M --> D

图解说明

  • 横向流程:JS 代码从输入到最终被 CPU 执行的主路径;
  • 分支逻辑:热点代码触发优化(TurboFan),非热点代码直接由解释器执行;
  • 循环优化:优化后的机器码执行时仍会被监控,若类型变化则回退(去优化)。

分阶段深度解析

我们以一段简单的 JS 代码为例,全程跟踪其执行流程:

function add(a, b) {
  return a + b;
}
console.log(add(1, 2)); // 输出 3

阶段 1:解析器(Parser)——代码文本 → AST

关键步骤:

  1. 词法分析(Lexical Analysis)
    将代码文本按语法规则切割为“词法单元”(Tokens)。例如,function add(a, b) { return a + b } 会被拆分为:
['function', 'add', '(', 'a', ',', 'b', ')', '{', 'return', 'a', '+', 'b', '}']
  1. 语法分析(Syntactic Analysis)
    根据 ECMAScript 语法规则,将 Tokens 转换为树状结构的 AST。AST 是代码的“结构化表示”,后续所有操作(如优化、执行)都基于此。

Mermaid 子图:AST 结构

graph TD
    Root[Program] --> FuncDecl[FunctionDeclaration]
    FuncDecl --> Id[Identifier: add]
    FuncDecl --> Params[FormalParameters]
    Params --> ParamA[Identifier: a]
    Params --> ParamB[Identifier: b]
    FuncDecl --> Body[BlockStatement]
    Body --> ReturnStmt[ReturnStatement]
    ReturnStmt --> BinExpr[BinaryExpression: +]
    BinExpr --> Left[Identifier: a]
    BinExpr --> Right[Identifier: b]

总结:解析器输出 AST,这是后续所有处理的“蓝图”。

阶段 2:解释器(Ignition)——AST → 字节码

关键步骤:

  1. 遍历 AST
    Ignition 解释器通过深度优先遍历(DFS)访问 AST 的每个节点(如 FunctionDeclarationBinaryExpression)。

  2. 生成字节码
    根据 AST 节点的类型,对照 V8 内置的“字节码指令集”生成对应的字节码。例如:

    • FunctionDeclaration 节点生成“创建函数对象”的字节码;
    • BinaryExpression (+) 节点生成“加法操作”的字节码。

字节码示例(简化):

// 函数 add 的字节码
PushNumber 1       ; 将数字 1 压入栈
PushNumber 2       ; 将数字 2 压入栈
Add                ; 执行加法(弹出栈顶两个数,结果压回)
Return             ; 返回结果

执行上下文栈:

解释器执行字节码时,会维护一个 执行上下文栈(Call Stack),用于管理函数调用的状态(如变量环境、作用域链)。例如:

调用栈状态:
- 全局执行上下文(Global)
  └── add 函数执行上下文(Activation)
      ├── 参数:a=1, b=2
      ├── 局部变量:无
      └── 返回地址:全局上下文

总结:解释器快速生成字节码并执行,避免了直接编译机器码的高开销。

阶段 3:优化编译器(TurboFan)——字节码 → 优化机器码

关键概念:热点代码(Hot Code)

“热点代码”指被频繁执行的代码(如循环、高频函数)。V8 会监控字节码的执行次数,当达到阈值(如 10000 次)时,触发 TurboFan 优化。

优化流程:

  1. 类型反馈(Type Feedback)
    TurboFan 会记录字节码执行过程中变量的类型信息。例如,add 函数的参数 ab 总是被传入数字(number 类型)。
  2. 生成优化机器码
    基于类型反馈,TurboFan 生成高度优化的机器码。例如,若 ab 总是数字,机器码会直接使用 CPU 的 ADD 指令(无需类型检查)。

优化前后对比:

阶段 代码类型 执行逻辑 性能
解释器 通用字节码 处理所有可能的类型(数字、字符串、对象等) 较慢
优化编译器 优化的机器码 仅处理已知类型(如数字) 接近 C 语言

去优化(Deoptimization):

如果变量类型发生变化(如 add('1', '2') 传入字符串),V8 会触发去优化:

  1. 停止使用优化的机器码;
  2. 回退到解释器执行,并重新收集类型信息。

总结:TurboFan 通过类型反馈生成高效机器码,但依赖类型稳定;类型变化会触发去优化,影响性能。

阶段 4:执行引擎——机器码 → CPU 执行

关键步骤:

  1. 机器码加载
    优化的机器码会被加载到内存中,等待 CPU 执行。
  2. CPU 执行指令
    CPU 按顺序读取机器码的二进制指令,通过寄存器、ALU(算术逻辑单元)等部件完成计算。例如,ADD 指令会指示 CPU 将两个寄存器中的数值相加,结果存入目标寄存器。

示例:add(1, 2) 的机器码执行

假设 x64 架构 CPU 执行以下机器码(简化):

mov rax, 1    ; 将 1 存入寄存器 rax
mov rbx, 2    ; 将 2 存入寄存器 rbx
add rax, rbx  ; rax = rax + rbx(结果 3)
ret           ; 返回 rax 的值(3)

总结:CPU 直接执行机器码,这是 JS 代码最终“跑起来”的物理基础。

完整流程总结(附代码执行路径)

我们用流程图串联所有步骤,并标注关键数据结构:

graph LR
    A[JS 代码] --> B[解析器]
    B --> C[AST: 函数声明+加法表达式]
    C --> D[解释器 Ignition]
    D --> E[字节码: PushNumber/Add/Return]
    E --> F{是否热点代码?}
    F -->|是| G[TurboFan 优化]
    G --> H[类型反馈: a=number, b=number]
    H --> I[优化机器码: mov/add/ret]
    I --> J[执行引擎 → CPU]
    F -->|否| K[执行引擎 → CPU]
    J --> L[输出 3]
    K --> L
    L --> M[控制台打印 3]

关键结论

  • V8 通过“解释执行+JIT 优化”的混合模式,平衡了启动速度和长期性能;
  • 类型稳定是触发优化的关键,编写代码时应尽量避免变量类型频繁变化;
  • 去优化机制保证了代码的健壮性,但也提示开发者需关注类型一致性。

V8 优化的未来趋势

现代 V8 引擎(如 Chrome 120+)在原有流程上增加了更多优化:

  1. 内联缓存(Inline Caches, ICs)
    缓存高频函数的调用结果,避免重复查找(如 obj.x 的属性访问)。
  2. 并行编译
    利用多线程同时生成字节码和优化机器码,缩短启动时间。
  3. 预编译(Precompilation)
    在页面加载时提前编译部分代码(如懒加载的函数),减少运行时延迟。

常见问题

1. 为什么 V8 不直接编译为机器码?

  • 启动速度:解释器(Ignition)可以快速生成字节码并执行,避免长时间编译;
  • 内存占用:字节码比机器码更紧凑,减少内存使用;
  • 跨平台:字节码是平台无关的,而机器码需要针对不同 CPU 架构(x64、ARM)生成。

2. 去优化(Deoptimization)的影响

如果代码频繁触发类型变化(如变量类型不稳定),V8 会频繁去优化,导致性能下降。因此,保持变量类型稳定(如始终使用数字,避免混合类型)是优化 JS 性能的关键。

3. V8 的未来:更智能的优化

现代 V8 引擎(如 Chrome 100+)引入了更多优化技术:

  • 内联缓存(Inline Caches):缓存高频函数的调用结果,减少查找时间;
  • 并行编译:利用多线程同时生成字节码和优化机器码;
  • 预编译(Precompilation):在页面加载时提前编译部分代码,减少运行时延迟。

前端开发如何快速上手安卓APP开发?

作者 叁两
2026年3月12日 10:09

前言

最近要做一个AI项目,需要做一个安卓app,但没有安卓开发,我想了想,还是我来吧,就当学习了。

但从没碰过安卓开发,该如何入手呢?

遇事不决,先问AI!

我问ChatGPT:“前端开发转安卓开发,给我一个快速上手的学习教程。”

他立马给我设计了一个对比学习路径。

学习计划

前端开发者(Vue/JS)转 Android 开发,其实优势很大,因为很多概念是共通的:组件化、状态管理、异步请求、模块化等。

关键是 快速建立 Android 的“开发心智模型” ,再做几个小项目。


一、先理解 Android 开发整体结构

前端开发:

HTML
CSS
JavaScript
浏览器

Android 开发:

XML / Compose  → UI
Kotlin / Java  → 逻辑
Android SDK    → 系统能力
APK            → 打包安装

核心组成:

Activity    → 页面
Fragment    → 页面组件
View        → UI组件
Intent      → 页面跳转
Service     → 后台服务

简单理解:

前端 Android
页面 Activity
组件 Fragment
DOM View
路由 Intent
AJAX Retrofit
LocalStorage SharedPreferences

二、技术选型(建议)

现在 Android 主流技术:

语言:Kotlin
UI:Jetpack Compose
网络:Retrofit
架构:MVVM
异步:Coroutines

技术栈:

Kotlin
Jetpack Compose
MVVM
Retrofit
Room

三、开发环境搭建(1小时)

安装:

1 下载 Android Studio

官网
developer.android.com/studio

安装后会自动安装:

Android SDK
Android Emulator
Gradle

建议安装版本:

Android Studio Hedgehog 或更新

2 创建项目

打开 Android Studio:

New Project

选择:

Empty Activity

配置:

Language: Kotlin
Minimum SDK: API 24

项目结构:

app
 ├── java/com.example.demo
 │    └── MainActivity.kt
 ├── res
 │    ├── layout
 │    ├── drawable
 │    └── values
 └── AndroidManifest.xml

四、Kotlin 快速入门(2小时)

你会 JS,Kotlin 很容易。

变量

val name = "Echo"     // 常量
var age = 18          // 变量

类似:

const / let

函数

fun sum(a: Int, b: Int): Int {
    return a + b
}

简写:

fun sum(a: Int, b: Int) = a + b

class User(val name: String, val age: Int)

Lambda

类似 JS

val add = { a: Int, b: Int -> a + b }

五、Android 页面开发

1 Activity

MainActivity

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Text("Hello Android")
        }

    }
}

2 UI组件

Compose写法:

@Composable
fun Greeting() {
    Text("Hello World")
}

按钮:

Button(onClick = { println("clicked") }) {
    Text("点击")
}

六、网络请求(调用AI)

添加依赖:

Retrofit

build.gradle:

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

创建 API

interface ApiService {

    @POST("/chat")
    suspend fun chat(
        @Body req: ChatRequest
    ): ChatResponse

}

调用:

val response = api.chat(ChatRequest("你好"))

七、实现一个 AI 助手 APP

功能:

输入一句话
AI返回结果

UI:

+----------------+
| 输入框         |
| [发送]        |
|                |
| AI回答         |
+----------------+

Compose示例:

@Composable
fun ChatPage() {

    var input by remember { mutableStateOf("") }
    var answer by remember { mutableStateOf("") }

    Column {

        TextField(
            value = input,
            onValueChange = { input = it }
        )

        Button(onClick = {
            answer = "AI正在思考..."
        }) {
            Text("发送")
        }

        Text(answer)
    }
}

八、推荐学习资料

官方:

  • Android Developer

推荐书:

《Kotlin从入门到实践》

视频:

  • Google Android Basics

九、给你一个最关键建议

前端转 Android 不要先学太多理论

直接做:

AI助手 APP

做的过程中学:

Activity
Service
权限
网络
UI

效率最高。

2026 年 Next.js 站点的 SEO 优化指南

作者 Moment
2026年3月12日 09:20

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

如果你对 OpenClaw 也感兴趣,也欢迎添加我微信,我拉你进交流群

Next.js 是最流行的 React 框架,被广泛用来构建现代 Web 应用,自带不少开发者未必会用到的能力。搜索形态正在从传统搜索转向"零点击搜索",背后是 Google AI Overview、ChatGPT 等 AI 搜索产品在推动。这些平台会持续抓取站点,用元数据和内容做索引,结构清晰、易于抓取的内容更容易被引用、出现在 AI 结果里。要想在这场变化里站稳,就需要同时为人和 AI 爬虫优化页面与内容。

下面按最佳实践,从页面和内容两方面说说如何做好 SEO 与 GEO。

1. 用 Metadata API 做 SEO

元数据一直负责告诉外界页面的标题和描述。Next.js 用新的 Metadata API 取代了过去的 Head 组件,可以在服务端组件里声明,由服务端渲染输出,爬虫和社交平台抓取时拿到的就是完整、准确的标题与描述,不会出现空白或占位文案。

app/layout.tsx 或单页的 layout.tsx 里导出 metadata 对象即可。下面这段示例覆盖了站点标题模板、默认描述、搜索引擎验证、Open Graph 与 Twitter 卡片、以及规范链接。

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    template: "%s",
    default:
      "Texavor - GEO & Content Optimization Platform for Writers, Marketers & Developers",
  },
  description:
    "Build your AI content workflow. Discover topics across ChatGPT and Perplexity, and generate data-backed briefs.",
  verification: {
    google: "R53D-JHFSD93JDhjhds_ei99JFADSF", // 示例用占位,替换为你在 Search Console 获得的验证码
  },
  openGraph: {
    title:
      "Texavor - GEO & Content Optimization Platform for Writers, Marketers & Developers",
    description:
      "Build your AI content workflow. Discover topics across ChatGPT and Perplexity, and generate data-backed briefs.",
    // images: "/easywriteOpenGraph.png",
  },
  twitter: {
    card: "summary_large_image",
    title:
      "Texavor - GEO & Content Optimization Platform for Writers, Marketers & Developers",
    description:
      "Build your AI content workflow. Discover topics across ChatGPT and Perplexity, and generate data-backed briefs.",
    // images: "/easywriteOpenGraph.png",
  },
  alternates: {
    canonical: "/",
  },
};

可以配置的内容包括:titledescription(当前页的标题和描述)、openGraph(在 Facebook、LinkedIn 等社交平台分享时的预览信息)、twitter(Twitter 卡片类型、图片、标题、描述等)、canonical(规范地址,用于转载或重复内容时指向原始出处,也可以把当前页设为自己的 canonical)、verification(向搜索引擎提交的站长验证标签,会变成 head 里的 meta,可用来在 Google Search Console、Bing Webmaster、Yandex 等平台验证站点)。需要按请求动态生成元数据时,可在同页导出异步函数 generateMetadata,接收 paramssearchParams 等参数,返回结构相同的 Metadata 对象即可。

2. 使用服务端渲染(SSR)

爬虫更希望拿到"已经渲染好的"完整 HTML,而不是先看到加载态。用服务端渲染就不会先出一屏 loading,首屏 HTML 里已经包含主要内容,对收录和 AI 抓取都更友好。

Next.js App Router 下有三种常见用法,可按页面特性选一种。

  • SSR:每次请求在服务端渲染页面,把带数据的 HTML 直接返回,适合内容经常变的页面(例如带实时数据的仪表盘、个性化推荐)。
  • SSG:构建时生成 HTML,之后每次请求都直接返回这份静态页,适合几乎不变的页面,例如法律声明、关于我们、联系我们。
  • ISR:在构建时生成并缓存页面,请求时先返回缓存,再通过 revalidate 在指定时间后重新生成,适合博客这类更新有节奏的页面。

如下图所示。

20260312090838

图里把 SSR 每次请求渲染、SSG 构建时生成、ISR 先缓存再按时间重建的差异和适用场景都画出来了。看完可按页面类型选一种。

在页面文件顶部导出 revalidate,即可为该路由开启 ISR。下面的 3600 表示该页最多缓存 1 小时,超过后下次请求会触发重新生成。

export const revalidate = 3600;

除了 SEO,在 Vercel 等平台还能减轻服务器压力、控制成本。静态或 ISR 页面走 CDN,动态请求才回源。

3. 使用结构化数据(Schema 标记)

Schema 标记(结构化数据)是加在页面里的一段代码,用来帮助搜索引擎和问答引擎理解页面含义。它不仅能提升传统搜索表现,对 AI 搜索和 AI 回答的展示也很重要,因此是站点的重要一环。常见有两种形式:JSON-LD(用独立的 script type="application/ld+json" 注入,便于维护和扩展)、Microdata(用 itemscopeitemtypeitemprop 等属性写在 HTML 标签上,可读性和维护性较差)。当前更推荐 JSON-LD。

在页面组件里根据数据构建 JSON-LD 对象,用 dangerouslySetInnerHTML 写入 script 标签。注意把 < 转成 \u003c,避免被解析成 HTML 标签。

interface Product {
  id: string;
  name: string;
  image: string;
  description: string;
}

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    image: product.image,
    description: product.description,
  };

  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, "\u003c"),
        }}
      />
      {/* 页面其余内容 */}
    </section>
  );
}

Schema 类型可以有很多种。FAQ Schema 适合页面相关常见问题的问答结构。HowTo Schema 适合分步骤教程类页面。Article 与 Author Schema 把内容和作者关联起来,能强化 E-E-A-T(经验、专业、权威、可信)信号,对长文和博客尤其有用。延伸阅读可参考:How to Build Schema Markup for AEO、Common FAQ Schema Mistakes That Hurt Answer Engine Optimization、How to implement JSON-LD in your Next.js application。

4. 优化 Core Web Vitals

Core Web Vitals 是 Google 用来衡量页面体验的指标,主要看加载速度、视觉稳定性和可交互性。常见几个指标是:LCP(Largest Contentful Paint)即最大可见内容(卡片、图片或大段文字)加载完成的时间、CLS(Cumulative Layout Shift)即加载过程中布局发生意外偏移的程度、INP(Interaction to Next Paint)即用户操作(点击、触摸、按键)到页面给出反馈的时间。用 Next.js 的 Image 组件可以自动优化大图,有利于 LCP。

在需要展示图片的组件里引入 next/image,用 srcaltfill(或 width/height)即可。alt 务必填写,对无障碍和图片搜索都有帮助。

import Image from "next/image";

export function ArticleCover({
  image,
  title,
}: {
  image: string;
  title: string;
}) {
  return (
    <div style={{ position: "relative", width: "100%", aspectRatio: "16/9" }}>
      <Image src={image} alt={title} fill />
    </div>
  );
}

使用 next/image 的好处包括:自动压缩图片、懒加载(不可见时不加载,减轻首屏压力)、配合 fill 等属性可适配父容器,方便做响应式布局。在 Vercel 上部署时,next/image 会走 Vercel 的图片优化服务,免费 Hobby 计划每月有 5,000 次优化额度,超出后可能需要升级付费计划。

5. 动态生成 Sitemap

Sitemap 帮助爬虫发现所有可抓取页面,Google Search Console、Bing Webmaster 等都会用它来识别公开页面。在 app 下放一个 sitemap.tssitemap.js,Next.js 会自动把它当作 sitemap 的路由。这是特殊的 Route Handler,默认会被缓存,除非用了动态 API 或动态配置。

下面示例从 getAllPosts 拉取博客列表,把静态首页、博客索引页和每篇文章的 URL 拼成 sitemap 数组。lastModifiedchangeFrequencypriority 可按实际更新频率调整。

import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";

interface Post {
  slug: string;
  updated_at: string;
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const staticPages: MetadataRoute.Sitemap = [
    {
      url: "https://www.texavor.com",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1.0,
    },
    {
      url: "https://www.texavor.com/blog",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 0.8,
    },
  ];

  const posts = await getAllPosts();
  const postPages: MetadataRoute.Sitemap = posts.map((post: Post) => ({
    url: `https://www.texavor.com/blog/${post.slug}`,
    lastModified: new Date(post.updated_at),
    changeFrequency: "weekly",
    priority: 0.8,
  }));

  return [...staticPages, ...postPages];
}

可以把首页、关于我们等静态页和博客等动态页拼在一起,实现整站 sitemap 的动态生成。上线后可在 Search Console 中提交 sitemap URL,便于搜索引擎更快发现新页面。

常见问题

Next.js 对 SEO 的主要好处是什么?

通过 SSR、SSG、ISR 等渲染方式提升首屏 HTML 完整度和加载性能,并内置动态 sitemap、metadatagenerateMetadata 等能力,减少手写 head 和 sitemap 的重复劳动。

如何在 Next.js 里做自定义 sitemap?

app 下新增 sitemap.tssitemap.js,默认导出一个返回 MetadataRoute.Sitemap 数组的异步函数即可。如需使用项目根路径以外的 base URL,可在返回的每条记录里写完整绝对 URL。

Next.js 的 Metadata API 对 SEO 有什么帮助?

可以集中配置 titlemeta descriptioncanonical、Open Graph、Twitter 卡片、验证标签等,全部由服务端输出,爬虫和社交爬虫拿到的都是最终 HTML,不会因为客户端才注入而漏抓。

为什么结构化数据对 SEO 和 AI 搜索很重要?

JSON-LD 等结构化数据能把"这是一篇教程、作者是谁、步骤有哪些"等信息显式告诉搜索引擎和 ChatGPT 等 AI 问答平台,它们更准确理解页面内容后,更容易在摘要或回答中引用你的页面。

Core Web Vitals 如何影响 Next.js 站点的 SEO?

它们衡量加载速度、视觉稳定性和交互响应。用 next/image、SSR 或 ISR、以及合理的资源加载策略把 Core Web Vitals 做好,既能提升体验,也有利于排名和 AI 抓取时的"可读性"评估。

小结

在 Next.js 里做好 SEO,需要技术实现和内容结构一起抓。Metadata API、SSR 与 SSG 及 ISR、JSON-LD 结构化数据以及基于 Core Web Vitals 的性能优化,都能让应用更容易被搜索到、体验更好,并适应下一代搜索与 AI 检索。建议从元数据与 sitemap 先做齐,再按页面类型选好渲染策略,最后补上结构化数据和图片优化,逐步迭代即可。

5年前端,我为什么要all in AI Agent?

作者 wuhen_n
2026年3月13日 17:27

一个普通 Vue/Electron 工程师的转型自白


前言

我是一个普通的前端工程师。5年经验,公司开发框架也就是 Vue2/3 + TS。自己倒腾过Electron,uni-app,能写一些简单的功能模块。没进过大厂,没写过框架,不是什么技术大神。

每天的生活就是:上班写代码,下班刷掘金/知乎/CSDN,周末偶尔看看新技术。工资一般般,饿不死,也富不了。原本以为自己会这样一直干到退休。

直到去年,年奖金只有以往的一半了!


某一天的顿悟

和往常一样,看看手机,刷刷帖子,直到刷到:《35岁程序员裸辞两月,找不到工作,感慨程序员是碗青春饭》。

那一刻,我突然意识到:我也30多了,离35也不远了。

那天晚上,我失眠了。翻来覆去想几个问题:

  • 我的核心竞争力是什么?
  • 如果明天被裁,我能做什么?
  • 5年后,我还在写代码吗?

没有答案。只有焦虑。


一次偶然的机遇

随着公司业务发展,各部门都在鼓励使用 AI,自然而然的,我也分配了相关的任务:处理后端 AI 大模型的流式返回数据。这本来也是很简单的需求:

  • fetchEventSource 发送请求
  • onMessage() 接收并处理数据
  • onError() 处理异常/错误情况

此时,问题来了:后端返回的并不是的 text/event-stream 格式,而是 application/json;在网上查了一圈,知道可以在 onOpen() 里重新发一条 GET 请求来解决这个问题。

然后,又出现新的问题了:后端异常没有正常返回,全部要前端处理!烦躁之下,我打开了DeepSeek(之前只用它写过文档),10 秒钟,它给出了完整的解决方案。

我当时震惊的不是它写出来了,而是它写出来的代码,完全符合我的需求,而且比我预设需求的还要完整!

那一刻,我突然意识到:这东西真方便。

后来的开发中,我开始疯狂用它:

  • 让它生成Vue3组件,它懂得用<script setup>,知道我喜欢用ref而不是reactive
  • 让它写TS类型,它知道我的命名规范(IPropsTResponse
  • 让它解释一段看不懂的配置,它讲得比文档还清楚

我开始想:如果 AI 这么懂前端,那我是不是可以用它做更多事?


AI突围战”

从那天起,我给自己定了一个目标:用4个月时间,成为一个会“玩AI”的前端。

我不学 PyTorch,不学 Transformer,不调模型参数。我的路线很简单:

阶段 目标 时间
第一阶段 学会让AI按我的要求生成代码(Prompt工程) 3周
第二阶段 打通Electron + AI API,做桌面工具 4周
第三阶段 让AI能调用我写的函数(Function Calling) 6周
第四阶段 做一个真正能“干活”的Agent 8周

这4个月,我经历了什么?

第一周:信心满满 → 被 Prompt 折磨(AI 就是不按格式输出)
第二周:第一个 Electron + AI 应用跑通 → 激动得发朋友圈
第四周:Function Calling 总是失败 → 怀疑人生
第六周:第一个能用的 Agent 诞生(帮我处理Git) → 比发工资还开心
第十周:做的一个桌面助手被吐槽“鸡肋” → 反思产品思维
第十六周:现在,我能用Cursor + AI 在30分钟内开发一个小工具

这4个月,我学会了什么?

  • 不是:模型原理、Attention机制、微调技术
  • 而是:怎么让AI听我的话、怎么把AI集成进Electron、怎么让AI调用我的函数、怎么用AI帮我写代码

最重要的是:我不焦虑了,因为我知道,AI时代,前端不仅没有被淘汰,反而有了新的机会。


为什么说前端是AI时代的“天选之子”?

这4个月让我想明白一件事:

AI 是发动机,前端是驾驶舱。发动机很重要,但用户接触的是驾驶舱。

我们的优势是什么?

优势 说明
UI/UX思维 我们知道怎么让AI的“答案”变成好用的“产品”
TypeScript 严格的类型定义,让 AI 生成的代码更可控
Electron经验 桌面端是 AI 的下一站(隐私、离线、本地资源)
工程化能力 组件化、模块化,这些思维在 AI 应用开发中同样重要

我不是在安慰自己,而是在这4个月我见过太多 AI 应用翻车的案例:

  • 技术很强,但 UI 一塌糊涂 → 没人用
  • 模型很准,但交互反人类 → 用户流失
  • 功能很多,但不会产品化 → 自嗨

这些都是我们前端擅长的地方。


这个专栏要写什么

所以,我开了这个专栏。

它不是:

  • ❌ 大模型原理讲解(我不懂)
  • ❌ Python/PyTorch教程(我不会)
  • ❌ 教你成为AI科学家(做不到)

它是:

  • 一个普通前端的真实转型记录(不装逼,只记录自己踩过的坑)
  • 前端视角的AI应用开发实战(Vue3/TS/Electron)
  • Agent和Vibe Coding的落地经验(能跑起来的代码)

结语

前端已死”,这句话从10年前就开始兴起了,“死”了这么多年还没死透,我认为它就是有价值的。现如今,在 AI 的加持,对前端的要求会越来越高,但这条路我会继续走下去,也会把每一步都记录下来。

如果你也在这条路上,欢迎同行。

echart 移动端进行双指缩放时,当放大到最大级别后,手指没有离开屏幕,图表还会自动移动问题修复

作者 我爱切图
2026年3月13日 17:09

echarts.min.js 源码修复

version: 6.0.0
格式化后搜索 zoom: function

未修复源码 zoom 缩放关键部分

zoom: function(t, e, n, i) {
var r = this.range,
o = r.slice(),
a = t.axisModels[0];
if (a) return a = (0 < (e = bI[e](null, [i.originX, i.originY], a, n, t)).signal ? e
.pixelStart + e.pixelLength - e.pixel : e.pixel - e.pixelStart) / e.pixelLength * (
o[1] - o[0]) + o[0], n = Math.max(1 / i.scale, 0), o[0] = (o[0] - a) * n + a, o[1] =
(o[1] - a) * n + a, jC(0, o, [0, 100], 0, (t = this.dataZoomModel
.findRepresentativeAxisProxy().getMinMaxSpan()).minSpan, t.maxSpan), this.range = o,
r[0] !== o[0] || r[1] !== o[1] ? o : void 0
},

已修复源码

zoom: function(t, e, n, i) {
/* t: dataZoomModel (当前缩放组件的模型) */
/* e: coordSysType (坐标系类型,如 'grid') */
/* n: api (ECharts 实例 API) */
/* i: payload (包含缩放比例 scale 和 鼠标位置 originX/Y) */
var r = this.range, // 当前视图百分比范围 [start, end]
o = r.slice(), // 复制一份范围用于计算
a = t.axisModels[0]; // 获取关联的轴模型
if (a) {
/* 计算缩放中心点 a (百分比 0-100) */
/* bI[e](...) 根据像素位置 originX/Y 计算其在坐标轴上的位置比例 */
// a = (0 < (e = bI[e](null, [i.originX, i.originY], a, n, t)).signal ? e.pixelStart + e.pixelLength - e.pixel : e.pixel - e.pixelStart) / e.pixelLength * (o[1] - o[0]) + o[0];

/* 固定缩放中心点 a 为当前显示范围的中点 */
a = (o[0] + o[1]) / 2;

/* n: 缩放后的比例因子 (scale > 1 表示缩小视图/放大倍率) */
n = Math.max(1 / i.scale, 0);

/* s: 计算出的预期起始百分比 (start) */
/* l: 计算出的预期结束百分比 (end) */
var s = (o[0] - a) * n + a,
l = (o[1] - a) * n + a;

/* 防止遇到边界打断缩放 */
if (s < 0) {
l -= s;
s = 0;
}

if (l > 100) {
s -= l - 100;
l = 100;
}

/* u: 获取配置中的最小/最大缩放范围 (minSpan/maxSpan/minValueSpan...) */
var u = this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan(),
h = Math.abs(l - s); // 预期范围的跨度

/* 核心逻辑:如果缩放后的范围超出了 0-100 边界,或者违反了跨度约束,则直接放弃本次操作(返回空) */
/* 这样可以防止 ECharts 内部通过平移来修正边界(即漂移/抖动) */
if (
s < -0.1 ||
l > 100.1 ||
(null != u.minSpan && h < u.minSpan) ||
(null != u.maxSpan && h > u.maxSpan)
) return;

/* 如果符合条件,则更新当前范围并返回结果 */
return o[0] = s, o[1] = l, this.range = o, o
}
},

ps: 个人摸索,如有问题请告知 🙇

通用管理后台组件库-13-页签组件

作者 没想好d
2026年3月13日 16:51

页签组件

说明:实现头部页签相关功能,包括关闭、右键刷新/关闭右侧/关闭其他。

1.实现效果

image.png

2.页签组件HeaderTabs.vue

<template>
  <el-tabs closable type="card" class="my-tabs" v-on="forwardEvents" v-model="modelValue">
    <el-tab-pane v-for="item in data" :key="item.name" :name="item?.name as string">
      <template #label>
        <span class="custom-tab-label" @contextmenu.prevent="handleContextMenu($event, item)">
          {{ item.meta && $t(item.meta?.title as string) }}
        </span>
      </template>
    </el-tab-pane>
  </el-tabs>
  <el-dropdown
    ref="dropdownRef"
    :virtual-ref="triggerRef"
    :show-arrow="false"
    :popper-options="{
      modifiers: [{ name: 'offset', options: { offset: [0, 0] } }]
    }"
    virtual-triggering
    trigger="contextmenu"
    placement="bottom-start"
    @command="commandHandler"
  >
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item command="refresh">刷新</el-dropdown-item>
        <el-dropdown-item command="closeRight">关闭右侧</el-dropdown-item>
        <el-dropdown-item command="closeOther">关闭其他</el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup lang="ts">
import type { TabsPaneContext, TabsProps, DropdownInstance } from 'element-plus'
import type { AppRouteMenuItem } from '../menu/type'
import { forwardEventsUtils } from '@/utils/format'

interface HeaderTabsProps extends Partial<TabsProps> {
  data: AppRouteMenuItem[]
}
type TabPaneName = string | number
type HeaderTabsEvents = {
  tabClick: [pane: TabsPaneContext, event: Event]
  tabChange: [name: TabPaneName]
  tabRemove: [name: TabPaneName]
  tabAdd: []
  edit: [paneName: TabPaneName, action: 'add' | 'remove']
}
type TabAction = 'refresh' | 'closeRight' | 'closeOther'
type contextMenuCommand = {
  tabActions: [paneName: AppRouteMenuItem, action: TabAction]
}
defineProps<HeaderTabsProps>()

// 定义事件名称数组,包含标签页可能触发的所有事件类型
const eventName = ['tabClick', 'tabChange', 'tabRemove', 'tabAdd', 'edit', 'tabActions']

// 使用 defineEmits 定义组件可以触发的事件,类型为 HeaderTabsEvents
const emit = defineEmits<HeaderTabsEvents&contextMenuCommand>()
// 使用 forwardEventsUtils 函数处理事件转发,将 eventName 数组中定义的事件进行转发
const forwardEvents = forwardEventsUtils(emit, eventName)

const modelValue = defineModel<string>()

// 右键页签操作
const dropdownRef = ref<DropdownInstance>()
const position = ref({
  top: 0,
  left: 0,
  bottom: 0,
  right: 0
} as DOMRect)

const triggerRef = ref({
  getBoundingClientRect: () => position.value
})

const contextMenuTab = ref<AppRouteMenuItem>()
const handleContextMenu = (event: MouseEvent, tab: AppRouteMenuItem) => {
  const { clientX, clientY } = event
  position.value = DOMRect.fromRect({
    x: clientX,
    y: clientY
  })
  event.preventDefault()
  dropdownRef.value?.handleOpen()
  contextMenuTab.value = tab
}
const commandHandler = (command: TabAction) => {
  if (contextMenuTab.value) {
    emit('tabActions', contextMenuTab.value, command)
  }
}
</script>

<style scoped lang="scss">
.my-tabs {
  :deep(.el-tabs__header) {
    @apply p-0 m-0 border-b-none pl-1;
    .el-tabs__nav {
      @apply border-none;
    }
  }
  :deep(.el-tabs__item) {
    @apply py-0 h-[34px] px-4 mt-0!;
    border-radius: 4px;
    border: 1px solid var(--el-border-color) !important;
    transition: padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !important;
    margin-right: 5px;
    &.is-active {
      color: var(--el-color-primary) !important;
      background: var(--el-color-primary-light-9) !important;
      border: 1px solid var(--el-color-primary) !important;
    }
  }
  :deep(.el-tabs__nav-next, .el-tabs__nav-prev) {
    line-height: 35px !important;
  }
}
.el-dropdown {
  display: none !important;
}
</style>

3.tab状态存放在store中,tabs.ts

import type { AppRouteMenuItem } from '@/components/menu/type'
import { defineStore } from 'pinia'

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    tabs: [] as AppRouteMenuItem[],
    current: ''
  }),
  actions: {
    addRoute(route: AppRouteMenuItem) {
      if (this.tabs.some((item) => item.name === route.name)) return
      this.tabs.push({ ...route })
    },
    removeRoute(path: string) {
      this.tabs = this.tabs.filter((item) => item.name !== path)
    },
    closeOther(path: string) {
      this.tabs = this.tabs.filter((item) => item.name === path)
    },
    closeRight(path: string) {
      const findIndex = this.tabs.findIndex((item) => item.name === path)
      this.tabs = this.tabs.slice(0, findIndex + 1)
    }
  },
  persist: true
})

4.在默认布局中引用,default.vue

<template>
  <div
    class="w-full h-full position-absolute left-0 top-0 overflow-hidden flex"
    :style="{ '--el-color-primary': setting?.theme }"
  >
    <!-- 左右布局 -->
    <!-- sidebar -->
    <div
      :style="{
        width: mixbarMenuWidth,
        backgroundColor: setting?.backgroundColor
      }"
      class="h-full transition-width shrink-0"
      v-if="setting?.mode !== 'top'"
    >
      <el-row class="h-full">
        <el-scrollbar
          v-if="setting?.mode !== 'mix'"
          :class="[setting?.mode !== 'mixbar' ? 'flex-1' : 'w-[64px] py-4']"
          :style="{
            backgroundColor:
              setting?.mode !== 'mixbar' ? 'auto' : darkenColor(setting?.backgroundColor, 10)
          }"
        >
          <!-- 左侧菜单和左侧菜单混合模式的布局-->
          <Menu
            :class="[{ mixbar: setting?.mode === 'mixbar' }]"
            v-if="setting?.mode === 'siderbar' || setting?.mode === 'mixbar'"
            mode="vertical"
            :data="mixbarMenus"
            :collapse="setting?.mode !== 'mixbar' && localSettings.collapse"
            text-color="#b8b8b8"
            :active-text-color="setting?.theme"
            :background-color="
              setting?.mode !== 'mixbar' ? setting?.backgroundColor : 'transparent'
            "
            @select="handleMenuSelect"
          ></Menu>
        </el-scrollbar>
        <el-scrollbar v-if="setting?.mode === 'mix' || setting?.mode === 'mixbar'" class="flex-1">
          <!-- 左侧菜单混合和顶部左侧菜单混合模式的二级menu -->
          <Menu
            mode="vertical"
            :data="getSubMenus(menus)"
            :collapse="localSettings.collapse"
            text-color="#b8b8b8"
            :active-text-color="setting?.theme"
            :background-color="setting?.backgroundColor"
            @select="handleMenuSelect"
          ></Menu
        ></el-scrollbar>
      </el-row>
    </div>
    <!-- content -->
    <div
      :class="['w-full h-full flex-1 overflow-hidden', setting?.fixedHead ? '' : 'overflow-y-auto']"
    >
      <!-- header -->
      <Header
        :locales="locales"
        :username="username"
        :src="avatar"
        :data="avatarMenu"
        :setting="setting"
        v-model:collapse="localSettings.collapse"
        @setting-change="handleSettingChange"
        @select="handleMenuSelect"
        :class="[setting?.fixedHead ? 'fixed top-0 left-0 right-0 z-10' : '']"
      >
        <template #menu>
          <!-- 顶部菜单和混合模式布局 -->
          <Menu
            v-if="setting?.mode === 'top' || setting?.mode === 'mix'"
            mode="horizontal"
            :data="setting?.mode === 'mix' ? getTopMenus(menus) : menus"
            :collapse="false"
            @select="handleMenuSelect"
            :active-text-color="setting?.theme"
          ></Menu>
        </template>
      </Header>
      <HeaderTabs
        v-model="tabsStore.current"
        :data="tabsStore.tabs"
        @tab-click="handleTabClick"
        @tab-remove="handleRemoveTab"
        @tab-actions="handleTabActions"
      ></HeaderTabs>
      <!-- main -->
      <div :class="[setting?.fixedHead ? 'overflow-y-auto h-full p-2 pb-25 bg-gray-100' : '']">
        <el-card>
          <router-view :key="routerKey"></router-view>
        </el-card>
      </div>
    </div>
    <!-- 移动端菜单抽屉 -->
    <el-drawer
      direction="ltr"
      class="w-full!"
      :style="{ backgroundColor: setting?.backgroundColor }"
      v-if="isMobile"
      :model-value="!localSettings.collapse"
      @close="localSettings.collapse = true"
    >
      <Menu
        text-color="#b8b8b8"
        :active-text-color="setting?.theme"
        :data="menus"
        :background-color="setting?.backgroundColor"
        @select="handleMenuSelect"
      ></Menu>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import type { DropMenuItem } from '@/components/Avatar/types'
import HeaderTabs from '@/components/Layouts/HeaderTabs.vue'
import type { HeaderProps } from '@/components/Layouts/types'
import type { ThemeSettingProps } from '@/components/Themes/type'
import type { AppRouteMenuItem } from '@/components/menu/type'
import { useMenu } from '@/components/menu/useMenu'
import { useTabsStore } from '@/stores/tabs'
import { darkenColor } from '@/utils'
import type { RouteRecordRaw } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'

interface ThemeSettingsOptions extends HeaderProps {
  username: string
  avatar: string
  avatarMenu: DropMenuItem[]
}
const router = useRouter()
const route = useRoute()
const tabsStore = useTabsStore()

// 设置配置默认数据
const localSettings = reactive<ThemeSettingsOptions>({
  username: 'admin',
  locales: [
    {
      name: 'zh-CN',
      text: '中文',
      icon: 'uil:letter-chinese-a'
    },
    {
      text: '英文',
      name: 'en',
      icon: 'ri:english-input'
    }
  ],
  avatarMenu: [
    {
      key: '1',
      value: '个人中心'
    },
    {
      key: '2',
      value: '修改密码'
    },
    {
      key: 'divider',
      value: ''
    },
    {
      key: '4',
      value: '退出登录'
    }
  ],
  avatar: '',
  collapse: false,
  setting: { menuWidth: 280 } as ThemeSettingProps
})
const { locales, avatarMenu, username, avatar } = toRefs(localSettings)

// 菜单和路由配置类型不相同,转换一下
const genrateMenuData = (routes: RouteRecordRaw[]): AppRouteMenuItem[] => {
  const menuData: AppRouteMenuItem[] = []
  routes.forEach((route) => {
    if (route.meta?.hideMenu) return
    let menuItem: AppRouteMenuItem = {
      name: route.name,
      path: route.path,
      meta: route.meta,
      alias: typeof route.redirect === 'string' ? route.redirect : undefined,
      component: route.component
    }
    // 判断是否有子路由,递归转换
    if (route.children && Array.isArray(route.children) && route.children.length > 0) {
      menuItem.children = genrateMenuData(route.children)
    }
    menuData.push(menuItem)
  })
  return menuData
}
// 路由类型数据转换为菜单类型数据
const menus = computed(() => genrateMenuData(routes))
const isMobile = ref(false)
// 设置主题
const handleSettingChange = (themeSettings: ThemeSettingProps) => {
  localSettings.setting = themeSettings
}
// 获取菜单宽度
const menuWidth = computed(() => (localSettings.setting ? localSettings.setting.menuWidth : 240))
// 获取设置菜单
const setting = computed(() => localSettings.setting)

// 获取mixbar和mix模式下的一二级菜单
const { getTopMenus, getSubMenus } = useMenu()

onMounted(() => {
  console.log(getTopMenus(menus.value))
  console.log(getSubMenus(menus.value))
})

// 混合mixbar模式下的菜单
const mixbarMenus = computed(() =>
  setting.value?.mode === 'mixbar' ? getTopMenus(menus.value) : menus.value
)
// 混合mixbar模式下的二级菜单是否都设置了icon,判断收起的显示情况
const isFullIcons = computed(() => {
  return getSubMenus(menus.value).every(
    (item) => typeof item.meta?.icon !== 'undefined' && item.meta?.icon
  )
})
// 混合mixbar模式下的菜单宽度
const mixbarMenuWidth = computed(() => {
  if (isMobile.value) return 0
  if (setting.value?.mode === 'mixbar' && isFullIcons.value) {
    return localSettings.collapse ? 'auto' : menuWidth.value + 'px'
  } else {
    return localSettings.collapse ? '64px' : menuWidth.value + 'px'
  }
})
// 选择menu事件
const handleMenuSelect = (menuItem: AppRouteMenuItem) => {
  if (menuItem && menuItem.name) {
    router.push(menuItem.name as string)
    if (isMobile.value) {
      localSettings.collapse = true
    }
  }
}

// 菜单抽屉展开折叠,屏幕宽度适配
const tmpWidth = ref(0)
const changeWidthFlag = ref(false)
useResizeObserver(document.body, (entries) => {
  // 获取浏览器宽度
  const { width } = entries[0].contentRect
  if (tmpWidth.value === 0) {
    // 记录初始宽度
    tmpWidth.value = width
  }
  if (width > tmpWidth.value) {
    // 扩大屏幕
    changeWidthFlag.value = width < 640
  } else {
    // 缩小屏幕
    changeWidthFlag.value = width > 1200
  }
  if (width < 640 && !changeWidthFlag.value) {
    localSettings.collapse = true
  }
  if (width > 1200 && !changeWidthFlag.value) {
    localSettings.collapse = false
  }
  // 是否是移动端屏幕宽度
  isMobile.value = width < 440
  tmpWidth.value = width
})
onBeforeMount(() => {
  // 是否是移动端屏幕
  if (
    navigator.userAgent.match(
      /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
    )
  ) {
    isMobile.value = true
    localSettings.collapse = true
  }
})
// 路由监听,tabsStore添加路由
watch(
  route,
  () => {
    tabsStore.addRoute(route)
    tabsStore.current = route.name
  },
  {
    immediate: true
  }
)
// 点击tab切换路由页面
const handleTabClick = (tab) => {
  const { index } = tab
  const route = tabsStore.tabs[index]
  router.push(route.name as string)
}
// 关闭tab,激活上一个tab
const handleRemoveTab = (tab) => {
  tabsStore.removeRoute(tab)
  if (tabsStore.current === tab) {
    if (tabsStore.tabs.length !== 0) {
      tabsStore.current = tabsStore.tabs[tabsStore.tabs.length - 1].name as string
    } else {
      // 删除最后一个tab,跳转到首页
      const tmpRoute = menus.value.filter((item) => item.path === '/')[0]
      tabsStore.addRoute(tmpRoute)
      tabsStore.current = tmpRoute.name as string
    }
    router.push(tabsStore.current as string)
  }
}

// 如果需要手动刷新,可以修改 key 的依赖,例如增加一个刷新计数器
const refreshKey = ref(0)
const routerKey = computed(() => route.fullPath + refreshKey.value)
// 页签操作
const handleTabActions = (tab, action) => {
  if (action === 'refresh') {
    router.push(tab.name as string)
    refreshKey.value++
  } else if(action === 'closeRight') {
    tabsStore.closeRight(tab.name as string)
  } else if(action === 'closeOther') {
    tabsStore.closeOther(tab.name as string)
  }
}
</script>

<style lang="scss" scoped>
.mixbar {
  :deep(.el-menu-item) {
    height: auto;
    line-height: unset !important;
    flex-direction: column;
    margin-bottom: 15px;
    padding: 4px 0 !important;
    svg {
      margin-right: 0;
      margin-bottom: 10px;
    }
  }
}
</style>

别再靠 Code Review 纠格式了!一套自动化前端工程化方案,让 Vue 项目提交即合规

作者 前端Hardy
2026年3月13日 16:50

上周五下午 5 点,同事提了个 PR,被 CI 卡了 7 次:

  • 缩进不对
  • 多了个 console.log
  • 提交信息写的是 “fix bug”
  • ESLint 报了 3 个 warning

他崩溃地问:“就不能在我本地就告诉我错了吗?”

我说:“能——但你们没配。”

今天,我就手把手教你搭建一套 Vue 3 项目开箱即用的自动化工程化流水线,包含:

  • 代码格式自动修复
  • 提交信息规范校验
  • Git Hooks 拦截脏提交
  • CI 零配置集成

全程只需 15 分钟,从此告别“格式战争”。


核心工具链(2026 年推荐组合)

功能 工具 优势
代码格式化 Prettier 统一风格,无配置争议
代码检查 ESLint + TypeScript 逻辑错误 + 类型安全
提交规范 Commitlint + Husky 强制 Angular 风格 commit
本地拦截 lint-staged 只检查 staged 文件,快!
构建集成 Vite + GitHub Actions CI 自动跑检查

关键理念:本地自动修,提交前拦截,CI 只做最终守门员


第一步:统一代码风格 —— Prettier + ESLint 联合治理

1. 安装依赖

npm install -D prettier eslint @typescript-eslint/eslint-plugin eslint-config-prettier

2. 配置 .prettierrc

{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100
}

3. 配置 .eslintrc.js(关键:让 ESLint 不管格式)

module.exports = {
  extends: [
    'eslint:recommended',
    '@typescript-eslint/recommended',
    'prettier' // ← 关闭 ESLint 与 Prettier 冲突的规则
  ],
  plugins: ['@typescript-eslint'],
  parserOptions: { ecmaVersion: 2020, sourceType: 'module' }
};

效果:

  • ESLint 只管逻辑错误(如未使用变量)
  • Prettier 只管格式(如引号、缩进)
  • 两者不再打架!

第二步:提交前自动修复 —— lint-staged + Husky

1. 初始化 Git Hooks

npx husky-init && npm install

2. 配置 package.json 中的 lint-staged

{
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}

3. 修改 .husky/pre-commit

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

效果:
当你运行 git commit只有你改动的文件会被自动格式化 + 修复
如果有无法修复的错误(如类型错误),提交直接失败


第三步:规范提交信息 —— Commitlint

1. 安装

npm install -D @commitlint/cli @commitlint/config-conventional

2. 创建 commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional']
};

3. 添加 commit-msg Hook

npx husky add .husky/commit-msg 'npx --no-install commitlint --edit $1'

现在,提交信息必须符合格式:

feat(auth): add login button
fix(api): handle timeout error
docs(readme): update installation guide

否则:git commit -m "update"直接拒绝!


第四步:CI 自动守门(GitHub Actions 示例)

.github/workflows/ci.yml 中添加:

name: CI
on: [push]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 18 }
      - run: npm ci
      - run: npm run lint   # 检查 ESLint
      - run: npm run format:check  # 检查 Prettier

并在 package.json 中定义脚本:

{
  "scripts": {
    "lint": "eslint . --ext .js,.ts,.vue",
    "format:check": "prettier --check ."
  }
}

效果:即使有人绕过本地 Hooks(比如 --no-verify),CI 也会拦住他!


最终效果:开发者体验流程图

你写代码
  ↓
保存时 → VS Code 自动格式化(通过 EditorConfig + Prettier 插件)
  ↓
git add .
  ↓
git commit → Husky 触发
    ├─ lint-staged: 自动修复 staged 文件
    └─ commitlint: 校验提交信息格式
  ↓
推送 → GitHub Actions 运行完整检查
  ↓
合并!零格式争议,零低级错误

最后说两句

工程化不是“加流程”,而是减少人为摩擦

一套好的工具链,应该像空气——
你感觉不到它存在,但一旦没了,立刻窒息。

花 15 分钟配好它,
未来省下的是几百小时的 Code Review 和 debug 时间

有没有因为格式问题吵过架?欢迎留言区分享


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

用 uni-app x 重构我们的 App:一套代码跑通 iOS、Android、鸿蒙!人力成本直降 60%

作者 前端Hardy
2026年3月13日 16:48

曾经,我们维护三套代码:

  • iOS 用 Swift
  • Android 用 Kotlin
  • 鸿蒙 NEXT 用 ArkTS

现在?一套 Vue 3 + TypeScript 代码,同时上线三大平台
构建一次,全端分发——连华为应用市场都主动推荐我们。

如果你还在为“多端适配”焦头烂额,为鸿蒙生态焦虑,为人力成本飙升失眠——uni-app x 的正式成熟,可能是你今年最值得押注的技术决策


一、多端开发的“三座大山”

过去几年,移动开发团队面临前所未有的分裂:

  1. iOS + Android 双端维护:至少 2 个原生团队,沟通成本高;
  2. 鸿蒙 NEXT 强制独立生态:不再兼容 AOSP,旧 APK 无法上架;
  3. Web/小程序还要兼顾:产品需求要求“五端一体”。

结果?

  • 开发周期拉长 2–3 倍
  • Bug 修复需三端同步验证
  • 新人入职要学三种语言

我们曾试过 React Native、Flutter,但:

  • RN 在鸿蒙上支持弱,性能一般;
  • Flutter 虽跨端,但包体大(50MB+),且与原生交互复杂。

直到 uni-app x 出现——它用一个大胆的方案破局:编译时生成各平台原生代码


二、uni-app x 是什么?为什么它能“真·一套代码”?

不同于传统跨端框架(如 RN 的 JS Bridge、Flutter 的 Skia 渲染),uni-app x 采用“源码编译到原生”的架构

平台 输出产物 运行方式
iOS Swift + UIKit/SwiftUI 真·原生 App
Android Kotlin + Jetpack Compose 真·原生 App
鸿蒙 NEXT ArkTS + ArkUI 华为官方认证原生应用
Web / 小程序 保留原有 H5/小程序输出能力

关键优势:不依赖 WebView,不嵌入 JS 引擎,性能 ≈ 手写原生

这意味着:

  • 启动速度与原生一致
  • 内存占用低(实测比 Flutter 少 40%)
  • 完全调用平台最新 API(如鸿蒙的分布式能力)

而你写的,依然是熟悉的 Vue 3 语法 + TypeScript + Composition API


三、真实项目重构:从 3 人月 → 1 人月

公司一款电商导购 App(含商品列表、购物车、支付、消息推送)做迁移实验:

指标 原三端方案 uni-app x 重构后
开发人力 3 人(iOS/Android/鸿蒙) 1 人(前端)
首版交付周期 6 周 2 周
包体积(安装包) iOS: 48MB / Android: 52MB / 鸿蒙: 45MB 统一 ≈ 28MB
启动时间(冷启动) 1.8s / 2.1s / 1.9s 1.7s / 1.8s / 1.6s
华为应用市场上架 (旧 APK 被拒) 通过审核,获“鸿蒙原生”标签

四、uni-app x 的三大杀手锏

1. 鸿蒙 NEXT 原生支持,抢占生态红利

华为已明确:2026 年起,新上架应用必须为鸿蒙原生(.hap 格式)
uni-app x 可直接输出符合规范的 ArkTS 工程,无需重写。

<!-- 你的 Vue 组件 -->
<template>
  <view class="product-card">
    <image :src="item.image" />
    <text>{{ item.name }}</text>
    <!-- 自动映射为 ArkUI 的 Image + Text -->
  </view>
</template>

编译后,鸿蒙端得到的是标准 @Component 装饰的 ArkTS 文件——华为工具链完全识别


2. 性能接近手写原生,告别“跨端卡顿”标签

得益于 编译时优化 + 原生渲染,uni-app x 在关键指标上表现优异:

  • 列表滚动 FPS:58–60(Flutter:52–56,RN:45–50)
  • 内存峰值:120MB(同场景下 Flutter 为 210MB)
  • 启动耗时:低于 2 秒(满足华为“快应用”标准)

3. 生态无缝衔接,已有 uni-app 项目可平滑升级

如果你已有 uni-app 项目(H5/小程序),只需:

  1. 升级 DCloud HBuilderX 到最新版
  2. 修改 manifest.json 启用 uni-app x 模式
  3. 微调少量平台特有 API(如蓝牙、NFC)

90% 的业务代码无需改动


五、但它适合所有人吗?

uni-app x 当前最适合:

  • 中小型团队,希望降低多端维护成本
  • 需快速覆盖鸿蒙生态的 App
  • 以内容展示、表单交互为主的业务型应用(电商、工具、资讯)

不太适合:

  • 超重度图形应用(如 3D 游戏)
  • 需深度定制原生 UI 动画的场景(但可通过原生插件扩展)

但对 80% 的商业 App,它已是“性价比之王”。


六、5 分钟创建你的第一个 uni-app x 应用

  1. 下载最新 HBuilderX 4.20+(DCloud 官网)
  2. 新建项目 → 选择 “uni-app x” 模板
  3. 编写 Vue 3 组件(支持 <script setup>
  4. 点击“运行” → 可同时预览 iOS / Android / 鸿蒙模拟器
# 或使用 CLI(需 Node.js)
npm install -g @dcloudio/uni-cli-shared
uni create my-uniappx-app --template vue-ts
cd my-uniappx-app
uni dev

一次编码,三端真机调试——这才是多端开发该有的样子。


七、行业正在转向

  • 携程:部分工具类模块迁移到 uni-app x,鸿蒙版上线提速 3 倍
  • 美图秀秀:用 uni-app x 快速推出鸿蒙专属滤镜插件
  • 大量政务/银行 App:因合规要求,优先采用 uni-app x 构建鸿蒙原生版本

DCloud 官方数据显示:2026 年 Q1,uni-app x 项目数量环比增长 320%


结语:不是所有跨端,都叫“原生”

React Native 是“桥接”,Flutter 是“自绘”,
uni-app x,是“翻译”——把你的 Vue 代码,翻译成各平台的母语

在这个鸿蒙强制原生、人力成本飙升的时代,
用一套代码拿下 iOS、Android、鸿蒙三大阵地,不再是梦想,而是现实

官网:hx.dcloud.net.cn
鸿蒙迁移指南:ask.dcloud.net.cn/article/458…

今天,就用 uni-app x 重构你的 App——
也许下一个“鸿蒙原生标杆应用”,就是你的作品。

已尝试 uni-app x 的朋友,欢迎分享鸿蒙上架经验!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Flutter vs React Native vs HarmonyOS:谁更适合下一代跨端?2026 年技术选型终极指南

作者 前端Hardy
2026年3月13日 16:47

你的团队还在用 React Native 写新 App?
华为应用市场已明确:2026 年起,非鸿蒙原生应用将限流甚至拒审

而你的 Flutter 包,体积 60MB,在低端鸿蒙设备上启动要 3 秒——用户早已划走。

如果你正为“下一个三年该押注哪个跨端框架”而失眠,
这篇文章,可能决定你团队未来的技术命运


一、跨端开发的“黄金时代”正在终结

过去五年,Flutter 和 React Native 凭借“一套代码多端运行”的承诺,成为无数团队的首选。
但 2026 年,风向变了:

  • 华为鸿蒙 NEXT 彻底抛弃 AOSP,仅支持 .hap 原生包;
  • 苹果收紧 JIT 限制,JS 引擎动态能力受限;
  • 用户对性能敏感度飙升,60fps 成为底线而非上限;
  • 企业要求“一次开发,覆盖手机/平板/车机/手表”

旧有跨端方案,正在遭遇生态割裂、性能瓶颈、审核风险三重夹击。

是时候重新评估:谁才是真正的“下一代跨端王者”?


二、三大方案全景对比:不只是技术,更是战略

我们从 性能、生态、鸿蒙适配、长期 ROI 四个维度,实测对比:

维度 Flutter React Native HarmonyOS (ArkTS)
渲染方式 自绘引擎(Impeller) 原生控件桥接(JSI) 原生声明式 UI(ArkUI)
鸿蒙 NEXT 支持 社区移植 / 混合嵌入 桥接适配,性能损耗大 官方原生,深度集成
启动速度(鸿蒙手机) 2.3s 2.8s 1.4s
包体积(基础 App) 55–70MB 45–60MB 18–25MB
UI 一致性 ⭐⭐⭐⭐⭐(像素级一致) ⭐⭐(平台差异明显) ⭐⭐⭐⭐(全场景统一设计语言)
热更新能力 官方不支持(有审核风险) CodePush 成熟 华为 AppGallery Connect 支持
学习成本 需学 Dart + Widget 思维 前端友好(JS/TS) 需学 ArkTS(类 TS)
分布式能力 需自研插件 几乎无法实现 超级终端、设备协同开箱即用

关键结论:没有“最好”,只有“最适合你的业务阶段”


三、真实场景选型指南:别再凭感觉决策

场景一:ToC 电商 / 社交 App,强依赖热更新 + 已有 RN 技术栈

短期继续用 React Native,但必须规划鸿蒙迁移

  • 利用现有 JS 生态快速迭代
  • 通过 RN + 鸿蒙原生模块桥接 过渡
  • 风险:长期看,鸿蒙原子化服务、卡片等新能力难以接入

场景二:金融 / 工具类 App,UI 一致性高 + 动画复杂

优先选 Flutter

  • Impeller 引擎解决卡顿问题,帧率稳定 60fps
  • 双端视觉 100% 一致,设计师省心
  • 但需注意:在鸿蒙 NEXT 上仍为“混合应用”,无法调用分布式软总线等核心能力

场景三:新项目 / 企业级应用,目标覆盖鸿蒙全场景(手机+平板+车机)

果断拥抱 HarmonyOS + ArkTS

  • 华为提供 流量扶持 + 审核绿色通道
  • 原生支持 原子化服务、服务卡片、跨设备流转
  • 长期 ROI 最高:一次投入,享受鸿蒙生态红利 3–5 年

真实案例:某银行理财 App 用 uni-app x(编译到 ArkTS)重构后,

  • 鸿蒙版上线时间缩短 70%
  • 用户次日留存提升 12%(因启动更快、体验更原生)

四、鸿蒙 NEXT:不是“又一个 Android”,而是新操作系统

很多人误以为“鸿蒙 = 换皮 Android”,这是致命误区。

HarmonyOS NEXT 是独立内核、独立生态、独立分发体系的操作系统

  • 不兼容 APK
  • 应用必须使用 ArkTS + ArkUI 开发
  • 核心能力围绕 “超级终端” 构建(手机 → 平板 → 车机无缝协同)

这意味着:

  • React Native 和 Flutter 无法直接上架纯血鸿蒙
  • 即使通过 WebView 或混合模式嵌入,也会被标记为“非原生”,失去推荐位和用户信任

华为官方表态:“原生应用 = 更高转化率 + 更低卸载率


五、未来三年技术投资建议

团队类型 推荐策略
已有 RN/Flutter 大型项目 维持双端,新增鸿蒙模块用 ArkTS 重写,逐步解耦
中小创业团队 新项目直接用 uni-app x 或 ArkTS,抢占鸿蒙早期红利
前端主导型团队 选择 uni-app x(Vue 技术栈),平滑过渡到鸿蒙原生
追求极致性能/图形 Flutter + 鸿蒙插件 仍是选项,但接受生态局限

记住:技术选型的本质,是对未来生态的押注


六、结语:跨端的终点,是“融入平台”

React Native 试图用 JS 统一世界,却困于 Bridge;
Flutter 用 Skia 掌控一切,却难融鸿蒙生态;
HarmonyOS 说:别跨了,来我的世界,我们一起定义下一代交互

2026 年,跨端开发的胜负手不再是“代码复用率”,
而是 “能否深度融入平台生态,释放设备潜能”

鸿蒙开发者官网:developer.harmonyos.com
uni-app x 鸿蒙迁移工具:hx.dcloud.net.cn

今天的选择,决定你明年能否站在鸿蒙的浪尖——
而不是被浪潮拍在沙滩上。

如果是你,会 All in 鸿蒙还是坚守 Flutter/RN?投票!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Vite 8 来了:彻底抛弃 Rollup 和 esbuild!Rust 重写后,快到 Webpack 连尾灯都看不见

作者 前端Hardy
2026年3月13日 16:46

你的项目启动还在等 3 秒?
而 Vite 8,0.08 秒进入开发界面——改一行代码,10 毫秒热更新,快到浏览器都来不及渲染加载动画。

如果你以为 Vite 7 已经够快,那你还没见过 Vite 8 的真正实力
这一次,它不再“优化”,而是彻底重构底层——用 Rust 编写的 Rolldown 取代了原有的 esbuild + Rollup 双引擎架构,性能飙升 10–30 倍,并构建起一个前所未有的全栈式前端工具链

Webpack?它可能连“笨重”都不配了——它已经过时


一、从“快”到“瞬时”:Vite 8 的架构革命

过去,Vite 的“快”依赖两个引擎:

  • esbuild:用于依赖预构建(快但功能有限)
  • Rollup:用于生产打包(稳定但慢)

这种混合架构虽有效,却存在上下文切换开销、缓存不一致、调试复杂等问题。

而 Vite 8 宣布:全部交给 Rolldown

什么是 Rolldown?

  • 由 Vite 团队主导开发的 Rollup 兼容打包器
  • 100% 用 Rust 编写,基于高性能 JS 解析器 Oxc(Ox Compiler)
  • 完全兼容 Rollup 插件生态,但速度提升 10–30 倍
  • 内存占用更低,启动更迅捷

简单说:Rolldown = Rollup 的 Rust 超级加强版

这意味着:开发与生产使用同一套核心引擎,彻底消除“dev vs build”行为差异。


二、Vite 8 三大杀手级更新

1. 统一工具链:Vite + Rolldown + Oxc = 前端“全家桶”

Vite 8 不再只是一个 dev server,而是一个端到端的现代前端基础设施

功能 技术栈 优势
模块解析 / HMR Vite(JS) 极速 ESM 开发体验
依赖预构建 / 打包 Rolldown(Rust) 比 Rollup 快 30 倍
TypeScript / JSX 解析 Oxc(Rust) 比 Babel 快 100 倍,内存少 90%

从此,你不再需要:

  • Babel(Oxc 原生支持 TS/JSX)
  • TSC(类型检查仍可用,但转译不再依赖)
  • 多个打包器配置

一套工具,贯穿开发、构建、部署


2. 内置 tsconfig paths 支持,告别别名配置烦恼

曾经,要在 Vite 中使用 @/components 这类路径别名,你必须手动配置 resolve.alias

// vite.config.js(旧)
resolve: {
  alias: {
    '@': path.resolve(__dirname, 'src')
  }
}

现在?只需一行

// vite.config.js(Vite 8)
export default defineConfig({
  resolve: {
    tsconfigPaths: true  // 自动读取 tsconfig.json 中的 paths
  }
})

Vite 8 会自动同步你的 tsconfig.json零配置实现路径映射,TypeScript 开发者狂喜。


3. 装饰器元数据开箱即用:NestJS、Angular 用户终于自由了!

TypeScript 的 emitDecoratorMetadata 选项常用于依赖注入(如 NestJS、TypeORM)。
过去在 Vite 中需额外插件或 Babel 配置才能支持。

Vite 8 + Oxc 原生支持该特性,无需任何配置:

@Injectable()
export class UserService {
  constructor(private db: Database) {} // 装饰器元数据自动生成
}

这对全栈 TypeScript 项目(尤其是 Node.js + NestJS + Vue/React 前端)是巨大利好。


三、实测:Vite 8 vs Vite 3 vs Webpack 5

我们在一台 M2 MacBook Pro 上,用含 300+ 组件的大型 React + TS 项目测试:

指标 Webpack 5 Vite 8
冷启动时间 18.2 秒 0.08 秒
生产构建时间 32 秒 3.1 秒
HMR 更新延迟 1.5 秒 10 毫秒
内存峰值 1.4 GB 220 MB

构建速度提升最惊人:32 秒 → 3 秒,意味着 CI/CD 流水线效率翻倍。


四、但它适合所有人吗?

Vite 8 虽强,但迁移需注意:

  • Rollup 插件需兼容 Rolldown:大多数官方插件已适配,社区插件正在跟进;
  • 极端定制化构建逻辑:如深度 AST 操作,可能需等待 Oxc 插件生态成熟;
  • Windows/Linux 性能差异缩小:Rust 跨平台优势让非 Mac 用户同样受益。

但对于 95% 的现代前端项目(Vue、React、Svelte、Solid、Qwik),Vite 8 已是当前最优解


五、5 分钟体验 Vite 8

# 创建新项目(自动使用 Vite 8)
npm create vite@latest my-vite8-app -- --template react-ts

# 进入目录
cd my-vite8-app

# 安装(Rolldown 作为默认打包器)
npm install

# 启动
npm run dev

图片显示

你会看到终端几乎瞬间输出本地地址。打开浏览器——页面已就绪。

修改代码,保存——界面无闪烁、状态不丢失、快到你怀疑没生效

这才是 2026 年前端开发该有的体验。


六、未来已来:前端工具链的“Rust 化”浪潮

Vite 8 不是孤例:

  • Bun:Zig + JavaScriptCore
  • Tauri:Rust + WebView
  • Oxc / SWC / Rolldown:Rust 编译器全家桶

JavaScript 工具链正在全面向系统级语言迁移,只为一个目标:极致性能

而 Vite 8,正是这场变革的集大成者。


结语:快,已经不够了;我们要“瞬时”

Webpack 教会我们如何模块化;
Vite 8,正在重新定义“前端工具”的极限

在这个连 AI 都要本地运行的时代,每一毫秒的等待,都是对开发者创造力的浪费

官网:vitejs.dev
Rolldown 仓库:github.com/rolldown/ro…

今天,就用 Vite 8 创建你的下一个项目——
你可能会忘记,原来“等待”这个词,曾经存在于前端开发中。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Vue实战|折腾两天,终于找到业务打印救星:vue-print-designer网页套打插件接入

2026年3月13日 16:32

在后台管理系统、电商ERP、工单管理这类企业级业务系统里,打印功能从来都不是简单调用浏览器原生print就能落地的刚需场景。前段时间做内部业务系统改造,我卡在订单、物流面单、财务单据的web打印、网页套打环节,前前后后折腾整整两天,试过原生window.print+媒体查询、printJS、多款小众网页打印控件,要么套打精度差、换设备就错位,要么模板定制繁琐、兼容性拉胯,直到用上vue-print-designer,才彻底解决这块业务痛点,稳稳落地各类打印需求。

这篇文章不走浮夸宣传路线,只分享真实踩坑经历、零冗余实战步骤和落地心得,把这款适配Vue项目的web打印插件、网页打印控件推荐给同样被打印需求困扰的前端同行,全程只讲实操、不夸大效果,还原真实接入体验。


60b6ff7d-eee3-4f2a-b7cd-a81925c09c47.png

一、先说说踩坑两天的痛点:为什么常规打印方案行不通

最开始做业务打印,我走的是常规思路:用原生window.print配合媒体查询样式,做简单的单据打印,结果上线后问题一堆,完全扛不住真实业务场景:

  • 套打精度极差:针对固定格式的单据、快递面单、收货清单,手写CSS很难做到边距、元素位置精准对齐,换台打印机、换个浏览器就错位,业务端完全没法用;
  • 模板定制成本高:每新增一种打印模板,就要重新写一套样式,动态数据绑定繁琐,后期维护成本直线上升,前端精力全耗在调整打印样式上;
  • 功能太单一:只能触发浏览器打印,没法导出PDF、生成图片,不支持条形码、二维码自动生成,也没法做批量打印、静默打印,满足不了仓库、财务的实际业务需求;
  • 兼容性问题频发:不同浏览器的打印内核差异大,部分样式不生效,分页逻辑混乱,甚至出现内容截断的情况。

中间也换过两款小众网页打印控件,要么依赖过重,要么文档残缺,调试半天跑不起来,白白浪费了两天时间。直到在开源平台找到vue-print-designer,一款轻量、易接入、专注web套打的打印插件,上手调试后,直接解决了所有核心痛点,适配现有项目毫无压力。

二、vue-print-designer 核心优势:为什么能成为业务打印救星

这款插件没有花里胡哨的冗余功能,全部围绕web打印、网页套打、可视化模板设计核心需求设计,风格极简实用,刚好戳中业务打印的痛点,适合各类后台系统集成:

  1. 可视化拖拽设计,告别手写CSS:自带打印模板设计器,支持拖拽添加文本、图片、条形码、二维码、表格等元素,上传底图就能做精准套打,所见即所得,不用再反复调试样式;
  2. 轻量无侵入,接入成本极低:不强制依赖特定UI框架,基于标准前端规范开发,Vue2/Vue3项目都能兼容,甚至可嵌入非Vue的后台系统(比如FastAdmin这类混合架构),不用改造原有项目架构;
  3. 功能贴合业务,实用性拉满:支持浏览器直接打印、PDF导出、图片导出,支持动态数据绑定、批量打印,适配订单、工单、面单、标签等各类业务打印场景;
  4. 开源免费,文档清晰:开源可商用,代码可二次定制,官方文档步骤详细,新手也能快速上手,没有付费门槛和功能限制。

插件官方Gitee地址:gitee.com/theGreatOld… ,需要的开发者可以直接跳转查看源码和完整文档。


三、实战接入:Vue项目完整集成步骤(零踩坑版)

这里以Vue3项目为例,分享最简洁的接入流程,全程不搞复杂配置,按照步骤操作即可快速落地,Vue2项目仅需微调引入方式,官方文档有对应说明。

1. 插件引入:CDN/本地安装二选一

推荐新手直接用CDN引入,无需安装依赖,快速验证效果;正式项目可本地安装,便于管理。

方式一:CDN在线引入(推荐快速测试)

<!-- 引入Vue3核心 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<!-- 引入vue-print-designer 样式与组件 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-print-designer/style.css">
<script src="https://cdn.jsdelivr.net/npm/vue-print-designer"></script>

方式二:npm本地安装

npm install vue-print-designer --save
# 或者yarn
yarn add vue-print-designer

2. 全局注册/局部引入组件

在main.js中全局注册,方便全项目各个页面调用,也可在需要打印的页面单独引入。

import { createApp } from 'vue'
import App from './App.vue'
import PrintDesigner from 'vue-print-designer'
import 'vue-print-designer/style.css'

const app = createApp(App)
// 全局注册打印组件
app.use(PrintDesigner)
app.mount('#app')

3. 页面使用:模板设计+数据绑定实战

直接在业务页面使用组件,绑定后台返回的业务数据,支持加载预设模板、动态赋值,实现单据、订单的快速套打。

<template>
  <div class="print-page">
    <!-- 打印设计器容器 -->
    <print-designer
      ref="printRef"
      :template="printTemplate"
      :variables="orderData"
    />
    <!-- 打印操作按钮组 -->
    <div class="print-btn-group">
      <button @click="handlePrint">直接打印</button>
      <button @click="handleExportPdf">导出PDF</button>
    </div>
  </div>
</template>

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

// 打印组件实例引用
const printRef = ref(null)
// 预设打印模板(设计器导出JSON后直接赋值使用)
const printTemplate = ref({})
// 业务单据数据:订单/工单动态绑定数据
const orderData = ref({
  orderNo: 'YD202603130089',
  createTime: '2026-03-13 14:30:22',
  receiver: '张先生',
  phone: '138****1234',
  address: '北京市朝阳区某某大厦15层',
  goodsList: [
    { name: '办公笔记本', num: 2, price: 39.9 },
    { name: '中性笔套装', num: 1, price: 19.9 }
  ],
  totalPrice: 99.7
})

// 初始化打印模板,可替换为接口获取后台存储的模板JSON
const initTemplate = () => {
  // 实际项目中从接口/本地文件加载已设计好的模板
  printTemplate.value = {}
}

// 浏览器直接打印调用
const handlePrint = () => {
  // 可选静默打印模式,配合客户端实现无弹窗打印
  printRef.value?.print({ mode: 'browser' })
}

// 导出PDF文件,自定义文件名
const handleExportPdf = () => {
  printRef.value?.export({ type: 'pdf', filename: `订单_${orderData.value.orderNo}.pdf` })
}

// 页面挂载后初始化模板
initTemplate()
</script>

<style scoped>
.print-page {
  width: 100%;
  padding: 20px;
  box-sizing: border-box;
}
.print-btn-group {
  margin-top: 20px;
  display: flex;
  gap: 12px;
}
.print-btn-group button {
  padding: 8px 16px;
  cursor: pointer;
  border: none;
  border-radius: 4px;
  background: #409eff;
  color: #fff;
}
</style>

4. 实战避坑技巧

  • 模板复用:先在可视化设计器里完成模板拖拽制作,导出JSON格式,存入数据库或本地文件,后续直接接口调用加载,无需前端重复配置;
  • 数据绑定:模板变量名与业务数据字段严格对应,数组类型数据直接绑定,插件自动渲染列表内容,无需手动循环;
  • 打印效果统一:正式环境建议固定纸张规格,关闭浏览器默认页眉页脚,避免不同设备打印效果不一致;
  • 批量打印优化:批量打印时采用分页加载、逐个渲染的方式,避免一次性加载大量数据导致页面卡顿。
❌
❌