从零读 Vite 4.0 源码(六)—— vite CSS 处理

原生 ES 是不支持在 js 文件中导入 css 文件的,因此需要构建引擎来支持。 我们来看下是如何支持的

function cssPostPlugin() {
 const styles = new Map();
 const outputToExtractedCSSMap = new Map()
 return {
  name: 'vite:css-post',

  async transform(css, id) {
      const inlined = /[?&]inline\b/.test(id)
      // 在开发模式下
      // `foo.module.css?inline` => cssContent
      if (inlined) {
          return `export default ${JSON.stringify(css)}`
      }

      // 在源 css 文件中注入代码
      const code = [
        `import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from "/@vite/client"
        )}`,
        `const __vite__id = ${JSON.stringify(id)}`,
        `const __vite__css = ${JSON.stringify(cssContent)}`,

        // 核心是__vite__updateStyle 函数,用于将 css 插入 <style> 标签中
        `__vite__updateStyle(__vite__id, __vite__css)`,
        // 使用 export default 导出字符串
        `${
          `import.meta.hot.accept()
          \n

          export default __vite__css`
        }`,
        `import.meta.hot.prune(() => __vite__removeStyle(__vite__id))`,
      ].join('\n')

      return { code, map: { mappings: '' } }


      // 在生产模式下, vite 会将 css 提取到独立的 css 文件中,因此此处不做处理
      // 具体在生产环境中通过 rollup处理 css
      styles.set(id, css)
      code = `export default ''`
    }

    // 用于修改生成的代码块的内容,它允许插件在生成最终代码块之前对代码进行转换
    // 该钩子只会在 rollup 构建过程中触发,而不会在开发模式下触发
    async renderChunk(code, chunk, opts) {
      let chunkCSS = ''
      let isPureCssChunk = true
      const ids = Object.keys(chunk.modules)
      for (const id of ids) {
        if (styles.has(id)) {
          chunkCSS += styles.get(id)
          // a css module contains JS, so it makes this not a pure css chunk
          if (cssModuleRE.test(id)) {
            isPureCssChunk = false
          }
        } else {
          // if the module does not have a style, then it's not a pure css chunk.
          // this is true because in the `transform` hook above, only modules
          // that are css gets added to the `styles` map.
          isPureCssChunk = false
        }
      }

      // 未配置 cssCodeSplit 的情况
      // 处理 css 中的路径
      chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssBundleName)
      // 用于在 generateBundle 阶段聚合模块
      outputToExtractedCSSMap.set(
          opts,
          (outputToExtractedCSSMap.get(opts) || '') + chunkCSS,
        )
    }
    async generateBundle(opts, bundle) {
      this.emitFile({
        name: 'style.css',
        type: 'asset',
        source: outputToExtractedCSSMap.get(opts),
      })
    }
  }
}

补充一下 updateStyle 是如何实现的:

function updateStyle(id, content) {
  style = document.createElement('style')
  style.setAttribute('type', 'text/css')
  style.setAttribute('data-vite-dev-id', id)
  style.textContent = content

  if (!lastInsertedStyle) {
    document.head.appendChild(style)

    // reset lastInsertedStyle after async
    // because dynamically imported css will be splitted into a different file
    setTimeout(() => {
      lastInsertedStyle = undefined
    }, 0)
  } else {
    lastInsertedStyle.insertAdjacentElement('afterend', style)
  }
  lastInsertedStyle = style
}

可以看到,每当 import 了 css, vite 会创建一个 style 标签,并将内容插入到 document 中。 同时可以看到,vite 通过 lastInsertedStyle 这个特殊的样式标签,用来标识最后一个插入文档中 style 标签。从而正确保证 css 插入的顺序。

在异步操作之后重置 lastInsertedStyle。在 Vite 中,当 CSS 动态导入时,它们可能会被拆分成不同的文件。由于异步加载的特性,可能在异步加载期间已经插入了一些 CSS 样式标签,而这些标签的 lastInsertedStyle 可能仍然指向之前的样式标签。为了解决这个问题,这段代码使用了 setTimeout 函数,将重置 lastInsertedStyle 的操作推迟到下一个事件循环中执行。通过将重置操作放在下一个事件循环中,可以确保异步加载的 CSS 样式标签已经被插入到文档中,并且它们的 lastInsertedStyle 已经更新为最新的值。

这样做的目的是确保在异步加载的 CSS 样式标签被插入到文档后,lastInsertedStyle 变量的值被正确地重置,以准确地反映当前最后插入的样式标签。这有助于保持 CSS 的加载和应用顺序的正确性。

同时在 vite 中,支持如下功能(from 官网):

  • 如果项目中包含 postcss 配置,会自动应用与所有导入的 CSS
  • 任何以 .module.css 为后缀名的 CSS 文件都被认为是一个 CSS modules 文件。导入这样的文件会返回一个相应的模块对象。
  • 提供了对 .scss, .sass, .less, .styl 和 .stylus 文件的内置支持

