首页/文章/javascript

用 JavaScript 画一颗【随机树】

2024-06-11
11644 分钟
...

用 JavaScript 画一棵树?

产品说要让前端用 JavaScript 画一棵树出来,但是这难道不能直接让 UI 给一张图片吗?

后来一问才知道,产品要的是一颗 随机树,也就是树的茂盛程度、长度、枝干粗细都是随机的,那这确实没办法叫 UI 给图,毕竟 UI 不可能给我 10000 张树的图片吧?

所以第一时间想到的就是 Canvas,用它来画这棵随机树(文末有完整代码)

Canvas 画一颗随机树

接下来使用 Canvas 去画这棵随机树

基础页面

我们需要在页面上写一个 canvas 标签,并设置好宽高,同时需要获取它的 Dom 节点、绘制上下文,以便后续的绘制

坐标调整

默认的 Canvas 坐标系是这样的

但是我们现在需要从中间去向上去画一棵树,所以坐标得调整成这样:

  • X 轴从最上面移动到最下面

  • Y 轴的方向由往下调整成往上,并且从最左边移动到画布中间

这些操作可以使用 Canvas 的方法

  • ctx.translate:  坐标系移动

  • ctx.scale:  坐标系缩放

绘制一棵树的要素

绘制一棵树的要素是什么呢?其实就是树枝果实,但是其实树枝才是第一要素,那么树枝又有哪些要素呢?无非就这几个点

  • 起始点

  • 树枝长度、树枝粗细

  • 生长角度

  • 终点

开始绘制

所以我们可以写一个 drawBranch 来进行绘制,并且初始调用肯定是绘制树干,树干的参数如下:

  • 起始点:(0, 0)

  • 树枝长度、树枝粗细:这些可以自己自定义

  • 生长角度:90度

  • 终点:需要算

这个终点应该怎么算呢?其实很简单,根据树枝长度、生长角度就可以算出来了,这是初高中的知识

于是我们可以使用 Canvas 的绘制方法,去绘制线段,其实树枝就是一个一个的线段

到现在我绘制出了一个树干 出来

但是我们是想让这棵树开枝散叶,所以需要继续递归继续去绘制更多的树枝出来~

递归绘制

其实往哪开枝散叶呢?无非就是往左或者往右

所以需要递归画左边和右边的树枝,并且子树枝肯定要比父树枝更短、比父树枝更细,比如我们可以定义一个比例

  • 子树枝是父树枝长度的 0.8

  • 子树枝是父树枝粗细的 0.75

而子树枝的生长角度,其实可以随机,我们可以在 0° - 30° 之间随机选一个角度,于是增加了递归调用的代码

但是这个时候会发现,报错了,爆栈了,因为我们只递归开始,但却没有在某个时刻递归停止

我们可以自己定义一个停止规则(规则可以自己定义,这会决定你这棵树的茂盛程度):

  • 粗细小于 2 时马上停止

  • (粗细小于 10 时 + 随机数)决定是否停止

现在可以看到我们已经大致绘制出一棵树了

不过还少了树的果实

绘制果实

绘制果实很简单,只需要在绘制树枝结束的时候,去把果实绘制出来就行,其实果实就是一个个的白色实心圆

至此这棵树完整绘制完毕

绘制部分的代码如下

完整代码

完整代码
<template>
  <div style="background-color: cadetblue">
    <canvas ref="canvasRef" width="1000" height="750"></canvas>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';

// 获取 canvas 的 dom 节点
const canvasRef = ref<HTMLCanvasElement | null>(null);

