普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月15日首页

🧸 前端不是只会写管理后台,我用 400 行代码画了一个 LABUBU !

作者 xiaohe0601
2025年11月13日 20:12

注意看,这个男人叫小何,别小看他,每天晚上 9 点 59 分他都准时打开泡泡玛特小程序蹲守 LABUBU 抢购。就在刚才,屏幕时钟倒计时又到 00:00:00 了,他立刻开始狂戳屏幕上的「立即购买」按钮,切换「购买方式」反复刷新库存,熟练的让人心疼。

可是,现实却从来没有什么“功夫不负有心人”,有的只是无数“黄牛”挥舞着自己的“科技”与小何同台竞技。毫无意外,今天的小何依然没有胜利,看着屏幕上的「已售罄」陷入了沉思 ……

拼尽全力也无法战胜吗?

空气里漂泊着手机屏幕反射的冷光,小何指尖的汗渍在「已售罄」三个字上洇出淡淡的印子。屏幕里 LABUBU 的笑脸还在倔强 —— 那只顶着毛茸茸耳朵、圆眼圆腮的小家伙,本该是用来治愈生活的,此刻却成了科技与欲望“厮杀”后,留给普通人的一道冷疤。

技术从来都该是温柔的,当“黄牛”用它筑起壁垒时,或许我该用同样的东西,造一扇窗!

我是一名前端开发工程师,不是切图仔,不是只会写管理后台,今天势必要夺回失去的一切!

是的,我画了一个专属于自己的 LABUBU !

👉 在线体验:labubu.xiaohe.ink

✍️ 开始创作

LeaferJS 是一款好用的 Canvas 引擎,革新的开发体验,可用于高效绘图 、UI 交互、图形编辑。

Leafer Vue 是由 @FliPPeDround 基于 LeaferJS 创建的项目,可以使用 Vue 组件化轻松构建 Leafer 应用,具有以下特性:

  • 使用 Vue 构建 Leafer 应用,高性能
  • 生态统一,完全兼容 Leafer 插件
  • 由 TypeScript 编写,提供强大的类型支持
  • 提供在线演练场,即开即用、畅享创作

现在,我们将使用 Leafer Vue 一起来完成这个作品!

一半茶叶蛋

首先是 LABUBU 的脑袋,看起来有点像被切开的茶叶蛋,可以用两段二次贝塞尔曲线来绘制一个非对称椭圆表示。

我们先编写 createBezierEllipsePath 工具方法,用于生成更自然流畅的椭圆路径:

import { PathCreator } from "leafer-ui";

interface Point {
  x: number;
  y: number;
}

/**
 * 以控制点 cp 为中心反射生成点 p 关于它的对称点
 */
function reflect(p: Point, cp: Point) {
  return {
    x: p.x + (p.x - cp.x),
    y: p.y + (p.y - cp.y)
  };
}

/**
 * 创建非对称椭圆路径
 */
export function createBezierEllipsePath(p1: Point, p2: Point, ox: number, oy: number) {
  const cp1 = { x: p1.x + ox, y: p1.y + oy };
  const cp2 = { x: p2.x - ox, y: p2.y + oy };

  // 通过反射生成另外两个控制点
  const cp3 = reflect(p2, cp2);
  const cp4 = reflect(p1, cp1);

  return new PathCreator()
    .moveTo(p1.x, p1.y)
    // 第 1 段贝塞尔曲线
    .bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y)
    // 第 2 段贝塞尔曲线
    .bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p1.x, p1.y)
    .closePath()
    .path;
}

然后调用 createBezierEllipsePath 创建头部和脸部的路径:

const headPath = createBezierEllipsePath(
  { x: 40, y: 240 },
  { x: 260, y: 240 },
  28,
  -120
);

const facePath = createBezierEllipsePath(
  { x: 60, y: 260 },
  { x: 240, y: 260 },
  -10,
  80
);

使用 Path 标签传入路径,再加上填充色和描边:

<!-- 头 -->
<Path
  :path="headPath"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
></Path>

<!-- 脸 -->
<Path
  :path="facePath"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="3"
></Path>

✨ 脑袋部分完成啦!

一个魔丸

画好了脑袋,现在开始画五官。光看五官 LABUBU 跟“魔丸”哪吒是不是有点神似?哪吒和泡泡玛特甚至推出过联名款!

眼睛画起来很简单,直接使用 Ellipse 标签绘制几个椭圆组合起来就好,至于眉毛就用 Line 标签画一条曲线吧 ~

<!-- 左眼白 -->
<Ellipse
  :x="93"
  :y="228"
  :width="40"
  :height="60"
  fill="#f9f9f9"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 左上眼睑 -->
<Ellipse
  :x="96"
  :y="206"
  :width="44"
  :height="26"
  :rotation="10"
  :start-angle="20"
  :end-angle="154"
  fill="#ffd9d0"
></Ellipse>

