陌上人如玉
公子世无双

10.13 设计稿到代码

10.13 设计稿到代码

一句话:设计稿到代码的正确流程是「先把设计稿里的『值』对齐到 token,再写组件」——因为设计稿是静态且不完整的(没有 hover/disabled/loading/空态/长文本/小屏),所以「逐像素还原」是错的目标,「用 token 把它翻译成一致的实现,并把设计稿的缺失项补全」才是对的。

一、设计交付的三种形态(决定了你的工作方式)

⭐⭐⭐ 设计交付的三种成熟度(越靠后越省事):

① 【切图 + 标注(原始形态)】
   设计师给「带标注的截图」(或 Zeplin/蓝湖标注),上面写着「间距 16px / 色值 #1677FF / 字号 14」
   ⚠️ 问题:这些「值」是「一次性的」——每个页面都可能出现新的 14/15/16px
   → ⭐ 代码里就会散落魔法数字

② 【设计规范文档(中间形态)】
   一份「间距刻度 / 色彩系统 / 字号阶梯」文档(可能写在 Figma 的一个页面里)
   ⚠️ 问题:文档与设计稿「可能不一致」(设计师用了 13px,但规范里没有)→ 需要沟通

③ 【Design Tokens(现代形态)】⭐
   设计稿的「值」被定义为 **Figma Variables**(或 Styles),
   → 可以**导出** → 直接生成代码里的 CSS 变量 / TS 常量(见第五节)
   ⭐ 好处:
     · 「一份来源」(Figma)→ 代码与设计永远一致
     · 「改 token 就能换肤」(见 10.8)
     · 「设计稿偏移」在工具层面就能被发现(用了不在 token 里的值)

二、正确流程:五步,顺序不能反

⭐⭐⭐ 「设计稿 → 代码」的五步(顺序反了就会返工):

① 【识别「这是哪种组件」】(不是「这是什么样式」)
   看到设计稿,先问:「这是已有的 Button 还是新的?」
   · 已有组件 → ⭐ **直接用**(哪怕它的细节与设计稿略有差异)
   · 已有组件的「新变体」→ 扩展变体(而不是复制一份改)
   · 全新组件 → 才新建
   ⚠️ 跳过这一步的后果:项目里出现「7 个长得差不多的按钮」

② 【把值映射到 token】(不是「照抄数值」)
   设计稿写「间距 16」「主色 #1677FF」「圆角 8」
   → `var(--space-inset-md)` / `var(--color-primary)` / `var(--radius-md)`
   ⭐ 判据:「**这个值在 token 里有对应吗**」
     · 有 → 用它
     · 没有 → ⭐ **问设计师**「这是新的刻度吗,还是应该归到最近的刻度?」
   ⚠️ 跳过这一步的后果:硬编码让「换肤/换主题」失效(见 10.8)

③ 【补全设计稿「没有画」的状态】
   ⭐ 这是「工程师的核心增值」(设计稿只有「正常态」)
   必须自己设计:`hover` / `active` / `focus-visible` / `disabled` /
     `loading` / `error` / `empty`(空数据)/ `长文本`(截断还是换行)/
     `超长列表`(分页还是虚拟滚动)/ `小屏`(折叠成什么)/ `网络失败`
   ✅ 做法:与设计师「一起补」,而不是「自己拍脑袋」(补齐后进设计系统)

④ 【实现 + 自测】
   · 用「语义化 HTML」(见 10.12)+ token + 已有的布局原语
   · 自测:键盘走一遍、缩放 200%、长文本、空数据、小屏

⑤ 【设计走查(Design Review)】⭐
   把实现拿去与设计稿对比 —— 但**目标是「一致」而不是「逐像素」**(见第四节)
   产出:把「偏差」分成「该改代码」与「该改设计稿」两类
⭐⭐⭐ 设计稿「必然缺失」的 10 类东西(这是「靠设计稿写不出好代码」的原因):

   ① 交互状态:hover / active / focus-visible / disabled / selected
   ② 异步状态:loading / 骨架 / 进度 / 重试
   ③ 错误状态:字段错误 / 表单错误 / 网络错误 / 权限不足
   ④ 空状态:无数据 / 无搜索结果 / 首次使用
   ⑤ 边界数据:超长文本 / 特殊字符 / 0 项 / 1 项 / 1000 项 / 超大数字
   ⑥ 响应式:小屏 / 平板 / 大屏 / 横屏
   ⑦ 国际化:长文案(德语比英语长 30%+)/ RTL / 日期数字格式
   ⑧ 动效时序:进入/退出/切换的时长与曲线
   ⑨ 无障碍:焦点顺序 / 键盘操作 / 对比度(设计稿的浅灰常常不达标)
   ⑩ 数据边界:加载顺序(先有壳还是有数据)/ 局部失败 / 部分成功
   ⭐ 所以「还原度 100%」是个**伪指标**——它只衡量了「正常态 + 一种屏幕 + 一种数据」。

