Vue 组件中用于样式隔离的 data-v-xxx 是如何生成的?

177 阅读3分钟

前几天,旁边的同事突然问我,在 <style scoped> 块中写 css 时,为什么有的需要使用样式穿透,而有的又不需要?我当时想了想,确定这又是一个知识盲区...

那 Vue 是如何做到样式隔离的呢?

这里先占个坑...

今天主要讲的是 scoped id 或者说 component id 在 Vue 内部是如何生成的。

本文主要是 vue3 + vite 生态,对于 webpack 生态来说,原理应该也是类似的。

如下图所示,由 <template> 模版映射到 DOM 之后,组件的元素节点上都加上了 data-v-7ba5bd90 属性,而 <style> 块中的 css 样式,最终也被编译成了 .count[data-v-7ba5bd90] 的形式,而这也正是组件实现样式隔离的关键。

image.png

源码实现

我们知道,在 vite 生态中,.vue 文件其实是由 vite-plugin-vue 插件来做转换的。

因此,component id 具体的代码实现其实是位于 vite-plugin-vue 仓库中。

packages\plugin-vue\src\utils\descriptorCache.ts:

import crypto from 'node:crypto'
import { normalizePath } from 'vite'
import path from 'node:path'

export function createDescriptor(
  filename: string,
  source: string,
  {
    root,
    isProduction,
    sourceMap,
    compiler,
    template,
    features,
  }: ResolvedOptions,
  hmr = false,
): SFCParseResult {
  const { descriptor, errors } = compiler.parse(source, {
    filename,
    sourceMap,
    templateParseOptions: template?.compilerOptions,
  })

  // ensure the path is normalized in a way that is consistent inside
  // project (relative to root) and on different systems.
  const normalizedPath = normalizePath(path.relative(root, filename))

  const componentIdGenerator = features?.componentIdGenerator
  if (componentIdGenerator === 'filepath') {
    descriptor.id = getHash(normalizedPath)
  } else if (componentIdGenerator === 'filepath-source') {
    descriptor.id = getHash(normalizedPath + source)
  } else if (typeof componentIdGenerator === 'function') {
    descriptor.id = componentIdGenerator(
      normalizedPath,
      source,
      isProduction,
      getHash,
    )
  } else {
    // 默认模式,开发模式下使用 文件相对路径;生产环境使用 文件相对路径 + 文件内容
    descriptor.id = getHash(normalizedPath + (isProduction ? source : ''))
  }

  ;(hmr ? hmrCache : cache).set(filename, descriptor)
  return { descriptor, errors }
}

const hash =
  // eslint-disable-next-line n/no-unsupported-features/node-builtins -- crypto.hash is supported in Node 21.7.0+, 20.12.0+
  crypto.hash ??
  ((algorithm, data, outputEncoding) =>
    crypto.createHash(algorithm).update(data).digest(outputEncoding))

function getHash(text) {
  return hash('sha256', text, 'hex').substring(0, 8)
}

先下结论: component id 是由 sha256 算法生成的 hash 截取的前 8 位字符串得到。

目前 vite-plugin-vue 已经支持自定义 componentId,也就是上面的 features?.componentIdGenerator

更多细节见: PRPlayground

自定义 componentId

componentIdGenerator 的类型定义如下:

export interface Options {
  // ... ignore other options
  features?: {
    componentIdGenerator?:
      | 'filepath'
      | 'filepath-source'
      | ((
          filepath: string,
          source: string,
          isProduction: boolean | undefined,
          getHash: (text: string) => string,
        ) => string)
  }
}

可以看到,它支持三种模式来生成 componentId:

  • filepath: 根目录下文件相对路径
  • filepath-source: 根目录下文件相对路径 + 文件内容
  • 自定义函数: 开发者决定

栗子🌰

假设在开发模式下,且没有配置 componentIdGenerator 选项。

HelloWorld.vue 文件在项目下的路径是 vite-project/src/components/HelloWorld.vue,打印一下,看看这些值长啥样。

import crypto from 'node:crypto'
import { normalizePath } from 'vite'
import path from 'node:path'

const hash =
  // eslint-disable-next-line n/no-unsupported-features/node-builtins -- crypto.hash is supported in Node 21.7.0+, 20.12.0+
  crypto.hash ??
  ((algorithm, data, outputEncoding) =>
    crypto.createHash(algorithm).update(data).digest(outputEncoding))

function getHash(text) {
  return hash('sha256', text, 'hex').substring(0, 8)
}

const filename = 'D:/www/demo/vite-project/src/components/HelloWorld.vue'
const root = process.cwd()
// 文件相对路径 'src/components/HelloWorld.vue'
const normalizedPath = normalizePath(path.relative(root, filename))
// development mode
const id = getHash(normalizedPath)
// production mode
// `source` is the source code of the file
// const id = getHash(normalizedPath + source)

console.log(
  JSON.stringify(
    {
      id,
      filename,
      root,
      normalizedPath,
      originalHash: hash('sha256', normalizedPath, 'hex'),
    },
    null,
    2,
  ),
)

输出:

{
    "id": "e17ea971",
    "filename": "D:/www/demo/vite-project/src/components/HelloWorld.vue",
    "root": "D:\\www\\demo\\vite-project",
    "normalizedPath": "src/components/HelloWorld.vue",
    "originalHash": "e17ea97189bf2c22f00b4906c6ccd156a96060cc8fdeccd3a4ee23bfa2a758ad"
}

结论: 使用 normalizedPath (文件相对路径) 生成的 hash,然后截取字符串的前 8 位就是 component id 了。

在这篇文章中 Vue 项目中的 data-v-xxx 是怎么生成的 看到一个关于微前端的样式隔离方案的讨论:

image.png

经过上面的分析,我们发现确实会存在这样的问题,那应该如何解决?

这就要用到 componentIdGenerator 选项了,vite.config.ts 配置如下:

import { defineConfig } from 'vite'
import vuePlugin from '@vitejs/plugin-vue'
// 项目名称
import { name } from './package.json'

export default defineConfig({
  plugins: [
    vuePlugin({
      features: {
        componentIdGenerator: (
          filepath: string,
          source: string,
          isProduction: boolean | undefined,
          getHash: (text: string) => string
        ) => {
          return getHash(name + filepath + (isProduction ? source : ''))
        }
      },
    }),
  ],
})

解决方案就是在原来的基础上再加上 项目名称 后再哈希,这样就解决了样式冲突的问题。

参考