移动端适配终极方案

移动端适配绕不开两个 PostCSS 插件:postcss-pxtorem(px → rem)和 postcss-px-to-viewport(px → vw/vh)。二者都是编译期自动转换,写 px 即可适配不同屏幕,但原理和适用场景完全不同。本文从配置到踩坑一次性讲透。

一、两句话理解区别

方案原理一句话
px → rem把 px 转成 rem,通过 <html>font-size 缩放按比例放大缩小根字号
px → vw把 px 转成 vw/vh,通过视口宽度自动缩放直接按视口百分比计算

对比:

1
2
3
4
5
6
7
8
9
10
11
12
设计稿 375px 宽,一个 100px 的盒子:

px → rem:
转换后 width: 1rem
html font-size = 375 / 10 = 37.5px
→ 实际宽度 = 1 × 37.5 = 37.5px (375 屏)
→ iPhone 14 Pro Max (430) : 1 × 43 = 43px ✅

px → vw:
转换后 width: 26.67vw
→ 实际宽度 = 375 × 26.67% = 100px (375 屏)
→ iPhone 14 Pro Max (430) : 430 × 26.67% = 114.7px ✅

二、postcss-pxtorem

2.1 安装

1
npm i postcss-pxtorem -D

2.2 配置

1
2
3
4
5
6
7
8
9
10
11
12
// postcss.config.js
module.exports = {
plugins: {
"postcss-pxtorem": {
rootValue: 37.5, // 设计稿宽度 / 10
propList: ["*"], // 所有属性都转换
selectorBlackList: [".norem"], // 该 class 下的 px 不转换
minPixelValue: 2, // 小于 2px 不转换
exclude: /node_modules/i, // 排除 node_modules
},
},
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Vite —— vite.config.ts
import pxtorem from "postcss-pxtorem";

export default defineConfig({
css: {
postcss: {
plugins: [
pxtorem({
rootValue: 37.5,
propList: ["*"],
}),
],
},
},
});

2.3 配套的 flexible.js

rem 方案依赖在 <html> 上动态设置 font-size

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// flexible.js —— 放在 index.html 的 <head> 中
(function () {
const baseSize = 37.5; // 基准:375 / 10

function setRem() {
const scale = document.documentElement.clientWidth / 375;
document.documentElement.style.fontSize = Math.min(baseSize * scale, baseSize * 2) + "px"; // 限制最大 2 倍
// 等价于:fontSize = clientWidth / 10
}

setRem();
window.addEventListener("resize", setRem);
window.addEventListener("pageshow", e => {
if (e.persisted) setRem();
});
})();

或者精简版(直接除 10):

1
2
3
<script>
document.documentElement.style.fontSize = document.documentElement.clientWidth / 3.75 + "px";
</script>

2.4 不同设计稿的 rootValue

设计稿宽度rootValue
375px37.5
750px75
640px64
1920px (PC)192

2.5 配置项详解

参数默认值说明
rootValue16根元素字体大小,通常设 设计稿宽度 / 10
unitPrecision5rem 小数精度
propList['*']转换的属性,如 ['font-size', 'width']
selectorBlackList[]不转换的选择器
replacetrue是否替换原 px,而非追加
mediaQueryfalse媒体查询中是否转换
minPixelValue0小于此值的 px 不转换
excludenull正则匹配排除文件

2.6 不想被转换的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 方式一:选择器黑名单 */
.norem .icon {
width: 20px;
} /* 不转换 */
.icon {
width: 20px;
} /* 转换 */

/* 方式二:大写 PX */
.border {
border: 1px solid #eee;
} /* 不转换,大写 */

/* 方式三:注释忽略 */
.border {
border: 1px solid #eee; /* no */
} /* 不转换 */

2.7 优点与缺点

优点缺点
兼容性好,全平台通用需要额外引入 flexible.js
第三方 UI 库(Vant)原生支持 rem第三方库如用 px 且被转换,布局可能出错
可精细控制哪些元素不转换font-size 缩放会受系统字体设置影响
成熟方案,Vant 2.x 官方推荐小数点精度问题(1rem = 37.5px 容易除不尽)

三、postcss-px-to-viewport

3.1 安装

postcss-px-to-viewport 原始包已停更,推荐使用新包:

1
npm i @minko-fe/postcss-px-to-viewport -D

3.2 基础配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// postcss.config.js
module.exports = {
plugins: {
"@minko-fe/postcss-px-to-viewport": {
viewportWidth: 375, // 设计稿宽度
unitPrecision: 5, // vw 小数位数
viewportUnit: "vw", // 转换单位
selectorBlackList: [".ignore"], // 不转换的选择器
minPixelValue: 1, // 小于 1px 不转换
mediaQuery: false, // 媒体查询中不转换
exclude: [/node_modules/], // 排除
},
},
};

3.3 Vite 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// vite.config.ts
import pxToViewport from "@minko-fe/postcss-px-to-viewport";

export default defineConfig({
css: {
postcss: {
plugins: [
pxToViewport({
viewportWidth: 375,
unitPrecision: 5,
viewportUnit: "vw",
}),
],
},
},
});

3.4 常用配置项

参数默认值说明
viewportWidth375设计稿宽度,必填
viewportHeight667设计稿高度(vh 转换时用)
unitPrecision5小数精度
viewportUnit‘vw’转换单位,可设 vh / vmin / vmax
fontViewportUnit‘vw’字体用的单位,可独立设
selectorBlackList[]不转换的选择器
minPixelValue1小于此 × 不转换
mediaQueryfalse媒体查询中的 px 是否转换
landscapefalse是否生成 @media (orientation: landscape)
landscapeWidth568横屏设计稿宽度
landscapeUnit‘vw’横屏单位
excludenull正则排除路径
includenull正则仅包含路径

3.5 不想转换的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 方式一:选择器黑名单 */
.ignore {
width: 16px;
} /* 不转换 */

/* 方式二:注释忽略 */
.box {
width: 100px; /* 转换 */
border: 1px solid #eee; /* px-to-viewport-ignore-next */ /* 不转换 */
}

/* 方式三:一行忽略 */
.box {
border: 1px solid #eee; /* px-to-viewport-ignore 不转换 */
}

/* 方式四:文件级忽略 */
/* postcss-px-to-viewport: disable */
.page {
width: 375px;
} /* 整个文件不转换 */

3.6 横屏适配

1
2
3
4
5
{
landscape: true,
landscapeWidth: 812, // iPhone X 横屏宽度
landscapeUnit: 'vw',
}

生效后,插件会在横屏时自动切换到以 812px 为基准的 vw 计算。

3.7 优点与缺点

优点缺点
无需引入 JS,纯 CSS 方案部分旧设备不支持 vw/vh
转换直观,和设计稿 1:1 对应第三方库的 px 可能被误转换
Vant 3.x / 4.x 官方推荐大屏设备(iPad/PC)元素过大
小数点精度好手机上 1px 边框线可能变很粗

四、两种方案深度对比

维度postcss-pxtorempostcss-px-to-viewport
依赖需额外 flexible.js原生 CSS 单位,无依赖
原理rem = px / rootValue,rootValue 动态变化vw = px / viewportWidth × 100
第三方 UI 兼容Vant 2.x 内置 rem(需配合)Vant 3/4 推荐 vw
横屏适配需手动调整 rootValue插件内置 landscape 模式
PC 端限制可设 maxWidth 限制大屏会等比例放大,需额外处理
1px 边框不转换或转大写 PX可用 minPixelValue: 1 排除
系统字体影响受影响(用户可改浏览器字号)不受影响
兼容性极好,全平台iOS 8+ / Android 4.4+
维护状态postcss-pxtorem 稳定原包停更,推荐 @minko-fe/ 分支

选型建议

1
2
3
4
5
6
7
8
你的项目:
├─ 用 Vant 2.x → 选 postcss-pxtorem
├─ 用 Vant 3.x / 4.x → 选 postcss-px-to-viewport
├─ 兼容老旧设备(iOS 7 以下)→ 选 postcss-pxtorem
├─ 对 1px 边框高要求 → 选 postcss-px-to-viewport + transform: scale
├─ 需要横屏适配 → 选 postcss-px-to-viewport
├─ PC 端也需要展示 → 选 postcss-pxtorem + maxWidth 限制
└─ 追求极致简洁 → 选 postcss-px-to-viewport

五、进阶:两者混用

有些项目需要 vw 做布局 + 某些场景保留 rem 作为兜底:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// postcss.config.js
module.exports = {
plugins: [
// 1. 先执行 pxtorem:将 font-size / 字号相关的 px 转 rem
require("postcss-pxtorem")({
rootValue: 37.5,
propList: ["font", "font-size", "line-height", "letter-spacing"],
exclude: /node_modules/,
}),
// 2. 再执行 px-to-viewport:将宽高/间距等布局属性转 vw
require("@minko-fe/postcss-px-to-viewport")({
viewportWidth: 375,
propList: ["*", "!font*", "!line-height", "!letter-spacing"],
exclude: /node_modules/,
}),
],
};

执行顺序:PostCSS 插件按声明顺序执行,先 rem 后 vw,通过 propList 互相排除避免重复转换。

六、Vant UI 库配合

Vant 2.x + pxtorem

1
2
3
4
5
6
7
8
9
10
// postcss.config.js
module.exports = {
plugins: {
// vant 2 设计稿基于 375,内部用 px
"postcss-pxtorem": {
rootValue: 37.5,
propList: ["*"],
},
},
};

Vant 3/4 + px-to-viewport

1
2
3
4
5
6
7
8
9
10
// postcss.config.js
module.exports = {
plugins: {
"@minko-fe/postcss-px-to-viewport": {
viewportWidth: 375,
// Vant 3/4 内部样式用 px,需要一并转换
// 如果 Vant 已适配 vw 则不需要 exclude
},
},
};

Vant 4 可直接按 vw 引入样式(推荐)

1
2
3
// vite.config.ts 中配置 Vant 按需引入
import Components from "unplugin-vue-components/vite";
import { VantResolver } from "unplugin-vue-components/resolvers";

八、常见问题与排查

问题原因解决
转换后页面元素巨大rootValueviewportWidth 搞反了检查设计稿宽度是否正确填写
vw 方案在 PC 端文字过大视口宽如 1920px 时 vw 会等比放大max-width: 750px 容器限制
第三方组件库样式错乱组件库 px 被误转换exclude: /node_modules/
1px 边框被转成 0.27vw 变粗小数值 vw 在 Retina 屏上显示异常minPixelValue: 1 或用大写 PX
flexible.js 导致 Android 页面抖动Android WebView resize 频繁触发加防抖或替换为 CSS 方案
rem 方案刷新后布局变化flexible.js 写入 font-size 的时机问题将 script 放在 <head> 最顶部同步执行
内联样式中的 px 不转换PostCSS 只处理 .css/.vue 文件内联样式手动改为 rem/vw
property ‘xxx’ does not exist插件版本与 PostCSS 8 不兼容确认版本:”postcss-pxtorem”: “^6.0” 对应 PostCSS 8

七、完整配置文件速查

pxtorem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// postcss.config.js —— pxtorem 完整版
module.exports = {
plugins: {
"postcss-pxtorem": {
rootValue: 37.5,
unitPrecision: 5,
propList: ["*"],
selectorBlackList: [".norem"],
replace: true,
mediaQuery: false,
minPixelValue: 1,
exclude: /node_modules/i,
},
},
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- index.html -->
<script>
(function (w, d) {
var docEl = d.documentElement,
timer;
function setRem() {
docEl.style.fontSize = docEl.clientWidth / 3.75 + "px";
}
w.addEventListener("resize", function () {
clearTimeout(timer);
timer = setTimeout(setRem, 100);
});
w.addEventListener("pageshow", function (e) {
e.persisted && setRem();
});
setRem();
})(window, document);
</script>

px-to-viewport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// postcss.config.js —— px-to-viewport 完整版
module.exports = {
plugins: {
"@minko-fe/postcss-px-to-viewport": {
viewportWidth: 375,
viewportHeight: 667,
unitPrecision: 5,
viewportUnit: "vw",
fontViewportUnit: "vw",
selectorBlackList: [".ignore"],
minPixelValue: 1,
mediaQuery: false,
landscape: false,
exclude: [/node_modules/],
// 保留 1px 边框不转换
// 可以在 CSS 中用大写 PX 或注释 px-to-viewport-ignore
},
},
};

总结

1
2
3
4
postcss-pxtorem       → 老牌方案,Vant 2 标配,兼容极致
postcss-px-to-viewport → 新潮方案,Vant 3/4 推荐,零 JS 依赖

选型一句话:新项目无脑 vw,老项目继续 rem,横屏场景必须 vw。

全文覆盖两种方案的原理对比、完整配置、Vite 集成、Vant 配合、横屏适配、混用策略、12 类常见坑排查,附赠配置文件一键复制。

Vue3 中 SCSS/Less 快捷写法

Vue3 单文件组件(SFC)天然支持预处理器,但在日常开发中很多写法都停留在原生 CSS 水平。本文整理一套 SCSS/Less 的快捷写法,涵盖嵌套、变量、Mixin、循环、函数等核心特性,写完就能提效 50% 以上。

一、Vue3 样式块基础配置

1.1 <style> 标签的四种形态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!-- 1. scoped:样式隔离 -->
<style scoped lang="scss">
.box { ... }
</style>

<!-- 2. module:CSS Modules -->
<style module lang="scss">
.active {
color: red;
}
</style>
<!-- 使用时:<div :class="$style.active"> -->

<!-- 3. v-bind:JS 变量驱动 CSS -->
<script setup>
const primaryColor = ref("#1890ff");
</script>
<style scoped>
.header {
color: v-bind(primaryColor);
}
</style>

<!-- 4. 多块并行 -->
<style scoped lang="scss">
/* 组件样式 */
</style>
<style lang="scss">
/* 全局样式 */
</style>

1.2 Vite 全局注入变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vite.config.ts
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
// 每个 .vue 文件自动注入
additionalData: `
@use "@/styles/variables.scss" as *;
@use "@/styles/mixins.scss" as *;
`,
},
},
},
});

Less 同理:

1
2
3
4
5
6
7
8
css: {
preprocessorOptions: {
less: {
additionalData: `@import "@/styles/variables.less";`,
javascriptEnabled: true
}
}
}
配置项SCSSLess
路径别名~@/@/~@/@/
全局注入additionalDataadditionalData
数学运算默认 / 是除法javascriptEnabled: true

二、嵌套语法 —— 告别重复选择器

2.1 基础嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
.card {
padding: 16px;
border-radius: 8px;
background: #fff;

// 子元素
.title {
font-size: 18px;
font-weight: 600;
}

// 伪类
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

// 伪元素
&::before {
content: "";
display: block;
}

// 属性选择器
&[data-type="primary"] {
background: var(--primary);
}

// 兄弟选择器
& + .card {
margin-top: 12px;
}
}

2.2 BEM 命名快捷写法

1
2
3
4
5
6
7
8
9
10
11
12
13
.block {
// &__element
&__header { ... }
&__body { ... }
&__footer { ... }

// &--modifier
&--active { ... }
&--disabled {
opacity: 0.5;
pointer-events: none;
}
}

编译结果:

1
2
3
4
.block__header { ... }
.block__body { ... }
.block--active { ... }
.block--disabled { opacity: 0.5; pointer-events: none; }

2.3 媒体查询内嵌

1
2
3
4
5
6
7
8
9
10
11
12
.sidebar {
width: 250px;

@media (max-width: 768px) {
width: 100%;
display: none;
}

@media (min-width: 1200px) {
width: 300px;
}
}

Less 写法一致,但变量插值语法不同:

1
2
3
4
5
6
7
8
9
// Less 中
@width: 200px;
.sidebar {
width: @width;

@media (max-width: (@width * 2)) {
width: 100%;
}
}

三、变量系统 —— 一处定义,全局生效

3.1 基础变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// variables.scss
// 颜色
$primary: #1890ff;
$success: #52c41a;
$warning: #faad14;
$danger: #f5222d;

// 尺寸
$header-height: 56px;
$sidebar-width: 240px;
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 16px;

// 字体
$font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
$font-size-base: 14px;
$font-size-lg: 16px;
$font-size-sm: 12px;

// 间距
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;

SCSS vs Less 变量语法差异

特性SCSSLess
定义$name: value@name: value
引用$name@name
插值#{$name}@{name}
作用域块级 + 全局 !global惰性求值,后定义覆盖前
计算$a / $b@a / @b

3.2 CSS 自定义属性联动

1
2
3
4
5
6
7
8
9
10
11
12
13
// 把 SCSS 变量转为 CSS 变量,支持运行时修改
:root {
--primary: #{$primary};
--header-height: #{$header-height};
--radius-md: #{$radius-md};
}

// 使用
.button {
background: var(--primary);
height: var(--header-height);
border-radius: var(--radius-md);
}

3.3 Map 变量(SCSS 独有)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 主题色集合
$theme-colors: (
"primary": #1890ff,
"success": #52c41a,
"warning": #faad14,
"danger": #f5222d,
"info": #909399,
);

// 取值
.tag {
color: map-get($theme-colors, "primary");
}

四、Mixin —— 重复代码的终结者

4.1 弹性布局一行搞定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// mixins.scss
@mixin flex($direction: row, $justify: flex-start, $align: stretch, $gap: 0) {
display: flex;
flex-direction: $direction;
justify-content: $justify;
align-items: $align;
gap: $gap;
}

// 使用
.header {
@include flex(row, space-between, center); // 水平两端对齐居中
}
.column {
@include flex(column, center, center, $gap: 12px); // 垂直居中 + 间距
}

4.2 文本省略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@mixin text-ellipsis($line: 1) {
overflow: hidden;
text-overflow: ellipsis;

@if $line == 1 {
white-space: nowrap;
} @else {
display: -webkit-box;
-webkit-line-clamp: $line;
-webkit-box-orient: vertical;
}
}

// 使用
.title {
@include text-ellipsis;
} // 单行
.desc {
@include text-ellipsis(3);
} // 三行

4.3 文本样式快捷

1
2
3
4
5
6
@mixin text-style($size: $font-size-base, $weight: normal, $color: inherit, $line-height: 1.5) {
font-size: $size;
font-weight: $weight;
color: $color;
line-height: $line-height;
}

4.4 居中方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@mixin center($type: "both") {
@if $type == "both" {
display: flex;
justify-content: center;
align-items: center;
} @else if $type == "horizontal" {
display: flex;
justify-content: center;
} @else if $type == "vertical" {
display: flex;
align-items: center;
} @else if $type == "absolute" {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

4.5 响应式断点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 断点定义
$breakpoints: (
"sm": 640px,
"md": 768px,
"lg": 1024px,
"xl": 1280px,
"2xl": 1536px,
);

@mixin respond($breakpoint, $type: "down") {
$width: map-get($breakpoints, $breakpoint);

@if $type == "down" {
@media (max-width: $width) {
@content;
}
} @else if $type == "up" {
@media (min-width: $width + 1px) {
@content;
}
} @else if $type == "only" {
$next: map-get($breakpoints, $breakpoint); // 简化示例
@media (min-width: $width + 1px) and (max-width: 1200px) {
@content;
}
}
}

// 使用
.card {
width: 33.33%;

@include respond(md) {
width: 50%;
}
@include respond(sm) {
width: 100%;
}
}

4.6 Less Mixin 对比

1
2
3
4
5
6
7
8
9
10
11
12
// Less 中 Mixin 不需要 @mixin 关键字
.flex(@direction: row, @justify: flex-start, @align: stretch) {
display: flex;
flex-direction: @direction;
justify-content: @justify;
align-items: @align;
}

// 使用:不加 @include,直接调用
.header {
.flex(row, space-between, center);
}
特性SCSS MixinLess Mixin
定义@mixin name($param) { }.name(@param) { }
调用@include name(val).name(val);
默认参数$param: default@param: default
可变参数$params...@params...
条件判断@if / @elsewhen 守卫

五、循环生成 —— 批量创建工具类

5.1 间距工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
$spacing-map: (
0: 0,
1: 8px,
2: 16px,
3: 24px,
4: 32px,
5: 48px,
);

// 生成 .m-0 ~ .m-5, .p-0 ~ .p-5, .gap-0 ~ .gap-5
@each $key, $val in $spacing-map {
.m-#{$key} {
margin: $val;
}
.mt-#{$key} {
margin-top: $val;
}
.mb-#{$key} {
margin-bottom: $val;
}
.ml-#{$key} {
margin-left: $val;
}
.mr-#{$key} {
margin-right: $val;
}
.mx-#{$key} {
margin-left: $val;
margin-right: $val;
}
.my-#{$key} {
margin-top: $val;
margin-bottom: $val;
}