三、与现代设计工具的对接

⭐⭐ 从 Figma 到代码的三条通路:

① 【Dev Mode(手动看 + 手动抄)】
   设计师切到 Dev Mode,开发者看到「标注 + 代码片段」
   ⚠️ ⚠️ **Figma 生成的代码片段不要直接用**(它是「绝对定位 + 内联样式」的思路,
     完全不可维护)→ ⭐ 只用它看「值」,代码自己写

② 【Design Tokens 导出(自动化)】⭐ 推荐
   Figma Variables →(插件 / REST API)→ 中间格式(DTCG JSON)→ 生成 CSS/TS
   工具:Tokens Studio、Style Dictionary、Figma REST API + 自研脚本
   ⭐ 这条通路让「设计变更 → 代码变更」变成「跑一个脚本」

③ 【设计稿截图 → 生成代码(AI)】
   ⚠️ 目前的定位是「**加速初稿**」,不是「替代工程判断」:
     它不知道「哪些是复用组件」「哪些状态缺失」「token 该怎么映射」
   ⭐ 正确用法:用它生成「结构骨架」→ 人工替换成「token + 已有组件」

四、像素级还原 vs 一致性:必须做的取舍

⭐⭐⭐ 一个真实冲突(几乎每个项目都会遇到):

   设计稿里:卡片间距 13px、按钮圆角 6px、次要文字 #8C8C8C
   token 系统里:space-3 = 12px、radius-md = 8px、color-text-secondary = #595959

   【方案 A:逐像素还原】→ 硬编码 13px / 6px / #8C8C8C
     ✅ 「还原度 100%」(截图对比能过)
     ❌ ⭐ 破坏了设计系统:下次换主题时这块不会跟着变;
        一个月后「哪些地方用了 13px」无从查起;新人会照抄

   【方案 B:对齐 token】→ 用 12px(space-3)/ 8px(radius-md)/ #595959
     ✅ 系统一致、可换肤、可维护
     ⚠️ 「还原度 98%」(截图对比会显示差异)

   ⭐ 正确做法(行业共识):
     · **默认选 B**(对齐 token)
     · 把「设计稿与 token 的偏差」**当作一个问题反馈给设计师**
       (要么「设计稿用它,我们补一个 token」,要么「设计稿改用已有 token」)
     · ⭐ 只有「品牌视觉关键路径」(如营销页的主视觉)才值得「破例 + 记录例外」

   ⭐ 一句话结论:**「一致性」优先于「像素级还原」**——
     因为「设计稿是某一刻的快照」,而「设计系统是长期的契约」。
⭐⭐ 判据表(什么样的偏差该改代码、什么样的该改设计稿):

   | 偏差 | 处理 |
   | --- | --- |
   | 间距差 1px(13 vs 12) | ⭐ 归到最近的 token(改代码) |
   | 圆角 6 vs 8 | ⭐ 归到 token(改代码),并反馈设计师 |
   | 颜色是「新的色值」 | ⭐ 问设计师:是新色板还是手滑?(决定「加 token」还是「改设计稿」) |
   | 组件结构不同(设计稿是新的布局) | 评估是新组件还是变体 → 讨论 → 决定 |
   | 字体/字号不在体系内 | ⭐ 归到 token;若确实需要新字号,进体系 |
   | 品牌关键路径的特殊效果 | 破例 + 记录例外(注释说明「为什么不用 token」) |

五、完整实现:一个「Figma Variables → Code Tokens」生成器(240 行)

// scripts/design-tokens.mjs —— 把 Figma Variables 导出转成 CSS + TS + JSON
// 用法:node scripts/design-tokens.mjs --input figma-variables.json --out packages/tokens/src
//
// 输入格式:Figma REST API 的 `GET /v1/files/:key/variables/local` 响应
//   { meta: { variableCollections: {...}, variables: {...} } }
// 也兼容「Tokens Studio / 插件」导出的等价结构(见 normalize 函数)

import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';

// ---------- 0. 参数 ----------
const args = process.argv.slice(2);
const getArg = (name, fallback) => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : fallback;
};
const inputPath = getArg('--input', 'figma-variables.json');
const outDir = getArg('--out', 'src');

