ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Less映射完全指南:从基础语法到设计系统实战

Less映射完全指南:从基础语法到设计系统实战 1. 为什么说映射被大多数前端低估了先从一个场景说起。团队里负责组件库的同事有一次找我吐槽说他们的设计令牌Design Token已经膨胀到了两百多个变量primary-color、primary-color-light、primary-color-lighter、primary-color-dark这种命名方式在代码里随处可见。改一次品牌色还好改一套主题方案的时候整个样式文件能翻得人头皮发麻。这其实是 Less 使用者特别容易踩进去的一个坑项目前期只用了 Less 的变量能力等到变量数量多起来、开始出现逻辑分组需求时还在用命名前缀硬撑完全忘了 Less 还有一个专门解决这类问题的语法结构——映射Maps。Less 的映射从 1.7.0 版本开始支持它本质上是一种键值对集合语法上跟普通的规则集Mixin 定义长得几乎一模一样。你可以把它理解成前端 JS 里的对象字面量或者是配置中心里的字典表。它的出现解决了两个很具体的问题一是把一组逻辑上相关的样式常量集中到一起避免散落成几十个平铺的变量二是让样式代码具备了按需查询的能力不再需要记那一长串前缀命名规则。colors: { primary: #1890ff; success: #52c41a; warning: #faad14; danger: #ff4d4f; };这段代码定义了一个叫colors的映射。取值的时候不用猜变量名直接通过键名访问.button--primary { background-color: colors[primary]; }就这么一个简单的结构能把原来七八个primary-*变量合并成一个映射代码组织逻辑瞬间清楚很多。不过这只是映射最基础的用法。它在主题切换、设计系统搭建、甚至遍历生成工具类这些更复杂的场景里价值会更大。这篇文章我会把 Less 映射的语法、原理、实战用法以及我在实际项目里踩过的几个坑完整梳理一遍。2. 映射的语法基础与核心机制2.1 映射定义它和 Mixin 规则集到底有什么区别Less 官方文档里映射的写法跟定义 Mixin 几乎一致都是name: { ... };的形式。第一次看到这种语法的同学通常会问那它和 Mixin 到底什么关系简单来说Less 编译器把映射当作只存数据、不产生 CSS 输出的特殊规则集处理。普通 Mixin 被调用后会把它内部的声明输出到调用处比如.hover-effect() { transition: all 0.3s ease; :hover { opacity: 0.8; } } .button { .hover-effect(); }编译器会把.hover-effect()内部的声明展开进.button。但映射不一样。你永远不会去调用它只把它当作数据容器。Less 编译器看到colors: { ... }这种顶层定义后不会向最终 CSS 输出任何东西它只维护内存里的一组键值对。等你在代码里写colors[primary]时编译器就去这组键值对里查一次相当于执行了一次字典查找。// 定义映射 theme: { bg: #f5f5f5; text: #333333; border: #e8e8e8; }; // 取值 .card { background-color: theme[bg]; color: theme[text]; border: 1px solid theme[border]; }编译结果.card { background-color: #f5f5f5; color: #333333; border: 1px solid #e8e8e8; }这里有个很关键的点要提醒映射本身不会输出 CSS但取出来的值会参与 CSS 计算。所以它可以做得非常轻量不用担心引入映射会导致样式冗余。这也意味着映射非常适合承载设计系统层面的元数据——颜色、间距、字号、阴影这些散落的常量都可以合并成一个个有名字的集合。2.2 映射取值方括号语法和嵌套读取Less 映射取值统一用方括号语法map[key]。这个跟 Sass 的map.get($map, key)比起来更直观看起来就像 JS 里访问对象属性一样。实际开发中我有两个经验第一键名不要带引号除非键名里含有特殊字符比如-或空格才建议加引号。看下面的例子breakpoints: { sm: 576px; md: 768px; lg: 992px; // 键名含特殊字符时加引号 xl-min: 1200px; }; media (min-width: breakpoints[lg]) { .container { max-width: 960px; } } // 带引号的键访问 media (min-width: breakpoints[xl-min]) { .container { max-width: 1140px; } }第二嵌套映射可以通过连续方括号逐层取值。这个特性在做复杂主题配置时特别有用theme-config: { colors: { brand: #3452ff; accent: #ff9f0a; }; typography: { h1: 32px; h2: 24px; }; }; .title { color: theme-config[colors][brand]; font-size: theme-config[typography][h1]; }编译结果.title { color: #3452ff; font-size: 32px; }我个人建议嵌套层级最多控制在两层。超过两层以后代码的可读性会明显下降而且排查问题的时候你会分不清到底是哪一层取错了。真遇到三层以上的复杂数据优先考虑把映射拆开做成多个扁平映射维护成本更低。2.3 键名可以作为 Mixin 参数传递Less 映射还有一个非常有意思的特性键名可以直接当作值传给 Mixin 或函数。什么意思呢看这段代码icons: { home: \e900; user: \e901; setting: \e902; }; .icon-font(icon-name) { content: icons[icon-name]; } .icon-home::before { .icon-font(home); }icons[icon-name]里的icon-name是参数它接收调用时传入的键名home然后在映射里查找对应的值。这种方式让我在写一些图标类、状态类工具样式的过程中避免写一堆重复的选择器特别是搭配循环遍历时效率提升非常明显。下一步就进入映射的高阶用法也是我认为它真正值钱的地方结合each()循环批量生成样式。3. 主题换肤与配置中心化一个完整的实战案例3.1 需求场景按钮组件的多主题支持假设我们正在做一个组件库里的 Button 组件需求是支持浅色主题、深色主题以及primary / success / warning / danger四种语义状态色。最常见的做法是什么大多数人会写一大堆变量再在各个类名里手动组合。搞着搞着就会出现这种代码btn-primary-bg: #1890ff; btn-primary-hover-bg: #40a9ff; btn-primary-active-bg: #096dd9; btn-success-bg: #52c41a; btn-success-hover-bg: #73d13d; btn-success-active-bg: #389e0d; // ... 后面还有 warning、danger 的各种状态先不提变量名越来越长、越来越难记单是hover 颜色比默认颜色亮一点这种规律就有更优雅的表达方式。把映射和each()循环结合起来代码可以收敛到极其简洁的程度。3.2 用映射定义语义色板我们先把所有颜色按状态收进一个映射里button-status-colors: { primary: #1890ff; success: #52c41a; warning: #faad14; danger: #ff4d4f; };这里只存了每个状态的基础色。那 hover 和 active 的颜色怎么来Less 内置了颜色函数lighten()和darken()可以基于基础色自动计算。这样我们就不需要为每个状态单独维护三个颜色值只需要维护一个基准色另外两个颜色统统由函数算出来。于是按钮的基础样式可以设计成这样.button { display: inline-flex; align-items: center; padding: 8px 20px; border-radius: 4px; border: none; font-size: 14px; cursor: pointer; transition: background-color 0.2s ease; } // 自动遍历所有状态生成对应类名 each(button-status-colors, { .button--key { background-color: value; color: #fff; :hover { background-color: lighten(value, 10%); } :active { background-color: darken(value, 10%); } } });重点看each()这一行。它的语法是each(map, { ... })在遍历过程中Less 会自动把键名赋值给key把键值赋值给value然后我们直接在规则集里使用这两个变量。编译结果就相当于手写了下面这段代码.button--primary { background-color: #1890ff; color: #fff; } .button--primary:hover { background-color: #40a9ff; } .button--primary:active { background-color: #096dd9; } .button--success { background-color: #52c41a; color: #fff; } .button--success:hover { background-color: #73d13d; } .button--success:active { background-color: #389e0d; } .button--warning { background-color: #faad14; color: #fff; } .button--warning:hover { background-color: #ffc53d; } .button--warning:active { background-color: #d48806; } .button--danger { background-color: #ff4d4f; color: #fff; } .button--danger:hover { background-color: #ff7875; } .button--danger:active { background-color: #d9363e; }如果以后产品增加了第六种状态比如info你只需要在button-status-colors里加一行info: #13c2c2;所有 hover、active 样式自动生成。新增一个状态只动一行数据这就是数据驱动样式带来的维护成本下降。3.3 深浅主题切换映射作为配置中心再进阶一步。深浅主题怎么用映射管理我们可以把整套主题配置都收进映射里通过一个开关变量控制读取哪套配置。Less 本身没有动态切换变量的能力因为它是编译期语言不是运行时——Less 变量的值在编译时就已经确定——但是我们可以借助选择器嵌套 映射取值来实现主题切换。思路是这样定义深浅两套映射然后在body.light-theme和body.dark-theme选择器下用两个映射分别输出一套 CSS 变量CSS Custom Properties。最终在浏览器里由 CSS 变量完成运行时切换Less 做的只是把映射里的值一次性编译进两套 CSS 变量里去。// 浅色主题映射 light-theme: { bg: #ffffff; text: #1f1f1f; border: #d9d9d9; shadow: rgba(0, 0, 0, 0.08); }; // 深色主题映射 dark-theme: { bg: #141414; text: #e8e8e8; border: #303030; shadow: rgba(0, 0, 0, 0.48); }; body.light-theme { --color-bg: light-theme[bg]; --color-text: light-theme[text]; --color-border: light-theme[border]; --color-shadow: light-theme[shadow]; } body.dark-theme { --color-bg: dark-theme[bg]; --color-text: dark-theme[text]; --color-border: dark-theme[border]; --color-shadow: dark-theme[shadow]; }在业务组件里我们不再直接写 Less 变量而是引用这些 CSS 变量.container { background-color: var(--color-bg); color: var(--color-text); border: 1px solid var(--color-border); box-shadow: 0 2px 8px var(--color-shadow); }这样做的最大收益是什么任何组件都可以在运行时响应主题切换不需要重新编译 Less。JS 只需要给body切换class页面整体配色立刻变化。Less 映射在这里扮演的角色是编译期的主题配置中心——把散落在整个工程里的主题相关常量集中到一处。我实际接手过的老项目里为了支持深色模式曾经在十几个*.less文件中手动改样式一改改三天。后来重构时把所有主题常量全部收进映射再通过上面这种方式输出成 CSS 变量整个改造时间压缩到了半天。区别就是映射让你把数据和使用数据的地方切割开来一旦切割清楚大范围改动就有了安全边界。4. 数据驱动样式用映射定义设计系统级的语义变量4.1 从颜色映射到功能语义映射颜色映射解决了按钮问题但真实项目里远不止按钮。你会发现设计规范里经常出现这些词success、warning、error、info、link。这些不是颜色而是语义。如果每个组件都自己去引用colors[success]那么有一天设计规范调整了成功色的色值你还是得全局搜colors[success]然后慢慢改。更推荐的做法是在基础色板之上再抽象一层语义映射。基础色板只管What color is it语义映射负责What does it mean。// 基础色板只管色值本身 palette: { blue-1: #e6f4ff; blue-6: #1890ff; blue-7: #096dd9; green-6: #52c41a; gold-6: #faad14; red-6: #ff4d4f; gray-6: #8c8c8c; }; // 语义层把基础色板里的色值映射到业务含义 semantic: { primary: palette[blue-6]; primary-hover: palette[blue-7]; info-bg: palette[blue-1]; success: palette[green-6]; warning: palette[gold-6]; danger: palette[red-6]; disabled-text: palette[gray-6]; link: palette[blue-6]; };注意看semantic的取值方式映射的值可以是另一个映射的查询结果。这意味着你可以用映射构建映射。这样的层级关系让整个项目的颜色体系非常清晰基础色板跟设计稿一一对应改起来无脑。语义层是业务含义和基础色板之间的桥梁。如果某天primary想从蓝色换成紫色只需要改semantic里的一行而不影响任何业务代码。组件里永远只引用语义层.alert { border: 1px solid semantic[warning]; background-color: semantic[warning-bg]; } .link { color: semantic[link]; :hover { color: semantic[primary-hover]; } }4.2 页面级数据映射表单校验消息与状态样式联动除了颜色映射还能帮我们把业务状态和UI 表现绑定在一起。举个例子一个表单校验场景里不同校验状态需要不同图标、不同配色、甚至不同提示文案。如果把这些全部放在一个映射里组件代码会非常干净。form-states: { success: { icon: ✓; color: semantic[success]; message: 校验通过; }; error: { icon: ✗; color: semantic[danger]; message: 输入有误; }; warning: { icon: !; color: semantic[warning]; message: 请注意; }; }; .form-item(state) { .form-icon { ::before { content: form-states[state][icon]; } } .form-message { color: form-states[state][color]; } // 可以在这里配置针对不同状态的额外样式 } .form-item--success { .form-item(success); } .form-item--error { .form-item(error); } .form-item--warning { .form-item(warning); }编译结果.form-item--success .form-icon::before { content: ✓; } .form-item--success .form-message { color: #52c41a; } .form-item--error .form-icon::before { content: ✗; } .form-item--error .form-message { color: #ff4d4f; } .form-item--warning .form-icon::before { content: !; } .form-item--warning .form-message { color: #faad14; }这是用数据驱动一组互相联动的样式的典型场景。换成一个状态图标、颜色、文案全部同步切换比在 CSS 里写一堆--success ... --error ...的逻辑清爽多了。4.3 间距与断点把魔法数字赶出代码每个项目里都有魔法数字问题——某个组件里忽然出现一个padding: 13px后来谁也不敢动因为不知道当初为什么是 13。使用映射可以把这些幽灵数字收集起来变成有名字的常量。spacing: { xs: 4px; sm: 8px; md: 16px; lg: 24px; xl: 48px; }; breakpoints: { mobile: 576px; tablet: 768px; desktop: 992px; wide: 1200px; }; .card { padding: spacing[md]; margin-bottom: spacing[lg]; } media (min-width: breakpoints[tablet]) { .card { padding: spacing[lg]; } }这套做法带来的直观好处是代码审查变得极其高效。看到spacing[md]你就知道这是 16px看到13px就得停下来思考。映射等于给每个数值加了注释而且是强制性的注释。5. 使用映射时最容易踩的坑5.1 键名引用错误不报错却编译出奇怪结果Less 对映射的错误处理有点宽容过头。如果你取了一个不存在的键比如map: { a: 1; }; value: map[b];Less不一定报错。在某些编译配置下它会把这个表达式当作普通值输出最终 CSS 里可能出现map[b]这种原文残留。这种情况在浏览器里不会报错只是样式不生效排查起来特别费劲因为你可能盯着 CSS 文件看半天才意识到是 Less 编译没有正确替换。我的经验是开启构建工具的 Less 严格模式或者至少配置strictMath和strictUnits这类选项。另外在给映射键名做修改时要格外小心全局搜索一下键名的引用位置。Less 没有 TypeScript 那样的类型检查键名只有运行时才知道。5.2 键名是字符串时的引号陷阱前面提到过键名本身不需要引号但如果某处写了引号另一处没写就可能导致取值失败。更隐蔽的是遍历场景colors: { primary: #1890ff; primary-dark: #096dd9; }; each(colors, { // 当 key 含特殊字符时直接拼接选择器可能会出问题 .color-key { color: value; } });当键名是primary-dark时.color-key会生成.color-primary-dark这反而没问题。但如果你用key做映射的二次查询引号规则就要非常严格。我建议团队统一规范键名一律不加引号除非键名里有中划线、空格等特殊字符才在所有引用处统一加引号。千万不要一会儿加一会儿不加。5.3 嵌套映射层级过深可读性急剧下降Less 映射支持嵌套但不代表你应该滥用。两层的嵌套在阅读时可以接受三层以上基本就是灾难了styles: { button: { theme: { light: { bg: #fff; }; }; }; }; // 取值时你能一眼看出这是背景色吗 .bg { background: styles[button][theme][light][bg]; }这种代码维护成本极高。当你发现自己写了一长串方括号的时候说明映射本身的设计该重构了。通常的做法是把深层嵌套映射拆出来让每一个映射保持单一职责button-light-theme: { bg: #fff; text: #333; }; button-dark-theme: { bg: #111; text: #eee; };两层以内可以用嵌套多层一律扁平化。5.4 变量作用域混淆局部覆盖全局映射Less 的变量有惰性求值和作用域提升的特性映射作为变量的一种同样继承这些怪癖。如果在某个规则集内部重新定义了一个跟全局映射同名的映射它不会改变全局映射只会影响当前作用域。theme: { color: #000; }; .container { // 局部分配不会影响全局 theme: { color: #fff; }; color: theme[color]; } .outside { color: theme[color]; // 仍然是 #000 }编译结果.container { color: #fff; } .outside { color: #000; }这其实是一把双刃剑。利用得好可以在局部做临时覆盖理解不到位会出现我明明改了映射为什么别处没变的疑惑。我的建议是全局映射只允许在顶层的variables.less之类的文件里定义组件内部一律不允许重命名同名映射免得产生心智负担。5.5 循环遍历时作用域中的隐藏坑each()循环体内定义的变量作用域只在当前循环迭代内。我遇到过一种情况循环里调用了一个 Mixin而 Mixin 内部想读取key结果发现是空值。map: { a: 1; b: 2; }; .generate(key) { .item-key { value: key; // 这里可能取不到 } } each(map, { .generate(key); });问题在于key是each()循环内的临时变量传给.generate()作为参数后在 Mixin 内部你可能需要显式声明参数名。如果.generate()内部引用了同名变量但未定义为参数Less 会向外层查找最终找到的是最后一次迭代的值或者空值。正确的写法.generate(key) { .item-key { value: key; } } each(map, { .generate(key); // 把 key 显式传进去 });凡是循环体内调用 Mixin一律显式传参不要依赖全局变量。这能避免 90% 以上循环相关的诡异问题。6. 让映射效率翻倍的组合技巧6.1 映射 each()批量生成工具类除了按钮案例each()最常用的场景之一是批量生成工具类。我手里有一个基于映射生成间距工具类的模板每次开新项目都会用上spacing-scale: { 0: 0; 1: 4px; 2: 8px; 3: 12px; 4: 16px; 5: 24px; 6: 32px; }; each(spacing-scale, { .m-key { margin: value; } .mt-key { margin-top: value; } .mb-key { margin-bottom: value; } .ml-key { margin-left: value; } .mr-key { margin-right: value; } .p-key { padding: value; } .pt-key { padding-top: value; } .pb-key { padding-bottom: value; } .pl-key { padding-left: value; } .pr-key { padding-right: value; } });这一下子就生成了 60 个工具类。以后设计规范调整了间距体系只需要改spacing-scale这个映射全站工具类同步更新。这在设计系统迭代过程中的价值不用多说——改一处数据全站生效而且不需要触碰任何组件源码。6.2 映射与import组合搭建全局设计令牌映射的维护通常建议放在独立的 Less 文件中比如tokens.less。一个结构清晰的设计令牌文件我会这样组织// tokens.less colors: { primary: #3452ff; success: #22c55e; warning: #f59e0b; danger: #ef4444; }; spacing: { xs: 4px; sm: 8px; md: 16px; lg: 24px; xl: 48px; }; font-sizes: { xs: 12px; sm: 14px; md: 16px; lg: 20px; xl: 24px; }; shadows: { sm: 0 1px 2px rgba(0, 0, 0, 0.04); md: 0 4px 12px rgba(0, 0, 0, 0.08); lg: 0 12px 32px rgba(0, 0, 0, 0.12); };然后在入口文件里统一引入// style.less import ./tokens.less; import ./components/button.less; import ./components/card.less;这种组织方式下任何组件文件都能直接使用 tokens 里定义的映射不需要每个文件重复import。有同事问过我为什么不把 tokens 合并成一个巨型映射。原因很简单拆分成多个小型映射每个映射关注一个领域命名更清晰git 冲突的概率也更低。一个文件改动了diff 范围很小review 起来很舒服。6.3 映射 媒体查询断点管理响应式布局中断点管理一直是个容易失控的地方。很多项目会在各个组件里分散写media (max-width: 768px)改一次断点全局大换血。用映射统一管理的方案如下breakpoints: { sm: 576px; md: 768px; lg: 992px; xl: 1200px; }; .respond-below(size) { min-width: breakpoints[size]; media (max-width: min-width) { content(); } }注意这里的content()调用方式——它是 Less 里用于抽象样式块的特性可以让 Mixin 接收一段样式内容作为回调。用法是这样.card { display: flex; gap: 16px; .respond-below(md, { flex-direction: column; }); }编译结果.card { display: flex; gap: 16px; } media (max-width: 768px) { .card { flex-direction: column; } }断点值统一来源于breakpoints映射组件里只需要传入语义化的尺寸名不再出现具体像素值。哪天全站把md从 768px 调整到 800px只改映射里的一行数字。6.4 映射用作状态机组件多形态样式的一站式配置最后一个技巧也是我在开发复杂业务组件时最常用的把组件的形态和状态做成映射。拿一个 Tag 标签组件举例它可能有默认、成功、警告、危险、关闭五种形态每种形态还要处理背景色、文字色、边框色、hover 效果tag-states: { default: { bg: #fafafa; text: #333; border: #d9d9d9; }; success: { bg: #f6ffed; text: #52c41a; border: #b7eb8f; }; warning: { bg: #fffbe6; text: #d46b08; border: #ffe58f; }; danger: { bg: #fff2e8; text: #cf1322; border: #ffbb96; }; }; .tag(type) { state: tag-states[type]; background-color: state[bg]; color: state[text]; border: 1px solid state[border]; } .tag--default { .tag(default); } .tag--success { .tag(success); } .tag--warning { .tag(warning); } .tag--danger { .tag(danger); }这个模式的精髓在于把组件所有形态的差异压缩到一个映射里真正做到了加一个形态只加一段数据。后续如果要支持信息提示形态只是在tag-states里补一组键值而已。7. 日常维护映射时的一些心里话映射这个语法本身不难难的是用好它的度和组织方式。在多个项目里实践下来我的体会有几点映射最适合承载的是跨组件共享的设计常量而不是某个组件内部的局部配置。前者像全局状态的字典后者会让组件文件变得臃肿难读。写映射前可以问自己一句这个数据以后会不会被两个以上的地方引用如果答案是不会也许写成普通变量就够了。命名规范上我倾向于映射名用复数或集合词比如colors、spacing、breakpoints、tag-states一眼看过去就知道这是一组颜色还是一个颜色。键名尽量用语义词而不是缩写primary比pri强得多这不只是为了可读性也是为了将来整个设计系统里不同文件之间能保持同一套语言。还有一点特别想让初学者明白映射不是用来炫技的更不应该为了用映射而用映射。如果一个 Less 文件里只有一两个取值点直接写普通变量更直白。映射的收益在大规模、多状态、需要统一维护的场景下才会显性化。小项目硬上映射反而会多一层间接跳转增加阅读负担。如果你现在正在重构一个样式代码逐渐失控的旧项目我建议从最小的点切入把散落的颜色变量收拢成第一个映射试着用each()生成一组工具类。当你体验到改一处数据全站样式联动变化的爽快感之后大概率就会想把间距、字号、阴影全都映射化了。Less 映射就像样式世界里的一张配置表它不直接产出视觉但决定了视觉的组织方式。用好它至少在维护设计系统的路上可以少掉很多头发。
返回列表