Webpack CSS热更新之微前端篇

221 阅读6分钟

Webpack CSS热更新之微前端篇

本文主要介绍在以micro-app为代表的基于WebComponents的技术实现的微前端框架 场景下子应用使用Webpack本地开发场景下的CSS热更新实现原理。

热更新两大策略

目前的前端开发工具实现本地开发的 hmr 功能分为两大阵营

  • 基于 bundle 思想的 Webpack
  • 基于 nobundle 思想的 Vite

针对 nobundle 场景,实现 hmr 是一件非常简单的事情。 只需要在当前环境请求一次最新的文件内容即可。 例如我们修改了 foo.vue 文件,只需要发送通知让浏览器重新请求一遍类似于 http://localhost:3000/foo.vue?timestamp=xxx 这样的请求链接即可

bundle 场景的热更新

针对 bundle 场景的热更新功能,我们的源文件将会被打包在多种不同类型的 chunk文件中。在这种场景下我们需要在修改源文件的时候让浏览器去重新加载包含源文件的 最新 chunk 文件。

js 热更新

对于 js 场景的热更新,Webpack 在底层会使用 module.hot.accept 方法来实现。

if (module.hot) {
  module.hot.accept('./foo.js', function () {
    console.log('Accepting the update...');
  });
}

通过在 module.hot.accept 方法中传入一个回调函数,当前模块被热更新的时候,就会执行这个回调函数。如果我们看过本地开发时 Webpack 构建出来的代码,就会看到充斥着很多 module.hot.accept 这样的代码。这里不展开赘述实现的具体过程,有兴趣的同学可以自行去搜索相关的文章。

css 热更新

对于 css 场景的热更新,这里的热更新逻辑是是现在了mini-css-extract-plugin 插件中。

我们将会展开讲这里的实现逻辑。

与 js 文件同理,我们的源文件无论是独立的 less 文件,亦或是在 Vue 组件中写的 style 标签包含的样式,都会构建到一个 css chunk 文件中。

例如我们的 foo.less 文件会被构建到 index.chunk.css 文件中。

在这个场景实现热更新能力,我们只需要在修改 foo.less 文件时通知到浏览器去重新加载最新的 http://localhost:3000/index.chunk.css?timestamp=xxx文件即可。

所以我们需要建立源文件与 chunk 文件的映射关系。

这里的核心实现代码在 mini-css-extract-plugin 是如下代码

function getCurrentScriptUrl(moduleId) {
  var src = srcByModuleId[moduleId];
  if (!src) {
    if (document.currentScript) {
      src = document.currentScript.src;
    } else {
      var scripts = document.getElementsByTagName('script');
      var lastScriptTag = scripts[scripts.length - 1];

      if (lastScriptTag) {
        src = lastScriptTag.src;
      }
    }
    srcByModuleId[moduleId] = src;
  }
}

这里的 moduleId 的形式通常是如下形式 ./web/pages/index/render.vue?vue&type=style&index=0&id=6781c9f2&scoped=true&lang=css

Vue 组件将会被编译成 template, script, style 三部分。上面的 moduleId 就是 style 部分编译出来的文件标识符。

这里使用了 document.currentScript 这个对象来建立映射关系。

当浏览器加载 index.chunk.js 的时候,render.vue 文件被打包在了这个 chunk 文件。这个 chunk 文件会包含 mini-css-extract-plugin 生成的 一些钩子代码。

(function(module, __webpack_exports__, __webpack_require__) {

        "use strict";
        __webpack_require__.r(__webpack_exports__);
        var _node_modules_ssr_mini_css_extract_plugin_dist_loader_js_ref_6_0_node_modules_css_loader_dist__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
        "./node_modules/ssr-mini-css-extract-plugin/dist/loader.js?!./web/pages/index/render.vue?vue&type=style&index=0&id=6781c9f2&scoped=true&lang=css");
    }
    ),

这块本质就是注册了一个热更新函数。在加载到这块代码时使用 ./web/pages/index/render.vue?vue&type=style&index=0&id=6781c9f2&scoped=true&lang=css 这个 moduleId 去调用 getCurrentScriptUrl 函数来建立映射关系。 此时通过 document.currentScript.src 拿到的 script 标签的地址是 http://127.0.0.1:3000/static/index.chunk.js。

也就是此时的映射关系是 render.vue?type=style 源文件与 index.chunk.js 最终 chunk。

由于 js 文件与包含的 css 文件将会被构建在同名chunk中。也就是 index.chunk.css文件。所以这里我们可以建立render.vue?type=style 和 index.chunk.css 的映射关系。

也就是当我们修改 render.vue?type=style 的时候,热更新函数最终会通知浏览器去请求最新的 index.chunk.css?timeStamp=xxx 来获取到最新的 css 内容。

微前端场景下的 CSS 热更新

通过上面的代码, 我们了解到 CSS 热更新是通过建立源文件与 chunk 文件的映射关系来实现的。而这里的映射关系是通过 document.currentScript.src 来获取的。这种方式要求当前页面是通过 script 标签来加载 js 脚本文件的。

但是在微前端场景下 micro-app 会在主应用通过 fetch 的形式加载子应用的 js 文件。而子应用的 js 文件中是不会包含 script 标签的。所以在这种场景下就不能直接使用 document.currentScript.src 来获取到当前脚本的地址。

所以在微前端场景下,我们需要使用其他的方式来获取到当前脚本的地址。

这里有两种改造方式

  • 改造 micro-app 源码。将并行的 fetch 改为串行,对齐 script 的加载形式。然后通过获取当前页面 fetch 的资源地址来获取到当前脚本的地址。建立映射关系

  • 在当前应用(在本场景是ssr)提供模块编译上下文信息。然后在子应用中通过 ssr 提供的上下文信息来获取到源文件与chunk文件的映射关系

