普通视图

发现新文章,点击刷新页面。
今天 — 2025年2月22日掘金 前端
昨天 — 2025年2月21日掘金 前端

Vite源码学习(十二)——热更新(下)

作者 HsuYang
2025年2月21日 17:53

前言

在上一篇文章中,我们主要聊了VITE热更新过程中所牵涉到的客户端的核心类,并且向大家解释了热更新边界的概念,这篇文章,我们就要分析VITE在服务端是如何判别热更新边界的。

这篇文章我们会详细聊VITE的内置插件:vite:import-analysis,并且会把之前阐述EnvironmentModuleGraph这个核心类遗留的一些逻辑补充完毕,从而完成VITE热更新完整流程的知识体系建设。

好了,废话不多说,我们就开始进入今天的正题吧......

vite:import-analysis

在这个插件中,VITE主要是利用词法分析来处理模块的依赖关系,这个插件的代码比较多,因此我们只挑关键的代码向大家进行展示。

首先,VITE是用的是es-module-lexer这个包来进行词法分析的,在使用之前,需要init一下,然后我们就可以得到所有的imports语句。

image.png 给大家展示一下我的例子运行起来,imports的内容是什么样的? image.png 再给大家看一下我的这个文件的源码是什么样的,大家就明白了。 image.png 根据我的实践,即带有引入标识的,n的值就不是undefined,一会儿,我们就可以利用这个特性,分析到源码里面哪些文件是都要import.meta的语句了。

接着,VITE对所有的imports的内容进行分析:

我们从源码里面取字符串长度就可以取到我们预期的字符串内容: image.png 接着,仍然通过取字符串长度,我们就可以知道源码里面有没有import.meta.hot.accept这样的语句。 image.png 所以,现在大家明白了为什么VITE的官方文档所说的对空格敏感了吗? image.png 因我们的写法可能是import.meta.hot.accept或者是import.meta.hot?.accept

我们在写热更新的时候,如果是需要处理自己的话,我们的写法可以是:

if(import.meta.hot) {
    import.meta.hot.accept();
}
//=======================================
if(import.meta.hot) {
    import.meta.hot.accept(() => {
        // 完成一些更新的逻辑
    })
}

image.pngimage.png 刚才我们已经说过了,specifier变量代表的就是我们写的import的资源定位地址,现在VITE会重写我们的这个路径。 image.png 先做一些前置的判断,简化后续的处理逻辑: image.png 接着就可以真正的处理了: image.png VITE重写资源的过程中,我们比较关心的一个点是为热更新注入的查询字符串,因为这个查询字符串可以避免浏览器的缓存。 image.pngimage.png 接下来的处理逻辑,这儿我们就只分析最简单的处理情况,即直接预热资源的逻辑,完成预热的话,一会儿就不用再次转换资源了,可以提高一定的效率。 image.png 到这个位置,我们把map回调函数内的逻辑就简单看了一遍了。

根据之前我们在VITE文档里面提到的,有import.meta.hot.accept这样的语句,VITE就会认为这是一个热更新边界,就会为我们注入热更新的方法import.meta.hot的实现。 image.png 最后,我们可以看到,VITE会更新依赖关系,如果有些内容是之前引入过,但是现在已经不需要了的内容,会把它分析出来,然后通过WS通知客户端,客户端在接收到WS消息之后,会调用import.meta.hot.prune回调函数,从而完成清理工作。 image.png 发送消息: image.png

EnvironmentModuleGraph

在之前的文章中,我们只讲了一部分EnvironmentModuleGraph类的内容,在这篇文章,我们会把关于它剩下的核心内容补全一下。

我们就结合着热更新的整体流程来看这部分的剩余内容哈。

之前我们讲过,VITE使用chokidar这个库进行文件内容的监听,当文件发送变化的时候,VITE需要调用EnvironmentModuleGraph这个类上的方法进行依赖的更新。 image.png 之前我们讲过,同一个文件可以在内存中映射成多个Module,比如.vue文件。 image.png 在之前我们讲VITE的核心类的时候,我们讲过,在EnvironmentModuleNode中,importer变量存储的是当前资源的被引用者image.png 所以,现在当前文件发生了变化,我们就要开始以当前节点为起点,沿着它的被引用者关系进行DFS(深度优先)的遍历,从而更新整个引用链。

