
开发 Vue 组件库时,样式丢失问题
最近在开发 hana-img-viewer 的时候,刚好遇到了 vite 打包时样式丢失的问题,稍微记录一下。
我们平常使用 Vue3 + Vite + TS 的组合来开发 SPA 应用,进行打包时,基本上只用使用 vite 的默认配置即可,非常的自然方便。
我们开发 SPA 时,有一个统一的入口文件 index.html,它的 <script> 标签和 <link> 标签分别用来引入 .js 和 .css。我们访问这个文件,它会自动根据浏览器的机制进行加载。
但是,当我们开发组件库时一切都变得不一样了。对于组件库来说,我们可能只是单纯的开发一个 vue 小组件,能够被其他 Vue 项目安装并像 import 其他我们自己写的 .vue 组件那样使用。
这个时候,实际上 .css 文件是不会被加载的,因为我们没有像 index.html 那样统一的入口自动触发对 .css 的加载。
首先明确一下 Vite 对于简单的 Vue SPA 项目的打包逻辑:
我们在 SFC .vue 文件内部写的 <style> 标签,实际上 都会被 Vite 打包时单独抽出来,单独写到 .css 文件里面 。
然后, index.html 会把这两个文件引进来:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<script type="module" crossorigin src="/assets/index-BRpKQ0iZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CApl5tz6.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
这样子,浏览器加载 index.html 时会自动加载这两个文件,可喜可贺可喜可贺
不过,对于我们自己想要写一个小的组件库来说,就变得很麻烦。如果我们在 <style> 里面写了样式,就会被拆出来,不会在我们编译好的 .js 文件里面自动 import './index.css'; 。如果用户装了库,必须得手动 import 'xxx/assets/index.css' 引入样式,否则不显示样式 。
这比较反直觉,不过实际上这也是 Vite 有意的设计为之。
如果我们想要让 css 内联到 js 内部,对 SSR 就是致命的:Node.js 环境没有 document,document.createElement("style") 就会报错。
而且 CSS 内联到 JS 中,代码体积也会变大。
其实上还有一种方案:我们可以直接在编译完成的 .js 文件的顶部加一行:import './index.css',不过实际上这就变成了 必须要确保消费者的打包工具必须能处理 CSS import,约定变成了消费者一方,不符合组件库开箱即用的逻辑。
所以,我们如果想要将样式直接内联到 .js 内部,必须要借助一个特殊的 vite 插件:vite-plugin-css-injected-by-js,这个插件可以把原本单独打包的 .css 文件样式内联到 .js 中,也就是 CSS-in-JS。
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import cssInjectedByJs from 'vite-plugin-css-injected-by-js'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
cssInjectedByJs(),
],
})
直接加两行代码就行了。
在我的组件库上面试一下,用这个插件之前的打包产物:
可以看到有个 .css,.js 里面只有一行很长的 import:
之后运行 pnpm build,观察一下打包产物:
看一下 .js:
可以看到最上面多出了一个 IIFE,里面就是我们的 CSS-in-JS 的核心逻辑,我们仔细看看:
;(function () {
'use strict'
try {
if (typeof document < 'u') {
var a = document.createElement('style')
a.appendChild(
document.createTextNode(
'.hana-mask-fade-enter-active,.hana-mask-fade-leave-active{transition:opacity var(--v28ae7430) var(--v4a6b54b1)}.hana-mask-fade-enter-from,.hana-mask-fade-leave-to{opacity:0!important}.hana-mask-fade-enter-to,.hana-mask-fade-leave-from{opacity:var(--v297ad3b9)!important}'
)
),
document.head.appendChild(a)
}
} catch (e) {
console.error('vite-plugin-css-injected-by-js', e)
}
})()
这里有个非常神奇的东西:typeof document < 'u',用这东西来判断是不是 Node.js 环境。实际上这算是一种比较常见的代码压缩方法,如果我们来写,可能会这么写:
if (typeof document !== 'undefined')
这比上面的逻辑多了 11 个字节。
因为 typeof 实际上只会返回 object、function、string、number、boolean、symbol、bigint、undefined 这几个字符串,而正好 undefined 的 u 是字典序中最大的,其他字符串的首字母都在 u 之前,而 JS 的字符串比较就是单纯一个个字符的字典序比过来,所以可以直接用 < 'u' 来判断是不是 typeof document !== 'undefined'。
这也是 Terser/esbuild 的标准压缩技巧。
可以看到,这个插件就是加了一个 style 标签,里面把 .css 文件里面的内容直接 createTextNode 塞进去就完事了,最后 appendChild 把这个 style 挂到 head 里面。
所以我们开发组件库的时候,如果想内联样式,就直接用这个插件吧。vite 对 vue 项目的打包的“开箱即用”,多半还是为了开发标准的 SPA 做的准备。