// ---------- 1. 读取 ----------
const raw = JSON.parse(readFileSync(inputPath, 'utf8'));
const meta = raw.meta ?? raw;

/** ============ 2. 归一化:把 Figma 的各种类型统一成「值 + 引用」 ============ */
function normalizeValue(value) {
  if (value && typeof value === 'object') {
    // ⭐ 别名(引用另一个变量)
    if (value.type === 'VARIABLE_ALIAS' || value.type === 'VARIABLE_ALIAS_TYPE') {
      return { kind: 'alias', refId: value.id };
    }
    // 颜色:Figma 给的是「0~1 的 rgba」
    if ('r' in value && 'g' in value && 'b' in value) {
      return { kind: 'color', value: rgbaToHex(value) };
    }
  }
  return { kind: 'primitive', value };
}

function rgbaToHex({ r, g, b, a = 1 }) {
  const to255 = (v) => Math.round(v * 255);
  const hex = [to255(r), to255(g), to255(b)]
    .map((v) => v.toString(16).padStart(2, '0'))
    .join('');
  const alpha = a < 1 ? (a < 0.999 ? Math.round(a * 100) / 100 : 1) : 1;
  // ⭐ 有透明度时用 rgb()/rgba()(hex 带 alpha 的兼容性虽好但可读性差)
  if (alpha < 1) return `rgb(${to255(r)} ${to255(g)} ${to255(b)} / ${alpha})`;
  return `#${hex}`;
}

/** ============ 3. 解析变量集合与模式 ============ */
const collections = meta.variableCollections ?? {};
const variables = meta.variables ?? {};

/** 变量 id → 信息 */
const variableById = new Map(
  Object.values(variables).map((v) => [
    v.id,
    {
      id: v.id,
      /** ⭐ 用「斜杠路径」做变量名:`color/primary/hover` → `--color-primary-hover` */
      name: v.name,
      cssName: toCssName(v.name),
      type: v.resolvedType ?? v.type,                      // COLOR | FLOAT | STRING | BOOLEAN
      collectionId: v.variableCollectionId,
      valuesByMode: v.valuesByMode ?? {},
      description: v.description ?? '',
    },
  ])
);

