刚入坑微信小程序那段时间,我犯过一个挺典型的错误:以为WXSS就是把CSS换个名字,顶多写样式时把px换成rpx。直到接手一个社区团购项目,写了一堆自以为很漂亮的模板样式,结果在真机上一查,大量样式要么不生效,要么在安卓和 iOS 上长得完全两个样,才意识到 WXSS 并不是 CSS 的“阉割版”,而是一套需要重新理解的“模板样式”。
所谓模板样式,我的理解是:它不只是普通样式表,而是给小程序渲染层量身定制的一套样式方案,核心包含三块——尺寸适配体系(rpx)、选择器规则、样式隔离机制。理解了这三件事,写出来的页面布局才能在各种机型上稳定呈现,也为后面接更多项目打好地基。这篇帖子不打算把官方文档复述一遍,而是把我实际做生鲜配送、社区团购、商品列表这类页面时踩过的坑和沉淀下来的写法都捋一捋,特别是顶部导航栏高度适配、列表加载更多状态这些被高频搜索的场景,全部给出一套可以直接抄作业的 WXSS 写法。
1. 为什么说 WXSS 是“模板样式”:它的定位和边界
1.1 WXSS 不是 CSS 的阉割版,而是定制版
很多刚转小程序开发的切图仔都会有一种错觉:CSS 能用的东西,WXSS 应该都能用。等真写起来就发现,WXSS 是一套与 CSS 大部分语法兼容、但又有明确边界的样式语言。它保留了类选择器、ID选择器、元素选择器、伪元素这些最常用的能力,但阉割了 CSS 里的部分选择器,并加入了 rpx、样式隔离这些 CSS 里没有的概念。
小程序之所以没有直接搬全套 CSS,是因为渲染层是基于 WebView 或者 Skyline 这类自绘渲染引擎的,开发者写的样式最终要经过一层编译与运行时映射。为了保证渲染性能和一致性,设计者会刻意砍掉一些不常用或者性能代价高的特性。比如通配符*在 WXSS 里就不支持,你在普通网站里写的 reset 样式直接搬过来,大概率会静默失效。
理解了这层逻辑,你就会明白一件事:遇到样式不生效,不一定是代码错了,更可能是 WXSS 的“边界”问题。我建议把 WXSS 当成“面向小程序场景的 CSS 方言”来看,先接受它的约束,再谈怎么用好它。
1.2 “模板”两个字到底指什么
“模板样式”这个词,我倾向于拆成两个层面去理解。
第一层是“页面模板”。小程序里每个页面有自己对应的一个 .wxml 骨架和 companion 的 .wxss 皮肤,同一个 .wxml 结构配不同的 wxss 类名,就能得到完全不同的视觉风格。说白了,wxml 是房子结构,wxss 是装修方案。
第二层是“复用模板”。实际项目里,商品列表、订单卡片、提示条、空状态、加载更多这些模块,几乎每个页面都会出现。真正高效的团队,不是每个页面重新写一遍样式,而是把公共样式沉淀成一套可复用的类名体系,甚至配合<template>或自定义组件做成“模板片段+模板样式”的组合。比如一个goods-card类,写一次,商品列表页、搜索页、收藏页、订单页都能用,这就是我理解的模板样式精髓。
所以写 WXSS 的时候,我建议你随时问自己:这个类以后会不会在别的页面复用到?如果会,就别把它的样式限定死在某一个页面里,而是抽到公共样式文件或组件里。
2. rpx 适配原理:为什么小程序要用这么一套单位
2.1 rpx 是怎么换算的
rpx 是小程序最早提出的一套响应式尺寸单位,设计初衷很简单:不管手机屏幕多宽,一律把屏幕宽度分成 750 份。也就是说,750rpx永远等于屏幕宽度,375rpx永远等于屏幕宽度的一半。
举个例子,iPhone 6/7/8 的逻辑分辨率是 375px,那么 1rpx = 0.5px。安卓常见机型逻辑宽度是 360px,那么 1rpx 就约等于 0.48px。换算公式是:
px = rpx × (屏幕逻辑宽度 / 750)
因为设计稿通常按 750px 宽度出图,所以我们写代码时可以直接把设计稿上的 px 数值当成 rpx 来写,比如设计稿里一个卡片宽度是 340px,那 WXSS 里就写width: 340rpx。这就是 rpx 省心的地方,设计师给的稿子基本能原样搬。
但是注意,这个换算公式只针对页面内容区域。如果需要用 JS 动态计算一些尺寸,比如后面要讲的自定义顶部导航栏,就不能直接用 rpx 了,因为 JS 拿到的胶囊按钮数据都是 px,需要获取窗口宽度后手算:(rpx / 750) * windowWidth。好在wx.getWindowInfo()这个接口能拿到窗口宽度,配合公式就能做动态适配。
2.2 rpx 不是万能的,这几个场景得绕道
rpx 虽然省事,但用久了你会发现它也不是没有边际。我总结了一下需要避开 rpx 的场景:
- 固定物理粗细的边框。rpx 在不同宽度屏幕上换算结果不一样,会导致边框视觉粗细不一致。比如你写
border: 2rpx solid #eee,在 iPhone 6 上约等于 1px,但在屏幕更宽的机型上可能变成 0.8px 或 1.2px,观感差很多。真需要极细的分隔线,建议用1px加transform: scale()的 hairline 写法,或者干脆接受普通border: 1px。 - 横屏和平板场景。某些小程序需要支持横屏,一旦横过来,窗口宽度变大,rpx 会把元素整体放大,比例容易失真。iPad 这类大屏设备更明显,一个 750rpx 宽的卡片在 iPad 上可能被拉伸得非常夸张。这种场景建议用 vw / vh 或百分比布局,必要时配合媒体查询。
- 极端注意字体大小。rpx 用于字号是多数团队的主流写法,但少数安卓机在极小字号下会对 rpx 换算做舍入,导致文字渲染偏糊。为保证关键文字清晰,也有人选择字号用 px,间距布局用 rpx,这算是一个折中策略,我不强推,但你可以根据自己的目标机型去测试。
3. 选择器与样式隔离:模板样式在组件体系里的边界
3.1 WXSS 支持哪些选择器,不支持哪些
模板样式最容易被忽略的地方是选择器边界。官方文档明确支持的选择器说多不多、说少也不少,我把实际工作中最常用的整理成一张表:
| 选择器类型 | WXSS 支持情况 | 实际建议 |
|---|---|---|
| 类选择器 | 支持 | 主力,用得最多 |
| ID 选择器 | 支持 | 少用,因为复用性差 |
| 元素选择器(view、text) | 支持 | 组件内避免,容易误伤 |
| 后代选择器 | 支持 | 注意嵌套层级别太深 |
| 子元素选择器 | 支持 | 可以用 |
| 属性选择器 | 支持,依赖基础库 | 建议只做少量使用 |
| 通配符 | 不支持 | 别写*,用容器类替代 |
| 伪元素 | 支持,需用双冒号 | 装饰类样式常用 |
| 伪类 | 有限支持 | 按钮态等谨慎使用 |
很多人写重置样式时习惯来一句* { margin: 0; padding: 0; },在 WXSS 里这就是无效代码。正确的替代方案是给根容器定义一个基础类,比如页面外层统一用.page,然后.page { margin: 0; padding: 0; }。虽然麻烦点,但这也逼着你从一开始就养成给容器命名的习惯,反而对维护有利。
这里还要提醒一句:小程序每个页面的根节点还有一个特殊选择器page,它对应整个页面容器。想在页面里写背景色,直接写page { background: #f5f5f5; }就够了,不需要给最外层 view 设置高度才能铺满。这个细节很多新手会卡很久。
3.2 样式隔离机制:为什么组件里的样式改不动
另一个高频踩坑点是:在自定义组件里写样式,页面或 app.wxss 里的样式怎么都影响不到组件内部。这不是 bug,是官方故意的。小程序自定义组件默认开启样式隔离,app.wxss里定义的样式默认不会作用到组件内部,页面 wxss 同样默认不会穿透进组件。
这么设计是有道理的:组件本来就是为了复用,如果不隔离样式,外部一改类名就可能把组件里所有页面拖下水,那组件就失去意义了。但实际开发中我们又确实需要让某些组件适配不同页面,比如同一个商品卡片,首页要圆角、搜索结果页要直角。
官方给的解决方案是配置组件构造器里的styleIsolation选项,可选值isolated(完全隔离,默认)、apply-shared(页面样式会影响组件,但组件不影响页面)、shared(双向影响)。我通常只在需要“皮肤定制”的组件上设置apply-shared,一般不推荐shared,因为它会让组件样式反过来污染页面,排查问题成本很大。
除了styleIsolation,还有externalClasses外部样式类方案:组件 js 里声明externalClasses: ['custom-class'],然后在组件内部使用custom-class作为样式入口,外部调用组件时传入自定义类名就能控制局部外观。这种方案比styleIsolation更语义化,建议作为首选。
4. 模板样式的组织方式:全局样式、页面样式和公共样式拆分
4.1 三级文件的职责划分
小程序样式文件天然分成三个层级:app.wxss、页面 wxss、自定义组件 wxss。这三个文件各司其职,我最怕看到的情况是把所有公共样式都塞进 app.wxss,页面 wxss 里全是页面私有临时的类,组件 wxss 又拿来盖页面样式,最后整个项目样式关系拧成一团。
app.wxss我建议只放三类东西:全局的基础 reset(注意别用通配符)、全局通用的颜色和间距变量(用类名约定模拟变量)、以及 App 级别真正所有页面都会用的布局类。放得越少越好,因为 app.wxss 会被所有页面注入,内容越庞杂,编译和启动阶段的样式处理开销越大。
页面 wxss 负责当前页面独有的布局和模块样式。采集页面和商品详情页这类同构页面之间,如果有大段重复的样式,不应该靠复制粘贴,而是抽到公共样式文件里通过 @import 引入。
组件 wxss 则要做到彻底的“自包含”:一个组件文件就往里放它自己需要的最小样式集合,不要依赖页面类名。这一点和前面讲的样式隔离机制是配套的。
4.2 用 @import 拆公共样式,而不是全部堆在 app.wxss
小程序支持@import语法,可以在任何一个 wxss 文件里引入其他 wxss 文件。我常用的组织方式是在项目里建一个styles目录,放几个职责单一的文件:
styles/reset.wxss:页面基础样式重置styles/common.wxss:通用布局类、通用按钮、通用表单元素styles/theme.wxss:颜色、圆角、间距的“约定类”,比如.text-primary { color: #07c160; }、.bg-primary { background: #07c160; }
需要哪个页面就@import "../../styles/common.wxss";。注意 @import 语句后面必须带分号,路径是相对于当前文件的相对路径,写错不会报错但样式就是不出来,排查起来挺头大。
这套组织方式的好处是,每个页面只加载自己需要的公共样式,app.wxss 体积能保持克制。做生鲜、团购这类多页面项目时,页面数一多,这个优势会非常明显。
4.3 类名命名规范:靠体系降低认知成本
模板样式能不能沉淀下来,关键看命名。我现在基本沿用 BEM 的思路简化出一套约定:
- 块:
.goods-card,代表一个独立可复用模块 - 元素:
.goods-card__title、.goods-card__img,内部组成 - 状态:
.goods-card--soldout、.goods-card--active,修饰状态
这套命名虽然敲起来长一点,但换来的是“看到类名就知道这个样式属于哪个模块,改动影响范围多大”的确定性。在小程序里尤其重要,因为模板样式复用频繁,没有清晰命名,类名冲突只是早晚的事。
5. 高频热搜场景的 WXSS 写法:顶部导航、列表加载、表单控件
5.1 自定义顶部导航栏的高度适配
“微信小程序顶部导航栏高度”是搜索频率很高的词,因为这个高度不是写死的。如果你在app.json或某个页面的 json 里配置了"navigationStyle": "custom",就会隐藏默认导航栏,这时候顶部区域全靠自己画。问题在于:不同机型的状态栏高度不一致,胶囊按钮的位置也不一致,写死一个 88rpx 肯定翻车。
正确做法是在 JS 里动态获取胶囊按钮位置,再换算导航栏高度,大概代码如下:
Page({ onLoad() { const windowInfo = wx.getWindowInfo(); const menu = wx.getMenuButtonBoundingClientRect(); const statusBarHeight = windowInfo.statusBarHeight; const navHeight = (menu.top - statusBarHeight) * 2 + menu.height; this.setData({ statusBarHeight, navHeight }); } });拿到数值后,模板里这样用:
<view class="nav" style="padding-top: {{statusBarHeight}}px; height: {{navHeight}}px;"> <view class="nav__title">首页</view> </view>WXSS 里只需要控制内容区的表现:
.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: #ffffff; box-sizing: border-box; } .nav__title { height: 56rpx; line-height: 56rpx; font-size: 34rpx; font-weight: 600; text-align: center; }这套写法的核心逻辑是:用状态栏高度撑开安全区域,用胶囊按钮的位置计算出真正导航栏的高度,保证右侧胶囊始终和自定义标题垂直方向对齐。注意这里内联 style 用的是 px,因为胶囊按钮返回的是 px,千万别自作聪明乘什么 rpx 系数。
5.2 列表页“加载更多”的三态样式
“页面列表加载更多”也是高频需求。实际项目里常见的问题是只做了加载中转圈,没有“全部加载完毕”和“加载失败”的状态,用户体验会差不少。我把加载更多抽成一个固定模板,用loadState字段驱动三种状态:
loading:加载中,显示转圈 + “加载中”error:加载失败,显示“加载失败,点击重试”end:没有更多了,显示一条分割线和“已经到底啦”
样式写法如下:
.loading-footer { display: flex; align-items: center; justify-content: center; padding: 24rpx 0; color: #999; font-size: 24rpx; } .loading-footer__spinner { width: 32rpx; height: 32rpx; border: 3rpx solid #ddd; border-top-color: #576b95; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 12rpx; } .loading-footer__line { flex: 1; height: 1px; background: #eee; } @keyframes spin { to { transform: rotate(360deg); } }模板部分大概长这样:
<view wx:if="{{loadState === 'loading'}}" class="loading-footer"> <view class="loading-footer__spinner"></view> <text>加载中</text> </view> <view wx:elif="{{loadState === 'error'}}" class="loading-footer" bindtap="reload"> <text>加载失败,点击重试</text> </view> <view wx:elif="{{loadState === 'end'}}" class="loading-footer"> <view class="loading-footer__line"></view> <text>已经到底啦</text> <view class="loading-footer__line"></view> </view>这样配合请求封装里的统一 loading 逻辑,所有列表页都能复用同一套视觉,而且测试同学也能清楚地知道当前处于什么状态。
5.3 单选框等表单控件的样式覆盖
单选框、复选框这类原生组件在小程序里的默认样式比较朴素,而且 iOS 和安卓长得不一样。想要统一观感,最稳的做法不是去抠原生控件的内部伪元素,而是把“原生控件”和“视觉展示”拆开,用自定义 view 来画。
我的做法是保留原生 radio 或 checkbox 的可点击区域,但通过绝对定位让它透明,再在旁边放一个自定义圆点或方块:
<label class="radio-item"> <radio value="1" class="radio-item__native" /> <view class="radio-item__dot {{checked ? 'radio-item__dot--checked' : ''}}"></view> <text>选项A</text> </label>.radio-item { position: relative; display: flex; align-items: center; } .radio-item__native { position: absolute; left: 0; top: 0; width: 40rpx; height: 40rpx; opacity: 0; } .radio-item__dot { width: 36rpx; height: 36rpx; border-radius: 50%; border: 2rpx solid #ccc; background: #fff; transition: all 0.2s; } .radio-item__dot--checked { border-color: #07c160; background: #07c160; box-shadow: inset 0 0 0 6rpx #fff; }这种写法兼容性很好,也不会被原生组件“真机生效、模拟器不生效”的坑拖累。需要注意,透明化的原生控件尺寸不能太小,否则点击区域过小,真机上会很费手指。
6. 业务项目里的一套模板样式:以生鲜配送和社区团购为例
6.1 从设计稿到 WXSS 的规划步骤
做生鲜配送、社区团购、宠物寄养这类 C 端项目时,页面里最常见的都是“商品/服务卡片 + 列表 + 按钮 + 状态标签”的组合。这些项目有一个共同点:视觉框架高度相似,差别主要是主色、圆角、间距和图片比例。所以第一次做这类项目时,我建议把模板样式的核心工作放在前期的样式规划上。
具体步骤是:先拿到设计稿,把颜色、字号、间距、圆角这些全局参数列成一张表,用类名固定下来。比如主色#07c160定义为.text-primary和.bg-primary,价格色#ff5722定义为.text-price,危险色#e64340定义为.text-danger。后面所有页面都不允许直接写色值,只能引用这类约定类。这样改主题或换项目时,只需要替换 theme.wxss 里的类定义。
第二步是抽取公共模块。凡是出现在两个以上页面的结构,比如商品卡片、搜索框、空状态、结算按钮,都应该抽成通用类或组件。生鲜项目里首页、分类页、搜索结果页都会有商品卡片,这时候如果每个页面单独写,后期统一调价格颜色或圆角时要改好几个文件,早晚漏改。
第三步才是写具体页面的布局样式。页面 wxss 只放真正“只属于这个页面”的样式,比如首页的轮播容器、分类页的左侧导航栏。
6.2 商品卡片、价格标签、状态标签的实现示例
以生鲜商品卡片为例,我通常维护一套如下模板样式:
<view class="goods-card {{soldOut ? 'goods-card--soldout' : ''}}"> <image class="goods-card__img" src="{{img}}" mode="aspectFill" lazy-load /> <view class="goods-card__info"> <view class="goods-card__title">{{title}}</view> <view class="goods-card__desc">{{desc}}</view> <view class="goods-card__footer"> <view class="goods-card__price"> <text class="goods-card__price-symbol">¥</text>{{price}} </view> <view class="goods-card__tag goods-card__tag--soldout" wx:if="{{soldOut}}">已售罄</view> <view class="goods-card__btn">加入购物车</view> </view> </view> </view>对应 WXSS 的核心部分:
.goods-card { display: flex; background: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 20rpx; } .goods-card__img { width: 180rpx; height: 180rpx; border-radius: 12rpx; flex-shrink: 0; background: #f5f5f5; } .goods-card__info { flex: 1; min-width: 0; margin-left: 20rpx; display: flex; flex-direction: column; justify-content: space-between; } .goods-card__title { font-size: 28rpx; color: #333; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .goods-card__price { font-size: 36rpx; color: #ff5722; font-weight: 600; } .goods-card__price-symbol { font-size: 24rpx; } .goods-card--soldout { opacity: 0.5; filter: grayscale(1); }我特意把图片宽高写死,主要是为了避免图片加载过程中布局上下跳动。商品标题用两行省略而不是保留全行,是为了保证卡片高度统一,这也是模板样式设计中容易被忽略的点。价格符号单独用一个元素而不是直接写死“¥”在前面,是为了后续改价格展示规则时不用碰布局。
6.3 购物车角标和底部操作栏的注意点
这类业务项目的另一个常见样式点就是购物车角标和底部固定结算栏。角标一般通过绝对定位挂在图标右上角,数字位数会变化,所以要做最小宽度适配,而不是写死宽度:
.badge { position: absolute; top: -8rpx; right: -12rpx; min-width: 32rpx; height: 32rpx; padding: 0 8rpx; border-radius: 20rpx; background: #e64340; color: #fff; font-size: 20rpx; line-height: 32rpx; text-align: center; }底部结算栏固定在页面底部时,iPhone 的 Home Indicator 区域容易挡住内容,可以考虑给操作栏加上padding-bottom: constant(safe-area-inset-bottom)和padding-bottom: env(safe-area-inset-bottom)。这种细节虽然小,但直接影响用户下单体验。
7. 样式不生效的排查链路:开发者工具里的 WXSS 调试经验
7.1 一套完整的排查思路
样式不生效,在小程序里是最常见也最恼火的问题。我总结了一套排查链路,按顺序走基本能定位绝大多数问题:
- 第一步:检查基础库版本。有些样式特性,比如部分 CSS Grid 能力,在低版本基础库里可能不稳定或干脆不支持。开发者工具有时能渲染,但真机上就是不行,先确认基础库版本再往下走。
- 第二步:核对类名。WXML 里的
class和 WXSS 里的类名是否完全一致,多一个字母、大小写不同、用了中划线却写成下划线,都不会有任何报错,就是不起作用。 - 第三步:看选择器是否被支持。如果使用了通配符、复杂属性选择器等边界特性,先确定当前基础库是否支持。不支持就替换成类名方案。
- 第四步:检查优先级和覆盖关系。组件和页面样式同名时,哪个生效要看样式隔离和选择器权重,而不是加载顺序。建议打开开发者工具的 wxml 面板,选中有问题的节点,右侧能看到当前生效的样式来源。
- 第五步:检查样式隔离。如果样式写在 app.wxss 或页面 wxss 里,目标是组件内的节点,那要考虑 Component 的
styleIsolation设置,改组件配置或者用externalClasses往内部传类名。 - 第六步:清理缓存重新编译。开发者工具从“编译”下拉菜单里选“清除缓存并重新编译”,很多时候热刷新不及时,清了缓存就正常了。这种问题最容易让人白费时间。
7.2 用开发者工具定位样式来源
我在排查样式时最依赖的工具就是 wxml 面板。点击页面里的元素,右侧会出现“样式”标签页,里面能看见这一节点被哪些选择器命中、每个属性来自哪个 wxss 文件,还能直接勾选取消某条样式看变化。这个面板比 Chrome DevTools 简单,但足够用了。
真机和模拟器不一致的问题,主要是 WebView 内核版本和系统渲染差异导致的。遇到这种情况,优先怀疑依赖新 CSS 特性的写法,其次检查 rpx 在极端机型上的显示差异。没有捷径,就是要多备几台真机,或者用预览功能的“真机调试”直接看实机效果。
7.3 模板样式的性能与维护建议
最后分享几个长期维护角度的建议。第一,app.wxss别塞太多选择器,每一行样式都会伴随每个页面渲染。第二,长列表里的商品卡片尽量用简单的类选择器,避免在循环列表里使用后代选择器,否则滚动时样式计算压力会变大。第三,模板样式一旦稳定,尽量别用内联 style 覆盖,内联样式没法被后续类名统一调整,会慢慢破坏掉模板体系的复用能力。
写 WXSS 这几年,我最大的转变是接受它的“不自由”。它没有 CSS 那么大的发挥空间,但正是这些边界让页面在不同机型上更可控。顶部导航栏高度、列表加载更多、表单控件美化这类高频需求,只要沉淀成一套模板,后续接生鲜、社区团购、宠物寄养这些新项目时,基本只需要换主题色和间距,不用从头写。希望这篇文章能帮你少走一点弯路,把模板样式这笔账一次性算清楚。