文章

Canvas 基础与绘制原理:画完就忘的立即模式

Canvas 面试的得分点不在你会画几个图形,而在你懂"立即模式"这个模型: 没有对象、没有事件、画完就忘,一切状态都得自己管。文章讲清坐标系与设备像素、 路径模型、save/restore 状态栈、1px 线为什么糊、命中检测、以及高 DPI 与性能清单。

Canvas 基础与绘制原理:画完就忘的立即模式

一句话概括

Canvas 是一块”像素画板”,不是一个”图形容器”。你调用 fillRect、arc、drawImage 的时候,浏览器只是把像素涂上去,涂完就把这件事忘了——画布上不存在”那个圆”这个对象。

这个模型叫立即模式(immediate mode),跟 SVG 的保留模式(retained mode)正好相反。抓住这一条,Canvas 的绝大多数面试问题都能推出来:

  • 想移动一个圆?不能”改圆的坐标”,只能清屏 + 按新状态重画一遍。
  • 想给圆加点击?画布不认”圆”,只能自己算鼠标坐标落在哪个图形里(命中检测)。
  • 想用 CSS 给圆加 hover?做不到,因为没有对应元素。
  • 想正确清空?clearRect 只把指定区域的像素变透明,路径和状态原封不动——所以”清像素”和”回退状态”是两件事,都得自己管。

再把一个坐标系扩展(ctx.scale(dpr, dpr))和一个状态栈(save/restore)加上,就是 Canvas 的全部基本功。下面逐条讲。

核心知识点

1. 立即模式 vs 保留模式:先把模型说清

面试官问”Canvas 和 SVG 怎么选”时,背场景不如把模型讲明白:

 CanvasSVG
数据模型位图(一块像素缓冲区)矢量(DOM 元素树)
渲染模型立即模式,画完即忘保留模式,浏览器持有每个图形
DOM 结构只有 1 个 <canvas> 节点每个图形都是真实节点
单独改一个图形清屏重绘(或用脏矩形)改属性/CSS,浏览器局部重绘
事件无,需自己命中检测每个图形可直接 addEventListener
CSS 样式只能通过 JS 绘制参数完整 CSS 支持,含 :hover、过渡
缩放位图放大会糊无限清晰
无障碍内容对读屏不可见,需额外 ARIA天然可访问、文字可选中
适合高频、海量、像素级的场景(游戏、粒子、图像处理、实时数据流)几十到几千个元素 + 强交互/可访问性(图标、图表、地图、示意图)

一句话判断标准:元素几千个往上、每帧都在变 → Canvas;元素几百个、需要单独交互或无障碍 → SVG。 这个分界线的根因就是”每个图形要不要是一个 DOM 节点”。

顺便记住两个逃生路线的存在:既要 GPU 加速又要复杂 3D 时,用的还是 <canvas> 元素,但走 getContext('webgl2') 或 getContext('webgpu'),绕开 2D 绘制 API;处理像素要用原生性能时,可以配合 OffscreenCanvas 放进 Worker。

2. 坐标系与设备像素:canvas.width 不是 CSS 像素

<canvas> 有两个尺寸,很容易混:

1
<canvas width="600" height="300" style="width: 300px; height: 150px"></canvas>
  • width/height 属性 = 画布内部的像素数量(backing buffer 大小),不写的话默认是 300 x 150。
  • CSS width/height = 这块画布在页面上”显示”多大。

绘制坐标系的原点在左上角,x 向右、y 向下,单位是画布内部像素(一个单位 = 一个缓冲区像素),跟 CSS 像素没关系。

高 DPI 下为什么糊:拿一块 300x150 的画布显示在 300x150 的 CSS 空间里,在 DPR=2 的屏幕上一个 CSS 像素要占 2x2 个物理像素,而你的缓冲区只有 1 个像素的信息,浏览器只能拉伸插值 → 糊。解法是”把缓冲区按 DPR 放大,再用 scale 把坐标系拉回来”:

1
2
3
4
5
6
7
8
9
10
11
12
13
const dpr = window.devicePixelRatio
const rect = canvas.getBoundingClientRect()

// ❌ 只设 style:缓冲区还是 300x150,在高 DPI 屏上糊
canvas.style.width = '300px'
canvas.style.height = '150px'