function toCssName(name) {
  return (
    '--' +
    name
      .replace(/\//g, '-')                    // 路径分隔 → 连字符
      .replace(/[^a-zA-Z0-9-_]/g, '-')
      .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // camelCase → kebab-case
      .replace(/[-\s]+/g, '-')
      .replace(/-+/g, '-')
      .toLowerCase()
      .replace(/^-+/, '')
  );
}

/** ============ 4. 别名解析(含「循环引用」检测) ============ */
function resolveValue(variable, modeId, stack = new Set()) {
  const rawValue = variable.valuesByMode[modeId];
  if (rawValue === undefined) return { kind: 'missing' };

  const normalized = normalizeValue(rawValue);

  if (normalized.kind === 'alias') {
    if (stack.has(variable.id)) {
      throw new Error(`⛔ 检测到循环引用:${[...stack, variable.id].join(' → ')}`);
    }
    const target = variableById.get(normalized.refId);
    if (!target) return { kind: 'missing', reason: `别名目标不存在:${normalized.refId}` };
    stack.add(variable.id);
    const resolved = resolveValue(target, modeId, stack);
    stack.delete(variable.id);
    // ⭐ 保留「别名」信息(用于生成 var(--x) 而不是字面值)
    return { ...resolved, aliasTo: target.cssName };
  }

  return normalized;
}
// ---------- 5. 分组与排序(按 collection 分块,按变量名排序) ----------
const collectionList = Object.values(collections);
/** collectionId → modeId[](含 name) */
const modesByCollection = new Map(
  collectionList.map((c) => [
    c.id,
    (c.modes ?? []).map((m) => ({ id: m.modeId, name: m.name })),
  ])
);

/** ⭐ 主题模式映射:把 Figma 的 mode 名映射到 CSS 选择器 */
const MODE_SELECTOR = {
  light: ':root',
  dark: "[data-theme='dark']",
  // 也可以支持「品牌」模式
  brand: "[data-brand='acme']",
};

function selectorForMode(modeName) {
  return MODE_SELECTOR[modeName.toLowerCase()] ?? `[data-mode='${modeName.toLowerCase()}']`;
}

// ---------- 6. 生成 CSS ----------
function generateCss() {
  const lines = [
    '/* ⚠️ 本文件由 scripts/design-tokens.mjs 自动生成,请勿手改 */',
    '/* 源:Figma Variables 导出 */',
    '',
  ];

  for (const collection of collectionList) {
    const modes = modesByCollection.get(collection.id) ?? [];
    const vars = Object.values(variables)
      .filter((v) => v.variableCollectionId === collection.id)
      .sort((a, b) => a.cssName.localeCompare(b.cssName));

    if (!vars.length) continue;

    lines.push(`/* ============ ${collection.name} ============ */`);

    for (const mode of modes) {
      // ⭐ 第一个模式(通常是 Light/默认)用 :root;其余用「属性选择器」
      const isDefault = mode.id === collection.defaultModeId;
      const selector = isDefault && collectionList.indexOf(collection) === 0
        ? ':root'
        : selectorForMode(mode.name);

      lines.push(`${selector} {`);
      for (const v of vars) {
        const value = resolveValue(v, mode.id);
        if (value.kind === 'missing') {
          lines.push(`  /* ⚠️ ${v.cssName}: 在模式「${mode.name}」下缺失 */`);
          continue;
        }
        const cssValue = value.value;
        const comment = v.description ? ` /* ${v.description} */` : '';
        lines.push(`  ${v.cssName}: ${cssValue};${comment}`);
      }
      lines.push('}');
      lines.push('');
    }
  }

  return lines.join('\n');
}

// ---------- 7. 生成 TS(带类型 + 只读) ----------
function generateTs() {
  const entries = [];
  const sorted = [...variableById.values()].sort((a, b) => a.cssName.localeCompare(b.cssName));

  for (const v of sorted) {
    const collection = collections[v.collectionId];
    const defaultMode = collection?.defaultModeId;
    const value = defaultMode ? resolveValue(v, defaultMode) : { kind: 'missing' };

    // ⭐ 生成「CSS 变量引用」而不是字面值:
    //    这样「换主题/换品牌」时代码侧不需要改变
    const tsValue = value.kind === 'missing'
      ? `'initial' /* ⚠️ 缺失 */`
      : `'var(${v.cssName})'`;

    entries.push(`  /** ${v.name}${v.description ? ` —— ${v.description}` : ''} */`);
    entries.push(`  ${toTsKey(v.name)}: ${tsValue},`);
  }

  return [
    '/* ⚠️ 本文件由 scripts/design-tokens.mjs 自动生成,请勿手改 */',
    '',
    'export const tokens = {',
    ...entries,
    '} as const;',
    '',
    'export type TokenName = keyof typeof tokens;',
    '',
    '/** ⭐ 取某个 token 的 CSS 值(会走 CSS 变量,因此主题切换即时生效) */',
    'export function token(name: TokenName): string {',
    '  return tokens[name];',
    '}',
    '',
  ].join('\n');
}

function toTsKey(name) {
  // ⭐ `color/primary/hover` → `colorPrimaryHover`
  const camel = name
    .replace(/\//g, '-')
    .replace(/[^a-zA-Z0-9-_]/g, '-')
    .split(/[-_]+/)
    .filter(Boolean)
    .map((part, i) => (i === 0 ? part : part[0].toUpperCase() + part.slice(1)))
    .join('');
  return /^[0-9]/.test(camel) ? `_${camel}` : camel;
}

// ---------- 8. 校验(把「设计稿偏移」变成「构建期错误」) ----------
function validate() {
  const problems = [];

  for (const v of variableById.values()) {
    // ⭐ ① 命名规范:必须「小写 + 斜杠分组 + 无空格」
    if (!/^[a-z0-9]+([/-][a-z0-9]+)*$/.test(v.name)) {
      problems.push(`命名不规范:${v.name}(应为 lowercase/with/slashes)`);
    }

    // ⭐ ② 必须属于某个 collection
    if (!collections[v.collectionId]) {
      problems.push(`变量不属于任何 collection:${v.name}`);
    }

    // ⭐ ③ 每个 mode 都要有值(否则暗色主题会「缺失变量」)
    const modes = modesByCollection.get(v.collectionId) ?? [];
    for (const mode of modes) {
      const value = resolveValue(v, mode.id);
      if (value.kind === 'missing') {
        problems.push(`变量 ${v.name} 在模式「${mode.name}」下缺失值`);
      }
    }
  }

  // ⭐ ④ 检测「硬编码的颜色」(FLOAT 类型的「魔法数值」提示)
  for (const v of variableById.values()) {
    if (v.type === 'FLOAT' && /^(color|spacing|radius|font)/.test(v.name)) {
      const value = resolveValue(v, collections[v.collectionId]?.defaultModeId);
      if (value.kind === 'primitive' && typeof value.value === 'number') {
        // ⚠️ 提示:尺寸类变量应「引用基础刻度」而不是「直接给数值」
        //    (这样改刻度时会级联更新)
        if (/^(spacing|radius)/.test(v.name) && !Number.isInteger(value.value)) {
          problems.push(`尺寸变量 ${v.name} 不是整数:${value.value}(可能偏离了刻度)`);
        }
      }
    }
  }

  return problems;
}

// ---------- 9. 输出 ----------
const problems = validate();
if (problems.length) {
  console.warn(`\n⚠️ 发现 ${problems.length} 个 token 规范问题:`);
  problems.forEach((p) => console.warn(`   · ${p}`));
  // ⭐ 可以选择「直接失败」把问题挡在 CI 里:
  // if (process.env.CI) process.exit(1);
}

mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'tokens.css'), generateCss(), 'utf8');
writeFileSync(join(outDir, 'tokens.ts'), generateTs(), 'utf8');
writeFileSync(
  join(outDir, 'tokens.json'),
  JSON.stringify(
    Object.fromEntries([...variableById.values()].map((v) => [v.name, { css: v.cssName, type: v.type }])),
    null,
    2
  ),
  'utf8'
);