.p-#{$key} {
padding: $val;
}
.pt-#{$key} {
padding-top: $val;
}
.pb-#{$key} {
padding-bottom: $val;
}
.pl-#{$key} {
padding-left: $val;
}
.pr-#{$key} {
padding-right: $val;
}
.px-#{$key} {
padding-left: $val;
padding-right: $val;
}
.py-#{$key} {
padding-top: $val;
padding-bottom: $val;
}

.gap-#{$key} {
gap: $val;
}
}

5.2 字体大小工具类

1
2
3
4
5
6
7
$font-sizes: 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48;

@each $size in $font-sizes {
.text-#{$size} {
font-size: #{$size}px;
}
}

5.3 主题色变体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$theme-colors: (
"primary": #1890ff,
"success": #52c41a,
"warning": #faad14,
"danger": #f5222d,
);

// 生成 .bg-primary, .text-primary, .border-primary 等
@each $name, $color in $theme-colors {
.bg-#{$name} {
background-color: $color;
}
.text-#{$name} {
color: $color;
}
.border-#{$name} {
border-color: $color;
}
}

// 生成颜色变浅 10% 的 hover 态
@each $name, $color in $theme-colors {
.btn-#{$name} {
background: $color;
&:hover {
background: lighten($color, 10%);
}
&:active {
background: darken($color, 10%);
}
}
}

5.4 透明度工具类

1
2
3
4
5
6
@for $i from 1 through 9 {
.opacity-#{$i}0 {
opacity: $i * 0.1;
}
}
// 生成 .opacity-10 ~ .opacity-90

5.5 Less 循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Less 使用递归实现循环
@spacing: 0, 8, 16, 24, 32, 48;

.generator(@i: 1) when (@i <= length(@spacing)) {
@val: extract(@spacing, @i);
.m-@{i} {
margin: unit(@val, px);
}
.p-@{i} {
padding: unit(@val, px);
}
.generator(@i + 1);
}
.generator();

六、函数 —— 数值计算复用

6.1 像素转 rem/vw

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 基准 16px = 1rem
@function rem($px) {
@return calc($px / 16) * 1rem;
}

// 设计稿宽 375px 下的 vw 转换
@function vw($px) {
@return calc($px / 375) * 100vw;
}

// 使用
.title {
font-size: rem(24); // 1.5rem
padding: vw(16); // 4.27vw
}

6.2 颜色透明度

1
2
3
4
5
6
7
8
// 给 hex 颜色加透明度,输出 rgba
@function alpha($color, $opacity) {
@return rgba($color, $opacity);
}

.overlay {
background: alpha(#1890ff, 0.15); // rgba(24, 144, 255, 0.15)
}

6.3 数学辅助

1
2
3
4
5
6
7
8
9
@function half($value) {
@return $value / 2;
}
@function double($value) {
@return $value * 2;
}
@function neg($value) {
@return -$value;
}

6.4 Less 函数

1
2
3
.rem(@px) {
return: unit(@px / 16, rem);
}

七、@extend —— 选择器继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 定义占位符(不生成 CSS)
%card-base {
padding: 16px;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

// 三个不同卡片继承同一套基础样式
.user-card {
@extend %card-base;
border-left: 4px solid $primary;
}
.article-card {
@extend %card-base;
border-left: 4px solid $success;
}
.notice-card {
@extend %card-base;
border-left: 4px solid $warning;
}

编译结果(选择器分组):

1
2
3
4
5
6
7
8
.user-card,
.article-card,
.notice-card {
padding: 16px;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

@extend vs @mixin 选择指南

维度@extend@mixin
CSS 输出分组选择器每次 include 复制一份
文件体积可能大(重复)
参数支持支持
适合场景样式完全相同需要参数变化

简单规则:需要传参时用 @mixin,不需要时用 %placeholder + @extend

八、Less 独有特性速览

特性语法说明
变量@name: value可被后续覆盖(惰性求值)
Mixin.name() { }无需 @mixin 关键字
调用.name();无需 @include
守卫.name() when (@width > 100)条件判断
运算@a + @b自动处理单位
合并属性background+: url(a.png)逗号合并
父选择器&和 SCSS 一样
循环递归 Mixin@for/@each,需手动递归

九、SCSS 独有特性速览

特性语法说明
变量$name: value块级作用域,不会被覆盖
Mapmap-get($map, key)键值对结构
Listnth($list, 1)列表操作
@function@function name($p) { @return $p }自定义函数
@if / @else原生条件判断比 Less 的 when 更直观
@for@for $i from 1 through 10数值循环
@each@each $item in $list列表遍历
@while@while $i > 0条件循环
@extend%placeholder + @extend选择器继承
内置函数lighten() darken() mix()颜色计算等

十、实战模板 —— 三文件结构

styles/variables.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// === 颜色 ===
$primary: #1890ff;
$success: #52c41a;
$warning: #faad14;
$danger: #f5222d;
$text-primary: #303133;
$text-regular: #606266;
$text-secondary: #909399;
$border-color: #dcdfe6;
$bg-color: #f5f7fa;

// === 尺寸 ===
$header-height: 56px;
$sidebar-width: 240px;
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 16px;

// === 字体 ===
$font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
$font-size-base: 14px;
$font-size-sm: 12px;
$font-size-lg: 16px;

// === 间距 ===
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;

// === 阴影 ===
$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
$shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
$shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);

// === 断点 ===
$breakpoints: (
"sm": 640px,
"md": 768px,
"lg": 1024px,
"xl": 1280px,
);

// === z-index ===
$z-dropdown: 1000;
$z-modal: 2000;
$z-toast: 3000;

styles/mixins.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 弹性布局
@mixin flex($direction: row, $justify: flex-start, $align: stretch, $gap: 0) {
display: flex;
flex-direction: $direction;
justify-content: $justify;
align-items: $align;
gap: $gap;
}

// 文本省略
@mixin text-ellipsis($line: 1) {
overflow: hidden;
text-overflow: ellipsis;
@if $line == 1 {
white-space: nowrap;
} @else {
display: -webkit-box;
-webkit-line-clamp: $line;
-webkit-box-orient: vertical;
}
}

// 绝对居中
@mixin abs-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

// 清除浮动
@mixin clearfix {
&::after {
content: "";
display: block;
clear: both;
}
}

// 滚动条美化
@mixin scrollbar($width: 6px, $thumb: #c0c4cc, $track: transparent) {
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-thumb {
background: $thumb;
border-radius: $width;
}
&::-webkit-scrollbar-track {
background: $track;
}
}

// 响应式
@mixin respond($bp, $type: "down") {
$width: map-get($breakpoints, $bp);
@if $type == "down" {
@media (max-width: $width) {
@content;
}
} @else {
@media (min-width: $width + 1px) {
@content;
}
}
}

// hover 激活态
@mixin hover-active {
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
&:active {
opacity: 0.6;
}
}

styles/global.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}

html {
font-family: $font-family;
font-size: $font-size-base;
color: $text-primary;
-webkit-text-size-adjust: 100%;
}

body {
background: $bg-color;
line-height: 1.5;
}

a {
color: $primary;
text-decoration: none;
}

img {
max-width: 100%;
height: auto;
}

十一、日常速查口诀

1
2
3
4
5
6
7
变量命名 $ 开头,颜色间距一把抓
嵌套靠 & 连父级,最多三层别太深
重复代码抽 @mixin,传参灵活用 @include
不要参数的用 %placeholder,@extend 继承不重复
生成工具类 @each 写,Map 搭配最省事
数值计算 @function,px 转 rem 一招鲜
全局注入靠 Vite,@use 引入不污染

全文覆盖 Vue3 样式块四种模式、SCSS/Less 嵌套/Mixin/循环/函数/继承全部特性、三文件生产级模板、两种预处理器的差异对照表,以及日常速查口诀

每个 Vue3 项目启动后第一件事往往是写一大堆全局样式:重置默认行为、封装布局工具类、定义主题变量……这些代码复用率极高,但每次都从头写一遍很低效。本文整理了一份开箱即用的 SCSS 通用样式集,所有值均通过变量、Map、Mixin 驱动,覆盖 Reset、布局、间距、文字、主题、响应式、动画等高频场景,复制进项目就能用。

一、全局变量 —— 所有值的唯一来源

分两个文件:_vars-only.scss 存放纯 Map 和变量(不产生 CSS),供 additionalData 注入;variables.scss 在此基础上输出 CSS 自定义属性,由 index.scss 一次性引入。

styles/_vars-only.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// styles/_vars-only.scss
// ═══════════════════════════════════════
// 仅 Map + 变量,不输出任何 CSS
// Vite 通过 additionalData 自动注入
// ═══════════════════════════════════════

// ====== 主题色 Map ======
$theme-colors: (
"primary": #1677ff,
"success": #52c41a,
"warning": #faad14,
"danger": #ff4d4f,
"info": #909399,
);

// ====== 语义色 Map ======
$semantic-colors: (
"bg-page": #f5f5f5,
"bg-white": #fff,
"text": #333,
"text-secondary": #666,
"text-disabled": #bbb,
"text-placeholder": #c0c4cc,
"border": #eee,
"border-light": #f0f0f0,
);

// ====== 暗色主题覆盖 Map ======
$dark-theme-colors: (
"bg-page": #141414,
"bg-white": #1e1e1e,
"text": #e5e5e5,
"text-secondary": #999,
"text-disabled": #555,
"border": #333,
"border-light": #2a2a2a,
);

// ====== 字体 Map ======
$font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
$font-size-base: 14px;
$font-sizes: (10, 12, 13, 14, 15, 16, 18, 20, 22, 24, 28, 32, 36, 40, 48);
$font-weights: (
"normal": 400,
"medium": 500,
"semibold": 600,
"bold": 700,
);
$line-heights: (
"tight": 1.25,
"normal": 1.5,
"relaxed": 1.75,
);

// ====== 圆角 Map ======
$radius-map: (
"sm": 4px,
"base": 8px,
"md": 12px,
"lg": 16px,
"full": 50%,
);

// ====== 间距 Map ======
$spacing-sizes: (0, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24);
$spacing-base: 16px;

// ====== 阴影 Map ======
$shadow-map: (
"sm": 0 1px 3px rgba(0, 0, 0, 0.08),
"base": 0 2px 8px rgba(0, 0, 0, 0.1),
"lg": 0 4px 16px rgba(0, 0, 0, 0.12),
);

// ====== 过渡变量 ======
$transition-duration: 0.3s;
$transition-timing: ease;
$transition-duration-base: $transition-duration;
$transition-base: all $transition-duration $transition-timing;

// ====== 断点 Map ======
$breakpoints: (
"sm": 576px,
"md": 768px,
"lg": 992px,
"xl": 1200px,
"xxl": 1400px,
);

// ====== SCSS 变量(引用 Map 值) ======
$color-primary: map-get($theme-colors, "primary");
$color-success: map-get($theme-colors, "success");
$color-warning: map-get($theme-colors, "warning");
$color-danger: map-get($theme-colors, "danger");
$color-info: map-get($theme-colors, "info");

$color-bg-page: map-get($semantic-colors, "bg-page");
$color-bg-white: map-get($semantic-colors, "bg-white");
$color-text: map-get($semantic-colors, "text");
$color-text-secondary: map-get($semantic-colors, "text-secondary");
$color-text-disabled: map-get($semantic-colors, "text-disabled");
$color-text-placeholder: map-get($semantic-colors, "text-placeholder");
$color-border: map-get($semantic-colors, "border");
$color-border-light: map-get($semantic-colors, "border-light");

$radius-sm: map-get($radius-map, "sm");
$radius-base: map-get($radius-map, "base");
$radius-md: map-get($radius-map, "md");
$radius-lg: map-get($radius-map, "lg");
$radius-full: map-get($radius-map, "full");

$shadow-sm: map-get($shadow-map, "sm");
$shadow-base: map-get($shadow-map, "base");
$shadow-lg: map-get($shadow-map, "lg");

styles/variables.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// styles/variables.scss
// 发布 CSS 自定义属性(_vars-only 的变量已由 additionalData 全局注入)

// ====== CSS 自定义属性(运行时切换) ======
:root {
// 主题色
@each $name, $color in $theme-colors {
--color-#{$name}: #{$color};
}
// 语义色
@each $name, $color in $semantic-colors {
--color-#{$name}: #{$color};
}
// 圆角
@each $name, $value in $radius-map {
--radius-#{$name}: #{$value};
}
// 阴影
@each $name, $value in $shadow-map {
--shadow-#{$name}: #{$value};
}
--font-size-base: #{$font-size-base};
--transition-base: #{$transition-base};
}

// 暗色主题
[data-theme="dark"] {
@each $name, $color in $dark-theme-colors {
--color-#{$name}: #{$color};
}
}

二、CSS Reset —— 变量驱动的基础重置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// styles/reset.scss
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}

html {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}

body {
font-family: $font-family-base;
font-size: $font-size-base;
line-height: map-get($line-heights, "normal");
color: var(--color-text);
background-color: var(--color-bg-page);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

a {
color: inherit;
text-decoration: none;
}

img {
max-width: 100%;
height: auto;
vertical-align: middle;
}

ul,
ol {
list-style: none;
}

button,
input,
select,
textarea {
font: inherit;
color: inherit;
outline: none;
border: none;
background: none;
}

table {
border-collapse: collapse;
border-spacing: 0;
}

三、Mixin 库 —— 全部参数化封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// styles/mixins.scss
@use "sass:math";

// ====== Flex 布局 ======
@mixin flex($direction: row, $justify: flex-start, $align: stretch, $gap: 0) {
display: flex;
flex-direction: $direction;
justify-content: $justify;
align-items: $align;
gap: $gap;
}

// ====== 文本省略 ======
@mixin ellipsis($line: 1) {
@if $line == 1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
overflow: hidden;
text-overflow: ellipsis;
}
}

// ====== 清除浮动 ======
@mixin clearfix {
&::after {
content: "";
display: block;
clear: both;
}
}

// ====== 1px 边框(移动端高清屏) ======
@mixin hairline($direction: "bottom", $color: $color-border, $offset: 0) {
position: relative;
&::after {
content: "";
position: absolute;
#{$direction}: $offset;
left: 0;
width: 100%;
height: 1px;
background-color: $color;
transform: scaleY(0.5);
@if $direction == "top" {
bottom: auto;
}
}
}

// ====== 安全区域适配 ======
@mixin safe-area-padding($positions...) {
@each $pos in $positions {
padding-#{$pos}: constant(safe-area-inset-#{$pos});
padding-#{$pos}: env(safe-area-inset-#{$pos});
}
}

// ====== 点击态 ======
@mixin active-opacity($opacity: 0.7) {
&:active {
opacity: $opacity;
}
}

// ====== 卡片容器(属性全用变量) ======
@mixin card($bg: var(--color-bg-white), $radius: var(--radius-base), $padding: $spacing-base, $shadow: var(--shadow-base)) {
background: $bg;
border-radius: $radius;
padding: $padding;
box-shadow: $shadow;
}

// ====== 滚动条美化 ======
@mixin custom-scrollbar($width: 6px, $thumb-color: $color-border-light, $track-color: transparent) {
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-thumb {
background: $thumb-color;
border-radius: $width;
}
&::-webkit-scrollbar-track {
background: $track-color;
}
}

四、响应式断点 —— Map + Mixin 语义化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// styles/breakpoints.scss

// 宽屏 (≥)
@mixin respond-to($key) {
$width: map-get($breakpoints, $key);
@if $width {
@media (min-width: $width) {
@content;
}
}
}

// 窄屏 (≤)
@mixin respond-below($key) {
$width: map-get($breakpoints, $key);
@if $width {
@media (max-width: $width) {
@content;
}
}
}

// 区间
@mixin respond-between($min, $max) {
$min-w: map-get($breakpoints, $min);
$max-w: map-get($breakpoints, $max);
@if $min-w and $max-w {
@media (min-width: $min-w) and (max-width: $max-w) {
@content;
}
}
}

五、布局工具类 —— Map 循环批量生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// styles/layout.scss
// 组合类 .flex-{justify}-{align} —— 一个类同时控制分布与对齐
@each $justify-name, $justify-value in $flex-justify {
@each $item-name, $item-value in $flex-items {
.flex-#{$justify-name}-#{$item-name} {
display: flex;
justify-content: $justify-value;
align-items: $item-value;
}
}
}

// ====== Grid 布局(Map 循环) ======
$grid-cols: (2, 3, 4);
@each $n in $grid-cols {
.grid-#{$n} {
display: grid;
grid-template-columns: repeat($n, 1fr);
gap: $spacing-base * 0.75;
}
}

// ====== 定位(Map 循环) ======
$positions: (relative, absolute, fixed, sticky);
@each $pos in $positions {
.#{$pos} {
position: $pos;
}
}

六、间距工具类 —— 一套 Mixin + Map 批量生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// styles/spacing.scss

$spacing-directions: (
"": "",
"t": "top",
"r": "right",
"b": "bottom",
"l": "left",
"x": (
"left",
"right",
),
"y": (
"top",
"bottom",
),
);

@mixin gen-spacing($property, $abbr) {
@each $size in $spacing-sizes {
@each $dir-abbr, $dir-prop in $spacing-directions {
.#{$abbr}#{$dir-abbr}-#{$size} {
@if type-of($dir-prop) == "list" {
@each $d in $dir-prop {
#{$property}-#{$d}: #{$size}px;
}
} @else if $dir-prop == "" {
#{$property}: #{$size}px;
} @else {
#{$property}-#{$dir-prop}: #{$size}px;
}
}
}
}
}
// 生成 .m-4, .mt-8, .mx-12... 和 .p-4, .pt-8, .px-12...
@include gen-spacing("margin", "m");
@include gen-spacing("padding", "p");

七、文字样式 —— 全部 Map + 循环驱动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// styles/typography.scss

// ====== 字号 ======
@each $size in $font-sizes {
.text-#{$size} {
font-size: #{$size}px;
}
}

// ====== 字重 ======
@each $name, $weight in $font-weights {
.font-#{$name} {
font-weight: $weight;
}
}

// ====== 行高 ======
@each $name, $lh in $line-heights {
.leading-#{$name} {
line-height: $lh;
}
}

// ====== 文字颜色(自动跟随主题切换) ======
@each $name, $color in $theme-colors {
.text-#{$name} {
color: var(--color-#{$name});
}
}
$text-color-suffixes: (
"secondary": var(--color-text-secondary),
"disabled": var(--color-text-disabled),
"placeholder": var(--color-text-placeholder),
);
@each $suffix, $value in $text-color-suffixes {
.text-#{$suffix} {
color: $value;
}
}

// ====== 文字对齐 ======
$text-aligns: (left, center, right);
@each $align in $text-aligns {
.text-#{$align} {
text-align: $align;
}
}