// ✅ 三件事一起做(MDN 推荐做法)
canvas.width = rect.width * dpr          // 1. 缓冲区放大
canvas.height = rect.height * dpr
ctx.scale(dpr, dpr)                      // 2. 坐标系缩回来,后续代码仍按 CSS 像素写
canvas.style.width = `${rect.width}px`   // 3. 显示尺寸保持不变
canvas.style.height = `${rect.height}px`

做完这三步,画布内部是 600x300 个真实像素(清晰),而你写代码时仍然按 300x150 的逻辑坐标画——第 2 步是关键,忘了它所有图形会画到两倍大。

⚠️ 这里有个必须记住的坑:重新设置 canvas.width 会把整个渲染上下文重置成默认状态。 MDN 的原文很明确:

Setting the width property resets the entire rendering context to its default state. This includes clearing the canvas (backing buffer), resetting the current path, and resetting all properties like fillStyle and globalCompositeOperation. This reset occurs for all context types, and occurs even when setting width to its current value.

三个关键词:清空缓冲区、重置当前路径、重置所有属性(包括 transform 和 fillStyle),而且设成同样的值也照样重置。所以常见的 resize 处理必须重新走一遍完整设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 典型 bug:resize 里改了尺寸,scale 丢了,图形全画到左上角两倍大
window.addEventListener('resize', () => {
  canvas.width = wrap.clientWidth * dpr
  canvas.height = wrap.clientHeight * dpr
  draw()                       // ctx.scale(dpr, dpr) 早就被重置没了
})

// ✅ resize 后必须把「尺寸 + 所有状态」重新建一遍
window.addEventListener('resize', () => {
  canvas.width = wrap.clientWidth * dpr
  canvas.height = wrap.clientHeight * dpr
  ctx.scale(dpr, dpr)          // 重建坐标系
  ctx.fillStyle = '#333'       // 其它状态也一并重设
  draw()
})

还有个更省的思路:画布尺寸完全没变时直接 return,避免无意义的重置(顺手也解决了 ResizeObserver 重复触发的问题)。

3. 路径模型:当前路径是隐式状态,不是对象

Canvas 画图形走的是”构造路径 → 用 fill() / stroke() 消费它”的流程:

1
2
3
4
5
6
ctx.beginPath()          // 开一条新路径,丢弃旧的
ctx.moveTo(20, 20)       // 起点
ctx.lineTo(120, 20)
ctx.lineTo(120, 80)
ctx.closePath()          // 回到起点(可选)
ctx.stroke()             // 描边;换成 ctx.fill() 就是填充

三个必须讲清的点:

① 路径是会累积的,beginPath() 才能把它清空。 这是 Canvas 里最经典的坑,而且它有两种表现,别混成一种:

先看第一种——stroke() / fill() 不会消费掉路径,所以漏写 beginPath() 会把之前画过的所有形状再画一遍。在动画里后果特别明显,实测这段代码:

1
2
3
4
5
6
7
8
9
// ❌ 每帧只画一条线,但漏了 beginPath
for (let f = 0; f < 5; f++) {
  ctx.clearRect(0, 0, 100, 100)      // 像素清干净了
  const y = 10.5 + f * 10
  ctx.moveTo(10, y); ctx.lineTo(90, y)
  ctx.stroke()                        // 路径里已经攒了 5 条子路径 → 5 条线一起被描
}
// 实测第 5 帧结束时,y = 10/20/30/40/50 五行全都有着墨
// 本该只有最后一条(y=50)

注意 clearRect 只清了像素、没清路径,所以旧的子路径还在路径对象里,”重画”就发生了。

第二种——要开始一段不相连的新图形却漏了 moveTo(),会从上一个点连一条线过来:

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 本意是两条不相交的线,第一条末尾直接接了 lineTo
ctx.beginPath()
ctx.moveTo(0, 0); ctx.lineTo(50, 50)
ctx.lineTo(0, 50); ctx.lineTo(50, 0)   // 没有 moveTo → 全部连成一条折线
ctx.stroke()
// 实测着墨像素 198,而正确写法只有 100,多出来的就是那两段连接线