onMounted(() => {
  const canvasEle = canvasRef.value;
  if (!canvasEle) return;

  // 获取 canvas 上下文
  const ctx = canvasEle.getContext('2d')!;
  // 坐标系移动
  ctx.translate(canvasEle.width / 2, canvasEle.height);
  // y轴反向
  ctx.scale(1, -1);

  //   coordinate 起始点
  //   len 树枝长度
  //   thick 树枝粗细
  //   angle 生长角度
  const drawBranch = (coordinate: [number, number], len: number, thick: number, angle: number) => {
    // 绘制结束条件
    if (thick < 10 && Math.random() < 0.1) return;
    if (thick < 2) {
      // 绘制果实
      ctx.beginPath();
      ctx.arc(...coordinate, 5, 0, 2 * Math.PI);
      ctx.fillStyle = '#fff';
      ctx.fill();
      return;
    }

    ctx.beginPath(); // 开启线段绘制
    ctx.moveTo(...coordinate); // 初始起始点

    // 计算结束点
    const endCoordinate: [number, number] = [
      coordinate[0] + len * Math.cos((angle * Math.PI) / 180),
      coordinate[1] + len * Math.sin((angle * Math.PI) / 180),
    ];

    ctx.lineTo(...endCoordinate); // 线段终点
    ctx.strokeStyle = '#333'; // 线段颜色
    ctx.lineWidth = thick; // 线段粗细
    ctx.lineCap = 'round';
    ctx.stroke(); // 开始画

    // 左分支
    drawBranch(endCoordinate, len * 0.8, thick * 0.75, angle + Math.random() * 30);
    // 右分支
    drawBranch(endCoordinate, len * 0.8, thick * 0.75, angle - Math.random() * 30);
  };

  // 先画树干
  drawBranch([0, 0], 100, 20, 90);

  //   // 坐标、长度、粗细、角度
  //   const drawBranch = (coordinate: [number, number], len: number, thick: number, angle: number) => {
  //     if (thick < 10 && Math.random() < 0.1) return;
  //     if (thick < 2) {
  //       ctx.beginPath();
  //       ctx.arc(...coordinate, 5, 0, 2 * Math.PI);
  //       ctx.fillStyle = '#fff';
  //       ctx.fill();
  //       return;
  //     }

  //     ctx.beginPath(); // 开启线段绘制
  //     ctx.moveTo(...coordinate); // 初始起始点

  //     const endCoordinate: [number, number] = [
  //       coordinate[0] + len * Math.cos((angle * Math.PI) / 180),
  //       coordinate[1] + len * Math.sin((angle * Math.PI) / 180),
  //     ];

  //     ctx.lineTo(...endCoordinate); // 线段终点
  //     ctx.strokeStyle = '#333'; // 线段颜色
  //     ctx.lineWidth = thick; // 线段粗细
  //     ctx.lineCap = 'round';
  //     ctx.stroke(); // 开始画

  //     // 左分支
  //     drawBranch(endCoordinate, len * 0.8, thick * 0.75, angle + Math.random() * 30);
  //     // 右分支
  //     drawBranch(endCoordinate, len * 0.8, thick * 0.75, angle - Math.random() * 30);
  //   };
});
</script>

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2026-02-24
navigator.sendBeacon全指南
在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户 关闭页面 、 刷新 或 跳转路由 时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。 但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— 。 一、 痛点与传统方案的挣扎 场景还原 当用户点击关闭按钮时,浏览器会触发生命周期事件( 或 )。如果我们直接使用普通的异步 AJAX ( 或 ) 发送请求,浏览...
学习
javascript2025-11-02
理解浏览器事件系统,从用户点击到事件对象的完整旅程
深入理解浏览器事件系统:从用户点击到事件对象的完整旅程 “当我点击页面按钮时,背后发生了什么?为什么回调函数能收到一个包含丰富信息的event对象?今天,让我们一起揭开浏览器事件系统的神秘面纱。” 一个令人困惑的现象 作为前端开发者,我们每天都在写这样的代码: 这段代码如此熟悉,以至于我们很少停下来思考:​ ​这个 对象到底从哪里来?它为什么能知道点击的精确坐标?为什么能识别是哪个元素被点击了?​ ​ 更神奇的是,当我们手动创建事件时:...
学习
javascript2025-10-01
实现大文件上传全流程详解
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中。 一、先看效果:我们要实现什么? 先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题: 大文件分片上传 :将文件切成固定大小的小片段分批上传,避免单次请求超时 秒传 :服务器已存在完整文件时,直接返...
学习
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-15
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。 老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。 解决方案:三条技术路线,你全踩了一遍 1\. 最偷懒:原生 一行代码就能跑,浏览器帮你搞定。 🔍 关键决策点 2020 年后现代浏览器全覆盖,IE 全军覆没。 必须写死 ,否则 CLS 会抖成 PPT。 适用场景...
学习
javascript2025-09-10
🚀 Web Worker让你的应用丝滑
🌟 引言 在日常的前端开发中,你是否遇到过这样的困扰: 大数据处理时页面卡死 :处理几万条数据时,页面直接卡成PPT,用户点击毫无反应 复杂计算阻塞UI :图片处理、数据分析等计算密集型任务让整个应用假死 文件上传/下载卡顿 :大文件操作时,其他功能完全无法使用 实时数据处理性能差 :WebSocket接收大量数据时,页面渲染严重滞后 今天分享6个Web Worker的核心技巧,让你的应用告别卡顿,用户体验丝滑如德芙! 💡 核心技巧...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录