资讯动态

Canvas扩展属性和方法实战指南:原型、元素属性与工厂函数

发布时间:2026/10/21 3:58:51 来源:尧图企业网站定制
先说一个让我印象很深的场景。我在做某个数据可视化项目时要在一张Canvas画布上反复画网格背景、圆角卡片、连线箭头还有仪表盘的半环刻度。一开始我特别老实用到什么就写什么beginPath、moveTo、lineTo、stroke一套组合拳打下来整个文件全是重复代码。后来要改线条颜色我不得不全局搜索一个地方一个地方改眼睛都看花了。从那天开始我才认真研究canvas的自定义扩展属性和方法并且在后来的几个项目里反复用效果非常稳定。这里提到的“扩展属性和方法”简单说就是给canvas元素或者canvas的绘图上下文对象增加你想要的自定义能力。你可能会觉得这是不是某种黑科技其实不是。canvas元素和绘图上下文本质上就是普通的JavaScript对象既然是对象就可以挂属性、挂函数。默认的画笔只会画基础图形但你完全可以教它画网格、画箭头、画仪表盘甚至让它记住上一次的绘制状态。这篇文章我会从原理讲到落地给你三条扩展路线和一个完整实战例子。你只要有基础的JavaScript经验知道getContext(2d)是什么意思就可以跟着在浏览器里跑通。1. 为什么默认的Canvas画布不够用从一个写到崩溃的需求说起1.1 反复出现的绘制逻辑复制粘贴是最快的“慢性自杀”我当时那个页面的需求是这样的画布上半部分是坐标网格用来衬托数据点位网格上面要排布若干张圆角卡片卡片之间用带箭头的连线表示流转关系页面右下角还有一个仪表盘式进度环用来展示整体完成率。听起来不难但真正写起来问题就来了。网格绘制要用循环画几十条横线和竖线圆角卡片需要调用arcTo拼接四条边箭头线要根据角度计算箭头的两个侧翼位置仪表盘又要算圆弧、刻度和标签坐标。每一块逻辑都得写几十行而且它们之间还有大量重复的上下文设置比如save、restore、lineWidth、strokeStyle。项目里的Canvas越来越多之后整份代码就变得又臭又长改一个全局样式要动好几个地方。后来我想能不能让画布本身“学会”这些画法我只需要调用一个方法比如ctx.grid(...)、ctx.roundRect(...)、ctx.arrow(...)画布就自动把该画的东西画好。这就是自定义扩展方法需要解决的第一个问题把反复出现的绘制逻辑抽成可复用的能力。1.2 扩展属性和方法到底改了什么自定义扩展属性解决的是另一个问题让画布拥有记忆和状态。举个例子一张画布需要保存自己的绘图历史方便实现撤销重做那这个历史数组往哪里放你当然可以在外部定义一个全局变量但如果同时存在多张画布每张的历史互相独立全局变量就不太够用了。比较自然的做法是把历史栈直接挂在每一张canvas元素自己身上比如canvas.history [...]。扩展方法与扩展属性通常是配合使用的。方法负责“做什么”属性负责“记住做过的状态”。比如在原型扩展方法里通过this.canvas就能访问到当前画布元素然后读取canvas上的自定义属性。这样一来画布就像一个自带工具箱和记事本的对象绘制逻辑与状态管理都收敛到一起代码会清爽非常多。1.3 这件事的本质并不神秘Canvas对象就是普通JS对象新手很容易被“原生API”这几个字吓住觉得浏览器提供的东西不能动。其实canvas.getContext(2d)返回的context实例只是CanvasRenderingContext2D这个类的一个普通对象而已。数组对象能通过Array.prototype使用map、filter原因就是原型链共享context也一样它天然可以使用CanvasRenderingContext2D.prototype上的所有方法。canvas元素本身则是HTMLCanvasElement对象它同样有原型链同时也允许我们自己挂自定义属性。也就是说你给canvas加一个history数组和给一个普通div挂一个onClick标识性质是差不多的。理解了这一点后面的扩展思路就都好理解了。2. 方法一把自定义方法挂到CanvasRenderingContext2D的原型上2.1 理解原型共享你就明白为什么所有画布都能用JavaScript里对象有一个隐藏的原型链机制。数组之所以都能调用push、pop不是每个数组自己拷贝了一份push函数而是它们共同继承了Array.prototype上的方法。CanvasRenderingContext2D也一样所有通过canvas.getContext(2d)拿到的对象都是同一个类的实例共享同一个原型对象。所以你想给所有画布上下文增加一个自定义方法最简单直接的方式就是扩展CanvasRenderingContext2D.prototype。只要改一次之后创建出来的任何一个context都能直接调用。这个特性用起来特别爽但也要注意污染问题我在后面会专门讲。先记住结论原型扩展是给“所有画布”增加统一能力的最快路径。2.2 动手封装网格、箭头、圆角矩形三个高频绘制方法先看网格。我建议把间距、颜色、线宽都作为参数并且让方法默认返回this方便后面链式调用。CanvasRenderingContext2D.prototype.grid function (options {}) { const spacing options.spacing || 40; const color options.color || #e5e7eb; const lineWidth options.lineWidth || 1; const w this.canvas.width; const h this.canvas.height; this.save(); this.strokeStyle color; this.lineWidth lineWidth; this.beginPath(); for (let x 0.5; x w; x spacing) { this.moveTo(x, 0); this.lineTo(x, h); } for (let y 0.5; y h; y spacing) { this.moveTo(0, y); this.lineTo(w, y); } this.stroke(); this.restore(); return this; };这里有两个细节很多人第一次写会踩一个是循环变量要避开头尾的0.5偏移否则细线会显得模糊另一个是方法内部一定要save/restore包裹不然你在画网格时改的strokeStyle会泄漏到后续其他绘制逻辑里。接着是箭头。箭头看起来不难但真正动手就会卡在三角形箭头的两个侧翼坐标上。最简单的算法是根据线段的夹角在终点处向左右各偏转30度再向前延伸一段距离。CanvasRenderingContext2D.prototype.arrow function (x1, y1, x2, y2, headLength 10, headWidth 6) { const angle Math.atan2(y2 - y1, x2 - x1); this.save(); this.beginPath(); this.moveTo(x1, y1); this.lineTo(x2, y2); this.lineTo( x2 - headLength * Math.cos(angle - Math.atan2(headWidth, headLength)), y2 - headLength * Math.sin(angle - Math.atan2(headWidth, headLength)) ); this.moveTo(x2, y2); this.lineTo( x2 - headLength * Math.cos(angle Math.atan2(headWidth, headLength)), y2 - headLength * Math.sin(angle Math.atan2(headWidth, headLength)) ); this.stroke(); this.restore(); return this; };headWidth用得不多可以不设太复杂固定偏转30度也够用。我的经验是给所有扩展方法都带上默认参数这样调用方不传参数也不会报错。最后是圆角矩形。虽然现代浏览器已经有原生roundRect但考虑到旧浏览器兼容性还是值得自己封一个。CanvasRenderingContext2D.prototype.roundedRect function (x, y, w, h, r 8) { this.beginPath(); this.moveTo(x r, y); this.arcTo(x w, y, x w, y h, r); this.arcTo(x w, y h, x, y h, r); this.arcTo(x, y h, x, y, r); this.arcTo(x, y, x w, y, r); this.closePath(); return this; };注意roundedRect只负责创建路径不负责fill和stroke这是为了方便你做渐变、描边等组合操作。如果你希望一步绘制完成可以再加参数判断不过我更推荐保持方法职责单一。2.3 原型上不仅能加方法还能放默认属性自定义方法解决了绘制逻辑复用自定义属性则可以解决默认状态存放。比如我想给某几个canvas页面设置一套统一的默认字体和主题色可以直接挂到原型上让所有context共享CanvasRenderingContext2D.prototype.defaultTheme { textColor: #374151, accentColor: #2563eb, gridColor: #e5e7eb };调用时就是ctx.defaultTheme.accentColor。但这里要特别提醒这个属性是所有context共用的如果某个页面改了这个对象里面的值其他页面也会跟着变。如果希望每个画布有独立默认值更好的做法是把状态挂在canvas元素上而不是挂在context原型上。至具体怎么挂下一部分会详细展开。2.4 别高兴太早原型扩展的代价直接改原型确实方便但代价也很明显。首先是全局污染。你的页面里可能还加载了图表库、游戏引擎或者同事写的脚本别人也扩展了一个同名grid后加载的人就会覆盖先加载的人这种问题极难排查。其次是IDE和TypeScript不认账。你扩展的方法原生类型定义里没有代码提示不会出现别人接手你的项目时根本不知道还有ctx.grid这个方法。最后是版本隔离差如果哪一天浏览器官方真的推出了同名方法你的覆盖可能让原生行为发生变化。所以我的建议是如果是单页小项目、demo演示、个人工具库直接用原型扩展没问题如果是多人合作的大型项目建议给方法加项目前缀比如ctx.myGrid、ctx.myArrow或者直接跳到后面讲的工厂函数方案。3. 方法二把状态绑定在canvas元素上让画布学会“记忆”3.1 用dataset还是直接挂JS属性看你存的是字符串还是对象canvas元素本身是DOM对象所以给它挂自定义属性是合法的。但这里有两类做法要区分清楚。第一类是dataset也就是HTML的data-*属性。它适合存储简单的字符串配置比如canvas.dataset.gridSpacing 40然后通过canvas.dataset.gridSpacing读取。优点是符合DOM规范在HTML结构里就能看到缺点是所有值都会变成字符串如果你存一个数组或者对象读取时还得做JSON.parse。第二类是直接把JavaScript对象挂到元素上比如canvas.history []、canvas.state { scale: 1 }。这种方式不会反映到HTML里但它可以存任意类型的数据访问起来也最自然。我的习惯是简单的可配置参数用dataset复杂的运行状态用直接挂属性的方式。两者并不冲突甚至可以混用。3.2 一个能撤销重做的画板用自定义属性实现历史栈这个例子特别能说明挂状态的价值。假设你正在做一个画板用户每次画完一笔就要自动保存当前画布快照然后点击撤销按钮时回到上一步。如果不往canvas上挂状态你得在外部维护一个Map才能区分不同canvas的历史。直接挂上去就简单了function initCanvas(canvas) { canvas.history []; canvas.historyIndex -1; canvas.isDrawing false; } function captureHistory(canvas) { const snapshot canvas.toDataURL(); canvas.history canvas.history.slice(0, canvas.historyIndex 1); canvas.history.push(snapshot); if (canvas.history.length 20) { canvas.history.shift(); } canvas.historyIndex canvas.history.length - 1; } function undo(canvas) { if (canvas.historyIndex 0) return; canvas.historyIndex--; const img new Image(); img.src canvas.history[canvas.historyIndex]; img.onload () { const ctx canvas.getContext(2d); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0); }; }这里我做了一个长度限制超过20步就丢掉最旧的快照防止内存爆炸。历史记录用的是canvas.toDataURL()生成的是base64图片存储成本比较大。如果你画布尺寸大、笔触频繁建议改用canvas.getImageData()加压缩或者只记录操作指令而不是整张图片。这是很多教程不会提醒你的点。3.3 自定义属性怎么和自定义方法合作通过this.canvas自定义方法里可以通过this.canvas访问当前画布元素这是context和canvas元素之间最方便的桥梁。比如我想让grid方法自动读取canvas上自定义的属性可以这样写CanvasRenderingContext2D.prototype.grid function () { const spacing Number(this.canvas.dataset.gridSpacing) || 40; // 后续绘制代码不变 };这样使用的时候就可以通过设置canvas.dataset.gridSpacing来控制网格密度绘制方法内部不再需要硬编码参数。它体现了一个很重要的思想方法与属性配合方法负责执行属性负责配置和记忆。一张画布需要什么风格看一眼canvas上的自定义属性就知道。4. 方法三用工厂函数给画布做一套“私人定制上下文”4.1 工程里不想污染全局时就返回一个新对象原型扩展虽然方便但全局污染问题在团队项目里确实让人头疼。尤其是当你不确定项目里有没有其他人也扩展过同名方法时bug会变得非常不可预测。另一个问题是实例级别的隔离原型方法大家都共用同一份实现但你没办法让不同的canvas拥有不同的默认配置副本除非再靠元素属性和dataset配合。第三种思路就是干脆不修改任何原型而是写一个工厂函数接收canvas元素返回一个增强后的对象。这个对象内部保存每一项配置并提供绘制方法。所有方法都是函数闭包里的局部函数跟全局原型没有任何关系污染风险直接归零。这是我在工程化项目里最推荐的方式。4.2 实现一个带链式调用和默认配置的createExtCanvas先看代码再解释关键点。function createExtCanvas(canvas) { const ctx canvas.getContext(2d); const state { spacing: 40, gridColor: #e5e7eb, lineWidth: 1, theme: #2563eb, history: [], historyIndex: -1 }; function setup(options) { Object.assign(state, options || {}); return api; } function drawGrid() { const { spacing, gridColor, lineWidth } state; ctx.save(); ctx.strokeStyle gridColor; ctx.lineWidth lineWidth; ctx.beginPath(); for (let x 0.5; x canvas.width; x spacing) { ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); } for (let y 0.5; y canvas.height; y spacing) { ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); } ctx.stroke(); ctx.restore(); return api; } function drawArrow(x1, y1, x2, y2) { const angle Math.atan2(y2 - y1, x2 - x1); const headLength 12; ctx.save(); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo( x2 - headLength * Math.cos(angle - Math.PI / 6), y2 - headLength * Math.sin(angle - Math.PI / 6) ); ctx.lineTo( x2 - headLength * Math.cos(angle Math.PI / 6), y2 - headLength * Math.sin(angle Math.PI / 6) ); ctx.closePath(); ctx.fill(); ctx.restore(); return api; } function snapshot() { state.history state.history.slice(0, state.historyIndex 1); state.history.push(canvas.toDataURL()); if (state.history.length 20) state.history.shift(); state.historyIndex state.history.length - 1; return api; } const api { setup, drawGrid, drawArrow, snapshot, state }; return api; }这个工厂函数的设计重点在返回的api对象上。它的每个方法最后都return api意味着你可以连续调用extCanvas.setup({ spacing: 30 }).drawGrid().drawArrow(...)。内部所有状态统一放在state对象里不再散落在全局或者元素上。这样做的好处是每个canvas实例的配置互相独立随便改一个不会影响另一个。由于方法都是局部函数它们访问的是闭包里的ctx、canvas和state不需要通过this绑定也不怕别人覆盖。缺点是每个实例会创建一组闭包实例多到一定程度会有一些内存开销但对普通页面来说完全可接受。4.3 三种扩展思路怎么选我用的判断标准我简单总结一下三种方式的特点方便你对照选择扩展方式优点缺点最适合场景原型扩展写法简单、所有context自动可用全局污染、无类型提示个人项目、小demo、内部工具库元素属性挂载状态自然隔离、语义清晰只适合存状态不适合封装绘制逻辑多canvas状态管理、撤销重做、配置记忆工厂函数无污染、实例隔离、可链式调用代码量稍多、需要显式传递对象团队项目、组件封装、需要多实例的项目我的习惯是三者配合使用画布的状态放元素属性绘制方法要么用带前缀的原型方法要么用工厂函数封装。如果你刚开始学我建议先玩原型扩展理解机制之后再切到工厂模式两条路都摸一遍后面看别人封装的Canvas库就不会发怵。5. 综合实战用扩展属性和方法做一个会画刻度盘的组件5.1 拆需求网格是背景刻度盘是主角现在把前面的思路合起来做一个实际例子。需求不复杂页面有一张画布背景是网格中间画一个仪表盘式的半环进度半环周围有刻度线中间显示一个百分比数值。整个组件需要支持更新数值并重绘。拆解下来就是三个绘制模块背景网格、半环轨道和进度弧、刻度线和标签。网格可以复用前面的grid半环轨道需要一个圆形路径这个原生canvas就能做刻度线和中心数值需要自己计算坐标。5.2 编写drawGauge扩展方法先基于原型写一个drawGauge方法这样整个组件可以直接复用上下文CanvasRenderingContext2D.prototype.drawGauge function (options) { const { cx, cy, radius, value, min 0, max 100, startAngle -Math.PI * 0.75, endAngle Math.PI * 0.75, trackColor #edeff2, progressColor #2563eb, label } options; const ratio Math.max(0, Math.min(1, (value - min) / (max - min))); const currentAngle startAngle (endAngle - startAngle) * ratio; this.save(); // 轨道弧 this.beginPath(); this.arc(cx, cy, radius, startAngle, endAngle); this.strokeStyle trackColor; this.lineWidth 22; this.lineCap round; this.stroke(); // 进度弧 this.beginPath(); this.arc(cx, cy, radius, startAngle, currentAngle); this.strokeStyle progressColor; this.lineWidth 22; this.lineCap round; this.stroke(); // 刻度线 for (let i 0; i 40; i) { const t i / 40; const angle startAngle (endAngle - startAngle) * t; const outerRadius radius 16; const innerRadius i % 10 0 ? radius 10 : radius 4; const xOuter cx Math.cos(angle) * outerRadius; const yOuter cy Math.sin(angle) * outerRadius; const xInner cx Math.cos(angle) * innerRadius; const yInner cy Math.sin(angle) * innerRadius; this.beginPath(); this.moveTo(xInner, yInner); this.lineTo(xOuter, yOuter); this.strokeStyle i % 10 0 ? #9ca3af : #d1d5db; this.lineWidth i % 10 0 ? 2 : 1; this.stroke(); } // 中心文字 this.fillStyle #1f2937; this.font bold 32px sans-serif; this.textAlign center; this.textBaseline middle; this.fillText(Math.round(ratio * 100) %, cx, cy - 6); if (label) { this.font 14px sans-serif; this.fillStyle #6b7280; this.fillText(label, cx, cy 24); } this.restore(); return this; };这个方法接受一个配置对象把仪表的圆心、半径、数值范围、起止角度、轨道颜色、进度颜色、文字标签全部作为参数。核心计算公式就一个把数值映射到0到1之间再按比例映射到起止角度之间。刻度线部分用for循环生成41条线每10条画一个长刻度其余画短刻度视觉上更像真实仪表盘。有人可能会问为什么进度弧线的lineWidth要设成22这种具体数字因为半环效果需要轨道有厚度太细了不像仪表盘太粗了又看不到轨道背景。不同项目风格差异很大所以我把颜色和线宽作为参数放出来方便外面覆盖。5.3 把仪表盘参数挂到canvas元素上并驱动重绘光有扩展方法还不够我们还需要画布的“记忆”也就是它的状态。这里我把仪表盘的参数直接挂到canvas元素上const canvas document.getElementById(gaugeCanvas); canvas.gaugeConfig { cx: 200, cy: 180, radius: 120, value: 65, min: 0, max: 100, label: 完成率 }; function renderGauge() { const ctx canvas.getContext(2d); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.grid({ spacing: 30, color: #e5e7eb, lineWidth: 1 }); ctx.drawGauge(canvas.gaugeConfig); } canvas.addEventListener(click, () { const newValue 35 Math.random() * 60; canvas.gaugeConfig.value Math.round(newValue); renderGauge(); }); renderGauge();这里有一个很关键的点grid方法和drawGauge方法都是通过原型扩展挂到CanvasRenderingContext2D.prototype上的所以在同一个ctx对象上可以连续调用。绘制配置全部放在canvas.gaugeConfig里以后要新增字段只需要改这个对象不需要改动绘制函数。通过点击触发随机数值模拟你会看到半环进度弧随之变化。如果你的需求是实时刷新数据把绑定在click上的逻辑改成从接口拉数据调用renderGauge即可。5.4 两个让效果更稳的细节DPI和缩放第一个细节是高DPI屏。很多小白会发现css里设置了canvas宽高300px但画出来线条很模糊原因是没有把canvas的内在尺寸设置成实际像素的两倍或三倍。一般处理方式是这样的function setupCanvasDpr(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); }注意这个缩放操作会影响后面所有绘制坐标。因为ctx被scale了所以你的网格坐标和仪表盘圆心坐标仍然可以用CSS像素的数值canvas内部会换算成物理像素文字和线条都会变得更清晰。这个步骤如果不做在Retina屏幕上画出来的内容会明显发虚。第二个细节是在resize画布时要同步更新canvas的高宽并且重新设置缩放比例。很多人在窗口变化后只改了css样式没有重新赋值canvas.width和canvas.height结果画布被拉伸或者内容消失。正确做法是封装一个resize函数每次调用都先重新设置尺寸再调用render更新画面。6. 写扩展时最容易翻车的地方踩坑清单提前看6.1 save/restore不对称状态泄漏让我找了半天bug扩展方法里最常见的错误就是改了ctx的样式之后忘了restore。你画完网格后strokeStyle变成了灰色接着画数据折线时忘记重新设置结果折线也是灰色的。这种bug不太致命但特别难找因为报错是零。解决方案就是所有扩展方法内部保持对称开头save结尾restore。如果方法中间有提前return的分支要确保分支里也执行restore或者干脆把绘制逻辑用try/finally包裹。我见过有人写得很随意每个方法都save但只有最后一个方法restore导致状态像滚雪球一样越积越多。6.2 忘了return this链式调用链到一半就断了自定义扩展方法设计成链式调用最大的好处是代码简洁。但如果你某个方法没有return this写成ctx.grid().arrow(...)就会报错。这种错误不是语法错误运行时才知道。我的习惯是封装之前就把“返回this”作为默认约定写进注释里。如果团队协作我会在代码评审时重点检查每个新增扩展方法是否延续了这个约定否则调用链会因为一个方法断掉而全部失效。6.3 命名冲突别让自定义方法和渲染引擎撞车全局扩原型时最容易撞车的就是方法名。原生没有grid不代表第三方库不会帮你加一个grid你今天加了drawGauge别人明天也可能加一个同名但行为不同的drawGauge。一旦两个脚本都扩展了同一个原型后加载的脚本会把前面的覆盖掉而且不会报任何错误。我现在的做法是在单页小项目里用短名在多人项目里强制加项目缩写前缀。比如teamA的网格方法叫ctx.projectGridteamB的箭头方法叫ctx.projectArrow肉眼就能分辨归属。这个规范要在项目开始之前说清楚不然后期改名成本很高。6.4 DPI和CSS尺寸清晰度与元素大小的双重陷阱另一个高频坑是canvas.width、canvas.height与CSS样式宽高混在一起用。canvas元素有两个尺寸概念一个是画布内在像素尺寸也就是width和height属性另一个是CSS渲染尺寸也就是style里的宽高。当你扩展grid方法时循环范围用的是canvas.width也就是内在像素但视觉上画布被CSS缩放显示网格间距看起来就和设计稿不一致。如果你做了DPI缩放canvas.width已经放大了但你的绘制逻辑仍然用CSS像素坐标所以扩展方法里如果要读取画布尺寸建议统一用ctx.canvas.clientWidth或者通过getBoundingClientRect获取视觉尺寸。这样不管你怎么缩放绘制结果都能保持在预期位置。6.5 快照和缓存对象撤销功能的隐藏内存炸弹讲历史栈时我用了canvas.toDataURL()它每执行一次就会生成一张base64图片。如果画布是1920×1080一张快照就可能占几百KB到几MB内存。用户画个几十笔浏览器内存直接飙升。如果项目确实需要撤销重做我建议优先记录操作指令例如每次画笔的起点、终点、颜色、线宽而不是保存整张位图。重做的时候把指令重新执行一遍。这样内存占用从几MB降到几十字节只是代码逻辑会复杂一些。如果实在要存位图快照必须限制历史长度并且在实例销毁时主动清理数组和相关的Image对象不然页面切换后内存也回收不掉。最后再分享一个我自己的使用习惯。每次接到带Canvas的需求我不会急着写绘制代码而是先花十分钟想一想哪些绘制动作会反复出现哪些状态需要被画布“记住”。想清楚之后要么用工厂函数封装一套增强上下文要么把最稳定的几个方法提升到原型上变成一个自己长期使用的绘制工具包。这套思路练熟之后你再回头看那些绘制逻辑分散在各个地方的旧代码就会明白为什么说canvas开发最需要的是“先封装再绘制”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