如果你对DFS遍历不清楚的话,可以查看我之前撰写的关于这部分知识的文章,理解深度优先思想和广度优先思想以及一些实际应用。

好了,说回来,我们知道这个方法对资源引用进行处理之后,还有一个关键的地方,是关于热更新处理的。 image.png 在之前上一小节,我们阐述vite:import-analysis这个插件的时候,当一个文件中如果引用其它内容的时候,如果有lastHMRTimestamp,VITE会在资源引用的后边附加上时间戳的参数,这样我们在热更新的时候,得到的内容肯定是不会被浏览器缓存的,这才是我们预期的。

说完了invalidateModule方法之后,我们现在看一下之前在阐述vite:import-analysis这个插件的时候看到的更复杂的updateModuleInfo方法。

这个方法的目的是更新资源的引用,并且还能计算出哪些资源是之前有引用,现在是不需要的。 image.pngimage.pngimage.png 我们把这个方法拆成3部分来看的话,其实就不觉得很难了。

到此为止,关于EnvironmentModuleGraph剩余的内容我们几乎就讲完了,现在大家应该明白为什么需要这个类了吧,这个类核心的功能就是加载资源,管理资源集合,当资源发生变化的时候,这个类会刷新资源的引用关系,计算出更新和删除的资源

其它内容

热更新边界检测的实现

到目前为止,我们还差一个内容没有搞明白,就是热更新的时候,VITE在服务端是如何处理热更新边界的呢?在之前的文章中,我们讲过,当我们在源代码中定义了import.meta.hot.accept内容的时候,就划分了热更新边界。

为了让大家直观的看到VITE的处理流程,我把VITE的调用栈帧都展示出来。 image.pngimage.pngimage.pngimage.png 我给大家举个例子,比如我现在有一个依赖链,index.ts->api.js->config.js->dep.js,假设我在index.ts这个文件中有定义import.meta.hot.accept这个代码,那么VITE将会为我们找到热更新的起点文件为index.ts,我们根据这个例子来做实验看一看。 image.pngimage.png 我们等会儿再来看一下这个boundaries是怎么找出来的,先不着急,我们先看后面发送给客户端更新的逻辑。 image.pngimage.png 这样,客户端拿到了更新边界信息,只需要把更新边界里面的资源重新加载一遍,所有的更改就都应用了。

好,现在我们再看一下propagateUpdate是如何实现的。其实从刚才我们看到的它的参数就知道,这又是一个DFS遍历(因为我对图这个数据结构足够熟悉)。 image.pngimage.png 当这个方法返回false的时候,浏览器就不会整页刷新了。

现在,我们来看一下这个isSelfAccepting这个变量是怎么来的?我们得回到vite:import-analysis这个插件中: image.pnglexAcceptedHmrDeps这个方法上,通过检测词法,如果有import.meta.hot.accept(不依赖其它节点的写法),这个方法就会返回true。 image.pngimage.png 所以,现在大家为什么热更新链的更新逻辑是我在上一篇文章中所阐述的那样了吗?这是一个很简单的DFS遍历就可以求得依赖链中包含import.meta.hot.accept语句深度最深的节点,DFS在处理的时候是反向的,第一个遇到的isSelfAccepting为true的节点就是包含import.meta.hot.accept语句深度最深的节点,然后就停止遍历返回结果给外界了。

到这个位置为止,propagateUpdate这个方法还没有完,我们还得看整页刷新的逻辑是在什么情况下出现。

还是回到之前的位置: image.png 假设我们在源码里面没有一句import.meta.hot.accept,那么,hasDeadEnd将会是true,因此就会走整页刷新的逻辑。

还有一些其它页面需要走整页刷新的逻辑,比如我们改了当前的一个文件,发现没有一个文件引用当前修改的文件。 image.png 最后一种情况,就是当我们的源码中存在循环引用,此刻也应该走整页刷新。 image.png