// ✅ 断开子路径用 moveTo
ctx.beginPath()
ctx.moveTo(0, 0); ctx.lineTo(50, 50)   // 第一段
ctx.moveTo(0, 50); ctx.lineTo(50, 0)   // moveTo 开新子路径,断开
ctx.stroke()                            // 着墨像素 100

一句话记:换形状用 beginPath()(或至少 moveTo() 断子路径),换样式不用。

② fill() 会自动闭合路径,stroke() 不会。 fill() 的语义是”填充围成的区域”,所以每条开放子路径都会被隐式闭合;stroke() 只描已声明的线段。想让描边也闭合,得显式 closePath()。

③ closePath() 不只是”连回起点”,它还会把当前点挪回子路径起点。 所以 closePath() 之后紧接着调 lineTo(),画出来的是”从起点出发”的新线段,而不是从最后一个点出发——这个语义很容易把人绕晕。另外,lineTo() 在完全没有当前点的时候会表现得像 moveTo()(不会从原点连一条线过来)。

4. 状态栈:save() / restore() 管什么、不管什么

Canvas 的样式和变换都是上下文状态,save() / restore() 就是一个后进先出的状态栈。栈里装的是:

  • 变换矩阵(translate / rotate / scale / transform)
  • 裁剪区域(clip() 的结果)
  • 样式:fillStyle、strokeStyle、lineWidth、lineCap、lineJoin、miterLimit、lineDashOffset、globalAlpha、globalCompositeOperation
  • 阴影:shadowBlur / shadowColor / shadowOffsetX/Y
  • 文字:font、textAlign、textBaseline、direction
  • 图像:imageSmoothingEnabled、imageSmoothingQuality、filter

它不管当前路径。 这点是个高频误区,实测确认:save() → 换一条新路径 → restore(),命中检测在新路径上仍然是 true,说明路径不在状态栈里。所以”想回退到上一个图形”必须自己重新 beginPath + 重建路径,或者把路径存成 Path2D。

正确用法一眼记:凡是会”污染后续绘制”的操作,都要用 save/restore 夹起来——裁剪、局部变换、临时改样式都是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ save 了但忘了 restore:裁剪区一直生效,后面画什么都出不去
ctx.save()
ctx.beginPath()
ctx.rect(0, 0, 50, 50)
ctx.clip()
ctx.fillRect(0, 0, 200, 200)   // 只有左上 50x50 有内容
// 漏了 ctx.restore() → 之后的所有绘制都被这个 50x50 裁剪区限制住

// ✅ 配对使用
ctx.save()
ctx.beginPath()
ctx.rect(0, 0, 50, 50)
ctx.clip()
ctx.fillRect(0, 0, 200, 200)   // 仍然只有 50x50
ctx.restore()                  // 裁剪区恢复
ctx.fillRect(0, 0, 200, 200)   // 现在能铺满

另外栈要配对。save() 和 restore() 次数不匹配时,多出来的状态会一直留在栈上(不报错),渲染结果莫名其妙。所以在 save() 后面如果有可能提前 return 或者 throw,应该用 try/finally 保证 restore()。

5. 1px 线为什么糊:线宽是”骑”在路径上的

这是用户”看到的 bug”,也是面试的经典追问。lineWidth 的描边是以路径为中心向两侧各扩一半,所以 lineWidth = 1 时,如果路径落在整数坐标上(比如 y = 10),这条线刚好跨在 y=9.5 到 y=10.5 之间,两个像素各分到 50% 覆盖率 → 抗锯齿成灰色。

实测数值很直观:

画法y=10 这一行的像素 alpha
moveTo(10, 10) → lineTo(90, 10)128(半透明,看起来”糊”)
moveTo(10.5, 10.5) → lineTo(90.5, 10.5)255(实心,清晰)

结论:画 1px 细线时坐标要偏移 0.5(或者用整数坐标配偶数 lineWidth)。这就是”设计师说描边发虚”的根因。

还有一个联动要注意:lineWidth 用的是用户坐标单位,会被 ctx.scale() 放大。所以贴上 DPR 之后写 lineWidth = 1,实际渲染是 1 个 CSS 像素(= dpr 个设备像素),这正是我们想要的;但如果你想画”物理 1 像素”的细线,就得写 1 / dpr。

