评论

使用Tailwind CSS自动从Figma设计生成React组件

前端自动化生产页面

Figma已经成为很多人喜爱的UI/UX设计工具。同时,React配合Tailwind CSS也是一个流行的前端技术栈。如果能够从Figma设计自动生成使用Tailwind CSS样式化的React组件可以提供一种自动化方案!

在这篇文章里,我将展示如何使用Figma API,将Figma设计转换成带有Tailwind CSS类的React组件。

概览

主要步骤如下:

  1. 使用Figma API从Figma文件中提取节点数据
  2. 获取节点中使用的图片资源的映射
  3. 扁平化和优化节点树结构
  4. 将图片引用映射到URL
  5. 将节点转换成React/JSX的AST抽象语法树
  6. 从AST生成React组件代码
  7. 为每个组件创建Next.js页面文件
  8. 下载图片资源和写入组件文件

最终结果是带有Tailwind CSS类的React组件,样式与Figma中的设计相匹配。

从Figma获取节点数据

首先我们将使用Figma API从Figma文件中获取节点数据。我们可以指定文件key、需要的节点以及像获取向量数据这样的可选参数:

type MixedNode =
  | FrameNode
  | RectangleNode
  | InstanceNode
  | TextNode
  | VectorNode
  | GroupNode
  | BooleanOperationNode
  | EllipseNode;

const getFigmaFileNodes = async ({ fileKey, accessToken, nodeIds }) => {
  const query = qs.stringify({
    ids: nodeIds,
    geometry: "paths",
  });
  const url = `https://api.figma.com/v1/files/${fileKey}/nodes?${query}`;
  const res = await fetch(url, {
    method: "GET",
    headers: {
      "X-FIGMA-TOKEN": accessToken,
    },
  });
  let nodes: MixedNode[] = [];
  const document = await res.json();

  for (let key in document.nodes) {
    nodes.push(document.nodes[key].document);
  }
  return nodes;
}