invalidate

最后,我们再看一下之前我们看的有点儿迷迷糊糊的热更新API->import.meta.hot.invalidate是怎么样实现的?

VITE是在DevEnvironment初始化的时候设置的监听: image.png 在invalidateModule这个方法中调用updateModules,即我们在上一小节中讲到的寻找热更新边界的方法,此刻,VITE的热更新的行为是继续更新,还是整页刷新,就取决于你的源码是如何编写的了。

VITE热更新的整体流程梳理

现在,我们就已经大致完成VITE的热更新实现学习了,我们来总结一下VITE热更新的总体流程。

VITE在dev阶段,会启动一个开发服务器,开发服务器会启动一个WebSocket服务,VITE在启动时,会向客户端注入一些工具方法,在这些工具方法中,我们就可以和VITE的服务端进行通信。

我们的VITE项目中的源码,每一个文件都是一个资源节点,VITE使用一个图来管理这些资源的引用关系。

VITE在处理源代码时,会重写我们编写的引用资源标识符,然后,VITE就可以知道这些源码的引用和被引用关系。在重写资源标识符时,VITE会为我们注入资源的最后更新时间的查询参数,这样可以有效避免资源的缓存。

VITE在对源码分析时,会根据我们的源码中是否包含import.meta.hot.accept这样的代码去划分热更新边界,也会根据这个依据决定是否向当前文件中注入热更新上下文(import.meta.hot)的实现方法,当我们的在代码编辑器中修改文件时,VITE会监听到文件的修改,然后VITE会根据资源图中的引用关系,刷新修改后的资源引用关系,这个过程中,VITE会找到不需要的资源和需要级联更新的资源,并且把这些资源信息通过WS消息发送给客户端,客户端在拿到WS消息时,就可以决定是更新资源还是移除资源了。

在更新资源时,VITE会找到依赖树中源码含有import.meta.hot.accept的且深度最深的资源信息,将其发送到客户端,当这个资源发生了更新,其引用的资源也就一并完成了更新,比如index.js->api.js->request.js->config.js,假设有且仅有index.js这个文件中划定了热更新边界,我们修改这个依赖链中的任何一个文件,VITE都将会更新index.js

以上就是热更新的整个过程了,这是我通过阅读VITE的源码和自己的实践总结出来的整体流程,如果各位读者觉得不全或者有错误,欢迎大家指正。

结语

我们通过3篇文章来学习了VITE热更新的处理过程,尤其是认知到VITE的热更新边界的设计在热更新时是极好的,即保证了所有的文件都可以更新到,也有较少的开销。

在这三篇文章中,因为主要都是在进行图数据机构的处理和计算,所以要求大家对数据结构和算法的基础知识有一定的掌握才能看懂,如果您还没有掌握的话,可以利用自己的空余时间刷一些算法题就掌握了,哈哈哈。

从下一篇文章开始,我们就开始学习VITE的依赖预构建相关知识点了,未完待续......

乾坤微服务(Qiankun)样式隔离深度解析

2025年2月21日 17:31

微前端架构的核心目标是将单体应用拆分为多个独立子应用,实现技术栈无关、独立开发和部署。然而,子应用间的样式隔离是微前端实践中的关键挑战之一。阿里开源的乾坤(Qiankun)框架通过多种技术手段解决这一问题,本文将深入解析其实现原理、技术选型及最佳实践。


一、样式隔离的必要性

在微前端架构中,子应用可能由不同团队开发,若未有效隔离样式,会导致以下问题:

  1. 全局污染:子应用的全局CSS(如body样式、类名冲突)相互覆盖。
  2. 选择器冲突:不同子应用使用相同类名或ID,导致布局错乱。
  3. 动态样式干扰:JavaScript动态插入的样式影响其他子应用。

二、乾坤的样式隔离方案

乾坤提供了两种主要样式隔离方案,分别针对不同场景:

1. Shadow DOM 隔离

原理
利用浏览器原生支持的Shadow DOM API,为子应用创建独立的DOM子树,其内部样式与外部完全隔离。