6. 命中检测:没有事件,全靠自己算

画布上的图形没有 DOM 节点,所以”点击了哪个图形”必须自己判断。三条路线,从简到难:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 路线一:几何公式手算(矩形最简单,圆形算距离)
const hitCircle = (p, c) => Math.hypot(p.x - c.x, p.y - c.y) <= c.r

// 路线二:用 isPointInPath 让浏览器帮你算(支持任意复杂路径和填充规则)
ctx.beginPath()
ctx.arc(100, 100, 40, 0, Math.PI * 2)
if (ctx.isPointInPath(x, y)) { /* 命中 */ }

// 路线三:Path2D —— 把路径变成可复用对象,这才是立即模式下的"图形对象"
const circle = new Path2D()
circle.arc(100, 100, 40, 0, Math.PI * 2)
// 每次鼠标移动都能直接拿它做检测,不必重建路径
if (ctx.isPointInPath(circle, x, y)) { /* 命中 */ }

Path2D 值得单独记:它是立即模式里唯一能”保留”下来的东西——可以反复 fill(path) / stroke(path),也可以传给 isPointInPath 做检测,还能用 addPath() 做路径合并。要做拖拽、选区、图形编辑器,第一件事就是把每个图形存成 Path2D。

坐标换算也是最常写错的一步:

1
2
3
4
5
6
7
8
9
function toCanvasCoords(canvas, evt) {
  const rect = canvas.getBoundingClientRect()
  // CSS 像素 → 画布内部像素:乘 canvas.width / rect.width
  // 高清屏上这个比值约等于 dpr,不乘的话点击会偏
  return {
    x: (evt.clientX - rect.left) * (canvas.width / rect.width),
    y: (evt.clientY - rect.top) * (canvas.height / rect.height),
  }
}

如果你做过 scale / translate,命中检测的坐标就更容易错位——MDN 明确说明 isPointInPath(x, y) 传入的点是”unaffected by the current transformation of the context”,而路径本身在用户坐标系里,两者不在一个空间。最稳的做法是检测前把变换恢复正常:

1
2
3
4
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)   // 单位矩阵
const hit = ctx.isPointInPath(px, py)
ctx.restore()

图形数量多起来(几千个以上)时,逐一遍历会卡,需要空间索引:网格划分最简单够用,四叉树 / R 树则适合分布不均的场景。思路都是”先用一个粗粒度的容器筛掉绝大多数候选,再精算”。

7. 动画与性能:rAF + 三个层次的手段

动画必须用 requestAnimationFrame,不用 setInterval。 前者跟浏览器刷新节奏对齐(一般 60Hz,高刷屏更高),页面切到后台会自动暂停,也不会像定时器那样因为 JS 执行时间长而堆帧。基本骨架就一句:

1
2
3
4
5
6
7
8
9
10
11
let rafId = 0
function loop(t) {
  update(t)                // 1. 更新状态(自己维护的数据,不是画布)
  // 2. 清屏。注意 clearRect 也受变换影响,这里有 ctx.scale(dpr, dpr),
  //    用 canvas.width/height 传进去会多清一点,但正好覆盖整块缓冲区
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  draw()                   // 3. 全量重绘
  rafId = requestAnimationFrame(loop)                 // 4. 预约下一帧
}
rafId = requestAnimationFrame(loop)
// 卸载时别忘了 cancelAnimationFrame(rafId),否则循环停不下来

性能优化按 MDN 的清单分三层就够了:

① 少画(收益最大)

  • 用分层画布:静止的背景/网格一个 canvas,频繁变的图形另一个,DOM 层叠(position: absolute + z-index)。静态层只在需要时重绘。
  • 静态大图交给 CSS background,别每帧 drawImage。
  • 只渲染变化区域(脏矩形),而不是整屏重绘。
  • 清屏方式按需要选:clearRect 得到透明背景;fillRect 铺一层不透明底色可以省掉后续绘制的 alpha 合成(配合 alpha: false 更快);直接重设 canvas.width 最彻底,但会顺带重置全部状态(见第 2 节)。