console.log(`✅ 已生成 ${outDir}/tokens.css、tokens.ts、tokens.json`);
console.log(`   变量 ${variableById.size} 个,集合 ${collectionList.length} 个,模式 ${[...modesByCollection.values()].reduce((n, m) => n + m.length, 0)} 个`);
void dirname;
// ✅ 示例输入(Figma Variables 的 REST API 响应摘要)
{
  "meta": {
    "variableCollections": {
      "VariableCollectionId:1": {
        "id": "VariableCollectionId:1",
        "name": "Colors",
        "defaultModeId": "1:0",
        "modes": [
          { "modeId": "1:0", "name": "Light" },
          { "modeId": "1:1", "name": "Dark" }
        ]
      }
    },
    "variables": {
      "VariableID:10": {
        "id": "VariableID:10",
        "name": "color/primary",
        "resolvedType": "COLOR",
        "variableCollectionId": "VariableCollectionId:1",
        "description": "主品牌色",
        "valuesByMode": {
          "1:0": { "r": 0.086, "g": 0.466, "b": 1, "a": 1 },
          "1:1": { "r": 0.251, "g": 0.588, "b": 1, "a": 1 }
        }
      },
      "VariableID:11": {
        "id": "VariableID:11",
        "name": "color/button/primary/bg",
        "resolvedType": "COLOR",
        "variableCollectionId": "VariableCollectionId:1",
        "valuesByMode": {
          "1:0": { "type": "VARIABLE_ALIAS", "id": "VariableID:10" },
          "1:1": { "type": "VARIABLE_ALIAS", "id": "VariableID:10" }
        }
      }
    }
  }
}
// ✅ package.json 里的脚本(token 是「构建产物」,要提交进仓库以便 review)
{
  "scripts": {
    "tokens:fetch": "node scripts/fetch-figma-variables.mjs > figma-variables.json",
    "tokens:build": "node scripts/design-tokens.mjs --input figma-variables.json --out packages/tokens/src",
    "tokens:check": "node scripts/design-tokens.mjs --input figma-variables.json --out /tmp/tokens-check && git diff --exit-code packages/tokens/src"
  }
}
⭐⭐⭐ 这个脚本解决的四个「工程化」问题:

① 【设计变更自动同步】
   Figma Variables 改了 → 跑 `pnpm tokens:build` → 生成新的 CSS/TS
   ⭐ 不再需要「设计师发一份新的色值表 + 开发者手动改」

② 【别名解析 + 循环引用检测】
   `color/button/primary/bg` → 别名到 `color/primary`
   ⭐ 检测循环(A→B→A)并在构建期报错

③ 【校验把「设计稿偏移」变成「构建期问题」】
   · 命名不规范(大写/空格)
   · 某个 mode 缺值(⭐ 这会导致「暗色主题下变量缺失」)
   · 尺寸变量不是整数(可能偏离刻度)
   ⭐ 可以配置成「CI 里直接失败」

④ 【生成的是「CSS 变量引用」而不是字面值】
   `tokens.colorPrimary === 'var(--color-primary)'`
   ⭐ 于是「主题切换」不需要重新生成、不需要 JS 参与(见 10.8)

六、设计走查清单(把「还原度」变成可执行的检查项)