// ====== 文字省略工具类 ======
@each $line in (1, 2, 3) {
.text-ellipsis-#{$line} {
@if $line == 1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.text-ellipsis {
@extend .text-ellipsis-1;
}

八、背景 / 边框 / 圆角 —— Map 循环 + 变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// styles/colors.scss

// ====== 背景色 ======
$bg-color-suffixes: (
"white": var(--color-bg-white),
"page": var(--color-bg-page),
);
@each $suffix, $value in $bg-color-suffixes {
.bg-#{$suffix} {
background-color: $value;
}
}
@each $name, $color in $theme-colors {
.bg-#{$name} {
background-color: var(--color-#{$name});
color: var(--color-bg-white);
}
}

// ====== 边框 ======
$border-width: 1px;
.border {
border: $border-width solid $color-border;
}
.border-t {
border-top: $border-width solid $color-border;
}
.border-b {
border-bottom: $border-width solid $color-border;
}
.border-l {
border-left: $border-width solid $color-border;
}
.border-r {
border-right: $border-width solid $color-border;
}
.border-none {
border: none;
}

// ====== 圆角 ======
@each $name, $value in $radius-map {
.rounded-#{$name} {
border-radius: $value;
}
}

九、视觉类 —— 阴影 / 光标 / 溢出 / 可见性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// styles/visual.scss

// ====== 阴影(Map 循环 + CSS 变量) ======
@each $name, $value in $shadow-map {
.shadow-#{$name} {
box-shadow: var(--shadow-#{$name});
}
}
.shadow {
@extend .shadow-base;
}

// ====== 光标 ======
$cursors: (
"pointer": pointer,
"not-allowed": not-allowed,
);
@each $name, $value in $cursors {
.cursor-#{$name} {
cursor: $value;
}
}
.pointer-events-none {
pointer-events: none;
}

// ====== 溢出 ======
$overflows: ("hidden", "auto", "scroll");
@each $val in $overflows {
.overflow-#{$val} {
overflow: $val;
}
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-y-auto {
overflow-y: auto;
}

// ====== 可见性 ======
.hidden {
display: none;
}
.invisible {
visibility: hidden;
}

十、滚动条美化

1
2
3
4
5
6
7
8
9
10
11
// styles/scrollbar.scss

// 全局滚动条
html {
@include custom-scrollbar();
}

// 局部滚动条
.scroll-container {
@include custom-scrollbar(4px, rgba($color-text, 0.15));
}

十一、过渡与动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// styles/transitions.scss

// ====== 五套预置动画 ======
// 淡入淡出
.fade-enter-active,
.fade-leave-active {
transition: opacity $transition-duration-base $transition-timing;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

// 从底部滑入
.slide-up-enter-active,
.slide-up-leave-active {
transition:
transform $transition-duration-base $transition-timing,
opacity $transition-duration-base $transition-timing;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}

// 缩放
.scale-enter-active,
.scale-leave-active {
transition:
transform $transition-duration-base $transition-timing,
opacity $transition-duration-base $transition-timing;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.9);
opacity: 0;
}

// 页面切换
.page-enter-active {
transition:
opacity $transition-duration-base $transition-timing,
transform $transition-duration-base $transition-timing;
}
.page-leave-active {
transition: opacity ($transition-duration-base * 0.67) $transition-timing;
}
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-leave-to {
opacity: 0;
}

// ====== 常用过渡工具类 ======
.transition-base {
transition: $transition-base;
}
.transition-fade {
transition: opacity $transition-duration-base $transition-timing;
}
.transition-slide {
transition: transform $transition-duration-base $transition-timing;
}

十二、移动端适配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// styles/mobile.scss

// 安全区
$safe-area-sides: (top, bottom, left, right);
@each $side in $safe-area-sides {
.safe-#{$side} {
padding-#{$side}: constant(safe-area-inset-#{$side});
padding-#{$side}: env(safe-area-inset-#{$side});
}
}

// 防橡皮筋
.no-bounce {
overscroll-behavior: none;
}

// iOS 输入框缩放修复
$ios-input-min-font: 16px;
.fix-ios-zoom {
font-size: $ios-input-min-font;
}

十三、通用组件样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// styles/components.scss

// ====== 空状态 ======
$empty-state-config: (
"padding-y": 60px,
"icon-size": 48px,
"icon-mb": 12px,
"icon-opacity": 0.4,
"text-size": 14px,
);

.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: map-get($empty-state-config, "padding-y") 0;
color: $color-text-secondary;

&__icon {
font-size: map-get($empty-state-config, "icon-size");
margin-bottom: map-get($empty-state-config, "icon-mb");
opacity: map-get($empty-state-config, "icon-opacity");
}

&__text {
font-size: map-get($empty-state-config, "text-size");
}
}

// ====== 骨架屏 ======
$skeleton-duration: 1.5s;
$skeleton-start: #f0f0f0;
$skeleton-mid: #e0e0e0;
$skeleton-end: #f0f0f0;
$skeleton-radius: map-get($radius-map, "sm");

.skeleton {
background: linear-gradient(90deg, $skeleton-start 25%, $skeleton-mid 50%, $skeleton-end 75%);
background-size: 200% 100%;
animation: skeleton-shimmer $skeleton-duration infinite;
border-radius: $skeleton-radius;
}

@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

// ====== 分割线 ======
.divider {
height: 1px;
background-color: $color-border;
margin: $spacing-base 0;
}

// ====== 标签 ======
$tag-config: (
"padding-x": 8px,
"padding-y": 2px,
"font-size": 12px,
"radius": 4px,
"line-height": 20px,
"bg-opacity": 0.1,
);

.tag {
display: inline-flex;
align-items: center;
padding: map-get($tag-config, "padding-y") map-get($tag-config, "padding-x");
font-size: map-get($tag-config, "font-size");
border-radius: map-get($tag-config, "radius");
line-height: map-get($tag-config, "line-height");

@each $name, $color in $theme-colors {
&--#{$name} {
color: $color;
background: rgba($color, map-get($tag-config, "bg-opacity"));
}
}
}

十四、全局入口文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// styles/index.scss

@import "./variables.scss";
@import "./mixins.scss";
@import "./breakpoints.scss";
@import "./reset.scss";
@import "./layout.scss";
@import "./spacing.scss";
@import "./typography.scss";
@import "./colors.scss";
@import "./visual.scss";
@import "./scrollbar.scss";
@import "./transitions.scss";
@import "./mobile.scss";
@import "./components.scss";

main.ts

1
2
3
4
5
import { createApp } from "vue";
import App from "./App.vue";
import "@/styles/index.scss";

createApp(App).mount("#app");

十五、Vite 配置 —— additionalData 自动注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";