实现方式

// 主应用配置子应用
registerMicroApps([
  {
    name: 'subApp',
    entry: '//localhost:7100',
    container: '#subapp-container',
    activeRule: '/sub-app',
    props: {
      sandbox: {
        strictStyleIsolation: true // 启用Shadow DOM
      }
    }
  },
]);

优点:- 强隔离性:Shadow DOM内部的CSS选择器不会影响外部,反之亦然。

  • 原生支持:无需额外处理CSS,由浏览器保证隔离性。

缺点

  • 兼容性限制:部分旧浏览器不支持(如IE11)。
  • UI组件穿透问题:如Ant Design的Modal组件可能因挂载到body导致样式失效。
  • 事件穿透复杂:需手动处理Shadow DOM内外的事件通信。

2. 动态样式表作用域(Scoped CSS)

原理
乾坤通过动态重写子应用的CSS规则,为每个选择器添加唯一前缀(如div → [qiankun-subapp] div),将样式限制在子应用容器内。

实现方式

// 主应用配置
start({
  sandbox: {
    experimentalStyleIsolation: true // 启用动态作用域
  }
});

优点

  • 无侵入性:无需修改子应用代码,乾坤自动处理样式作用域。
  • 兼容性佳:支持所有浏览器。
  • 灵活可控:支持动态加载/卸载子应用样式表。

缺点

  • 性能损耗:动态重写CSS可能影响大型应用的加载性能。
  • 动态样式失效:通过JavaScript插入的样式需额外处理(如监听DOM变化)。

三、技术对比与选型建议

方案 适用场景 注意事项
Shadow DOM 高隔离需求、现代浏览器环境 处理全局组件(如弹窗)、事件通信
动态样式表作用域 兼容旧浏览器、快速迁移现有项目 监控动态插入样式、性能优化

推荐实践

  • 默认启用动态作用域:通过experimentalStyleIsolation平衡兼容性与隔离性。
  • 关键业务使用Shadow DOM:对样式敏感的核心子应用采用严格隔离。
  • CSS Modules辅助:在子应用内部结合CSS Modules进一步避免局部冲突。

四、进阶:乾坤样式隔离的实现细节

  1. CSS重写机制
    乾坤劫持document.createElement等方法,在子应用加载时解析其CSS文本,通过正则匹配重写选择器,例如:

    /* 原始CSS */
    .button { color: red; }
    /* 重写后 */
    [data-qiankun-subapp] .button { color: red; }
    
  2. 样式卸载策略
    子应用卸载时,乾坤自动移除其动态注入的<style>标签,避免残留样式影响。

  3. 第三方库适配
    针对UI库(如Ant Design)的全局样式,可通过配置excludeAssetFilter排除特定资源,或在子应用内使用定制前缀。


五、常见问题与解决方案

  1. 弹窗组件样式失效

    • 方案:将弹窗挂载到子应用容器内,而非document.body

    • 代码示例

      // 子应用修改Modal挂载点
      Modal.config({ rootSelector: '#subapp-container' });
      
  2. 动态加载样式丢失

    • 方案:监听DOMNodeInserted事件,对新增<style>标签自动重写。
  3. 字体图标跨域问题

    • 方案:在主应用配置跨域头,或通过乾坤的fetch劫持重定向资源路径。

六、总结

乾坤通过Shadow DOM动态作用域CSS双轨机制,为微前端应用提供了灵活的样式隔离方案。在实际项目中,开发者需根据浏览器兼容性、子应用技术栈及性能要求选择合适的策略。未来,随着Web Components技术的普及,Shadow DOM或成为微前端样式隔离的终极解决方案,但动态作用域仍将在过渡期扮演重要角色。

最佳实践路线图

  1. 默认启用experimentalStyleIsolation
  2. 对高安全要求的子应用逐步迁移至Shadow DOM。
  3. 结合CSS-in-JS方案(如styled-components)进一步规避冲突。

通过合理运用乾坤的样式隔离能力,可显著提升微前端架构的稳定性和可维护性。

❌
❌