⭐⭐⭐ 一份可执行的设计走查清单(比「像不像」有用得多):

【视觉与 token】
   □ 颜色/间距/圆角/字号是否都来自 token?(用 DevTools 检查「有没有硬编码值」)
   □ 浅色/深色主题下是否都正确?(⭐ 切一遍主题看)
   □ 对比度是否达标(普通文本 4.5:1,见 10.12)?

【状态完整性】(⭐ 最常被漏)
   □ hover / active / focus-visible / disabled / selected
   □ loading / 骨架 / 空态 / 错误态
   □ 校验失败时的字段错误与表单错误

【数据边界】
   □ 超长文本(截断?换行?tooltip?)
   □ 0 项 / 1 项 / 1000 项
   □ 特殊字符与 emoji
   □ 超大/超小数字(如 999999999)

【响应式】
   □ 320 / 768 / 1024 / 1440 / 1920 宽
   □ 横屏 / 竖屏
   □ 缩放 200%(⭐ WCAG 要求)
   □ 移动端「虚拟键盘弹出」时的布局

【国际化】
   □ 长文案(德语/俄语)
   □ RTL(阿拉伯语)—— ⭐ 用「逻辑属性」(`margin-inline-start`)而不是 `margin-left`
   □ 日期/数字/货币格式

【无障碍】
   □ 键盘能否走完全流程(见 10.12)
   □ 焦点是否可见、是否被粘性头部遮住
   □ 屏幕阅读器能否读明白
   □ 点击目标 ≥ 24×24(建议 44×44)

【性能】
   □ 首屏 LCP 元素是否受影响(图片/字体,见 5.15)
   □ 是否引入了「布局抖动」(见 1.14)
   □ 动效是否只改 transform/opacity(见 5.17)

七、本篇特有的坑

① 直接照抄 Figma Dev Mode 生成的代码片段
   ⚠️ 它生成的是「绝对定位 + 内联样式」,完全不可维护
   ✅ 只用它「看值」,代码自己写(用 token + 已有组件 + 布局原语)

② 「逐像素还原」优先于「token 一致性」
   ⚠️ 后果:设计系统失效、换肤失效、魔法数字遍地
   ✅ 默认对齐 token,把偏差反馈给设计师(见第四节)

③ 设计稿的「浅灰文字」直接照抄(对比度不达标)
   ⚠️ 常见缺陷:`#999` 在 `#fff` 上只有 2.85:1(低于 4.5:1)
   ✅ 用「已在体系里且达标的」次要文字色(见 10.12)

④ 只实现「设计稿画了的状态」(hover/disabled/loading/空态全缺)
   ✅ 按「状态清单」补全(第三步)

⑤ 只适配「设计稿的屏幕宽度」(如 1440)
   ✅ 至少测 320 / 768 / 1440,并确认「折叠策略」

⑥ 用「物理属性」写样式(`margin-left`/`text-align: left`)
   ⚠️ RTL 语言下会错位
   ✅ 用「逻辑属性」(`margin-inline-start`/`text-align: start`)

⑦ 硬编码文案(不接 i18n 系统)
   ⚠️ 后期接 i18n 要「全文替换」,且容易漏
   ✅ 一开始就用 `t('key')`(见 10.7)

⑧ 把「设计稿里的图片资源」直接下载使用(未压缩/未转格式)
   ✅ 走图片优化流程(AVIF/WebP + srcset,见 5.15)

⑨ 设计改了但代码没跟上(或反之)→ 两边长期漂移
   ✅ ⭐ 用「token 自动生成 + CI 检查」把漂移变成「构建失败」
      (`tokens:check` 脚本:重新生成后跑 `git diff --exit-code`)

⑩ 「设计稿的字体」没在项目里(回退到系统字体,视觉差异大)
   ✅ 确认字体授权 + 子集化 + 加载策略(见 5.15)

⑪ 「组件视觉特例」直接改组件内部样式
   ⚠️ 污染了组件(其他页面也被改了)
   ✅ 优先「加变体/size」;确属特例时用「外层容器 + 局部样式」

⑫ 「设计走查」变成「设计师挑毛病 → 开发者硬改」
   ⭐ 后果:越改越乱、token 被破坏
   ✅ 约定「走查规则」:偏差必须归到「改代码」或「改设计稿」两类之一,
      且「新增 token」需要评审

⑬ 只在 Chrome 看效果(Safari/Firefox 的差异被忽略)
   ✅ 至少在目标浏览器矩阵里抽测(尤其 `gap`/`aspect-ratio`/`backdrop-filter`)