export default defineConfig({
plugins: [vue()],
resolve: { alias: { "@": resolve(__dirname, "src") } },
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "@/styles/_vars-only.scss" as *;
@use "@/styles/mixins.scss" as *;
@use "@/styles/breakpoints.scss" as *;
`,
},
},
},
});

配置 additionalData 后,每个 Vue 组件 <style scoped lang="scss"> 内无需手动 @import,可直接使用变量和 Mixin。

十六、文件目录一览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
src/
└── styles/
├── index.scss # 总入口
├── _vars-only.scss # 纯变量 Map(不输出 CSS,供 additionalData 注入)
├── variables.scss # 引入 _vars-only + 输出 CSS 自定义属性
├── mixins.scss # Mixin 库(全部参数化)
├── breakpoints.scss # 响应式 Mixin(Map 驱动)
├── reset.scss # 浏览器重置(引用变量)
├── layout.scss # Flex / Grid / 定位(Map 循环)
├── spacing.scss # margin / padding(Mixin + Map)
├── typography.scss # 文字工具类(Map 循环)
├── colors.scss # 背景 / 边框 / 圆角(Map 循环 + 变量)
├── visual.scss # 阴影 / 光标 / 溢出 / 可见性(Map 循环)
├── scrollbar.scss # 滚动条样式
├── transitions.scss # 动画(变量驱动)
├── mobile.scss # 移动端适配(变量集中)
└── components.scss # 通用组件样式(Map + Mixin)

十七、日常使用

组件中直接用工具类名:

1
2
3
4
5
6
7
8
9
10
<template>
<div class="flex-between px-16 py-12 bg-white rounded-base shadow">
<span class="text-16 font-semibold text-ellipsis flex-1">标题文字</span>
<span class="text-12 text-secondary cursor-pointer ml-12">更多</span>
</div>

<Transition name="slide-up">
<div v-if="visible" class="popup">...</div>
</Transition>
</template>

SCSS 中引用变量和 Mixin(前提:已配置 additionalData):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<style scoped lang="scss">
.nav-bar {
@include flex($justify: space-between, $align: center, $gap: 16px);
}

.card {
@include card;
@include active-opacity;

&__title {
@include ellipsis(2);
color: $color-primary;
}

&__footer {
margin-top: $spacing-base * 0.75;
padding-top: $spacing-base * 0.75;
border-top: 1px solid $color-border;
}
}
</style>

变量体系一览:

层级形式用途
Map$theme-colors $shadow-map $radius-map分组存放关联值,供循环遍历和取值
SCSS 变量$color-primary $spacing-base $transition-duration编译时引用,一处修改全局生效
CSS 自定义属性var(--color-primary) var(--shadow-base)运行时切换暗色模式,Map 循环自动生成
Mixin@include card @include ellipsis(2)参数化复用,默认值来自变量

本文所有代码均为可直接复制使用的生产级片段。下次开新项目时,把 styles/ 目录整体复制,修改 variables.scss 中的 Map 值即可适配新品牌的视觉规范。

前言

在现代 Web 应用中,富文本编辑器是一个不可或缺的组件。无论是内容管理系统、博客平台还是在线文档工具,都离不开一个功能完善的编辑器。

本文将详细介绍两款主流富文本编辑器 —— wangEditorTinyMCE 的集成与定制方法,并提供完整的可运行 Demo。

两款编辑器对比速览

特性wangEditorTinyMCE
开源协议MITMIT(核心)/ 商业(高级插件)
包体积轻量(~200KB)较大(~1MB+,按需加载)
上手难度低,配置简洁中,插件体系丰富
插件生态内置基本功能丰富,50+ 官方插件
自定义扩展Boot 注册机制插件 API + setup 回调
适合场景轻量内容编辑、移动端复杂文档编辑、企业级应用
Vue/React 集成官方适配包官方封装组件

第一部分:wangEditor

一、为什么需要自定义编辑器行为?

市面上的富文本编辑器大多开箱即用,但在实际业务场景中,我们常常需要:

  • 自定义元素的渲染方式:如图片、视频、表格等特殊节点的 HTML 输出
  • 处理外部数据的加载:从服务端获取的 HTML 内容需要正确解析并渲染
  • 保持编辑状态的一致性:确保保存和加载的数据格式完全一致

二、自定义元素的 HTML 输出

大多数编辑器内部使用 JSON 节点来描述内容,输出时再将节点转换为 HTML。默认的输出格式往往比较简单,无法满足业务需求。

以视频元素为例

默认情况下,编辑器可能只输出视频的基本标签:

1
2
<!-- 默认输出 -->
<video controls><source src="video.mp4" /></video>

但在实际使用中,我们需要保留更多的视觉信息:

1
2
3
4
<!-- 期望输出 -->
<video width="640" height="360" controls style="width:640px;height:360px">
<source src="video.mp4" />
</video>

实现方案:注册 elem-to-html 转换器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Boot } from "@wangeditor/editor";

Boot.registerElemToHtml({
type: "video",
elemToHtml: elem => {
const src = elem?.src || "";
const style = elem?.style || "max-width:100%;height:auto";
const width = elem.width ? `width="${elem.width}"` : "";
const height = elem.height ? `height="${elem.height}"` : "";
return `<video ${width} ${height} controls style="${style}">
<source src="${src}">
</video>`;
},
});

关键点

  • 从节点中提取 srcstylewidthheight 等属性
  • 同时输出 HTML 属性和内联样式,确保兼容性
  • 提供合理的默认值(如 max-width:100%;height:auto

三、处理外部数据加载

当编辑器加载外部传入的 HTML 内容时,内置的解析器可能无法正确处理自定义格式。

问题分析

编辑器的内置 HTML 解析器通常只解析标准的 HTML 属性:

1
2
3
4
5
6
7
8
9
10
// 内置解析器的典型实现
function parseVideoHtml(element) {
return {
type: "video",
src: element.getAttribute("src") || "",
width: element.getAttribute("width") || "auto",
height: element.getAttribute("height") || "auto",
// ❌ 不解析 style 属性中的宽高
};
}

如果之前的输出将宽高信息只放在了 style 中,而没有单独的 width/height HTML 属性,加载时这些信息就会丢失。

解决方案一:输出时同时保留属性和样式

1
2
3
4
5
// 在 elemToHtml 中同时输出属性和 style
return `<video width="${wNum}" height="${hNum}"
controls style="${style}">
<source src="${src}">
</video>`;

这样无论内置解析器读属性还是读样式,都能获取到正确的尺寸信息。

解决方案二:加载前预处理内容

对于已经存储的历史内容,可以在加载前做预处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 预处理 HTML:为没有 width/height 属性的 video 标签从 style 中提取并补充
*/
function preprocessContent(html) {
if (!html) return html;
return html.replace(/<video\b([^>]*)>/gi, (_, attrs) => {
let result = attrs;
const w = attrs.match(/style\s*=\s*"[^"]*width\s*:\s*(\d+)/i);
if (w) result += ` width="${w[1]}"`;
const h = attrs.match(/style\s*=\s*"[^"]*height\s*:\s*(\d+)/i);
if (h) result += ` height="${h[1]}"`;
return `<video${result}>`;
});
}

四、完整的数据流闭环

1
2
3
4
编辑器节点 ──(elemToHtml)──> HTML 字符串 ──(保存)──> 服务端
^ │
│ │
└──(解析)── 预处理函数 ◄──(加载)─────────────────────┘
阶段处理方式目的
节点 → HTML自定义 elemToHtml确保输出包含完整信息
HTML 保存直接存储保留原始格式
HTML 加载preprocessContent 预处理统一新老数据格式
HTML → 节点内置解析器从属性中还原节点信息

wangEditor 完整 Demo

以下是一个可直接运行的完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wangEditor Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f2f5;
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.header {
padding: 24px 32px;
border-bottom: 1px solid #eee;
}
.header h1 {
font-size: 22px;
color: #1a1a1a;
margin-bottom: 6px;
}
.header p {
font-size: 14px;
color: #999;
}
.toolbar {
border-bottom: 1px solid #eee;
}
#editor-container {
min-height: 400px;
}
.w-e-text-container {
min-height: 400px;
}
.actions {
display: flex;
gap: 12px;
padding: 20px 32px;
border-top: 1px solid #eee;
flex-wrap: wrap;
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.btn-primary {
background: #1677ff;
color: #fff;
}
.btn-primary:hover {
background: #4096ff;
}
.btn-outline {
background: #fff;
color: #333;
border: 1px solid #d9d9d9;
}
.btn-outline:hover {
border-color: #1677ff;
color: #1677ff;
}
.preview-section,
.source-section {
border-top: 1px solid #eee;
padding: 32px;
display: none;
}
.preview-section.show,
.source-section.show {
display: block;
}
.preview-section h3,
.source-section h3 {
font-size: 16px;
color: #1a1a1a;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.preview-content {
line-height: 1.8;
color: #333;
}
.preview-content img,
.preview-content video {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.preview-content blockquote {
border-left: 4px solid #1677ff;
padding: 8px 16px;
margin: 12px 0;
background: #f6f8fa;
color: #666;
}
.preview-content table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
}
.preview-content th,
.preview-content td {
border: 1px solid #d9d9d9;
padding: 8px 12px;
text-align: left;
}
.preview-content th {
background: #fafafa;
font-weight: 600;
}
.preview-content a {
color: #1677ff;
}
.source-code {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>wangEditor Demo</h1>
<p>轻量级富文本编辑器 — 支持视频、图片、表格、代码块等</p>
</div>
<div class="toolbar" id="toolbar-container"></div>
<div id="editor-container"></div>
<div class="actions">
<button class="btn btn-primary" onclick="getContent()">获取 HTML</button>
<button class="btn btn-outline" onclick="getText()">获取纯文本</button>
<button class="btn btn-outline" onclick="setContent()">加载示例内容</button>
<button class="btn btn-outline" onclick="clearContent()">清空</button>
</div>
<div class="preview-section" id="preview-section">
<h3>HTML 预览</h3>
<div class="preview-content" id="preview-content"></div>
</div>
<div class="source-section" id="source-section">
<h3>HTML 源码</h3>
<pre class="source-code" id="source-code"></pre>
</div>
</div>

<script crossorigin src="https://unpkg.com/@wangeditor/editor@5.1.23/dist/index.min.js"></script>

<script>
const { createEditor, createToolbar, Boot } = window.wangEditor;

// 1. 自定义视频元素输出(保留样式的关键)
try {
Boot.registerElemToHtml({
type: "video",
elemToHtml: elem => {
const src = elem?.src || "";
const toSize = v => {
const num = parseFloat(v);
return v === "auto" || v === "" || v == null || isNaN(num) || num <= 0 ? "" : `${num}px`;
};
const wPx = toSize(elem.width);
const hPx = toSize(elem.height);
let style = elem?.style || "";
if (!style) {
if (wPx) style += `width:${wPx};`;
if (hPx) style += `height:${hPx};`;
}
style = style.replace(/;+$/, "") || "max-width:100%;height:auto";
const wAttr = wPx ? ` width="${parseFloat(wPx)}"` : "";
const hAttr = hPx ? ` height="${parseFloat(hPx)}"` : "";
return `<video${wAttr}${hAttr} controls style="${style}"><source src="${src}"></video>`;
},
});
} catch (e) {}

// 2. 预处理:为仅有 style 的 video 补上 width/height 属性
function preprocessContent(html) {
if (!html || typeof html !== "string") return html;
return html.replace(/<video\b([^>]*)>/gi, (_, attrs) => {
let result = attrs;
const w = attrs.match(/style\s*=\s*"[^"]*width\s*:\s*(\d+)/i);
if (w) {
result = result.replace(/\bwidth\s*=\s*"[^"]*"/i, "").replace(/\bwidth\s*=\s*\d+/i, "");
result += ` width="${w[1]}"`;
}
const h = attrs.match(/style\s*=\s*"[^"]*height\s*:\s*(\d+)/i);
if (h) {
result = result.replace(/\bheight\s*=\s*"[^"]*"/i, "").replace(/\bheight\s*=\s*\d+/i, "");
result += ` height="${h[1]}"`;
}
return `<video${result}>`;
});
}

// 3. 创建编辑器
const editor = createEditor({
selector: "#editor-container",
html: "",
config: {
placeholder: "请输入内容...",
autoFocus: false,
scroll: true,
},
mode: "default",
});

createToolbar({
editor,
selector: "#toolbar-container",
config: { excludeKeys: ["fullScreen"] },
mode: "default",
});

// 4. 操作函数
function getContent() {
const html = editor.getHtml();
document.getElementById("preview-section").classList.add("show");
document.getElementById("preview-content").innerHTML = html;
document.getElementById("source-section").classList.add("show");
document.getElementById("source-code").textContent = html;
}
function getText() {
alert("纯文本:\n\n" + editor.getText());
}
function setContent() {
const html = `<h2>欢迎使用 wangEditor</h2><p>这是一段<strong>示例内容</strong>。</p>
<table><tr><th>功能</th><th>说明</th></tr><tr><td>标题</td><td>支持多级标题</td></tr><tr><td>表格</td><td>支持增删行列</td></tr></table>
<blockquote>这是一个引用块。</blockquote>
<p><a href="https://www.wangeditor.com" target="_blank">wangEditor 官网</a></p>
<p><video width="640" height="360" controls style="width:640px;height:360px"><source src="https://www.w3schools.com/html/mov_bbb.mp4"></video></p>
<pre><code>function hello() { console.log('Hello!') }</code></pre>`;
setTimeout(() => editor.setHtml(preprocessContent(html)), 300);
}
function clearContent() {
editor.clear();
document.getElementById("preview-section").classList.remove("show");
document.getElementById("source-section").classList.remove("show");
}
window.addEventListener("DOMContentLoaded", setContent);
</script>
</body>
</html>

第二部分:TinyMCE

TinyMCE 是全球使用最广泛的富文本编辑器之一,拥有强大的插件生态系统和丰富的 API。

一、基础安装与配置

CDN 引入

TinyMCE 提供免费 CDN,无需注册即可使用:

1
<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>

生产环境建议注册 Tiny Cloud 获取免费 API Key,或使用自托管版本。

npm 安装

1
2
3
npm install tinymce
# 或
yarn add tinymce

最简配置

1
2
3
4
5
6
tinymce.init({
selector: "#editor", // 绑定 textarea 或 div
height: 500,
plugins: "lists link image table code",
toolbar: "undo redo | bold italic | alignleft aligncenter | bullist numlist",
});

二、核心配置详解

1. 插件系统(plugins)

TinyMCE 的功能以插件形式组织,按需加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
plugins: [
"advlist", // 高级列表(有序/无序列表样式选择)
"autolink", // 自动识别并转换链接
"link", // 插入/编辑链接
"image", // 插入/编辑图片
"table", // 表格编辑
"code", // 源码编辑
"codesample", // 代码高亮
"media", // 插入视频/音频
"fullscreen", // 全屏编辑
"preview", // 预览
"searchreplace", // 查找替换
"wordcount", // 字数统计
].join(" ");

2. 工具栏配置(toolbar)

通过 | 分组,空格分隔按钮:

1
2
3
4
5
toolbar: "undo redo | styles | bold italic underline strikethrough | " +
"alignleft aligncenter alignright alignjustify | " +
"bullist numlist outdent indent | " +
"link image media table | " +
"code preview fullscreen";

3. 图片与视频上传

TinyMCE 通过 images_upload_handler 自定义上传逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
images_upload_handler: (blobInfo, progress) =>
new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", blobInfo.blob(), blobInfo.filename());

// 调用你自己的上传接口
fetch("/api/upload", { method: "POST", body: formData })
.then(res => res.json())
.then(data => {
if (data.url) {
resolve(data.url); // 返回图片 URL
} else {
reject("上传失败");
}
})
.catch(reject);
});

三、自定义样式与格式

1. content_style — 编辑器内样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
content_style: `
body { font-family: -apple-system, sans-serif; font-size: 15px; line-height: 1.8; color: #333; }
img { max-width: 100%; height: auto; border-radius: 6px; }
video { max-width: 100%; height: auto; border-radius: 6px; }
blockquote {
border-left: 4px solid #1677ff; padding: 8px 16px;
margin: 12px 0; background: #f6f8fa; color: #666;
}
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #d9d9d9; padding: 8px 12px; }
th { background: #fafafa; font-weight: 600; }
a { color: #1677ff; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; }
`;

2. style_formats — 自定义格式菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
style_formats: [
{ title: "正文", block: "p" },
{ title: "一级标题", block: "h1" },
{ title: "二级标题", block: "h2" },
{ title: "引用块", block: "blockquote" },
{ title: "代码块", block: "pre", classes: "code-block" },
{
title: "红色文字",
inline: "span",
styles: { color: "#ff4d4f", "font-weight": "bold" },
},
{
title: "高亮标记",
inline: "span",
styles: { background: "#fff7e6", padding: "2px 6px", "border-radius": "3px" },
},
];

3. formats — 自定义格式规则

1
2
3
4
5
6
formats: {
// 自定义 red-text 格式
redtext: { inline: 'span', styles: { color: '#ff4d4f', fontWeight: 'bold' } },
// 自定义 highlight 格式
highlight: { inline: 'span', styles: { background: '#fff7e6', padding: '2px 6px', borderRadius: '3px' } },
}

四、setup 回调 — 编辑器生命周期钩子

setup 是 TinyMCE 最强大的扩展点,在编辑器初始化时执行,可以绑定事件、添加自定义按钮等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
setup: editor => {
// 1. 初始化完成
editor.on("init", () => {
console.log("编辑器就绪");
});

// 2. 内容变化
editor.on("change", () => {
console.log("内容变化:", editor.getContent());
});

// 3. 按键事件
editor.on("keydown", e => {
if (e.key === "Tab") {
e.preventDefault();
editor.execCommand("mceInsertContent", false, " ");
}
});

// 4. 粘贴预处理 — 过滤从 Word 复制的内容
editor.on("paste", e => {
// 清理 Word 格式但保留基本格式
// e.content 包含粘贴的 HTML
});

// 5. 添加自定义按钮
editor.ui.registry.addButton("customDate", {
text: "插入日期",
onAction: () => {
const now = new Date().toLocaleDateString("zh-CN");
editor.insertContent(`<span>${now}</span>`);
},
});

// 6. 自定义菜单项
editor.ui.registry.addMenuItem("customMenu", {
text: "自定义操作",
onAction: () => editor.insertContent("<p>自定义内容</p>"),
});
};

五、获取与设置内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 获取带格式的 HTML
const html = tinymce.activeEditor.getContent();

// 获取纯文本
const text = tinymce.activeEditor.getContent({ format: "text" });

// 设置内容(会替换编辑器全部内容)
tinymce.activeEditor.setContent("<p>新内容</p>");

// 在光标处插入内容
tinymce.activeEditor.insertContent("<span>插入的内容</span>");

// 动态禁用/启用
tinymce.activeEditor.mode.set("readonly"); // 只读
tinymce.activeEditor.mode.set("design"); // 编辑

六、TinyMCE 完整 Demo

以下是一个功能完整的 Demo,展示了 TinyMCE 的常见配置和使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TinyMCE Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f2f5;
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 960px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.header {
padding: 24px 32px;
border-bottom: 1px solid #eee;
}
.header h1 {
font-size: 22px;
color: #1a1a1a;
margin-bottom: 6px;
}
.header p {
font-size: 14px;
color: #999;
}
.actions {
display: flex;
gap: 12px;
padding: 12px 32px;
border-bottom: 1px solid #eee;
flex-wrap: wrap;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.btn-primary {
background: #1677ff;
color: #fff;
}
.btn-primary:hover {
background: #4096ff;
}
.btn-outline {
background: #fff;
color: #333;
border: 1px solid #d9d9d9;
}
.btn-outline:hover {
border-color: #1677ff;
color: #1677ff;
}

/* 双栏布局 */
.main-content {
display: flex;
min-height: 500px;
}
.editor-panel {
flex: 1;
min-width: 0;
}
.output-panel {
width: 380px;
border-left: 1px solid #eee;
display: flex;
flex-direction: column;
max-height: 600px;
}
.output-tabs {
display: flex;
border-bottom: 1px solid #eee;
}
.output-tab {
flex: 1;
padding: 10px 16px;
text-align: center;
font-size: 13px;
cursor: pointer;
background: #fafafa;
border: none;
color: #666;
transition: all 0.2s;
}
.output-tab.active {
background: #fff;
color: #1677ff;
border-bottom: 2px solid #1677ff;
font-weight: 500;
}
.output-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.output-body .preview-content {
line-height: 1.8;
color: #333;
}
.output-body .preview-content img,
.output-body .preview-content video {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.output-body .preview-content blockquote {
border-left: 4px solid #1677ff;
padding: 8px 16px;
margin: 12px 0;
background: #f6f8fa;
color: #666;
}
.output-body .preview-content table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
}
.output-body .preview-content th,
.output-body .preview-content td {
border: 1px solid #d9d9d9;
padding: 8px 12px;
text-align: left;
}
.output-body .preview-content th {
background: #fafafa;
font-weight: 600;
}
.output-body .preview-content a {
color: #1677ff;
}
.source-code {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}

/* 响应式 */
@media (max-width: 800px) {
.main-content {
flex-direction: column;
}
.output-panel {
width: 100%;
border-left: none;
border-top: 1px solid #eee;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>TinyMCE Demo</h1>
<p>企业级富文本编辑器 — 插件体系 + 自定义格式 + 实时预览</p>
</div>
<div class="actions">
<button class="btn btn-primary" id="btn-refresh">刷新预览</button>
<button class="btn btn-outline" id="btn-load-sample">加载示例内容</button>
<button class="btn btn-outline" id="btn-get-text">获取纯文本</button>
<button class="btn btn-outline" id="btn-clear">清空</button>
</div>
<div class="main-content">
<!-- 编辑区 -->
<div class="editor-panel">
<textarea id="tinymce-editor"></textarea>
</div>
<!-- 输出区:预览 + 源码 -->
<div class="output-panel">
<div class="output-tabs">
<button class="output-tab active" data-tab="preview">预览</button>
<button class="output-tab" data-tab="source">源码</button>
</div>
<div class="output-body">
<div class="preview-content" id="output-preview"></div>
<pre class="source-code" id="output-source" style="display:none;"></pre>
</div>
</div>
</div>
</div>

<!-- TinyMCE CDN -->
<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>

<script>
// ==================== 示例内容 ====================
const sampleContent = `
<h2 style="text-align: center;">欢迎使用 TinyMCE</h2>
<p>TinyMCE 是全球最流行的<strong>富文本编辑器</strong>之一,提供丰富的插件生态和灵活的 API。</p>

<h3>📊 表格功能</h3>
<table style="border-collapse: collapse; width: 100%;">
<thead>
<tr><th>插件</th><th>功能</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td>advlist</td><td>高级列表</td><td>支持有序/无序列表样式切换</td></tr>
<tr><td>table</td><td>表格编辑</td><td>支持增删行列、单元格合并</td></tr>
<tr><td>codesample</td><td>代码高亮</td><td>基于 Prism.js 的语法高亮</td></tr>
<tr><td>media</td><td>媒体嵌入</td><td>支持视频、音频嵌入</td></tr>
</tbody>
</table>

<h3>🎨 格式与样式</h3>
<p>支持<strong>加粗</strong>、<em>斜体</em>、<span style="text-decoration: underline;">下划线</span>、<span style="text-decoration: line-through;">删除线</span>、
<span style="color: #ff4d4f; font-weight: bold;">红色强调</span>、
<span style="background-color: #fff7e6; padding: 2px 6px; border-radius: 3px;">高亮标记</span>等格式。</p>

<h3>💬 引用块</h3>
<blockquote><p>TinyMCE 的 setup 钩子提供了强大的扩展能力,可以在编辑器初始化、内容变化等生命周期节点执行自定义逻辑。</p></blockquote>

<h3>🔗 超链接</h3>
<p>插入链接:<a href="https://www.tiny.cloud" target="_blank" rel="noopener">TinyMCE 官方网站</a></p>

<h3>📹 视频嵌入</h3>
<div style="max-width: 640px;">
<video width="640" height="360" controls="controls" style="width: 640px; height: 360px;">
<source src="https://www.w3schools.com/html/mov_bbb.mp4">
</video>
</div>

<h3>💻 代码高亮</h3>
<pre class="language-javascript" style="background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px;">
function useRichEditor() {
const editor = tinymce.get('editor')
const html = editor.getContent()
console.log('编辑器内容:', html)
}</pre>

<p style="text-align: center; color: #999; margin-top: 32px;">
—— 你可以自由编辑以上内容 ——
</p>
`;

// ==================== TinyMCE 初始化 ====================
tinymce.init({
selector: "#tinymce-editor",
height: 500,
language: "zh_CN",
menubar: false,
branding: false,
statusbar: true,
resize: true,

// 内容样式
content_style: `
body { font-family: -apple-system, sans-serif; font-size: 15px; line-height: 1.8; color: #333; padding: 16px; }
img { max-width: 100%; height: auto; border-radius: 6px; }
video { max-width: 100%; height: auto; border-radius: 6px; }
blockquote { border-left: 4px solid #1677ff; padding: 8px 16px; margin: 12px 0; background: #f6f8fa; color: #666; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #d9d9d9; padding: 8px 12px; text-align: left; }
th { background: #fafafa; font-weight: 600; }
a { color: #1677ff; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; }
`,

// 插件
plugins: [
"advlist autolink lists link image charmap",
"searchreplace visualblocks code fullscreen",
"media table codesample preview wordcount",
].join(" "),

// 工具栏:split 分组创建多行工具栏
toolbar:
"undo redo | styles | " +
"bold italic underline strikethrough forecolor backcolor | " +
"alignleft aligncenter alignright alignjustify | " +
"bullist numlist outdent indent | " +
"link image media table codesample | " +
"code preview fullscreen",

// 自定义格式
style_formats: [
{ title: "正文", block: "p" },
{ title: "一级标题", block: "h1" },
{ title: "二级标题", block: "h2" },
{ title: "三级标题", block: "h3" },
{ title: "引用块", block: "blockquote" },
{
title: "红色强调",
inline: "span",
styles: { color: "#ff4d4f", "font-weight": "bold" },
},
{
title: "高亮标记",
inline: "span",
styles: { background: "#fff7e6", padding: "2px 6px", "border-radius": "3px" },
},
],

// 表格默认属性
table_default_attributes: { border: "1" },
table_default_styles: { "border-collapse": "collapse", width: "100%" },

// 图片自适应
image_dimensions: false,
image_class_list: [
{ title: "自适应", value: "img-responsive" },
{ title: "圆角", value: "img-rounded" },
],

// 媒体嵌入
media_live_embeds: true,
media_dimensions: false,

// 粘贴配置:保留基本格式
paste_data_images: false,
paste_retain_style_properties: "color font-size font-weight text-decoration",

// 代码高亮
codesample_languages: [
{ text: "JavaScript", value: "javascript" },
{ text: "HTML/XML", value: "markup" },
{ text: "CSS", value: "css" },
{ text: "Python", value: "python" },
{ text: "SQL", value: "sql" },
{ text: "Bash", value: "bash" },
],

// setup 生命周期钩子
setup: editor => {
// 编辑器就绪后加载示例内容
editor.on("init", () => {
editor.setContent(sampleContent);
updatePreview(editor.getContent());
updateSource(editor.getContent());
});

// 内容变化时更新预览
editor.on("change keyup", () => {
const html = editor.getContent();
updatePreview(html);
updateSource(html);
});
},
});

// ==================== 预览与输出 ====================
function updatePreview(html) {
document.getElementById("output-preview").innerHTML = html;
}
function updateSource(html) {
document.getElementById("output-source").textContent = html;
}

// ==================== 按钮事件 ====================
document.getElementById("btn-refresh").addEventListener("click", () => {
const html = tinymce.activeEditor.getContent();
updatePreview(html);
updateSource(html);
});

document.getElementById("btn-load-sample").addEventListener("click", () => {
tinymce.activeEditor.setContent(sampleContent);
});

document.getElementById("btn-get-text").addEventListener("click", () => {
const text = tinymce.activeEditor.getContent({ format: "text" });
alert("纯文本内容:\n\n" + text);
});

document.getElementById("btn-clear").addEventListener("click", () => {
if (confirm("确定要清空编辑器内容吗?")) {
tinymce.activeEditor.setContent("");
updatePreview("");
updateSource("");
}
});

// ==================== Tab 切换 ====================
document.querySelectorAll(".output-tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".output-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
const target = tab.dataset.tab;
document.getElementById("output-preview").style.display = target === "preview" ? "" : "none";
document.getElementById("output-source").style.display = target === "source" ? "" : "none";
});
});
</script>
</body>
</html>

TinyMCE Demo 功能说明

功能说明
插件体系按需加载 advlist、table、codesample、media 等插件
自定义格式红色强调、高亮标记等自定义内联样式
双栏实时预览左侧编辑,右侧同步展示预览和 HTML 源码
setup 生命周期初始化时加载示例、内容变化时同步预览
代码高亮codesample 插件支持 JS/HTML/CSS/Python/SQL 等多语言
表格与媒体支持表格编辑、视频嵌入,图片自适应
粘贴配置保留颜色、字号、加粗等基本格式

总结

对比维度wangEditorTinyMCE
适用场景轻量编辑、移动端、简单内容复杂文档、企业后台、多格式需求
扩展方式Boot.registerElemToHtmlplugins + setup 钩子
数据处理节点模型 + 解析器直接操作 HTML
学习曲线低,1天上手中,2-3天熟悉
社区成熟度中文社区活跃全球社区,文档丰富

选型建议

  • 如果追求轻量、快速集成,选 wangEditor
  • 如果需要丰富的插件生态、企业级功能,选 TinyMCE
  • 两者可以共存:管理后台用 TinyMCE 处理复杂内容,移动端用 wangEditor 做简单编辑

希望本文和两个 Demo 能帮助你在富文本编辑器集成的道路上少走弯路!

本文详细介绍如何在 Vue 3 + Element Plus 项目中集成天地图 JavaScript API v4.0,涵盖 SDK 加载、地图选点定位、多边形绘制/编辑、轨迹展示等常见场景的完整实现方案。

一、为什么选择天地图

天地图(TianDiTu)是国家地理信息公共服务平台,提供标准的 JavaScript API,相较于国外地图服务具有以下优势:

  • 国内访问稳定,加载速度快
  • 提供卫星影像、混合地图等多种图层
  • API 覆盖标记、折线、多边形、信息窗、比例尺等常用功能
  • 完全免费,无需注册信用卡

二、SDK 动态加载方案

天地图 JS API 通过 CDN 加载,建议在组件内按需动态引入,避免首屏加载不必要的资源。

2.1 加载函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const TIANDITU_TK = "your_api_key_here";

/**
* 动态加载天地图 JavaScript SDK
* SDK 加载完成后 window.T 全局对象可用
*/
const loadTdtScript = () => {
return new Promise((resolve, reject) => {
// 已加载则直接返回,避免重复加载
if (window.T) return resolve();

const script = document.createElement("script");
script.src = `https://api.tianditu.gov.cn/api?v=4.0&tk=${TIANDITU_TK}`;
script.onload = () => resolve();
script.onerror = () => reject(new Error("天地图加载失败"));
document.head.appendChild(script);
});
};

2.2 关键设计点

设计说明
Promise 化将异步加载封装为 Promise,便于 async/await 调用
幂等性加载前检查 window.T,多次调用不会重复创建 <script> 标签
按需加载仅在打开地图弹窗时触发,首页不加载地图 SDK
错误处理onerror 回调 reject,调用方可以捕获加载失败并给出友好提示

三、核心场景实现

3.1 场景一:地图选点定位

在地图弹窗中点击任意位置,自动获取经纬度坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 初始化地图
const initMap = async () => {
await loadTdtScript();
const T = window.T;

// 创建地图实例
const map = new T.Map("mapContainer");
map.centerAndZoom(new T.LngLat(116.40769, 39.89945), 12);
map.setMapType(TMAP_SATELLITE_MAP); // 卫星影像图

// 点击地图放置标记
map.addEventListener("click", e => {
const { lng, lat } = e.lnglat;
setMarker(map, lng, lat);
});
};

// 放置/移动标记点
let marker = null;
const setMarker = (map, lng, lat) => {
const T = window.T;
if (marker) map.removeOverLay(marker);

const icon = new T.Icon({
iconUrl: markerIconUrl,
iconSize: new T.Point(32, 32),
iconAnchor: new T.Point(16, 32),
});

marker = new T.Marker(new T.LngLat(lng, lat), { icon });
map.addOverLay(marker);
};

关键点

  • 使用 TMAP_SATELLITE_MAP 卫星图层,方便辨认实际地物
  • 标记的 iconAnchor 设为图标底部中心,定位更准确
  • 每次点击先移除旧标记再创建新标记,保证地图上只有一个标记点

3.2 场景二:多边形区域绘制

用户在地图上依次点击顶点,绘制多边形区域,支持撤销和清空操作。

3.2.1 数据结构

1
2
3
4
5
6
7
// 存储所有顶点
const mapPoints = ref([]); // [[lng, lat], [lng, lat], ...]

// 地图实例 + 覆盖物引用
let map = null;
let polygon = null;
let pointMarkers = [];

3.2.2 点击绘制顶点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
map.addEventListener("click", e => {
const { lng, lat } = e.lnglat;
mapPoints.value.push([lng, lat]);
redrawPolygon(map);
});

const redrawPolygon = map => {
const T = window.T;

// 清除旧覆盖物
if (polygon) map.removeOverLay(polygon);
pointMarkers.forEach(m => map.removeOverLay(m));
pointMarkers = [];

const points = mapPoints.value.map(([lng, lat]) => new T.LngLat(lng, lat));

// 为每个顶点添加标记
pointMarkers = points.map((p, i) => {
return new T.Marker(p, {
title: `顶点${i + 1}`,
icon: new T.Icon({
iconUrl: smallCircleIcon,
iconSize: new T.Point(12, 12),
iconAnchor: new T.Point(6, 6),
}),
});
});
pointMarkers.forEach(m => map.addOverLay(m));

// 至少3个点才绘制多边形
if (points.length >= 3) {
polygon = new T.Polygon(points, {
color: "#409eff", // 边框颜色
weight: 3, // 边框宽度(px)
opacity: 0.8, // 边框不透明度
fillColor: "#79bbff", // 填充颜色
fillOpacity: 0.3, // 填充不透明度
});
map.addOverLay(polygon);
}
};

3.2.3 撤销与清空

1
2
3
4
5
6
7
8
9
10
11
// 撤销上一点
const undoLastPoint = () => {
mapPoints.value.pop();
redrawPolygon(map);
};

// 清空全部
const clearPolygon = () => {
mapPoints.value = [];
redrawPolygon(map);
};

3.2.4 自动计算中心点

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 计算多边形中心经纬度(顶点算术平均)
*/
const calcCenter = points => {
const len = points.length;
if (len === 0) return [0, 0];

const sumLng = points.reduce((s, [lng]) => s + lng, 0);
const sumLat = points.reduce((s, [, lat]) => s + lat, 0);

return [sumLng / len, sumLat / len];
};

3.2.5 已有多边形回显展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const initDetailMap = async boundaryData => {
await loadTdtScript();
const T = window.T;

const map = new T.Map("detailMap");
const points = parseBoundary(boundaryData);

// 绘制多边形
const polygon = new T.Polygon(
points.map(p => new T.LngLat(p.lng, p.lat)),
{ color: "#409eff", weight: 3, opacity: 0.8, fillColor: "#79bbff", fillOpacity: 0.3 },
);
map.addOverLay(polygon);

// 自适应视野
map.setViewport(polygon.getBounds());

// 中心点标记 + 信息窗
const [centerLng, centerLat] = calcCenter(boundaryData);
const centerMarker = new T.Marker(new T.LngLat(centerLng, centerLat), { icon: new T.Icon({ iconUrl: centerPinIcon, iconSize: new T.Point(28, 28), iconAnchor: new T.Point(14, 28) }) });
map.addOverLay(centerMarker);

// 鼠标悬浮信息窗
const infoWin = new T.InfoWindow("");
centerMarker.addEventListener("mouseover", () => {
infoWin.setContent(`<div>经度: ${centerLng.toFixed(6)}</div><div>纬度: ${centerLat.toFixed(6)}</div>`);
map.openInfoWindow(infoWin, centerMarker.getLngLat());
});
centerMarker.addEventListener("mouseout", () => map.closeInfoWindow());
};

关键点

  • setViewport 自动计算最优缩放级别和中心点,使多边形完整显示
  • 中心点使用自定义 SVG 图标,视觉上更突出
  • InfoWindow 用于悬浮展示详细信息,交互体验好
  • 数据驱动视图:先维护状态数组,再统一调用 redrawPolygon() 重绘,撤销/清空操作简洁可靠

3.3 场景三:路径轨迹展示

展示从起点到终点的完整路径,使用折线 + 起终点标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const initTrackMap = async trajectory => {
await loadTdtScript();
const T = window.T;

const map = new T.Map("trackMap");

// 轨迹折线
const points = trajectory.map(p => new T.LngLat(p.lng, p.lat));
const polyline = new T.Polyline(points, {
color: "#26B165", // 绿色
weight: 4,
opacity: 0.8,
});
map.addOverLay(polyline);

// 起点标记(绿色 "起" 字)
const startIcon = new T.Icon({
iconUrl: generateMarkerSvg("#FFFFFF", "#26B165", "起"),
iconSize: new T.Point(32, 32),
iconAnchor: new T.Point(16, 32),
});
map.addOverLay(new T.Marker(points[0], { icon: startIcon }));

// 终点标记(红色 "终" 字)
const endIcon = new T.Icon({
iconUrl: generateMarkerSvg("#FFFFFF", "#FF4C4C", "终"),
iconSize: new T.Point(32, 32),
iconAnchor: new T.Point(16, 32),
});
map.addOverLay(new T.Marker(points[points.length - 1], { icon: endIcon }));

// 自适应视野
map.setViewport(polyline.getBounds());

// 比例尺控件
map.addControl(new T.Control.Scale());
};

起/终点 SVG 图标生成函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 生成带文字的圆形标记 SVG Data URI
* @param {string} fillColor 填充色
* @param {string} strokeColor 描边色
* @param {string} text 文字
*/
const generateMarkerSvg = (fillColor, strokeColor, text) => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40">
<circle cx="16" cy="14" r="12" fill="${fillColor}" stroke="${strokeColor}" stroke-width="2.5"/>
<text x="16" y="19" text-anchor="middle" fill="${strokeColor}" font-size="14" font-weight="bold">${text}</text>
<polygon points="10,26 16,38 22,26" fill="${strokeColor}"/>
</svg>`;
return "data:image/svg+xml," + encodeURIComponent(svg);
};

关键点

  • 使用内联 SVG Data URI 生成自定义标记图标,无需额外静态资源
  • setViewport 配合 getBounds() 自动适配,确保整条轨迹完整可见
  • 比例尺控件帮助用户直观感知距离

四、地图实例生命周期管理

每个使用地图的组件都应维护独立的实例引用,确保组件销毁时正确清理:

1
2
3
4
5
6
7
8
9
10
11
12
// 模块级变量,存储地图实例引用
let mapInstance = null;
let markerInstance = null;

// onUnmounted / 弹窗关闭时清理
const destroyMap = () => {
if (mapInstance) {
mapInstance.clearOverLays(); // 清除所有覆盖物
mapInstance = null;
}
markerInstance = null;
};

生命周期对照表

事件操作
弹窗 @opened调用 initMap() 创建地图
弹窗 @closed调用 destroyMap() 销毁实例
组件 onUnmounted清理可能残留的地图实例

五、API 能力速查

API用途关键参数
T.Map(containerId)创建地图实例DOM 容器 id
map.centerAndZoom(center, zoom)设置中心点和缩放T.LngLat, 缩放级别 1-18
map.setMapType(type)设置图层TMAP_NORMAL_MAP / TMAP_SATELLITE_MAP / TMAP_HYBRID_MAP
T.Marker(lngLat, opts)添加标记点坐标、T.Icon 图标配置
T.Polyline(points, style)绘制折线坐标数组、颜色、线宽
T.Polygon(points, style)绘制多边形坐标数组、边框/填充样式
T.InfoWindow(content)信息窗HTML 内容字符串
map.setViewport(bounds)自适应视野polyline.getBounds() / polygon.getBounds()
T.Control.Scale()比例尺控件添加到 map.addControl()
map.addEventListener(event, fn)事件监听clickmouseovermouseout
map.clearOverLays()清除所有覆盖物

六、踩坑记录与最佳实践

6.1 弹窗中地图初始化时机

问题:在 el-dialog 中使用天地图,如果在 onMounted 中初始化,此时弹窗 DOM 可能还未渲染,地图容器 div 的实际宽高为 0,导致地图无法正常显示。

解决:监听弹窗的 @opened 事件,此时 DOM 已完全渲染,再初始化地图:

1
2
3
<el-dialog @opened="initMap" @closed="destroyMap">
<div id="mapContainer" style="width:100%;height:480px"></div>
</el-dialog>

6.2 多次打开弹窗地图未重绘

问题:关闭弹窗后再次打开,地图容器中残留上次的 DOM 节点,两次初始化互相干扰。

解决:关闭弹窗时彻底销毁地图实例和 DOM:

1
2
3
4
5
6
7
8
9
const destroyMap = () => {
if (map) {
map.clearOverLays();
map = null;
}
// 手动清空容器 DOM(天地图可能残留节点)
const container = document.getElementById("mapContainer");
if (container) container.innerHTML = "";
};

6.3 经纬度顺序陷阱

问题:天地图 API 中 LngLat 的构造函数参数顺序是 (经度, 纬度),即 (lng, lat),而非更常见的 (lat, lng)。如果搞反会导致标记点飞到错误位置。

正确写法

1
new T.LngLat(lng, lat); // ✅ 经度在前,纬度在后

6.4 多边形数据格式兼容

问题:后端返回的多边形数据格式可能不统一,有的是 [[lng, lat]] 二维数组,有的是 [{lng, lat}] 对象数组。

解决:编写兼容解析函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const parseBoundary = data => {
if (!data || !data.length) return [];

// 格式1: [[lng, lat], [lng, lat]]
if (Array.isArray(data[0])) {
return data.map(([lng, lat]) => ({ lng, lat }));
}

// 格式2: [{lng, lat}] 或 [{longitude, latitude}]
return data.map(p => ({
lng: p.lng ?? p.longitude,
lat: p.lat ?? p.latitude,
}));
};

6.5 API Key 安全防护

注意:天地图 API Key 不应直接硬编码在前端代码中。建议方案:

  • 将 Key 存储在 .env 环境变量中:VITE_TIANDITU_TK=your_key
  • 使用 import.meta.env.VITE_TIANDITU_TK 读取
  • 生产环境可通过后端接口动态获取,进一步降低泄漏风险

七、进阶封装建议

当项目中有多个页面使用天地图时,建议将通用逻辑抽取为 Composable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// composables/useTdtMap.js
export function useTdtMap(containerId, options = {}) {
const map = ref(null);
const markers = ref([]);
const polyline = ref(null);
const polygon = ref(null);

const init = async () => {
await loadTdtScript();
const T = window.T;
map.value = new T.Map(containerId);
map.value.centerAndZoom(new T.LngLat(options.center?.[0] ?? 116.4, options.center?.[1] ?? 39.9), options.zoom ?? 12);
if (options.mapType) map.value.setMapType(options.mapType);
};

const addMarker = (lng, lat, iconUrl) => {
const T = window.T;
const m = new T.Marker(new T.LngLat(lng, lat), {
icon: iconUrl ? new T.Icon({ iconUrl, iconSize: new T.Point(32, 32), iconAnchor: new T.Point(16, 32) }) : undefined,
});
map.value.addOverLay(m);
markers.value.push(m);
return m;
};

const drawPolyline = (points, style = {}) => {
const T = window.T;
polyline.value = new T.Polyline(
points.map(p => new T.LngLat(p[0], p[1])),
{ color: "#26B165", weight: 4, opacity: 0.8, ...style },
);
map.value.addOverLay(polyline.value);
map.value.setViewport(polyline.value.getBounds());
};

const drawPolygon = (points, style = {}) => {
const T = window.T;
polygon.value = new T.Polygon(
points.map(p => new T.LngLat(p[0], p[1])),
{ color: "#409eff", weight: 3, opacity: 0.8, fillColor: "#79bbff", fillOpacity: 0.3, ...style },
);
map.value.addOverLay(polygon.value);
map.value.setViewport(polygon.value.getBounds());
};

const clearAll = () => {
if (map.value) map.value.clearOverLays();
markers.value = [];
polyline.value = null;
polygon.value = null;
};

const destroy = () => {
clearAll();
map.value = null;
const el = document.getElementById(containerId);
if (el) el.innerHTML = "";
};

onUnmounted(destroy);

return {
map,
markers,
polyline,
polygon,
init,
addMarker,
drawPolyline,
drawPolygon,
clearAll,
destroy,
};
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<el-dialog v-model="visible" @opened="map.init" @closed="map.destroy">
<div id="myMap" style="height:500px"></div>
</el-dialog>
</template>

<script setup>
import { useTdtMap } from "@/composables/useTdtMap";

const map = useTdtMap("myMap", {
center: [116.4, 39.9],
zoom: 12,
mapType: TMAP_SATELLITE_MAP,
});
</script>

八、总结

在 Vue 3 项目中落地天地图,核心在于以下几点:

  1. 按需加载:通过动态注入 <script> 标签实现 SDK 按需加载,避免首屏性能损耗
  2. 生命周期对齐:弹窗 @opened 初始化、@closed 销毁,避免地图渲染异常
  3. 数据驱动视图:以状态数组为单一数据源,通过重绘函数统一渲染覆盖物,撤销/清空操作简洁可靠
  4. 适度抽象:当使用场景超过 2 个时,建议抽取 Composable 统一管理地图生命周期和操作

本文覆盖了选点定位、多边形绘制/编辑、路径轨迹展示三个典型场景,掌握了这些模式后,扩展到热力图、聚合点等高级功能也会更加得心应手。

本文基于天地图 JavaScript API v4.0 编写。

告别 XML 写布局的年代!Compose 是 Android 未来的 UI 开发方式。这篇指南用最直白的方式,带你从安装到写出第一个完整页面。

什么是 Jetpack Compose?

一句话

用 Kotlin 代码直接写 UI,不再需要 XML。

过去写 Android 页面要两个文件配合:activity_main.xml(画界面)+ MainActivity.kt(写逻辑)。Compose 把两者合二为一,全部用 Kotlin 搞定。

为什么学 Compose?

对比维度传统 XMLCompose
语言XML + Kotlin/Java纯 Kotlin
文件数1 个页面 ≥ 2 个文件1 个文件搞定
UI 更新findViewById + 手动设值自动重组(数据变了界面自动刷新)
学习曲线平缓但写起来累稍陡但写起来爽
嵌套性能嵌套越深越卡无嵌套问题
Google 态度维护但不再主推⭐ 官方主推,全力投入

结论:现在开始学 Android,直接从 Compose 起步,不需要学 XML。

环境搭建

下载安装

  1. Android Studio 下载页 下载最新版(Hedgehog 以上)
  2. 一路 Next 安装,SDK 按默认勾选即可
  3. 首次启动可能会下载 SDK,等它完成

创建第一个 Compose 项目

  1. 打开 Android Studio → New Project
  2. 选择 「Empty Activity」(不是 Empty Views Activity!)
  3. 填写:
    • Name:MyFirstApp
    • Package name:com.example.myfirstapp
    • Language:Kotlin
    • Minimum SDK:API 24(Android 7.0)
  4. 点击 Finish,等待 Gradle 同步完成

⚠️ 关键区分Empty Activity 是 Compose 项目,Empty Views Activity 是传统 XML 项目。选错了你就回到旧时代了。

项目长什么样?

创建完成后,你看到的 MainActivity.kt 大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.myfirstapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.myfirstapp.ui.theme.MyFirstAppTheme

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyFirstAppTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Greeting("Android")
}
}
}
}
}

@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
MyFirstAppTheme {
Greeting("Android")
}
}

各部分解释

代码作用
setContent { }Compose 的入口,替代了 setContentView(R.layout.xxx)
@Composable标记函数是一个 UI 组件(这是 Compose 的核心注解)
@Preview让函数可以在 Android Studio 右侧预览,不需要跑模拟器
MaterialTheme应用 Material Design 3 主题

纯 Kotlin写UI和 XML 的对应关系

XML 方式Compose (Kotlin)
FrameLayoutBox
LinearLayout(vertical)Column
LinearLayout(horizontal)Row
RecyclerViewLazyColumn / LazyRow
android:layout_width="match_parent"Modifier.fillMaxWidth()
android:layout_height="match_parent"Modifier.fillMaxHeight() / fillMaxSize()
android:layout_margin / paddingModifier.padding()
android:backgroundModifier.background()
android:gravityModifier.align() / Arrangement

本文是 Compose 系列配套参考手册,包含每个控件的完整参数、示例和注意事项。配合 Compose 从 0 到 1 食用更佳。


前言:Compose 控件体系概览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Composable 函数(所有 UI 都是函数)
├── Text(文本)
├── Button(按钮)
├── TextField / OutlinedTextField(输入框)
├── Image(图片)
├── Checkbox(复选框)
├── Switch(开关)
├── RadioButton(单选按钮)
├── Slider(滑动条)
├── Column(纵向布局)
├── Row(横向布局)
├── Box(层叠布局)
├── LazyColumn / LazyRow(列表控件)
├── Scaffold(页面骨架)
│ ├── TopAppBar(顶部栏)
│ ├── BottomAppBar / NavigationBar(底部栏)
│ └── FloatingActionButton(浮动按钮)
└── Modifier(外观修饰,适用于所有控件)

Modifier 是所有控件共用的外观修饰系统,详见第十四章。

Text —— 文本

作用

显示文字。Compose 中最基础的控件,替代传统 TextView

核心属性

参数类型说明
textString显示的文本内容
modifierModifier外观修饰(大小、边距、背景等)
colorColor文字颜色,如 Color.BlueColor(0xFF333333)
fontSizeTextUnit字号,如 16.sp
fontStyleFontStyleFontStyle.Normal / FontStyle.Italic
fontWeightFontWeight字重:Thin / Light / Normal / Medium / Bold / Black
fontFamilyFontFamily字体,如 FontFamily.Default / FontFamily.Monospace
letterSpacingTextUnit字母间距,如 1.sp
textDecorationTextDecorationNone / Underline / LineThrough
textAlignTextAlign对齐方式:Left / Center / Right / Justify / Start / End
lineHeightTextUnit行高,如 24.sp
maxLinesInt最大行数,超出后按 overflow 处理
overflowTextOverflow超出处理:Ellipsis(省略号)/ Clip(裁剪)/ Visible(可见)
softWrapBoolean是否自动换行,默认 true
styleTextStyle统一样式对象,可一次性设置多个文字属性
onTextLayout(TextLayoutResult) -> Unit文字布局完成回调

示例

1
2
3
4
5
6
7
8
Text(
text = "Hello Compose",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.Blue,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)

适用场景

  • 页面标题、正文、标签、提示信息等一切需要显示文字的地方

⚠️ 注意

  • fontSize 必须带单位 .sp,否则编译报错
  • overflow = TextOverflow.Ellipsis 只在 maxLines 设置后生效
  • 需要渐变文字或复杂样式时,使用 buildAnnotatedString + ClickableText

Button —— 按钮

作用

用户点击触发操作。Compose 中最核心的交互控件,Material 3 风格。

核心属性

参数类型说明
onClick() -> Unit点击回调
modifierModifier外观修饰
enabledBoolean是否可点击,false 时变灰,默认 true
colorsButtonColors颜色配置,由 ButtonDefaults.buttonColors() 创建
elevationButtonElevation阴影高度,由 ButtonDefaults.buttonElevation() 创建
shapeShape按钮形状,如 RoundedCornerShape(8.dp)
borderBorderStroke?边框,如 BorderStroke(1.dp, Color.Gray)
contentPaddingPaddingValues内容内边距,默认 ButtonDefaults.ContentPadding
interactionSourceMutableInteractionSource交互状态源(高级用法)
content@Composable RowScope.() -> Unit按钮内部内容(通常是 Text + 图标组合)

示例

1
2
3
4
5
6
7
8
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(
containerColor = Color.Blue
)
) {
Text("点击我")
}

文字按钮(TextButton)

1
2
3
TextButton(onClick = { }) {
Text("取消")
}

描边按钮(OutlinedButton)

1
2
3
OutlinedButton(onClick = { }) {
Text("了解更多")
}

适用场景

  • 提交表单、页面跳转、确认/取消操作等点击交互

⚠️ 注意

  • content 中使用 RowScope,可以横向排列多个元素(如图标 + 文字)
  • Material 3 默认没有阴影,区别于传统 MaterialButton
  • ButtonDefaults.buttonColors() 可配置:containerColorcontentColordisabledContainerColordisabledContentColor

TextField —— 输入框

作用

用户输入文字。Material 3 提供两种样式:TextField(填充)和 OutlinedTextField(描边)。

核心属性

参数类型说明
valueString当前输入内容,必填
onValueChange(String) -> Unit输入变化回调,必填
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
readOnlyBoolean是否只读,默认 false
label@Composable (() -> Unit)?浮动标签,如 { Text("用户名") }
placeholder@Composable (() -> Unit)?占位文字,如 { Text("请输入") }
leadingIcon@Composable (() -> Unit)?左侧图标
trailingIcon@Composable (() -> Unit)?右侧图标(常用于清除按钮)
isErrorBoolean是否显示错误状态,默认 false
supportingText@Composable (() -> Unit)?底部辅助文字(错误提示 / 字符计数)
singleLineBoolean是否单行,默认 false
maxLinesInt最大行数
keyboardOptionsKeyboardOptions键盘类型:KeyboardOptions(keyboardType = KeyboardType.Password)
keyboardActionsKeyboardActions键盘动作:KeyboardActions(onDone = { ... })
colorsTextFieldColors颜色配置
textStyleTextStyle文字样式
visualTransformationVisualTransformation视觉变换,如 PasswordVisualTransformation()
shapeShape形状

KeyboardType 常用值

场景
KeyboardType.Text普通文本
KeyboardType.Password密码
KeyboardType.Number数字
KeyboardType.Decimal带小数点数字
KeyboardType.Phone电话号码
KeyboardType.Email邮箱地址
KeyboardType.Uri网址

示例

1
2
3
4
5
6
7
8
9
var text by remember { mutableStateOf("") }

TextField(
value = text,
onValueChange = { text = it },
label = { Text("用户名") },
placeholder = { Text("请输入") },
singleLine = true
)

适用场景

  • 登录/注册表单、搜索框、评论输入、聊天输入、任何需要用户输入的地方

⚠️ 注意

  • valueonValueChange必填参数,缺一不可
  • 必须配合 remember { mutableStateOf("") } 使用,否则无法输入
  • 密码框用 visualTransformation = PasswordVisualTransformation()
  • OutlinedTextField 参数与 TextField 几乎相同,只是外观为描边样式

Image —— 图片

作用

显示图片,支持本地资源、网络图片、位图等。

核心属性

参数类型说明
painterPainter图片资源,painterResource(R.drawable.xxx)
contentDescriptionString?无障碍描述,必填(纯装饰可传 null
modifierModifier外观修饰
alignmentAlignment图片在控件内的对齐方式,默认 Alignment.Center
contentScaleContentScale缩放方式(见下表)
alphaFloat透明度,0f ~ 1f,默认 1f
colorFilterColorFilter?颜色滤镜,如 ColorFilter.tint(Color.Red)

ContentScale 常用值

效果
Crop裁剪填满,保持比例(类似 centerCrop)
Fit等比缩放,完全显示(类似 fitCenter)
FillBounds拉伸填满,不保持比例(类似 fitXY)
FillWidth宽度填满,高度按比例
FillHeight高度填满,宽度按比例
None原始尺寸,不缩放

示例

1
2
3
4
5
6
Image(
painter = painterResource(id = R.drawable.ic_launcher),
contentDescription = "应用图标",
modifier = Modifier.size(100.dp),
contentScale = ContentScale.Crop
)

适用场景

  • 头像、商品图、Banner、图标、背景图等

⚠️ 注意

  • contentDescription 应始终提供有意义的描述,纯装饰性图片传 null
  • 加载网络图片需要引入图片加载库(Coil / Glide),painterResource 只能加载本地资源
  • 圆形头像裁剪用 Modifier.clip(CircleShape)

Checkbox —— 复选框

作用

多选开关,用户勾选/取消勾选。

核心属性

参数类型说明
checkedBoolean是否勾选
onCheckedChange((Boolean) -> Unit)?勾选状态变化回调
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
colorsCheckboxColors颜色配置,CheckboxDefaults.colors()

示例

1
2
3
4
5
6
var checked by remember { mutableStateOf(false) }

Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = checked, onCheckedChange = { checked = it })
Text("我同意用户协议")
}

三态复选框(TriStateCheckbox)

1
2
3
4
5
6
7
8
9
var state by remember { mutableStateOf(ToggleableState.Off) }

TriStateCheckbox(state = state, onClick = {
state = when (state) {
ToggleableState.Off -> ToggleableState.Indeterminate
ToggleableState.Indeterminate -> ToggleableState.On
ToggleableState.On -> ToggleableState.Off
}
})

适用场景

  • 协议勾选、多选列表、设置项开关

⚠️ 注意

  • onCheckedChange 是可空的,设为 null 可禁用交互(但不改变外观)
  • 通常搭配 Row + Text 组成完整的选项行
  • TriStateCheckbox 支持三个状态:未选 / 半选 / 全选,适合全选场景

Switch —— 开关

作用

开关控件,用于切换两种互斥状态(如开关某项功能)。

核心属性

参数类型说明
checkedBoolean是否开启
onCheckedChange((Boolean) -> Unit)?状态变化回调
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
colorsSwitchColors颜色配置,SwitchDefaults.colors()
thumbContent@Composable (() -> Unit)?滑块内部内容(可放图标)

示例

1
2
3
var isOn by remember { mutableStateOf(true) }

Switch(checked = isOn, onCheckedChange = { isOn = it })

带图标的开关

1
2
3
4
5
6
7
Switch(
checked = isWiFi,
onCheckedChange = { isWiFi = it },
thumbContent = if (isWiFi) {
{ Icon(Icons.Default.Wifi, contentDescription = null, modifier = Modifier.size(16.dp)) }
} else null
)

适用场景

  • 设置页功能开关(WiFi、蓝牙、通知等)

⚠️ 注意

  • SwitchCheckbox 语义不同:Switch 表示即时生效的状态切换,Checkbox 表示需要提交的选项
  • colors 可配置:checkedThumbColorcheckedTrackColoruncheckedThumbColoruncheckedTrackColor

RadioButton —— 单选按钮

作用

多选一,用户从一组选项中选择一个。

核心属性

参数类型说明
selectedBoolean是否被选中
onClick(() -> Unit)?点击回调
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
colorsRadioButtonColors颜色配置,RadioButtonDefaults.colors()

示例

1
2
3
4
5
6
7
8
9
10
11
12
val options = listOf("男", "女", "保密")
var selected by remember { mutableStateOf(options[0]) }

options.forEach { option ->
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = (option == selected),
onClick = { selected = option }
)
Text(option)
}
}

适用场景

  • 性别选择、支付方式选择、任何互斥选项场景

⚠️ 注意

  • RadioButton 本身不包含文字,需要手动组合 Row + Text
  • 和传统的 RadioGroup 不同,Compose 中需要手动管理互斥逻辑(通过状态变量)
  • colors 可配置:selectedColorunselectedColordisabledSelectedColordisabledUnselectedColor

Slider —— 滑动条

作用

通过拖动滑块在连续范围内选择一个值。

核心属性

参数类型说明
valueFloat当前值
onValueChange(Float) -> Unit拖动回调
modifierModifier外观修饰
enabledBoolean是否可用,默认 true
valueRangeClosedFloatingPointRange<Float>取值范围,如 0f..100f
stepsInt分段数,0 表示连续,1 表示 1 段
colorsSliderColors颜色配置,SliderDefaults.colors()
onValueChangeFinished(() -> Unit)?拖动结束回调

示例

1
2
3
4
5
6
7
8
9
var sliderValue by remember { mutableStateOf(0.5f) }

Slider(
value = sliderValue,
onValueChange = { sliderValue = it },
valueRange = 0f..1f
)

Text("当前值:${"%.0f".format(sliderValue * 100)}%")

带刻度的 Slider

1
2
3
4
5
6
7
8
var volume by remember { mutableStateOf(5f) }

Slider(
value = volume,
onValueChange = { volume = it },
valueRange = 0f..10f,
steps = 9 // 10 个档位需要 9 个分段
)

适用场景

  • 音量调节、亮度调节、进度条、价格区间选择

⚠️ 注意

  • steps = 0 表示连续拖动,steps = n 表示分成 n+1 档
  • onValueChangeFinished 适合在拖动结束时触发保存或网络请求
  • 需要显示范围标签时,配合 Row + Text 放在 Slider 两端

Column —— 纵向布局

作用

子元素纵向依次排列。等同于传统 LinearLayout(orientation=vertical)

核心属性

参数类型说明
modifierModifier外观修饰
verticalArrangementArrangement.Vertical垂直排列方式(见下表)
horizontalAlignmentAlignment.Horizontal水平对齐方式:Start / CenterHorizontally / End
content@Composable ColumnScope.() -> Unit子元素内容

Arrangement.Vertical 常用值

效果
Arrangement.Top顶部对齐(默认)
Arrangement.Center垂直居中
Arrangement.Bottom底部对齐
Arrangement.SpaceBetween两端对齐,中间均匀留空
Arrangement.SpaceAround每个元素两侧有相同间距
Arrangement.SpaceEvenly所有间距相等
Arrangement.spacedBy(8.dp)每个元素之间固定间距

示例

1
2
3
4
5
6
7
8
9
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("第一行")
Text("第二行")
Text("第三行")
}

适用场景

  • 表单页面、设置列表、任何需要纵向排列内容的场景

⚠️ 注意

  • verticalArrangement 只在 Column 高度大于子元素总高度时生效
  • 子元素默认靠左对齐,通过 horizontalAlignment 控制水平位置
  • 使用 Modifier.weight(1f) 可以让子元素按比例分配剩余空间

Row —— 横向布局

作用

子元素横向依次排列。等同于传统 LinearLayout(orientation=horizontal)

核心属性

参数类型说明
modifierModifier外观修饰
horizontalArrangementArrangement.Horizontal水平排列方式(同 Column 中的 Arrangement 值,方向改为水平)
verticalAlignmentAlignment.Vertical垂直对齐方式:Top / CenterVertically / Bottom
content@Composable RowScope.() -> Unit子元素内容

示例

1
2
3
4
5
6
7
8
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("左边")
Text("右边")
}

适用场景

  • 工具栏、操作栏、表单行、任何需要横向排列的场景

⚠️ 注意

  • horizontalArrangement 只在 Row 宽度大于子元素总宽度时生效
  • 子元素默认顶部对齐,通过 verticalAlignment 控制垂直位置
  • 常用 Modifier.weight(1f) 让文字填充剩余空间(搭配 TextOverflow.Ellipsis

Box —— 层叠布局

作用

子元素层叠排列,后写的盖在先写的上面。等同于传统 FrameLayout

核心属性

参数类型说明
modifierModifier外观修饰
contentAlignmentAlignment所有子元素的对齐方式,默认 TopStart
propagateMinConstraintsBoolean是否传递最小约束给子元素,默认 false
content@Composable BoxScope.() -> Unit子元素内容

子元素独立对齐

Box 内,子元素可通过 Modifier.align() 独立控制自己的对齐位置:

位置
Alignment.TopStart左上
Alignment.TopCenter上中
Alignment.TopEnd右上
Alignment.CenterStart左中
Alignment.Center正中
Alignment.CenterEnd右中
Alignment.BottomStart左下
Alignment.BottomCenter下中
Alignment.BottomEnd右下

示例

1
2
3
4
5
6
7
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center
) {
Image(painter = painterResource(R.drawable.bg), contentDescription = null)
Text("盖在图片上的文字", color = Color.White)
}

不同位置叠加

1
2
3
4
5
Box(modifier = Modifier.fillMaxSize()) {
Text("左上角", modifier = Modifier.align(Alignment.TopStart))
Text("正中间", modifier = Modifier.align(Alignment.Center))
Text("右下角", modifier = Modifier.align(Alignment.BottomEnd))
}

适用场景

  • 图文叠加(文字盖在图片上)、角标(Badge)、加载覆盖层、水印

⚠️ 注意

  • Box 的大小由最大的子元素决定(除非指定了固定尺寸)
  • 子元素默认对齐左上角,通过 contentAlignment 统一设置或 Modifier.align() 单独设置
  • BoxScope 提供了 matchParentSize() 修饰符,让子元素匹配 Box 的大小

LazyColumn / LazyRow —— 列表控件

作用

高性能列表,自带回收复用机制,只渲染屏幕上可见的 item。替代传统 RecyclerView

核心属性

参数类型说明
modifierModifier外观修饰
stateLazyListState列表状态(滚动位置、首个可见项等)
contentPaddingPaddingValues列表内边距
reverseLayoutBoolean是否反转布局,默认 false
verticalArrangement / horizontalArrangementArrangement.Vertical / Arrangement.Horizontalitem 间距排列方式
flingBehaviorFlingBehavior滑动惯性行为
userScrollEnabledBoolean是否允许用户滚动,默认 true
contentLazyListScope.() -> Unit列表内容

LazyListScope 常用方法

方法说明
item { }添加单个 item
items(count) { index -> }添加 count 个 item
items(list) { item -> }遍历集合添加 item
itemsIndexed(list) { index, item -> }遍历集合(带索引)
stickyHeader { }粘性头部

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val items = List(1000) { "第 ${it + 1} 项" }

LazyColumn {
items(items) { item ->
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
fontSize = 18.sp
)
Divider()
}
}

横向列表

1
2
3
4
5
6
7
8
9
10
11
12
LazyRow {
items(20) { index ->
Box(
modifier = Modifier
.size(100.dp)
.padding(8.dp)
.background(Color.Gray)
) {
Text("$index")
}
}
}

带头部的列表

1
2
3
4
5
LazyColumn {
item { Text("头部", fontSize = 24.sp) }
items(dataList) { item -> Text(item) }
item { Text("底部", fontSize = 12.sp) }
}

适用场景

  • 聊天列表、商品列表、新闻列表、任何数据量较大需要滚动的场景

⚠️ 注意

  • 不要LazyColumn 外套 ColumnRow,会导致性能问题和布局异常
  • LazyVerticalGrid 可实现网格列表,LazyHorizontalGrid 可实现横向网格
  • 使用 rememberLazyListState() 可程序化控制滚动位置
  • contentPadding + Arrangement.spacedBy() 可控制列表边距和 item 间距

Scaffold —— 页面骨架

作用

快速搭建标准 App 页面结构:顶部栏 + 内容区 + 底部栏 + 浮动按钮。

核心属性

参数类型说明
modifierModifier外观修饰
topBar@Composable () -> Unit顶部栏,通常放 TopAppBar
bottomBar@Composable () -> Unit底部栏,放 BottomAppBarNavigationBar
floatingActionButton@Composable () -> Unit浮动按钮,通常放 FloatingActionButton
floatingActionButtonPositionFabPositionFAB 位置:FabPosition.Center / FabPosition.End
snackbarHost@Composable () -> UnitSnackbar 宿主
containerColorColor内容区背景色
content@Composable (PaddingValues) -> Unit页面主体内容,必须在内容上加上 innerPadding

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Scaffold(
topBar = {
TopAppBar(
title = { Text("我的应用") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Blue,
titleContentColor = Color.White
)
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = "首页") },
label = { Text("首页") },
selected = true,
onClick = { }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Person, contentDescription = "我的") },
label = { Text("我的") },
selected = false,
onClick = { }
)
}
},
floatingActionButton = {
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.Add, contentDescription = "添加")
}
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Text("页面内容在这里")
}
}

TopAppBar 核心属性

参数类型说明
title@Composable () -> Unit标题内容
modifierModifier外观修饰
navigationIcon@Composable () -> Unit左侧导航图标(返回键)
actions@Composable RowScope.() -> Unit右侧操作按钮组
colorsTopAppBarColors颜色配置
scrollBehaviorTopAppBarScrollBehavior?滚动行为
参数类型说明
modifierModifier外观修饰
containerColorColor背景色
contentColorColor内容主色
tonalElevationDp色调高度
content@Composable RowScope.() -> Unit导航项内容

FloatingActionButton 核心属性

参数类型说明
onClick() -> Unit点击回调
modifierModifier外观修饰
shapeShape形状,默认 CircleShape
containerColorColor背景色
contentColorColor内容色
content@Composable () -> Unit内部内容

适用场景

  • 几乎任何需要标准 App 页面的场景(首页、设置、个人中心等)

⚠️ 注意

  • **必须使用 innerPadding**,否则内容会被 TopAppBar / BottomBar 遮挡
  • Scaffold 自身不滚动,列表内容需要放在 content 内部的 LazyColumn
  • BottomAppBarNavigationBar 的区别:前者是通用底部栏,后者专用于底部导航
  • FloatingActionButtonshape 默认圆形,可用 RoundedCornerShape(16.dp) 改为圆角矩形

Modifier 速查表

作用

Modifier 是所有 Compose 控件共用的外观修饰系统,控制控件的尺寸、边距、背景、形状、点击等。

常用 Modifier 速查

Modifier类别说明
fillMaxWidth()尺寸宽度填满父容器
fillMaxHeight()尺寸高度填满父容器
fillMaxSize()尺寸宽高都填满
size(width, height)尺寸固定宽高,如 size(100.dp, 50.dp)
width(dp)尺寸固定宽度,如 width(200.dp)
height(dp)尺寸固定高度,如 height(48.dp)
wrapContentWidth()尺寸宽度包裹内容
wrapContentHeight()尺寸高度包裹内容
wrapContentSize()尺寸包裹内容尺寸
padding(all)内边距四边相同内边距,如 padding(16.dp)
padding(horizontal, vertical)内边距水平和垂直内边距
padding(start, top, end, bottom)内边距分别设置四边内边距
background(color)外观背景色,如 background(Color.Red)
background(color, shape)外观带形状的背景色
clip(shape)外观裁剪形状,RoundedCornerShape(12.dp) / CircleShape
border(width, color)外观边框,border(1.dp, Color.Gray)
border(width, color, shape)外观带形状的边框
shadow(elevation, shape)外观阴影,shadow(4.dp, RoundedCornerShape(8.dp))
alpha(float)外观透明度,0f(全透明) ~ 1f(不透明)
clickable { }交互点击事件
combinedClickable(onClick, onLongClick)交互点击 + 长按
weight(float)布局按权重分配空间(Row/Column 内使用)
offset(x, y)布局偏移位置,不影响布局计算
align(alignment)布局在父容器中的对齐方式(Box 内使用)
scrollable(state, orientation)滚动使控件可滚动
verticalScroll(rememberScrollState())滚动垂直滚动(Column 内使用)
horizontalScroll(rememberScrollState())滚动水平滚动(Row 内使用)
animateContentSize()动画内容尺寸变化时自动动画过渡
testTag(string)测试测试标签

综合示例

1
2
3
4
5
6
7
8
9
10
11
Box(
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Blue)
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
.clickable { },
contentAlignment = Alignment.Center
) {
Text("点我", color = Color.White)
}

⚠️ 注意

  • Modifier 顺序很重要:先 paddingbackground,背景色包含 padding 区域;反之则不包含
  • 链式调用顺序是从外到内包裹的:Modifier.padding(16.dp).background(Color.Red) 等效于先包一层红色,再在红色内部加 16dp 边距
  • 同一个 Modifier 只能使用一次(如不能写两个 .padding()),但可以通过链式组合其他 Modifier

本文深入剖析 Compose 的四个核心概念,帮你从「会用」进阶到「理解」。配合 Compose 从 0 到 1 阅读效果最佳。

@Composable —— UI 就是函数

作用

@Composable 是 Compose 的灵魂注解。被它标记的函数就是一个 UI 组件,可以直接在别的函数里调用。

核心规则

规则说明
函数名首字母大写约定俗成,与普通函数区分(如 MyButton 而非 myButton
只能被 @Composable 函数调用普通函数不能直接调用 Composable 函数
没有返回值Composable 函数通常返回 Unit(描述 UI,不返回对象)
可接收任意参数通过参数控制 UI 外观和行为,实现复用
默认没有顺序调用顺序不代表实际排列顺序(需要在布局容器中才有顺序)

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义一个 Composable 控件
@Composable
fun MyTitle() {
Text(text = "我是标题")
}

// 在别的地方直接调用函数名就能用
@Composable
fun MyPage() {
MyTitle()
MyTitle()
MyTitle() // 调三次就显示三个标题
}

带参数的可复用组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Composable
fun GreetingCard(name: String, isVip: Boolean = false) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(
if (isVip) Color(0xFFFFD700) else Color.White,
RoundedCornerShape(8.dp)
)
.padding(12.dp)
) {
Text(
text = "欢迎,$name",
fontWeight = if (isVip) FontWeight.Bold else FontWeight.Normal
)
if (isVip) {
Text("👑", modifier = Modifier.padding(start = 8.dp))
}
}
}

// 使用
@Composable
fun UserList() {
Column {
GreetingCard("张三")
GreetingCard("李四", isVip = true)
GreetingCard("王五")
}
}

适用场景

  • 一切 UI 相关的函数:控件、布局、页面、弹窗、标题栏等

⚠️ 注意

  • Composable 函数可能被频繁调用(重组时),不要在内部做耗时操作
  • 副作用(网络请求、数据库读写)应使用 LaunchedEffect / SideEffect
  • 函数内部创建的局部变量,每次重组都会重新创建
  • 命名规范:Composable 函数首字母大写,参数顺序按重要性排列

Modifier —— 所有外观全靠它

作用

Modifier 是 Compose 的外观装饰系统,控制控件的尺寸、内边距、背景、圆角、边框、点击、滚动等一切外观和行为。所有控件都可以通过 modifier 参数接收 Modifier。

核心概念

链式调用

1
2
3
4
5
Modifier
.size(100.dp) // 最外层:固定 100x100
.background(Blue) // 第二层:蓝色背景
.padding(16.dp) // 第三层:内边距 16dp
.clip(CircleShape) // 最内层:圆形裁剪

Modifier 是从外到内依次包裹的。上面这段代码的效果:最外限定 100x100 区域 → 铺蓝底 → 在蓝色内部再留 16dp 边距 → 最终裁剪成圆形。

顺序决定效果

1
2
3
4
5
6
7
8
9
// 先 background 再 padding:背景色填满,然后内容区域向内缩
Modifier
.background(Color.Red)
.padding(16.dp)

// 先 padding 再 background:内边距也被背景色覆盖
Modifier
.padding(16.dp)
.background(Color.Red)
顺序效果
.background(...).padding(...)背景在 padding 之外,背景区域更大
.padding(...).background(...)背景在 padding 之内,背景区域更小

常用 Modifier 分类速查

尺寸类

Modifier说明
size(width, height)固定宽高
width(dp) / height(dp)固定宽度 / 高度
fillMaxWidth() / fillMaxHeight()填满父容器宽度 / 高度
fillMaxSize()宽高都填满
wrapContentWidth() / wrapContentHeight()包裹内容宽度 / 高度
defaultMinSize(minWidth, minHeight)最小尺寸约束
sizeIn(minWidth, maxWidth, minHeight, maxHeight)尺寸范围约束

内边距类

Modifier说明
padding(all)四边相同
padding(horizontal, vertical)水平和垂直
padding(start, top, end, bottom)分别设置

外观类

Modifier说明
background(color)纯色背景
background(color, shape)带形状的背景
clip(shape)裁剪形状
border(width, color)边框
border(width, color, shape)带形状的边框
shadow(elevation, shape)阴影
alpha(fraction)透明度
rotate(degrees)旋转
scale(scaleX, scaleY)缩放
offset(x, y)偏移(不影响布局)

交互类

Modifier说明
clickable { }点击
combinedClickable(onClick, onLongClick)点击 + 长按

布局类

Modifier说明
weight(fraction)权重分配(Row / Column 内)
align(alignment)对齐(Box 内)

综合示例

1
2
3
4
5
6
7
8
9
10
11
Box(
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Blue)
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
.clickable { },
contentAlignment = Alignment.Center
) {
Text("点我", color = Color.White)
}

适用场景

  • 几乎所有控件的外观控制:设置大小、加边距、上颜色、加边框、添加交互

⚠️ 注意

  • 顺序很重要:从外到内依次应用,不同顺序效果不同
  • 同一个 Modifier 只能使用一次:不能写两个 .padding(),需要用参数组合
  • 自定义 Modifier:通过扩展函数封装复用逻辑
  • 可组合Modifier 是不可变的,每个 .xxx() 返回一个新的 Modifier 对象

状态(State)—— 数据变了,界面自动刷新

作用

State 是 Compose 最核心的心智模型:你只管改数据,UI 自动刷新。不需要 findViewByIdsetTextnotifyDataSetChanged

核心 API

mutableStateOf —— 创建可观察变量

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }

Column {
Text(text = "当前计数:$count")
Button(onClick = { count++ }) {
Text("点我 +1")
}
}
}
关键字作用
mutableStateOf(0)创建一个”可观察”的变量,值变了会自动通知界面刷新
remember { }让变量在界面重组时”记住”当前值,不会被重置
byKotlin 委托语法,让你直接写 count++ 而不是 count.value++

mutableStateOf 的三种写法

1
2
3
4
5
6
7
8
9
10
11
// 方式 1:委托(推荐,最简洁)
var count by remember { mutableStateOf(0) }
count++ // 直接用

// 方式 2:直接使用 .value
val count = remember { mutableStateOf(0) }
count.value++ // 通过 .value 访问

// 方式 3:解构声明
val (value, setValue) = remember { mutableStateOf(0) }
setValue(value + 1)

常见状态类型

状态写法示例
基本类型mutableStateOf(0)计数器、开关状态
字符串mutableStateOf("")输入框内容
布尔值mutableStateOf(false)展开/折叠、选中状态
列表mutableStateListOf<T>()待办列表、聊天记录
集合mutableStateMapOf<K, V>()键值对状态
对象mutableStateOf(MyData())表单数据
可空类型mutableStateOf<T?>(null)可选数据

列表状态示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Composable
fun TodoList() {
val todoList = remember { mutableStateListOf<String>() }
var inputText by remember { mutableStateOf("") }

Column {
Row {
TextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f)
)
Button(onClick = {
if (inputText.isNotBlank()) {
todoList.add(inputText)
inputText = ""
}
}) {
Text("添加")
}
}
LazyColumn {
items(todoList.size) { index ->
Text(
text = "${index + 1}. ${todoList[index]}",
modifier = Modifier.padding(8.dp)
)
}
// 列表变化 → 自动刷新!
}
}
}

remember 的进阶用法

remember(key) —— 依赖 key 变化重新计算

1
2
3
4
5
6
7
8
@Composable
fun UserProfile(userId: String) {
// 当 userId 变化时,重新加载用户数据
val userData = remember(userId) {
loadUserData(userId) // 仅 userId 变化时执行
}
Text("用户名:${userData.name}")
}

rememberSaveable —— 屏幕旋转后保留

1
2
3
4
5
// remember:屏幕旋转后丢失
var temp by remember { mutableStateOf("") }

// rememberSaveable:屏幕旋转后保留(自动存到 Bundle)
var saved by rememberSaveable { mutableStateOf("") }

remember 生命周期

1
2
3
4
5
6
7
8
9
10
首次进入 Composable
remember {} 执行,创建状态

用户交互,状态变化
→ 触发重组(Recomposition)
remember 返回已有值,不重新创建

从界面移除(不再显示)
→ 状态被销毁
→ 下次显示时重新创建

适用场景

  • 任何需要「数据变化 → UI 自动更新」的场景:表单、列表、动画、主题切换等

⚠️ 注意

  • 必须用 remember 包裹var count = mutableStateOf(0)(缺少 remember)每次重组都会重置
  • 状态提升(State Hoisting):将状态提升到父组件,通过参数传递,实现单向数据流
  • mutableStateListOfmutableStateMapOf 自带可观察能力,内部元素变化会自动刷新
  • 避免过度使用:不是所有变量都需要 state,只有 UI 相关的才需要

重组(Recomposition)—— 谁变了就刷新谁

作用

当状态(State)变化时,Compose 会智能地只重绘受影响的控件,没有变化的部分原地不动。这是 Compose 高性能的核心机制。

工作原理

1
2
3
4
5
6
7
8
9
State 变化

Compose 标记「受影响的 Composable 函数」

执行 Recomposition:重新调用被标记的函数

对比新旧 UI,仅更新变化的部分

不受影响的函数 → 直接跳过,零开销

直观理解

1
2
3
count 变了 → 只有 Text("当前计数:$count") 会重新渲染
→ Button 不动
→ 页面上 100 个其他控件也不动

重组的特点

特性说明
选择性只重组读取了变化状态的函数
乐观任何状态变化都可能触发重组,但 Compose 会跳过无变化的部分
可中断新的状态变化可以打断正在进行的重组
并行多个 Composable 可以并行执行重组
幂等同一输入多次重组结果相同(不应该有副作用)

避免不必要的重组

使用不可变数据

1
2
3
4
5
// ❌ 不稳定,每次重组都算新对象
data class User(var name: String, var age: Int)

// ✅ 稳定,值不变对象就不变
data class User(val name: String, val age: Int)

使用 derivedStateOf 派生状态

1
2
3
4
5
6
7
8
9
10
11
12
@Composable
fun FilteredList(items: List<String>, query: String) {
// ❌ 每次重组都重新过滤(即使 query 没变)
val filtered = items.filter { it.contains(query) }

// ✅ 仅在 items 或 query 变化时重新过滤
val filtered by remember { derivedStateOf { items.filter { it.contains(query) } } }

LazyColumn {
items(filtered) { item -> Text(item) }
}
}

3. 状态读取延迟

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun HeavyItem(onClick: () -> Unit, isSelected: Boolean) {
// ⚠️ isSelected 变化 → 整个 HeavyItem 重组
// 包括 1000 行的复杂 UI
Box(modifier = Modifier.clickable(onClick = onClick)) {
// ... 1000 行复杂 UI
if (isSelected) {
Border()
}
}
}
1
2
3
4
5
6
7
8
@Composable
fun HeavyItem(onClick: () -> Unit, isSelected: Boolean) {
// ✅ 把 isSelected 读解放到单独的 Composable 中
Box(modifier = Modifier.clickable(onClick = onClick)) {
// ... 1000 行复杂 UI(不重绘)
SelectionBorder(isSelected) // 仅这个函数被重绘
}
}

SideEffect —— 在重组中安全执行副作用

1
2
3
4
5
6
7
8
9
10
@Composable
fun AnalyticsTracker(pageName: String) {
// ❌ 不要在 Composable 直接做副作用(会在每次重组时执行)
// FirebaseAnalytics.logEvent("page_view", ...)

// ✅ 使用 LaunchedEffect,仅 pageName 变化时执行
LaunchedEffect(pageName) {
logPageView(pageName)
}
}
副作用 API使用场景
LaunchedEffect(key)协程操作,key 变化时重新启动
SideEffect每次重组后执行(同步)
DisposableEffect(key)需要清理的副作用(如注册/注销监听)
rememberCoroutineScope()获取 Composable 生命周期内的协程作用域

适用场景

  • 理解 Compose 性能、排查 UI 不刷新 bug、优化复杂列表

⚠️ 注意

  • Composable 函数应该幂等:同样的输入,无论调用多少次结果相同
  • 不要在 Composable 中直接做副作用:使用 Effect API
  • 不要依赖 Composable 函数的调用顺序和次数:Compose 可能会跳过、重复调用
  • 不要读取或修改全局变量:使用 State 代替
  • 使用 Layout Inspector 或 Compose 编译器报告的「restartable」「skippable」标记来排查性能问题

Compose 页面生命周期完全指南

Compose 的生命周期不是单一概念,而是三层叠加模型

  1. Android 生命周期LifecycleOwner)—— Activity / Fragment 的传统生命周期
  2. 组合生命周期(Composition)—— Composable 进入/离开组合树
  3. Effect 生命周期LaunchedEffect / DisposableEffect)—— 副作用随 key 变化

理解这三层如何协作,是写出无内存泄漏、无状态错乱的 Compose 代码的关键。


一、三层生命周期全景图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
     onCreate()     →  Composition    →  Composition
onStart() 首次进入 重组
onResume()
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────┐
│ Activity Lifecycle │
│ Lifecycle.Event.ON_START / ON_RESUME / ... │
└─────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────┐
│ Composition(组合) │
│ 进入组合 → 重组(recomposition) → 退出组合 │
└─────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────┐
│ Effect(副作用) │
│ LaunchedEffect → key 变化 → 协程取消 │
│ DisposableEffect → key 变化 → dispose → 重启 │
└─────────────────────────────────────────────────┘

关键区别

层次粒度触发时机主要用途
Android Lifecycle页面级onStart / onResume / onStop整体可见性、前后台切换
Composition组件级首次组合 / 重组 / 退出UI 渲染、remember
Effect副作用key 变化 / 退出组合协程、监听器、资源管理

二、Composition 生命周期 —— 进入与退出

2.1 进入组合(Enter Composition)

Composable 函数首次被调用并加入组合树

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun MyScreen() {
// 每次重组都会执行这里
val name = remember { "World" } // 只在首次组合时计算

// 首次进入组合时执行
DisposableEffect(Unit) {
println("📌 Entered composition")
onDispose { println("📌 Left composition") }
}

Text("Hello $name")
}

2.2 重组(Recomposition)

State 发生变化,依赖该 State 的 Composable 会被重新执行

1
2
3
4
5
6
7
8
9
10
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
// ↑ count 变化会触发重组

Text("Count: $count") // 重新执行
Button(onClick = { count++ }) { // onClick lambda 不变
Text("Increment")
}
}

2.3 退出组合(Leave Composition)

Composable 从组合树中移除(条件渲染、导航离开等):

1
2
3
4
5
6
@Composable
fun Parent(showChild: Boolean) {
if (showChild) {
Child() // showChild = false 时退出组合
}
}

三、Effect API —— 副作用四大件

Compose 提供 4 个 Effect API 来管理副作用。它们的核心区别在于何时执行何时清理

3.1 LaunchedEffect —— 协程的入口

key 变化 → 取消旧协程 → 启动新协程;退出组合 → 取消协程

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun LoadUserData(userId: String) {
var user by remember { mutableStateOf<User?>(null) }

LaunchedEffect(userId) {
// userId 变化时:取消旧协程 → 启动新协程
user = api.fetchUser(userId)
}

user?.let { Text(it.name) } ?: CircularProgressIndicator()
}

LaunchedEffect 生命周期时序

1
2
3
4
5
6
7
8
userId="a"          userId="b"        退出组合
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│协程: a │ → │取消 a │ → │取消 b
│fetch... │ │协程: b │ │ │
│ │ │fetch... │ │ │
└─────────┘ └─────────┘ └─────────┘

3.2 DisposableEffect —— 需要清理的副作用

**key 变化 → dispose() → 重新 enter();退出组合 → dispose()**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable
fun ObserveUserStatus(userId: String) {
DisposableEffect(userId) {
val observer = object : Observer {
override fun onChange(status: String) {
// 更新状态
}
}
api.registerObserver(userId, observer)

onDispose {
api.unregisterObserver(userId, observer) // ← 清理!
}
}

// 这里不需要 LaunchedEffect,一次性注册 + 清理即可
}

DisposableEffect 典型场景

场景注册onDispose
Lifecycle 观察lifecycle.addObserver(...)lifecycle.removeObserver(...)
广播接收器context.registerReceiver(...)context.unregisterReceiver(...)
传感器监听sensorManager.registerListener(...)sensorManager.unregisterListener(...)
EventBusEventBus.register(...)EventBus.unregister(...)
WebSocketws.connect()ws.close()

3.3 SideEffect —— 每次重组后执行(无 key)

每次成功重组后调用,无清理逻辑。适用于与非 Compose 状态同步:

1
2
3
4
5
6
7
8
9
@Composable
fun AnalyticsScreen(screenName: String) {
val analytics = LocalAnalytics.current

SideEffect {
analytics.logScreenView(screenName)
// 每次重组后都执行(无 key,无法跳过)
}
}

3.4 rememberUpdatedState —— 保持引用最新

解决闭包捕获旧值的问题。长生命周期的 Effect 需要获取最新状态,但又不希望 key 变化导致重启:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun LandingPage(onTimeout: () -> Unit) {
// ❌ 错误:onTimeout 变化会导致 LaunchedEffect 重启
LaunchedEffect(Unit) {
delay(3000L)
onTimeout() // 可能拿到旧的回调
}

// ✅ 正确:referenceToTimeout 总是最新的,但不会重启协程
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(3000L)
currentOnTimeout() // 始终拿到最新的 onTimeout
}
}

3.5 Effect 对比总结

APIkey 变化退出组合每次重组后有清理可挂起
LaunchedEffect取消 + 重启取消自动取消协程
DisposableEffectdispose + 重启dispose手动 onDispose
SideEffect
rememberUpdatedState

四、Android 生命周期集成

4.1 获取 LifecycleOwner

1
2
3
// Compose 中获取 Lifecycle
val lifecycleOwner = LocalLifecycleOwner.current
val lifecycle = lifecycleOwner.lifecycle

LocalLifecycleOwner 由 Navigation 或包含 ComposeView 的 Activity/Fragment 自动提供。

4.2 监听生命周期事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Composable
fun LifecycleAwareComponent() {
val lifecycleOwner = LocalLifecycleOwner.current

DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> println("👀 页面可见")
Lifecycle.Event.ON_STOP -> println("🙈 页面不可见")
Lifecycle.Event.ON_RESUME -> println("👉 获得焦点")
Lifecycle.Event.ON_PAUSE -> println("👈 失去焦点")
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)

onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}

4.3 常用生命周期事件含义

1
2
3
4
5
6
7
8
9
10
11
 ON_CREATE ═══ ON_START ═══ ON_RESUME
│ │ │
│ │ [页面在前台,可交互]
[页面可见]
[初始化完成] │ ON_PAUSE
│ │
▼ ▼
ON_STOP ← [部分不可见]


ON_DESTROY

4.4 组合 Lifecycle 与 StateFlow 的协程

**核心 API:repeatOnLifecycle**。在正确的生命周期状态下收集 flow,自动暂停/恢复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun ObserveWithLifecycle(viewModel: MyViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current

val uiState by produceState<UiState>(
initialValue = UiState.Loading,
key1 = lifecycleOwner,
key2 = viewModel,
) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { value = it }
}
}
}

更简洁的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ViewModel 中
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}

// Composable 中 —— 使用 flowWithLifecycle 扩展
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val lifecycleOwner = LocalLifecycleOwner.current
val uiState by viewModel.uiState
.flowWithLifecycle(
lifecycle = lifecycleOwner.lifecycle,
minActiveState = Lifecycle.State.STARTED
)
.collectAsStateWithLifecycle(initialValue = UiState.Loading)
}

collectAsStateWithLifecycle(推荐)

1
2
3
4
5
6
7
8
// lifecycle-runtime-compose 库
// implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")

@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 自动在 STARTED 收集,在 STOPPED 取消,无需手动 repeatOnLifecycle
}

4.5 生命周期状态速查

状态何时进入适合做的事情
CREATEDActivity 创建后一次性初始化
STARTED页面可见开始收集 Flow、动画、网络请求
RESUMED页面在前台可交互相机、位置更新、高优先级任务
PAUSED部分可见(如弹窗遮挡)暂停相机、降低刷新频率
STOPPED完全不可见停止 Flow 收集、释放重资源
DESTROYED销毁前最终清理

五、remember 与 rememberSaveable

5.1 remember —— 跨重组保留

1
2
3
4
5
6
7
8
9
@Composable
fun RememberExample() {
// ✅ 重组后保留
var count by remember { mutableIntStateOf(0) }

// ❌ 每次重组重置为 0
var badCount by mutableIntStateOf(0)
// ———— 因为重组会重新执行整个函数!
}

5.2 rememberSaveable —— 跨进程死亡保留

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun SearchScreen() {
var query by rememberSaveable { mutableStateOf("") }
// 进程被杀死后恢复,query 值仍然保留

OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search") }
)
}
方式跨重组跨配置变更跨进程死亡
remember
rememberSaveable
ViewModel否(需 SavedStateHandle
ViewModel + SavedStateHandle

5.3 自定义 Saveable Saver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
data class User(val name: String, val age: Int)

val UserSaver = run {
val nameKey = "name"
val ageKey = "age"
mapSaver(
save = { mapOf(nameKey to it.name, ageKey to it.age.toString()) },
restore = { User(it[nameKey]!!, it[ageKey]!!.toInt()) }
)
}

@Composable
fun UserScreen() {
var user by rememberSaveable(stateSaver = UserSaver) {
mutableStateOf(User("Alice", 25))
}
}

六、ViewModel 生命周期

6.1 ViewModel 何时销毁

1
2
3
4
5
6
7
8
9
10
Activity/Fragment 创建


ViewModel 创建

... 配置变更(旋转屏幕)... ViewModel 不销毁!


onCleared()
(Activity 真正销毁 / Fragment 移除)

6.2 ViewModel + SavedStateHandle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class EditorViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

var title: String
get() = savedStateHandle.get<String>("title") ?: ""
set(value) { savedStateHandle["title"] = value }

var content: String
get() = savedStateHandle.get<String>("content") ?: ""
set(value) { savedStateHandle["content"] = value }
}

@Composable
fun EditorScreen(viewModel: EditorViewModel = viewModel()) {
// 进程杀死后恢复时,title 和 content 自动还原
OutlinedTextField(
value = viewModel.title,
onValueChange = { viewModel.title = it },
)
}

6.3 ViewModel 初始化时机(懒加载)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sealed interface EditorUiState {
data object Loading : EditorUiState
data class Ready(val title: String, val content: String) : EditorUiState
}

class EditorViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {

private val _uiState = MutableStateFlow<EditorUiState>(EditorUiState.Loading)
val uiState: StateFlow<EditorUiState> = _uiState.asStateFlow()

init {
// ViewModel 创建时加载数据(懒加载:首次访问 ViewModel 时)
loadContent()
}

private fun loadContent() {
val title = savedStateHandle.get<String>("title") ?: ""
val content = savedStateHandle.get<String>("content") ?: ""
_uiState.value = EditorUiState.Ready(title, content)
}
}

七、CompositionLocal —— 作用域内生命周期

CompositionLocal 的值在组合树的某个节点提供,子节点退出组合时自动不可见:

1
2
3
4
5
6
7
8
9
val LocalThemeColor = compositionLocalOf { Color.Unspecified }

@Composable
fun ThemeProvider(color: Color, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalThemeColor provides color) {
content() // 这里及子节点可获取 LocalThemeColor
}
// 离开这个 scope,回到默认值
}

八、配置变更 —— 屏幕旋转、语言切换等

资源配置变更时行为进程死亡时行为
remember丢失丢失
rememberSaveable保留保留
ViewModel保留丢失
ViewModel + SavedStateHandle保留保留
object / companion 单例保留丢失
DataStore / SharedPreferences保留保留

最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun OrderScreen(orderId: String) {
// 短期 UI 状态 → rememberSaveable
var expanded by rememberSaveable { mutableStateOf(false) }

// 业务数据 → ViewModel + SavedStateHandle
val viewModel: OrderViewModel = viewModel()
val order by viewModel.order.collectAsStateWithLifecycle()

LaunchedEffect(orderId) {
viewModel.loadOrder(orderId)
}
}

九、页面生命周期实战场景

9.1 回到前台时刷新数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun RefreshOnResume(viewModel: DataViewModel) {
val lifecycle = LocalLifecycleOwner.current.lifecycle

DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
}

9.2 离开页面时暂停视频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Composable
fun VideoPlayer(videoId: String) {
val lifecycle = LocalLifecycleOwner.current.lifecycle

DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> player.pause()
Lifecycle.Event.ON_RESUME -> player.resume()
Lifecycle.Event.ON_STOP -> player.release()
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
player.release()
}
}
}

9.3 秒杀倒计时(退出页面自动取消)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun FlashSaleCountdown(endTime: Long) {
var remaining by remember { mutableLongStateOf(endTime - System.currentTimeMillis()) }

LaunchedEffect(Unit) {
while (remaining > 0) {
delay(1000)
remaining = (endTime - System.currentTimeMillis()).coerceAtLeast(0)
}
}
// ✅ 退出页面时协程自动取消,无需手动清理

Text("${remaining / 1000} 秒后结束")
}

9.4 权限请求(关联生命周期)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Composable
fun RequestCameraPermission(onGranted: () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) onGranted()
}

DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
launcher.launch(android.Manifest.permission.CAMERA)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}

9.5 定时器 + 可见性联动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun AutoRefreshFeed(viewModel: FeedViewModel) {
val lifecycle = LocalLifecycleOwner.current.lifecycle

// lifecycle 作为 key:在 STARTED 时启动协程,STOPPED 时自动取消
LaunchedEffect(lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) {
delay(30_000)
viewModel.refresh()
}
}
}
}

十、常见生命周期错误与修复

问题原因修复
协程泄漏LaunchedEffect key 用 Unit 但外部依赖变化key 传入依赖项(如 userId
Flow 在后台仍然收集直接用 collectAsState()改用 collectAsStateWithLifecycle()repeatOnLifecycle
配置变更后状态丢失用了 remember 存重要状态改用 rememberSaveableSavedStateHandle
ViewModel 重新创建在 Composable 函数内手动 ViewModel() 且传了错误参数确保 key 参数正确;优先使用 viewModel() 工厂
动画在 ON_STOP 时继续动画未绑定生命周期LaunchedEffect + repeatOnLifecycle 包裹动画循环
onDispose 不执行DisposableEffect key 用了可变对象key 使用 stable 类型(StringIntUnit 等)
闭包捕获过期值Effect 启动后 lambda 被替换rememberUpdatedState 包装回调

十一、生命周期相关依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
dependencies {
// Compose 基础(含 remember、Effect API)
implementation("androidx.compose.runtime:runtime")

// ViewModel 集成
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

// collectAsStateWithLifecycle(一键生命周期安全收集)
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")

// SavedStateHandle
implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7")
}

十二、生命周期决策速查表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
需要做一件事,放在哪里?

┌─ 是否需要在「每次重组」后执行?
│ ├─ 是 → SideEffect { }
│ └─ 否 ↓

├─ 是否需要在「离开组合 / key 变化」时清理?
│ ├─ 是 → DisposableEffect(key) { onDispose { } }
│ └─ 否 ↓

├─ 是否需要「协程 / delay / 异步」?
│ ├─ 是 → LaunchedEffect(key) { }
│ └─ 否 ↓

├─ 是否需要「依赖 Android 生命周期」?
│ ├─ 是 → DisposableEffect(lifecycle) + repeatOnLifecycle
│ └─ 否 ↓

├─ 是否「UI 状态」需要在配置变更后恢复?
│ ├─ 是 → rememberSaveable { }
│ └─ 否 → remember { }

└─ 是否「业务数据」需要在进程死亡后恢复?
├─ 是 → ViewModel + SavedStateHandle
└─ 否 → ViewModel

全文覆盖 Compose 生命周期三层模型(Android Lifecycle / Composition / Effect)、四大 Effect API、rememberSaveablerepeatOnLifecycle、5 个实战场景和 7 类常见错误修复方案。

本文深入讲解 Compose Navigation 的全部用法,从基础跳转到深层链接、BottomNavigation 联动、动画转场。配合 Compose 从 0 到 1 阅读效果最佳。

Navigation 三大核心组件

Compose Navigation 由三个核心组件构成:

组件作用创建方式
NavController管理返回栈,控制跳转rememberNavController()
NavHost路由表,把路由和界面绑定NavHost(navController, startDestination) { }
composable()注册一个路由对应的页面composable("route") { Screen() }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
fun AppNavigation() {
val navController = rememberNavController() // ① 创建 NavController

NavHost(
navController = navController,
startDestination = "home" // ② 默认首页
) {
composable("home") { // ③ 路由"home" → HomeScreen
HomeScreen(navController)
}
composable("detail") {
DetailScreen(navController)
}
}
}

⚠️ rememberNavController() 必须在 NavHost 外部创建,如果在 NavHost 内部创建会导致每次重组都重新生成。

路由定义

字符串路由(基础)

1
2
3
4
5
6
// 方式一:直接写字符串(适合简单场景)
composable("home") { HomeScreen() }
composable("profile") { ProfileScreen() }

// 跳转
navController.navigate("profile")

常量管理(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
object Routes {
const val HOME = "home"
const val DETAIL = "detail/{itemId}"
const val SETTINGS = "settings"
const val PROFILE = "profile/{userId}"

// 辅助函数:生成带参数的路由
fun detail(itemId: String) = "detail/$itemId"
fun profile(userId: Int) = "profile/$userId"
}

// 使用
navController.navigate(Routes.detail("abc123"))

✅ 集中管理路由常量,方便跳转和避免拼写错误。

路由参数速查表

参数写法含义示例路由
{arg}必选参数detail/{id}detail/123
{arg}={default}带默认值list/{page}=1
{arg}?argType=Int指定类型user/{id}arguments.add(NavType)
?arg={arg}可选查询参数search?q={query}

参数传递

必选参数(路径参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 路由定义
composable(
route = "detail/{itemId}",
arguments = listOf(
navArgument("itemId") { type = NavType.StringType }
)
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")
DetailScreen(itemId = itemId ?: "")
}

// 跳转
navController.navigate("detail/abc123")

可选参数(查询参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 路由定义
composable(
route = "profile?userId={userId}&showEdit=false",
arguments = listOf(
navArgument("userId") {
type = NavType.IntType
defaultValue = -1 // ← 默认值
},
navArgument("showEdit") {
type = NavType.BoolType
defaultValue = false
}
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId") ?: -1
val showEdit = backStackEntry.arguments?.getBoolean("showEdit") ?: false
ProfileScreen(userId, showEdit)
}

// 跳转(两种方式都可以)
navController.navigate("profile?userId=42")
navController.navigate("profile?userId=42&showEdit=true") // 全部参数

🔑 可选参数 = ? + = + defaultValue,三者缺一不可。

参数类型对照表

NavTypeKotlin 类型取值方法
StringTypeStringgetString("key")
IntTypeIntgetInt("key")
LongTypeLonggetLong("key")
FloatTypeFloatgetFloat("key")
BoolTypeBooleangetBoolean("key")
StringArrayTypeArray<String>getStringArray("key")
IntArrayTypeIntArraygetIntArray("key")

导航操作 —— 跳转、返回、清栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 基础跳转
navController.navigate("detail")

// navigate 常用选项
navController.navigate("detail") {
// 1. 启动模式:单例(返回栈已存在则复用)
launchSingleTop = true

// 2. 弹出页面(跳转前把当前页移出返回栈)
popUpTo(Routes.HOME) { inclusive = true } // 回退到 HOME,并移除 HOME 本身

// 3. 恢复之前的状态
restoreState = true
}

popBackStack() 返回

1
2
3
4
5
6
7
8
// 返回到上一个页面
navController.popBackStack()

// 返回到指定路由(不包含该路由本身)
navController.popBackStack("home", inclusive = false)

// 返回到指定路由(并移除该路由本身)
navController.popBackStack("home", inclusive = true)
1
2
3
4
5
// 等同于物理返回键
navController.navigateUp()

// 如果返回栈已是根节点,回到上一个 Activity
// 等同于 navController.popBackStack()

导航选项组合实战

1
2
3
4
5
// 场景:登录成功后跳转主页,且按返回键不再回到登录页
navController.navigate("home") {
popUpTo("login") { inclusive = true } // 把登录页也清掉
launchSingleTop = true // 防止重复创建主页
}

返回栈管理

返回栈原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
NavHost 启动 startDestination="home"
┌─────────┐
│ home │ ← 根页面
└─────────┘

navigate("detail")
┌─────────┐
│ detail │ ← 推到栈顶
├─────────┤
│ home │
└─────────┘

popBackStack()
┌─────────┐
│ home │ ← 栈顶回到这里
└─────────┘

获取当前路由

1
2
3
4
5
6
7
8
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route

// 典型用途:高亮当前 tab
BottomNavigationItem(
selected = currentRoute == "home",
...
)

监听导航事件

1
2
3
4
5
6
7
// 监听路由变化(例如埋点统计)
val currentEntry by navController.currentBackStackEntryAsState()
LaunchedEffect(currentEntry) {
currentEntry?.destination?.route?.let { route ->
Log.d("Navigation", "当前页面:$route")
}
}

Deep Link —— 从外部打开指定页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
composable(
route = "detail/{itemId}",
arguments = listOf(
navArgument("itemId") { type = NavType.StringType }
),
deepLinks = listOf(
navDeepLink { uriPattern = "myapp://detail/{itemId}" },
navDeepLink {
uriPattern = "https://example.com/detail/{itemId}"
action = Intent.ACTION_VIEW
}
)
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: ""
DetailScreen(itemId)
}

AndroidManifest.xml 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<activity
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="detail" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="example.com"
android:pathPrefix="/detail" />
</intent-filter>
</activity>

BottomNavigation + Navigation 联动

标准实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
data class BottomNavItem(
val label: String,
val icon: ImageVector,
val route: String
)

@Composable
fun MainScreen() {
val navController = rememberNavController()

val items = listOf(
BottomNavItem("首页", Icons.Default.Home, "home"),
BottomNavItem("搜索", Icons.Default.Search, "search"),
BottomNavItem("消息", Icons.Default.Notifications, "message"),
BottomNavItem("我的", Icons.Default.Person, "profile"),
)

Scaffold(
bottomBar = {
NavigationBar {
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route

items.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
// 防止多个栈的同一个页面重叠
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(innerPadding)
) {
composable("home") { HomeScreen() }
composable("search") { SearchScreen() }
composable("message") { MessageScreen() }
composable("profile") { ProfileScreen() }
}
}
}

⚠️ 关键popUpTo(findStartDestination) + saveState + restoreState 这个三件套可防止切换 tab 时重复创建页面并保持滚动位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Scaffold(
// 侧边护栏(宽度 > 600dp 时自动推荐)
// 用法和 NavigationBar 完全一样
) { innerPadding ->
NavigationRail {
items.forEach { item ->
NavigationRailItem(
icon = { Icon(item.icon, null) },
label = { Text(item.label) },
selected = currentRoute == item.route,
onClick = { ... },
alwaysShowLabel = false
)
}
}
}

动画转场

composable 动画参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
composable(
route = "detail/{id}",
enterTransition = {
slideInHorizontally { it } + fadeIn() // 从右侧滑入 + 淡入
},
exitTransition = {
slideOutHorizontally { -it } + fadeOut() // 向左滑出 + 淡出
},
popEnterTransition = {
slideInHorizontally { -it } + fadeIn() // 返回时从左侧滑入
},
popExitTransition = {
slideOutHorizontally { it } + fadeOut() // 返回时向右滑出
}
) {
DetailScreen(it)
}

常用动画速查表

动画方法效果
slideInHorizontally { it }从右侧滑入
slideInHorizontally { -it }从左侧滑入
slideInVertically { it }从下方滑入
slideInVertically { -it }从上方滑入
fadeIn(initialAlpha = 0f)淡入
fadeOut(targetAlpha = 0f)淡出
scaleIn(initialScale = 0.8f)缩放进入
scaleOut(targetScale = 0.8f)缩放退出
slideInHorizontally { it } + fadeIn()组合动画(常见的推入+淡入)

🔧 it 代表完整宽度(水平)或完整高度(垂直),用 { it / 2 } 可控制滑动距离。

AnimatedNavHost 全局动画(Material 3)

1
2
3
4
5
6
7
8
9
10
11
// build.gradle.kts
implementation("androidx.compose.animation:animation:1.6.0")

@Composable
fun AppNavigation() {
val navController = rememberNavController()
AnimatedNavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable("detail") { DetailScreen(navController) }
}
}

嵌套导航图

当模块较多时,可以把路由分组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
NavHost(navController, startDestination = "main") {
// 嵌套导航图
navigation(
route = "auth",
startDestination = "login"
) {
composable("login") { LoginScreen() }
composable("register") { RegisterScreen() }
composable("forgotPassword") { ForgotPasswordScreen() }
}

// 主模块
navigation(
route = "main",
startDestination = "home"
) {
composable("home") { HomeScreen() }
composable("search") { SearchScreen() }
}

// 独立页面
composable("settings") { SettingsScreen() }
}

跳转到嵌套路由navController.navigate("auth/register")

常见坑与最佳实践

原因解决
快速双击跳转多个相同页面navigate 默认允许重复launchSingleTop = true
切换 tab 后页面状态丢失没有 saveState/restoreStateBottomNavigation 三件套
参数获取为空未声明 navArgument必须用 arguments = listOf(...) 声明
返回键直接退出应用返回栈为空判断 navController.previousBackStackEntry == null 时给提示
跳转后按返回又回到原始页没 popUpTo登录成功跳转后 popUpTo("login") { inclusive = true }
深层链接打不开AndroidManifest 没配 intent-filter两个 intent-filter 都要加

完整实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
object Routes {
const val HOME = "home"
const val DETAIL = "detail/{id}"
const val CART = "cart"
fun detail(id: String) = "detail/$id"
}

@Composable
fun ShopApp() {
val navController = rememberNavController()

NavHost(navController, startDestination = Routes.HOME) {
// 首页
composable(Routes.HOME) {
HomeScreen(
onItemClick = { id -> navController.navigate(Routes.detail(id)) },
onCartClick = { navController.navigate(Routes.CART) { launchSingleTop = true } }
)
}

// 详情页
composable(
route = Routes.DETAIL,
arguments = listOf(navArgument("id") { type = NavType.StringType }),
enterTransition = { slideInHorizontally { it } + fadeIn() },
exitTransition = { fadeOut() },
deepLinks = listOf(navDeepLink { uriPattern = "shop://detail/{id}" })
) { entry ->
DetailScreen(
id = entry.arguments?.getString("id") ?: "",
onBack = { navController.popBackStack() }
)
}

// 购物车
composable(Routes.CART) {
CartScreen(onBack = { navController.popBackStack() })
}
}
}