② 少算

  • 离屏 canvas 预渲染重复图形(图标、文字标签、粒子贴图),主循环只 drawImage 一次。
  • 不要在 drawImage 里缩放:需要几个尺寸就预先缓存几个尺寸。
  • 坐标取整(Math.floor),否则会触发子像素渲染,浏览器要多做抗锯齿计算。
  • 批量调用:一次 stroke() 画整条折线,而不是每段都 stroke() 一次。
  • 避免不必要的状态切换(fillStyle 换来换去),以及尽量不用 shadowBlur 和文字渲染——这两个都是出名的慢。

③ 交给合适的硬件

  • 用 CSS transform 缩放画布(走 GPU),而不是画大图再缩小。
  • 不需要透明背景时 getContext('2d', { alpha: false }),让浏览器省掉合成开销。
  • 需要频繁读像素(滤镜、笔迹采样、热力图)时,getContext('2d', { willReadFrequently: true })。它让浏览器改用 CPU 缓冲区,避免 GPU 回读。

最后一条值得展开,因为很多人只记住了”要加”,忘了反面:willReadFrequently 只在确实频繁调 getImageData 时才加,纯绘制场景加了反而更慢(放弃硬件加速)。浏览器在你不加却频繁 getImageData 时会给你警告:

1
2
Canvas2D: Multiple readback operations using getImageData are faster
with the willReadFrequently attribute set to true.

兼容性:Chrome 99+ / Firefox 28+ / Safari 18+(2024-09 才补上,之前 Safari 是静默忽略这个参数),2024 年 9 月才进 Baseline。现在主流版本都覆盖了,但要清楚它不是一个”跨浏览器都能提速”的保证——老设备上传了也不生效。

8. 导出与”画布污染”

toDataURL() / toBlob() 能把画布导出成图片,但有一条安全红线:只要画布上绘制过任何”跨域且没带 CORS 头”的图片,画布就被标记为 tainted(污染),之后任何导出或 getImageData() 都会抛安全错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 直接用跨域图片,drawImage 成功但之后导出会抛错
const img = new Image()
img.src = 'https://other-origin.com/a.png'
img.onload = () => {
  ctx.drawImage(img, 0, 0)
  canvas.toDataURL()          // SecurityError: Tainted canvases may not be exported
}

// ✅ 声明 crossorigin,并且服务端要返回 Access-Control-Allow-Origin
const img2 = new Image()
img2.crossOrigin = 'anonymous'
img2.src = 'https://other-origin.com/a.png'
img2.onload = () => {
  ctx.drawImage(img2, 0, 0)
  canvas.toDataURL()          // 正常
}

注意顺序:crossOrigin 必须在设置 src 之前赋值,否则请求已经发出去了。另外 drawImage 本身不报错,要等到导出时才炸,所以这个坑往往出现在”开发环境(同源)没事、线上(CDN 跨域)挂了”。

9. 面试怎么答(口述版,可以直接背)

Canvas 的核心是”立即模式”:它是一块像素缓冲区,不是一个图形容器。

你调 fillRect、arc 只是往位图上涂像素,涂完浏览器就把这件事忘了——画布上不存在图形对象,所以没有 DOM 事件、不能用 CSS、也不能只改一个图形的坐标。要动一个图形,就得清屏、按自己维护的数据重画一遍;要响应点击,就得自己做命中检测(isPointInPath 或几何计算)。这也是选型的依据:图形多、每帧都在变就用 Canvas,图形少、需要单独交互或无障碍就用 SVG。

两个最容易踩的工程细节:一是坐标系——canvas.width 是缓冲区像素、CSS width 才是显示尺寸,高清屏要”缓冲区 × DPR + ctx.scale(dpr, dpr)“才不糊;二是状态管理——save/restore 管变换、裁剪和样式,但不管当前路径,而重新赋值 canvas.width 会把上下文整个重置回默认状态(连 scale 都没了),所以 resize 之后必须重建坐标系和样式。