上面的代码一每个document下的节点作为每个react组件的节点数据 这将返回包含id、name、节点位置、填充等信息的节点的多叉树。这个多叉树正好可以映射成jsx element。下面就是考虑应该怎么样去转换代码。(更多的节点细节可以参考figma官网的文档

下载图片资源

许多设计使用了图片填充,所以我们需要下载文件中使用的图片:

const getImagesInFile = async ({ fileKey, accessToken}) => {
  const query = qs.stringify({
    ids: nodeIds,
  });
  const res = await fetch(
    `https://api.figma.com/v1/files/${fileKey}/images?${query}`,
    {
      method: "GET",
      headers: {
        "X-FIGMA-TOKEN": accessToken,
      },
    }
  );
  const data = await res.json();
  return data.meta.images;
}

这将给我们一个从图片ID到图片URL的映射。
需要注意的是这里面返回的图片资源是原始的图片,但是在figma中使用的图片一般都经历过裁剪, 旋转等,所以我们还需要写一个 图片服务来专门处理图片的压缩,裁剪。这个可以再另外一遍文章里介绍。

映射图片引用

我们可以遍历节点,将任何节点填充对应的引用映射到已下载的图片hash:

  const getImageRefsInNodes = (nodes, imagesMap) => {
    const getImageRefsInNodes = (
    nodes: MixedNode[],
    imagesMap: { [key: string]: string }
  ) => {
    let imageRefsMap: {
      [key: string]: string;
    } = {};

    for (let layerNode of nodes) {
      traverseNode(layerNode, null, {
        All: {
          enter(node, parentNode) {
            for (let fill of node.fills) {
              if (fill.type === "IMAGE") {
                if (fill.imageRef) {
                  imageRefsMap[fill.imageRef] = imagesMap[fill.imageRef];
                }
              }
            }
          },
        },
      });
    }
    return imageRefsMap;
  };

这个映射之后在生成图片组件时会用到。代码中的traverseNode用的是一种设计模式(访问者模式),一般就是应用在处理 ast节点转换成另外一种ast的情况。这里面使用非常合适。

  type ExtendMixedNode = MixedNode & {
    ast?: t.JSXElement;
  };

  const traverseNode = (
    node: ExtendMixedNode,
    parentNode: ExtendMixedNode | null,
    visitor: Visitor
  ) => {
    let methods = visitor[node.type];
    if (methods && methods.enter) {
      methods.enter(node, parentNode);
    }
    let methodsAll = visitor["All"];

    if (methodsAll && methodsAll.enter) {
      methodsAll.enter(node, parentNode);
    }
    if (Array.isArray(node.children)) {
      for (let i of node.children) {
        traverseNode(i as ExtendMixedNode, node, visitor);
      }
    }
    if (methods && methods.exit) {
      methods.exit(node, parentNode);
    }
    if (methodsAll && methodsAll.exit) {
      methodsAll.exit(node, parentNode);
    }
  };

优化节点树结构

事实上ui给的设计稿中 节点的结构。并不总是按视觉的结构那样是一颗从大到小,从里到外的这样一颗节点树。而可能是一颗很随意分布的节点,这样其实不利于未来的代码维护。造成这种情况是figma节点布局有两种,一种是类似于浏览器的flex布局,这种布局方式产生的结构是有序的。还有一种是absolute的布局,这种布局是无序的排列中layer上。同是absolute的布局是基于整个文件的canvas图层的左上角来布局的,这块的属性我们也需要处理。

  const walk = (
    node: MixedNode,
    parentNode: MixedNode | null,
    rootOffset: {
      x: number;
      y: number;
    },
    flattenResult: OptimizedNode[],
    flattenNodeMap: {
      [key: string]: OptimizedNode;
    }
  ) => {
    const { children, ...rest } = node;

    let optimizedNode = rest as OptimizedNode;

    if (parentNode) {
      optimizedNode.parentId = parentNode.id;
      optimizedNode.absoluteBoundingBox.x -= rootOffset.x;
      optimizedNode.absoluteBoundingBox.y -= rootOffset.y;
    } else {
      optimizedNode.parentId = null;
      rootOffset.x = node.absoluteBoundingBox.x;
      rootOffset.y = node.absoluteBoundingBox.y;
      optimizedNode.absoluteBoundingBox.x = 0;
      optimizedNode.absoluteBoundingBox.y = 0;
    }

    // 过滤掉不显示的节点
    if (isBoolean(node.visible) && !node.visible) {
      return;
    }

    if (isNumber(node.opacity) && node.opacity === 0) {
      return;
    }

    flattenResult.push(optimizedNode);
    flattenNodeMap[optimizedNode.id] = optimizedNode;

    if (Array.isArray(node.children)) {
      for (let i of node.children) {
        walk(i as MixedNode, node, rootOffset, flattenResult, flattenNodeMap);
      }
    }
  };

上面是将absolute布局转换成浏览器的模式按父节点的非static布局的位置来布局。同事将视觉上不显示的节点隐藏掉。

还有一部分就是更改节点的位置这一部分代码可以按照自己的业务需求,合并,移动节点。这一部分代码就不展示了。

将节点转换成JSX AST

现在我们可以遍历优化后的节点,将其转化成React JSX AST。我们将为每种节点类型写个处理函数:

const transformNodeToAst = (node) => {
  switch(node.type) {
    FRAME: {
      enter(node, parentNode) {
        let ast;
        const imagesAst = getNextImageAst(node.fills, node);
        if (imagesAst.length > 0) {
          ast = template.expression.ast(
            nodeTemplate(node, parentNode, "", false),
            {
              plugins: ["jsx"],
            }
          ) as t.JSXElement;
          ast.children.push(...imagesAst);
        } else {
          ast = template.expression.ast(
            nodeTemplate(node, parentNode, "", !node.children),
            {
              plugins: ["jsx"],
            }
          ) as t.JSXElement;
        }
        node.ast = ast;
      },
      exit(node, parentNode) {
        if (parentNode && parentNode.ast && node.ast) {
          if (Array.isArray(parentNode.ast.children)) {
            parentNode.ast.children.push(node.ast);
          } else {
            parentNode.ast.children = [];
          }
        }
      },
    },
    case "TEXT":
      let textAST = jsx`<Text>`;  
      return textAST;
    // ...其他类型  
  }
}

构建AST时,我们将其挂载到父节点下,以保证正确的嵌套结构。

生成组件代码

为生成组件代码,我们可以使用模板字符串包装根AST:

const generateCode = (ast) => {
  let programTemplate = template.program(`
    import React from 'react';
    import Image from 'next/image';

    const Section${index} = () => {
      %%sectionArrowFnBody%%
    }

    export default Section${index}
  `);

  const ast = programTemplate({
    sectionArrowFnBody: t.returnStatement(jsxElement),
  }) as t.Node;

  return generate(ast).code;
}

我们可以为每个顶级AST调用此方法,现在就得到了生成的React组件代码!

创建Next.js页面

为方便使用,我们可以为每个组件创建自己的Next.js页面文件。

首先创建pages/components文件夹。然后遍历组件,写入文件,下载图片:

components.forEach((component, i) => {
  fs.writeFileSync(
    `./pages/components/Section${i}.js`,
    component  
  );
  downloadImages(i);
});

现在我们有独立的组件文件和图片资源啦!

添加Tailwind CSS样式

到现在我们只有原生的JSX代码。为添加样式,我们可以遍历节点并为如下属性生成Tailwind类名:

  • 布局定位
  • 布局模式
  • 尺寸大小
  • 颜色
  • 效果
  • 边框

下面是布局定位的代码

const getTailwindClasses = (node) => {
  if (
    node.type === "FRAME" ||
    node.type === "INSTANCE" ||
    node.type === "GROUP"
  ) {
    if (node.layoutMode) {
      classNames.push("flex");
      if (node.layoutMode === "HORIZONTAL") {
        classNames.push("flex-row");
      }

      if (node.layoutMode === "VERTICAL") {
        classNames.push("flex-col");
      }

      // auto layout children
      if (node.layoutAlign) {
        classNames.push(`self-${node.layoutAlign.toLowerCase()}`);
      }

      // TODO
      if (node.counterAxisSizingMode) {
        if (node.counterAxisSizingMode === "AUTO") {
        }

        if (node.counterAxisSizingMode === "FIXED") {
        }
      }

      if (node.counterAxisAlignItems) {
        if (node.counterAxisAlignItems === "CENTER") {
          classNames.push("items-center");
        }

        if (node.counterAxisAlignItems === "BASELINE") {
          classNames.push("items-baseline");
        }

        if (node.counterAxisAlignItems === "MAX") {
          classNames.push("items-stretch");
        }

        if (node.counterAxisAlignItems === "MIN") {
        }
      }

      if (node.primaryAxisAlignItems) {
        if (node.primaryAxisAlignItems === "CENTER") {
          classNames.push("justify-center");
        }

        if (node.primaryAxisAlignItems === "SPACE_BETWEEN") {
          classNames.push("justify-between");
        }
      }

      if (node.itemSpacing) {
        classNames.push(`gap-[${node.itemSpacing}px]`);
      }
    }
  }

  if (
    parentNode &&
    (parentNode.type === "FRAME" ||
      parentNode.type === "INSTANCE" ||
      parentNode.type === "GROUP")
  ) {
    if (!parentNode.layoutMode) {
      classNames.push(
        ...getPositionClassNames(node, parentNode === null ? node : parentNode)
      );
    }
  } else {
    classNames.push(
      ...getPositionClassNames(node, parentNode === null ? node : parentNode)
    );
  }

  if (node.type === "VECTOR") {
    if (isNumber(node.layoutGrow)) {
      classNames.push(`grow-[${node.layoutGrow}]`);
    }
  }

  return classNames;
}

关键是将Figma的样式属性映射到相应的Tailwind CSS实用类。
我们可以在转换AST时为元素应用这些类。

总结

通过这种方式,我们可以从一个Figma设计文件直接生成带Tailwind CSS样式的React组件,可以直接导入前端代码库中使用。

优点是设计师和开发可以在自己偏爱的工具中独立工作,同时保持一致的实现。

一些可以扩展的方向包括:

  • 支持更多节点和样式类型
  • 生成响应式样式类
  • 更好地处理文本样式和图片组件
  • 优化SVG处理

但这为从设计自动转换为代码奠定了坚实的基础。
所以下次需要从Figma构建某些设计时,不必从零开始编码 - 使用为设计定制的React组件自动生成代码骨架吧!

最后一次编辑于  2023-07-25  
点赞 11
收藏
评论

2 个评论

  • 兵
    2023-07-26

    谢谢,学习了,这个挺有创意的

    2023-07-26
    赞同 1
    回复
  • hanhan
    hanhan
    2023-07-26

    很赞,值得收藏

    2023-07-26
    赞同 1
    回复
登录 后发表内容