⑭ 「暗色主题」只在最后做(发现颜色全都要调)
   ✅ ⭐ 一开始就用「语义 token」写(见 10.8),暗色主题只改语义层

⑮ 认为「设计稿 = 规格」
   ⭐ 设计稿是「某个状态下的快照」;规格是「设计系统 + 交互约定」
   ✅ 有疑问就问设计师(「这个间距是特例还是新刻度?」)
/* ⑥ 的完整处理(逻辑属性) */
/* ❌ 物理属性(RTL 下会错) */
.card { margin-left: 12px; padding-right: 8px; text-align: left; border-left: 2px solid; }

/* ✅ 逻辑属性(RTL 下自动镜像) */
.card {
  margin-inline-start: 12px;
  padding-inline-end: 8px;
  text-align: start;
  border-inline-start: 2px solid;
}

/* ⭐ 对应的 RTL 支持(HTML 上设 dir) */
/* <html dir="rtl" lang="ar"> */
// ⑨ 的完整处理(CI 里把「token 漂移」变成失败)
// package.json
// "tokens:check": "pnpm tokens:build && git diff --exit-code packages/tokens/src"
//
// ⭐ 含义:如果「生成的产物」与「仓库里提交的产物」不一致 →
//    说明「有人改了 Figma 但没重新生成」,或「有人手改了产物」→ CI 失败
//    解法:跑 `pnpm tokens:build` 并提交

八、面试延伸

  1. 「设计稿给你,你怎么落地成代码?」
    五步,顺序不能反:① ⭐ 先识别「这是哪种组件」(已有组件直接用 / 是已有组件的变体则扩展变体 / 全新才新建)——跳过这步会出现「7 个长得差不多的按钮」;② ⭐ 把值映射到 token(16px → var(--space-inset-md);⚠️ 判据是「token 里有对应吗」,没有就问设计师「是新刻度还是该归到最近的」);③ ⭐ 补全设计稿没画的状态(hover/active/focus-visible/disabled/loading/error/空态/长文本/小屏/网络失败)——这是「工程师的核心增值」;④ 实现 + 自测(语义化 HTML + 键盘走一遍 + 缩放 200% + 长文本 + 空数据);⑤ 设计走查——但目标是「一致」而不是「逐像素」。

  2. 「设计稿有哪些『必然缺失』的东西?」
    十类:交互状态(hover/active/focus/disabled/selected)、异步状态(loading/骨架/重试)、错误状态(字段/表单/网络/权限)、空状态(无数据/无搜索结果/首次使用)、边界数据(超长文本/0 项/1 项/1000 项/超大数字)、响应式(小屏/平板/大屏/横屏)、国际化(长文案/RTL/日期数字格式)、动效时序、无障碍(焦点顺序/对比度——设计稿的浅灰常常不达标)、数据边界(加载顺序/局部失败)。⭐ 所以「还原度 100%」是伪指标——它只衡量了「正常态 + 一种屏幕 + 一种数据」。

  3. 「『像素级还原』和『一致性』冲突时怎么办?」
    场景:设计稿写 13px,而 token 里是 12px。逐像素还原会硬编码 13px(✅ 截图能过,❌ ⭐ 破坏设计系统——换肤失效、魔法数字遍地、后人照抄);对齐 token 用 12px(✅ 系统一致可维护,⚠️ 还原度 98%)。⭐ 行业共识是「默认对齐 token」,并把偏差当作问题反馈给设计师(要么「设计稿用它,我们补一个 token」,要么「设计稿改用已有 token」);只有「品牌视觉关键路径」才值得破例 + 记录例外。一句话:「设计稿是某一刻的快照,设计系统是长期的契约」。

  4. 「设计稿里的色值/间距,怎么保证代码与设计长期一致?」
    ⭐ 靠自动化,不靠自觉:① Design Tokens——让设计师在 Figma 里用 Variables(而不是随手写色值),然后导出(REST API 或 Tokens Studio 插件);② 生成代码——用脚本把导出转成 tokens.css / tokens.ts(本篇的实现:解析 collection/mode、颜色转换、别名解析 + 循环检测、生成 CSS 变量与 TS 常量);③ ⭐ CI 检查漂移——tokens:build && git diff --exit-code,如果「生成的产物与仓库里的不一致」就失败(说明「有人改了 Figma 没重新生成」或「有人手改了产物」);④ 校验命名与完整性(如「每个 mode 都要有值」——否则暗色主题会缺变量)。

  5. 「Figma Dev Mode 生成的代码能直接用吗?」
    不能直接用——它生成的是「绝对定位 + 内联样式」的思路(因为它只看到「这一帧的视觉结果」,不知道你的布局意图、组件体系与 token)。⭐ 正确用法是「只用它看值」:看间距/圆角/色值/字号,然后自己写代码(用 token + 已有组件 + 布局原语)。AI 生成代码(截图 → 代码) 目前的定位也一样——⭐ 它是「加速初稿」,不是「替代工程判断」:它不知道「哪些是复用组件」「哪些状态缺失」「token 该怎么映射」。正确用法是「用它生成结构骨架」→ 人工替换成「token + 已有组件」。

  6. 「怎么做设计走查?走查什么?」
    走查的核心是「把『像不像』变成可执行的检查项」,分成七类:① 视觉与 token(有没有硬编码值?浅色/深色都对吗?对比度达标吗?);② ⭐ 状态完整性(hover/active/focus-visible/disabled/selected + loading/骨架/空态/错误态)——最常被漏;③ 数据边界(超长文本/0 项/1 项/1000 项/特殊字符/超大数字);④ 响应式(320/768/1024/1440/1920 + 横竖屏 + 缩放 200%);⑤ 国际化(长文案/RTL ⭐ 用逻辑属性/日期数字格式);⑥ 无障碍(键盘走完流程/焦点可见/屏幕阅读器/点击目标 ≥ 24×24);⑦ 性能(首屏 LCP 元素/布局抖动/动效只改 transform 与 opacity)。⭐ 还要约定走查规则:每个偏差必须归到「改代码」或「改设计稿」两类之一(不能含糊过去),且「新增 token」需要评审。

  7. 「RTL(阿拉伯语)适配要注意什么?」
    核心是「用逻辑属性写样式」:① 盒模型——margin-left/right → margin-inline-start/end,padding-* 同理;② 文本——text-align: left → text-align: start;③ 边框——border-left → border-inline-start;④ 定位——left/right → inset-inline-start/end;⑤ 图标——「箭头/返回」这类方向性图标需要镜像(transform: scaleX(-1));⑥ 数字与日期在 RTL 里仍然是从左到右(所以要用 <bdi> 或 dir="ltr" 包起来);⑦ ⭐ HTML 上设 dir="rtl"(浏览器会自动处理相当一部分)。注意:flex-direction 在 RTL 下不需要改(它本身就跟随书写方向)。

  8. 「设计改版(Redesign)怎么和代码同步?」
    四条实践:① ⭐ 用 token 做「翻译层」——设计改版如果是「值的变化」(颜色/间距/圆角),理论上只改 token(不改组件代码);这正是「语义 token」的价值(见 10.8);② 分阶段走——先「改 token」让全局观感变化 → 再「逐个组件改结构」→ 最后「改页面布局」;③ ⭐ 视觉回归测试护航——每个组件的「变体 × 尺寸 × 状态」截图比对(见 10.8),改版时能立刻看到「哪些组件被意外影响了」;④ Feature Flag + 灰度(见 8.9)——大改版用「新旧两套主题 + 按用户切换」上线,出问题能立刻回退。