接下来我们来看一下 vite 是如何实现这一系列的处理的。

// 该插件在所有用户插件之前调用
function cssPlugin() {
  return {
    name: 'vite:css',
    async transform(raw, id) {
        const {
          code: css,
          modules,
          deps,
          map,
        } = await compileCSS(id, raw, config, urlReplacer)

        // 如果在开发模式下,需要更新依赖图,在下文 HMR 部分介绍
    }
}
}

我们来看一下核心函数 compileCSS 的实现:

const importPostcss = createCachedImport(() => import('postcss'))

function compileCSS(id, code) {
  const lang = id.match(/\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/)?.[1] | undefined

  // 1. plain css 不需要任何处理,直接返回
  if (lang === 'css') {
    return {
      code,
      map: null
    }
  }

  // 2. 预处理 sass 等
  if (isPreProcessor(lang)) {
    const preprocessorResult = await compileCSSPreprocessors(
      id,
      lang,
      code,
      config,
    )

    code = preprocessorResult.code
    preprocessorMap = preprocessorResult.map
    preprocessorResult.deps?.forEach((dep) => deps.add(dep))
  }

  // 3. 如何配置了 postcss-plugin 或使用了 css-module, @import 等能力,则调用 postcss 处理
  const postcss = await importPostcss()
  postcssResult = await postcss.default(postcssPlugins).process(code, {})

  // 收集依赖(根据 css 中的 @import)
   for (const message of postcssResult.messages) {
      if (message.type === 'dependency') {
        deps.add(message.file)
      }
   }

  return {
    ast: postcssResult,
    code: postcssResult.css,
    map: combineSourcemapsIfExists(cleanUrl(id), postcssMap, preprocessorMap),
    deps,
  }
}

HMR 支持

css 的热重载是如何实现的呢? 我们回忆一下热重载整体的步骤,首先生成依赖图,之后监听到文件修改后,生成热更相应的信息,发送给客户端(浏览器),由浏览器执行对应的 import.meta.hot.accept 回调,来重新执行对应的代码。css 也是作为模块进行处理,因此与 js 热重载有相似之处,我们依次来看下在对应的实现。

  1. 生成依赖图

在预处理 css 的插件中,存在如下逻辑:

function cssPlugin() {
  return {
    name: 'vite:css',
    async transform(raw, id) {
        // 预处理 css

        // 如果在开发模式下,需要更新依赖图
        const thisModule = moduleGraph.getModuleById(id)
        if (thisModule) {
          if (deps) {
            for (const file of deps) {
              // 通过 @import 引入的 css 文件,没有自己的 url, 因为他们内嵌在 main css 中,但他们仍需要包含在依赖图中
              // 因此为他们 mock 了一个 url, 并加入到依赖图中
              depModules.add(moduleGraph.createFileOnlyEntry(file))
            }

            // 缓存到依赖图中
            moduleGraph.updateModuleInfo(
              thisModule,
              depModules
            )
          }
        }
    }
  }
}

2.服务端生成热更信息 这一步与 js 的生成方式一致,生成的热更信息示例如下:

{
  acceptedPath: '/src/style.css',
  path: 'src/style.css',
  type: 'js-update',
  timestamp: 1638052794000,
  // 对于 css 文件,除了使用了 Inline, css module 特性的文件,其他都是 selfAccepting
  isSelfAccepting: true
}
  1. 客户端执行热更回调 css 文件修改后是:
import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/style.css");
import {updateStyle, removeStyle} from "/@vite/client"
const id = "/src/style.css"
const css = "color: red;"
// 替换 style 标签
updateStyle(id, css)
import.meta.hot.accept()
export default css

// 当模块被移除时, css 样式也会被移除
import.meta.hot.prune(()=>removeStyle(id))

这里我一开始对 import.meta.hot.accept() 这句的作用比较模糊,看起来它并没有指定热更新对应的 callback 函数,也就是说当 style.css 变化也不会触发任何回调的执行,那么它是如何执行热模块替换的呢?

仔细回顾一下代码,可以发现 import.meta.hot.accept() 这句其实是为了在 hotModulesMap 对象中注册 style.css 模块,表示该模块会响应服务端发送过来的热更信息,执行新模块的加载和回调的执行。

如果没有 import.meta.hot.accept() 这句,那么 style.css 文件的改动将不会触发任何热更新行为。

那么既然可以响应服务端发过来的热更信息,但是却没有回调,说明不是靠回调来完成的热更新,而是通过 css 特有的方式:注入的 updateStyle 函数,每次加载新的模块时,都会去执行 updateStyle 方法,来更新 style 标签中的内容,从而实现了热更新。

当 style.css 文件被移除时, import.meta.hot.prune 钩子会被触发,清除 style.css 引入的副作用。