现状分析
起因是一个以微信小程序为主开发的 uni-app 应用,在编译到 App 端后,出现了各种样式问题。
为了更好地理解这个问题,让我们创建一个最小化的示例项目:
pnpm create uni@latest # 什么都不加
一个经典的嵌套组件,用于测试三个端组件样式表现:

这里可以看到,每个组件都开启了 style scoped,此时三个端的表现是一致的,没有任何问题:

然而,在实际开发中,我们经常会遇到这样一种情况:由于启用了 style scoped,往往会倾向于使用简单的 class 命名,比如大量使用 container 这样的通用类名。让我们看一个具体示例:

在这种情况下,三个端的表现会出现明显的差异:

这是 bug?先说结论,这是 Vue 的 feature:
使用 scoped 后,父组件的样式将不会渗透到子组件中。不过,子组件的根节点会同时被父组件的作用域样式和子组件的作用域样式影响。这样设计是为了让父组件可以从布局的角度出发,调整其子组件根元素的样式。
可前面说了,我们是以微信小程序为主进行开发,看到这种情况还是会非常懵逼。
所以,这就不得不先看一下 uni-app 在不同端是如何实现样式隔离的。
样式隔离
App 端的实现方式其实与我们熟悉的 Vue 浏览器端机制一样:它们都是通过为每个标签动态添加 [data-v-scopeId] 属性来实现样式的作用域隔离。
而在微信小程序端,由于小程序的 CSS 不支持属性选择器,uni-app 采用了一种变通方案:为每个标签添加带有 [data-v-scopeId] 的 class 来实现隔离效果。
下面这张图可以帮助我们直观地理解三个端的 HTML 结构差异:

这里我们可以留意到,子组件的根元素会附带上父组件的 scopeId,所以父组件可以影响它的样式。
而在微信小程序,因为多了一层 <components/comp-child> 这样的东西,scopeId 并不在真正的组件根元素,所以它的表现会与其余两个端不一致。
解决方案
因为我们还是希望 App 端可以与微信小程序端的表现保持一致,此时有两条路可以走:
- 不要这个 feature
- 保留这个 feature
反正我们的目的只有一个:减少开发时的心智负担。
方案一
对于第一条路子,实现方式非常简单粗暴,既然 Vue 只针对单根组件会有这个 feature,那我们强制所有组件变成多根节点就好了,简单说就是实现一个 Vite 插件,利用 transform 钩子往 Vue 组件顶层插入一个 <Fragment /> 元素,这样立马可以让 App 和 H5 的表现与微信小程序保持一致。
然而这样会损失 Vue 特地带来的便利,正如官方文档所说,这个 feature 会无形中减少很多布局实现上的麻烦,因此最终还是决定让微信小程序能够对齐另外两端的实现。
方案二
根据前面的 HTML 结构图可以看到,微信小程序主要是因为多了一层节点(虚拟节点),是不是只要把它干掉就好了?
说干就干,在 uni-app 文档中指出,只要在 Vue 组件中配置 virtualHost: true 并且配合 mergeVirtualHostAttributes 即可合并组件虚拟节点外层属性:

然而,此时 HTML 结构中 scopeId 虽然已经放到组件的根节点上,但样式表现仍然没有发生变化,这是因为 uni-app 默认给微信小程序组件设置的样式隔离是 apply-shared,还需要把 styleIsolation 改成 shared 才能使其影响其它组件。
对应到本文例子就是要给 comp-parent.vue 添加:
defineOptions({
options: {
virtualHost: true,
styleIsolation: "shared",
},
});
修改以后,微信小程序上的表现已经完全与其余两端一致:

这个方案的好处在于,可以让微信小程序组件无限接近 Vue 组件的表现,除了样式透传,还包括可以属性透传,比如 id、class、style、v-show 等。
我写了一个 Vite 插件自动注入这个 virtualHost: true 配置,有兴趣可以看:https://github.com/gd4Ark/vite-plugin-uni-virtual-host
页面样式优先级
其实到这里三端表现已经基本一致,但在微信小程序还有一些细微的差别,主要问题是出在样式优先级。
还是上面那个例子,在页面级组件去覆写子组件样式:

由于微信小程序加载 css 顺序的问题,表现与其余两端不一致:

这问题看起来是无解的,只能手动识别这种情况,并添加 !important 提高样式权重。