第一种方式改造起来简单,而且心智模型与原本的 script 标签思路一致。但需要修改 micro-app 源码导致需要发新的 npm 包。不够通用化。

故我们选择了第二种方式,在当前应用中提供模块编译上下文信息。然后在子应用中通过 ssr 提供的上下文信息来获取到源文件与chunk文件的映射关系。

这里的核心代码在 ssr 框架中是如下形式。

编写 webpack 插件生成映射关系文件

import { relative } from 'path'
import type { Compiler } from 'webpack'

interface FileToChunkMap {
  [filePath: string]: string
}

export class FileToChunkRelationPlugin {
  apply (compiler: Compiler) {
    compiler.hooks.emit.tapAsync(
      'FileToChunkRelationPlugin',
      (compilation, callback) => {
        const fileToChunkMap: FileToChunkMap = {}

        // Iterate through all chunks
        for (const chunk of compilation.chunks) {
          // Get all modules for this chunk
          const chunkName = chunk.name || chunk.id as string
          for (const module of chunk.modulesIterable) {
            if (module.resource) {
              let source = relative(compiler.context, module.resource)
              if (!source.startsWith('.')) {
                source = `./${source}`
              }
              fileToChunkMap[source] = chunkName
            }
          }
        }

        // Add the map as a new asset
        const content = JSON.stringify(fileToChunkMap, null, 2)
        compilation.assets['chunkMap.json'] = {
          source: () => content,
          size: () => content.length
        }

        callback()
      }
    )
  }
}

上面的插件将会生成 chunkMap.json 文件。这个文件包含了所有被打包的文件与 chunk 文件的映射关系。格式通常如下

{
"./build/ssr-declare-routes.js": "Page",
"./build/ssr-manual-routes.js": "Page",
"./web/components/layout/App.vue?vue&type=script&lang=ts&setup=true": "Page",
"./web/components/layout/index.vue?vue&type=script&setup=true&lang=ts": "Page",
"./web/components/layout/index.vue": "Page",
"./web/store/index.ts": "Page",
"./web/store/modules/detail.ts": "Page",
"./web/store/modules/index.ts": "Page",
"./web/store/modules/search.ts": "Page",
"./web/components/brief/index.vue?vue&type=script&lang=ts&setup=true": "detail-id",
"./web/components/recommend/index.vue?vue&type=template&id=ff5d5e22&scoped=true&ts=true": "detail-id",
"./web/components/search/index.vue?vue&type=template&id=5ee97dab&scoped=true&ts=true": "detail-id",
"./web/pages/detail/render$id.vue?vue&type=template&id=29d0a63f&ts=true": "detail-id",
"./web/components/brief/index.vue?vue&type=style&index=0&id=0ae141a6&lang=less&scoped=true": "detail-id",
"./web/components/player/index.vue?vue&type=style&index=0&id=d5c980b8&lang=less&scoped=true": "detail-id",
"./web/components/recommend/index.vue?vue&type=style&index=0&id=ff5d5e22&lang=less&scoped=true": "detail-id",
"./web/components/search/index.vue?vue&type=style&index=0&id=5ee97dab&lang=less&scoped=true": "detail-id",
}

key 为源文件,value 为源文件将会被构建到的最终 chunk 文件。

接着我们修改 mini-css-extract-plugin 的代码,让它在微前端场景下使用 chunkMap.json 文件来获取映射关系,而不是通过 document.currentScript.src 来获取当前脚本的地址。

这块的实现代码放在了 ssr-mini-css-extract-plugin 中。

let map = {}
if (window.microApp) {
  const { fePort, https } = window.ssrDevInfo;
  const href = `${https ? 'https' : 'http'}://127.0.0.1:${fePort}/chunkMap.json`
  map = fetchDataSync(href)
}
function getCurrentScriptUrl(moduleId) {
  var src = srcByModuleId[moduleId];
  if (window.microApp && !src) {
    // 如果是微前端场景,则根据 filename 从 chunkMap.json 中获取到对应的 chunk 文件
    var fileName = moduleId.split('!').at(-1)
    src = map[fileName]
    srcByModuleId[moduleId] = src
  }
}

接着在 update 函数中,我们实现了 css 标签的请求插入功能。(由于微前端场景 micro-app 会提取出页面的 link/script 标签自行处理,所以无法触发正常的 update 函数)

 if (window.microApp) {
      const src = getScriptSrc(options.filename);
      const { manifest, fePort, https } = window.ssrDevInfo;
      src.map(item => item.startsWith('http') ? item : manifest[item + '.css'])
        .forEach(item => {
          console.log('[HMR] css reload %s', item);
          const link = document.createElement('link');
          const href = item.startsWith('http') ? `${item}?${Date.now()}` : `${https ? 'https' : 'http'}://127.0.0.1:${fePort}${item}?${Date.now()}`
          link.href = href
          link.rel = 'stylesheet';
          link.type = 'text/css';
          document.head.appendChild(link);
        });
    }

在 update 函数中,如果当前环境是微前端场景,则获取 options.filename 对应的脚本 URL。 从 window.ssrDevInfo 中获取 manifest、fePort 和 https 信息。 遍历 src 数组中的每个项,如果项以 'http' 开头,则直接使用该项,否则从 manifest 对象中获取对应的 CSS 文件 URL。 对于每个项,创建一个新的 link 标签,并设置其 href、rel 和 type 属性,然后将其添加到 document.head 中。

最终 Demo

上述代码的最终简化版 Demo 可以通过 micro-app-ssr 来运行体验。最终效果可以做到 css 的精确热更新。在修改源文件时,只会重新加载包含该源文件的 css chunk 文件