前几天,旁边的同事突然问我,在 <style scoped>
块中写 css 时,为什么有的需要使用样式穿透,而有的又不需要?我当时想了想,确定这又是一个知识盲区...
那 Vue 是如何做到样式隔离的呢?
这里先占个坑...
今天主要讲的是 scoped id
或者说 component id
在 Vue 内部是如何生成的。
本文主要是 vue3 + vite 生态,对于 webpack 生态来说,原理应该也是类似的。
如下图所示,由 <template>
模版映射到 DOM 之后,组件的元素节点上都加上了 data-v-7ba5bd90
属性,而 <style>
块中的 css 样式,最终也被编译成了 .count[data-v-7ba5bd90]
的形式,而这也正是组件实现样式隔离的关键。
源码实现
我们知道,在 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
。
更多细节见: PR 和 Playground
自定义 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 是怎么生成的 看到一个关于微前端的样式隔离方案的讨论:
经过上面的分析,我们发现确实会存在这样的问题,那应该如何解决?
这就要用到 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 : ''))
}
},
}),
],
})
解决方案就是在原来的基础上再加上 项目名称 后再哈希,这样就解决了样式冲突的问题。