一句话速记

正确流程是「先识别组件(已有→直接用 / 变体→扩展 / 全新→新建)→ 把值映射到 token(没有对应就问设计师)→ 补全设计稿没画的状态 → 实现自测 → 设计走查」,⭐ 顺序不能反;设计稿必然缺失 10 类东西(交互状态 / 异步状态 / 错误 / 空态 / 边界数据 / 响应式 / 国际化 / 动效 / 无障碍 / 数据边界),所以「还原度 100% 是伪指标」;⭐ 「一致性」优先于「像素级还原」(设计稿是某一刻的快照,设计系统是长期契约)——默认对齐 token,把偏差反馈给设计师(要么补 token、要么改设计稿),只有品牌关键路径才例外记录;Figma Dev Mode / AI 生成的代码不要直接用(它是「绝对定位 + 内联样式」的思路)——⭐ 只用它看值;长期一致靠自动化:Figma Variables → 脚本生成 tokens.css/tokens.ts(含别名解析 + 循环检测 + 命名校验 + 每个 mode 都要有值)→ CI 用 git diff --exit-code 检查漂移;RTL 要用逻辑属性(margin-inline-start/text-align: start/inset-inline-start)+ 方向性图标镜像;设计走查清单要覆盖「token / 状态完整性 / 数据边界 / 响应式(含缩放 200%)/ 国际化 / 无障碍 / 性能」七类,且每个偏差必须归到「改代码」或「改设计稿」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.13 设计稿到代码

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