<!-- 左眉毛 -->
<Line
  :points="[96, 226, 104, 233, 124, 235, 134, 232]"
  curve
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
></Line>

<!-- 左眼球 -->
<Ellipse
  :x="100"
  :y="242"
  :width="28"
  :height="45"
  fill="#000000"
></Ellipse>

<!-- 左眼光 -->
<Ellipse
  :x="111"
  :y="245"
  :width="6"
  :height="10"
  fill="#ffffff"
></Ellipse>

<!-- 右眼白 -->
<Ellipse
  :x="165"
  :y="228"
  :width="40"
  :height="60"
  fill="#f9f9f9"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 右上眼睑 -->
<Ellipse
  :x="158"
  :y="214"
  :width="44"
  :height="26"
  :rotation="-10"
  :start-angle="24"
  :end-angle="158"
  fill="#ffd9d0"
></Ellipse>

<!-- 右眉毛 -->
<Line
  :points="[164, 232, 176, 236, 194, 233, 202, 226]"
  curve
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
></Line>

<!-- 右眼球 -->
<Ellipse
  :x="171"
  :y="242"
  :width="28"
  :height="45"
  fill="#000000"
></Ellipse>

<!-- 右眼光 -->
<Ellipse
  :x="181"
  :y="245"
  :width="6"
  :height="10"
  fill="#ffffff"
></Ellipse>

鼻子也是一个非对称椭圆,可以用之前编写的 createBezierEllipsePath 创建一个小小的椭圆:

const nosePath = createBezierEllipsePath(
  { x: 141, y: 275 },
  { x: 157, y: 275 },
  2,
  9
);
<!-- 鼻子 -->
<Path
  :path="nosePath"
  fill="#ff0154"
  stroke="#000000"
  :stroke-width="2"
></Path>

嘴巴是一条 0.76 曲率的曲线,使用 Path 标签的 curve 参数可以轻松实现。

但是牙齿画起来就比较麻烦了,因为要紧密贴合嘴巴曲线,所以我们需要编写一个方法将嘴巴的曲率转换为三次贝塞尔曲线,再根据传入牙齿的数量和大小沿曲线切线方向排布并生成对应的路径数组。

方法的具体实现如下:

// 嘴巴曲线
const mouthPoints = [76, 266, 150, 304, 224, 266];
// 嘴巴曲率
const mouthCurve = 0.76;

/**
 * 创建牙齿路径
 */
function createTeethPaths(
  count: number,
  toothWidth: number,
  toothHeight: number,
  curve: number
) {
  const p1 = { x: mouthPoints[0], y: mouthPoints[1] };
  const c0 = { x: mouthPoints[2], y: mouthPoints[3] };
  const p2 = { x: mouthPoints[4], y: mouthPoints[5] };

  function lerp(a: number, b: number, t: number) {
    return a + (b - a) * t;
  }

  // 贝塞尔曲线中间控制点
  const c1 = {
    x: lerp(p1.x, c0.x, 0.5) - curve * 20,
    y: lerp(p1.y, c0.y, 0.5) + curve * 43
  };
  const c2 = {
    x: lerp(c0.x, p2.x, 0.5) + curve * 20,
    y: lerp(c0.y, p2.y, 0.5) + curve * 43
  };

  /**
   * 三次贝塞尔计算
   */
  function cubic(t: number): [number, number] {
    return [
      (1 - t) ** 3 * p1.x + 3 * (1 - t) ** 2 * t * c1.x + 3 * (1 - t) * t ** 2 * c2.x + t ** 3 * p2.x,
      (1 - t) ** 3 * p1.y + 3 * (1 - t) ** 2 * t * c1.y + 3 * (1 - t) * t ** 2 * c2.y + t ** 3 * p2.y
    ];
  }

  /**
   * 贝塞尔切线
   */
  function derivative(t: number): [number, number] {
    return [
      3 * (1 - t) ** 2 * (c1.x - p1.x) + 6 * (1 - t) * t * (c2.x - c1.x) + 3 * t ** 2 * (p2.x - c2.x),
      3 * (1 - t) ** 2 * (c1.y - p1.y) + 6 * (1 - t) * t * (c2.y - c1.y) + 3 * t ** 2 * (p2.y - c2.y)
    ];
  }

  const value: number[][] = [];

  for (let i = 0; i < count; i += 1) {
    const t = i / (count - 1);

    const [cx, cy] = cubic(t);
    const [dx, dy] = derivative(t);

    const length = Math.sqrt(dx * dx + dy * dy);

    // 法向量
    const nx = -dy / length;
    const ny = dx / length;

    const halfWidth = toothWidth / 2;

    const x1 = cx - halfWidth * dx / length;
    const y1 = cy - halfWidth * dy / length;
    const x2 = cx + halfWidth * dx / length;
    const y2 = cy + halfWidth * dy / length;

    const xt = cx + toothHeight * nx;
    const yt = cy + toothHeight * ny;

    const path = new PathCreator()
      .moveTo(x1, y1)
      .quadraticCurveTo(xt, yt, x2, y2)
      .closePath()
      .path;

    value.push(path);
  }

  return value;
}