其实你每天都在用

  • 网页上的验证码、滑块拼图、涂鸦板,都是 canvas 画的。
  • 视频弹幕飘过、图表库里 ECharts 的底层、地图上的热力图和轨迹线,都落在 canvas 上。
  • 头像裁剪、图片压缩上传(canvas.toBlob 控制 quality),是最常见的图片处理入口。
  • 前端截图工具(如 html2canvas 类方案)本质是”把 DOM 重画到 canvas 上再导出”。
  • 手写签名、白板协作的笔迹采集,靠 mousemove 收点 + lineTo 连成路径。
  • 大屏可视化里那条”实时刷新的折线图”,就是每帧 clearRect + 重绘整条线。
  • 页面上的粒子特效、雪花、烟花,是用 requestAnimationFrame 驱动的几千个 arc。
  • 需要跑在 Worker 里、避免阻塞主线程的像素计算,用的是 OffscreenCanvas + transferControlToOffscreen()。

常见误解(FAQ)

❌ 误区1:”canvas 画出来的图形可以用 CSS 或 DOM 事件操作。” 不行。canvas 里只有像素,没有元素。想 hover、想点击、想用 CSS 动画,全部得自己做:维护一份图形数据 → 每帧重绘 → 用 isPointInPath 或几何计算做命中检测 → 自己切换高亮状态。这也是”图表用 canvas 时 tooltip 特别难做”的根本原因。

❌ 误区2:”设置 canvas.width 只是改尺寸,不影响已有内容和状态。” 这是最贵的一个误区。设置 width(或 height)会清空缓冲区、重置当前路径、重置所有上下文属性(含 transform 和 fillStyle),而且设成当前值也会重置。所以 resize 处理里改完尺寸必须重新 ctx.scale(dpr, dpr) 并重设样式,否则图形尺寸和位置全错。

❌ 误区3:”CSS 尺寸设成 2 倍,画面就清晰了。” 不会。CSS 尺寸只影响”显示多大”,缓冲区大小由 width/height 属性决定。要在高清屏上清晰,必须让缓冲区像素数 = CSS 像素数 × devicePixelRatio,再用 ctx.scale(dpr, dpr) 把坐标系拉回逻辑坐标。只调 CSS 只会把同一张位图放大得更糊。

❌ 误区4:”clearRect 会重置画布状态 / save 能保存路径。” 两个都不对。clearRect 只把指定区域的像素变透明,不动路径也不动任何状态;save/restore 保存的是变换、裁剪和样式,不包括当前路径(实测 restore 之后路径还是新的那条)。想让路径可回退,用 Path2D。

❌ 误区5:”1px 的线画出来就该是 1 像素实心。” 默认不是。lineWidth 的描边以路径为中心向两侧各扩半宽,整数坐标上会让相邻两行各拿到 50% 覆盖率,抗锯齿成灰(实测 alpha = 128)。要清晰就把坐标偏移 0.5(实测 alpha = 255)。这不是浏览器 bug,是抗锯齿的正确行为。

❌ 误区6:”动画用 setInterval(16) 效果和 requestAnimationFrame 一样。” 不一样。setInterval 跟屏幕刷新节奏无关,可能丢帧或堆帧;页面切到后台也不会停,白白耗电。requestAnimationFrame 与刷新率对齐(高刷屏自动跑更顺),后台自动暂停,且回调自带时间戳方便做基于时间的运动计算。

❌ 误区7:”用 getImageData 做像素处理总是更慢,应该避免。” 要看场景。做滤镜、热力图、笔迹这类确实频繁读写像素的工作时,主动加 { willReadFrequently: true } 反而会快很多(浏览器会改用 CPU 缓冲区,避免 GPU 回读)。反过来说,纯绘制场景加上它会让硬件加速失效、整体更慢——所以是”看用途决定”,不是”无脑加”或”无脑不加”。

❌ 误区8:”toDataURL 跟画上去的图片来源无关,随时能导出。” 只要画布上有跨域且未携带 CORS 的图片,画布就进入污染状态,toDataURL() / toBlob() / getImageData() 全部抛 SecurityError。而且报错发生在导出那一刻、不是 drawImage 那一刻,本地开发同源测不出来,最容易在线上翻车。

一句话总结

Canvas 是立即模式的像素画板:没有对象、没有事件、画完就忘——图形状态要自己维护、每帧清屏重绘、命中检测要自己算;把 canvas.width(缓冲区像素)和 CSS 尺寸分清、让缓冲区乘以 DPR 再用 scale 拉回坐标系,就避开了 90% 的”糊”和”错位”。

本文由作者按照 CC BY 4.0 进行授权