const teethPaths = createTeethPaths(11, 16, 18, mouthCurve);

然后使用 v-for 循环生成牙齿:

<!-- 嘴巴 -->
<Line
  :points="mouthPoints"
  :curve="mouthCurve"
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
></Line>

<!-- 牙齿 -->
<Path
  v-for="(item, index) in teethPaths"
  :key="index"
  :path="item"
  fill="#ffffff"
  stroke="#000000"
  :stroke-width="2"
></Path>

🥳 我们完成了整个作品中最困难的部分!

滑稽兔耳朵

LABUBU 的耳朵跟滑稽兔很像,画起来也比较容易,用 Ellipse 标签绘制两个纵向的扁椭圆:

<!-- 左耳 -->
<Ellipse
  :x="74"
  :y="56"
  :width="65"
  :height="150"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
></Ellipse>

<!-- 右耳 -->
<Ellipse
  :x="156"
  :y="56"
  :width="65"
  :height="150"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
></Ellipse>

再用两个 Ellipse 标签绘制不同颜色的小椭圆表示内耳和耳蜗:

<!-- 左内耳 -->
<Ellipse
  :x="82"
  :y="72"
  :width="50"
  :height="120"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 左耳蜗 -->
<Ellipse
  :x="95"
  :y="118"
  :width="26"
  :height="60"
  fill="#ffbbbf"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 右内耳 -->
<Ellipse
  :x="164"
  :y="72"
  :width="50"
  :height="120"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 右耳蜗 -->
<Ellipse
  :x="176"
  :y="118"
  :width="26"
  :height="60"
  fill="#ffbbbf"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

🐰 整个头部都完成啦!

像个布娃娃

身体部分需要花一些心思,我们这里使用两段二次贝塞尔曲线(手臂)和两段三次贝塞尔曲线(腿)组合完成:

const bodyPath = new PathCreator()
  .moveTo(84, 316)
  .quadraticCurveTo(40, 374, 90, 368)
  .bezierCurveTo(74, 460, 140, 440, 147, 430)
  .bezierCurveTo(154, 444, 224, 454, 204, 368)
  .quadraticCurveTo(254, 374, 210, 316)
  .closePath()
  .path;

再加上填充色和描边就形成了身体:

<!-- 身体 -->
<Path
  :path="bodyPath"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
></Path>

🐻 是不是很像一个布娃娃?可爱捏!

加上小手和小脚

终于到了作品的最后一部分,使用多段二次贝塞尔曲线组合绘制出 LABUBU 的小手和小脚:

const leftHandPath = new PathCreator()
  .moveTo(68, 352)
  .quadraticCurveTo(48, 348, 59, 360)
  .quadraticCurveTo(42, 372, 58, 370)
  .quadraticCurveTo(50, 386, 66, 372)
  .quadraticCurveTo(68, 392, 76, 366)
  .closePath()
  .path;

const rightHandPath = new PathCreator()
  .moveTo(226, 352)
  .quadraticCurveTo(246, 348, 235, 360)
  .quadraticCurveTo(252, 372, 236, 370)
  .quadraticCurveTo(244, 386, 228, 372)
  .quadraticCurveTo(226, 392, 218, 366)
  .closePath()
  .path;

const leftFootPath = new PathCreator()
  .moveTo(104, 430)
  .quadraticCurveTo(103, 456, 115, 444)
  .quadraticCurveTo(122, 456, 128, 444)
  .quadraticCurveTo(144, 456, 140, 430)
  .closePath()
  .path;

const rightFootPath = new PathCreator()
  .moveTo(191, 430)
  .quadraticCurveTo(192, 456, 180, 444)
  .quadraticCurveTo(173, 456, 167, 444)
  .quadraticCurveTo(151, 456, 155, 430)
  .closePath()
  .path;
<!-- 左手 -->
<Path
  :path="leftHandPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
></Path>

<!-- 右手 -->
<Path
  :path="rightHandPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
></Path>

<!-- 左脚 -->
<Path
  :path="leftFootPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
></Path>

<!-- 右脚 -->
<Path
  :path="rightFootPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
></Path>

🎉 LABUBU 诞生!

🖥️ 源码

项目的完整代码可以在 leafer-labubu 仓库中查看。

赠人玫瑰,手留余香,如果对你有帮助可以给我一个 ⭐️ 鼓励,这将是我继续前进的动力,谢谢大家 🙏!

🍬 感谢

项目灵感及图形创意来源于 LABUBU 简笔画教程 - Thomas

🍵 写在最后

我是 xiaohe0601,热爱代码,目前专注于 Web 前端领域。

欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!

❌
❌