前端canvas基础复习,canvas学习笔记
- canvas
- 2022-10-29
- 1159热度
- 0评论
最开始学html5的时候,曾特意了解过canvas,还记得当时为了搞明白canvas的api,绞尽脑汁了很多个日日夜夜。
但实际工作后用的非常少,到现在canvas的api忘的也差不多了。目前打算好好学一下canvas,尝试一下更多的可能性。
相关知识
一些资料的收集:
- Canvas相关的框架的使用,小程序有自带的Canvas框架,还有Egret 、Phaser等;可视化数据的相关框架有:echarts、highcharts等;3D开发有:ThreeJS、playcanvas等;其他框架还有:heatmap.js、createjs 、PixiJS、spritejs等等。
- HTML5 Canvas 开发详解:https://www.zhihu.com/pub/book/119583977
- canvas教程:https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial
- 时至今日前端canvas还是否有深入学习的必要?:https://www.zhihu.com/question/59197508/answer/1696430489
参考了很多文章,真正需要使用canvas开发的大都侧重于游戏开发,以及基于web平台的一些工具(类似蓝湖、BoradMix这些);前端的范围和广度说大不大、说小不小,Canvas或许能带来更多的可能性。
时至今日,前端能做的早就不是简单的画页面了,WebGL、WebRTC、WebAssembly等等这些技术含量更高的方向,或许我们可以尝试一二。
Canvas基础
1.介绍
Canvas API(画布)是在HTML5中新增的标签用于在网页实时生成图像,并且可以操作图像内容,基本上它是一个可以用JavaScript操作的位图(bitmap)。
Canvas API 提供了一个通过JavaScript 和 HTML的<canvas>元素来绘制图形的方式。它可以用于动画、游戏画面、数据可视化、图片编辑以及实时视频处理等方面。
WebGL 使得网页在支持 HTML <canvas>标签的浏览器中,不需要使用任何插件,便可以使用基于 OpenGL ES 2.0 的 API 在 canvas 中进行 3D 渲染。
2.基本用法
<canvas> 标签只有两个属性 width和height。这些都是可选的,并且同样利用 DOM properties 来设置。
当没有设置宽度和高度的时候,canvas 会初始化宽度为 300 像素和高度为 150 像素。该元素可以使用CSS来定义大小,但在绘制时图像会伸缩以适应它的框架尺寸:如果 CSS 的尺寸与初始画布的比例不一致,它会出现扭曲。
canvas 起初是空白的。为了展示,首先脚本需要找到渲染上下文,然后在它的上面绘制。<canvas> 元素有一个叫做 getContext() 的方法,这个方法是用来获得渲染上下文和它的绘画功能。getContext()接受一个参数,即上下文的类型。
<-- canvas元素 -->
<canvas id="canvas"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d'); //CanvasRenderingContext2D对象
</script>
3.检查canvas是否被支持
var canvas = document.getElementById('tutorial');
if (canvas.getContext){
var ctx = canvas.getContext('2d');
// drawing code here
} else {
// canvas-unsupported code here
}
样式和颜色
1.fillStyle
CanvasRenderingContext2D.fillStyle 是 Canvas 2D API 使用内部方式(填充图形)描述颜色和样式的属性。默认值是 #000 (黑色)。
ctx.fillStyle = color; //字符串颜色代码,符合 CSS3 颜色值标准 的有效字符串
/* 比如 */
ctx.fillStyle = "orange";
ctx.fillStyle = "#FFA500";
ctx.fillStyle = "rgb(255,165,0)";
ctx.fillStyle = "rgba(255,165,0,1)";
ctx.fillStyle = gradient; //CanvasGradient 对象(线性渐变或者放射性渐变).
/* 比如 */
var lingrad = ctx.createLinearGradient(0,50,0,95);
lingrad.addColorStop(0.5, '#000');
lingrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = pattern;
ctx.fillStyle = pattern; //CanvasPattern 对象(可重复图像)。
/* 比如 */
var img = new Image();
img.src = 'https://mdn.mozillademos.org/files/222/Canvas_createpattern.png';
img.onload = function() {
// 创建图案
var ptrn = ctx.createPattern(img, 'repeat');
ctx.fillStyle = ptrn;
}
2.strokeStyle
CanvasRenderingContext2D.strokeStyle 是 Canvas 2D API 描述画笔(绘制图形)颜色或者样式的属性。默认值是 #000 (black)。
/* 同上 */
ctx.strokeStyle = color;
ctx.strokeStyle = gradient;
ctx.strokeStyle = pattern;
3.渐变 Gradients
经过测试,渐变色未填满整体图形时,最外层颜色会扩散到整个图形的剩余部分;
3.1 createLinearGradient
CanvasRenderingContext2D.createLinearGradient()方法用于创建一个沿参数坐标指定的直线的渐变,该方法返回一个线性 CanvasGradient对象。
//创建一个线性渐变色
CanvasGradient ctx.createLinearGradient(x0, y0, x1, y1);
let gradient=context.createLinearGradient(200,50,400,50);
// 添加一个由偏移(offset)和颜色(color)定义的断点到渐变中。
// 正如函数名:到达指定位置,颜色停止
gradient.addColorStop(0, 'green');
gradient.addColorStop(.5, 'cyan');
gradient.addColorStop(1, 'green');
3.2 createRadialGradient
CanvasRenderingContext2D.createRadialGradient() 是 Canvas 2D API 根据参数确定两个圆的坐标,绘制放射性渐变的方法。
经过测试,开始圆比结束圆大的时候向内渐变,比结束圆小的时候向外渐变。
/*
* 从100,100,位置开始画一个半径为100的圆
* 向100,100,位置半径半径为10的圆,开始渐变色
* white从外层圆向内,渐变色到达内部圆圆边时停止
* 内部圆会被外层颜色自动扩散从而被填充。
* 可以理解为这个渐变圆和fill填充的图形的重叠部分,为最终图形
*/
var gradient = ctx.createRadialGradient(100,100,100,100,100,10);
gradient.addColorStop(0,"white");
gradient.addColorStop(1,"green");
ctx.fillStyle = gradient;
ctx.fillRect(0,0,200,200);
3.3 总结
直线渐变在垂直、倾斜时会左右平移填充整个图片,水平时上下平移。圆形的渐变则是取重叠部分,形成最终的图形。
canvas栅格
canvas 元素默认被网格所覆盖。通常来说网格中的一个单元相当于 canvas 元素中的一像素。栅格的起点为左上角(坐标为(0,0))。所有元素的位置都相对于原点定位。
canvas状态属性
在 Canvas 中,如果以下状态属性发生改变的时候,我们可以在这些状态改变之前使用 save()方法来保持,然后在状态保存之后使用 restore()方法恢复。是否需要进行保存和恢复,这个取决于我们的开发需求。
- 填充效果:fillStyle。
- 描边效果:strokeStyle。
- 线条效果:lineCap、lineJoin、lineWidth、miterLimit。
- 文本效果:font、textAlign、textBaseline。
- 阴影效果:shadowBlur、shadowColor、shadowOffsetX、shadowOffsetY。
- 全局属性:globalAlpha、globalCompositeOperation。
填充、描边、剪切
不带fill、stroke的方法都只会在画布上产生路径状态,不会绘制实际图像。调用fill、stroke等等方法之后才会进行绘制。
1.填充(fill)
fill() 是 Canvas 2D API 根据当前的填充样式,填充当前或已存在的路径的方法。采取非零环绕或者奇偶环绕规则。
ctx.rect(10, 10, 100, 100);
ctx.fill();
//填充正方形
ctx.fillRect();
//填充文本
ctx.fillText();
2.描边(stroke)
stroke() 是 Canvas 2D API 使用非零环绕规则,根据当前的画线样式,绘制当前或已经存在的路径的方法。
ctx.rect(10, 10, 100, 100);
ctx.stroke();
//绘制正方形
ctx.strokeRect();
//绘制文本
ctx.strokeText();
3.裁剪(clip)
clip() 是 Canvas 2D API 将当前创建的路径设置为当前剪切路径的方法。 clip用于设置一个剪切区域,当使用 clip()方法指定剪切区域后,后面所有绘制的图形如果超出这个剪切区域,则超出部分不会显示。
// 创建一个弧形剪切区域
ctx.arc(100, 100, 75, 0, Math.PI*2, false);
ctx.clip();
ctx.fillRect(0, 0, 100,100);
常用操作
平移、旋转、放大、缩放等操作不会对已绘制的图像产生任何影响,因为它们修改的是坐标系,之后对之后重新绘制的图像产生影响(相当于用修改后的上下文状态进行绘制)!
1.平移(translate)
translate() 方法,将 canvas 按原始 x 点的水平方向、原始的 y 点垂直方向进行平移变换
ctx.translate(50, 50);
ctx.fillRect(0,0,100,100);
// reset current transformation matrix to the identity matrix
ctx.setTransform(1, 0, 0, 1, 0, 0);
2.旋转(rotate)
(2π = 360)rotate() 方法用于旋转坐标系;
ctx.rotate(45 * Math.PI / 180);
ctx.fillRect(70,0,100,30);
// 这次旋转是一上次旋转45度之后进行旋转,相当于旋转了90度
ctx.rotate(45 * Math.PI / 180);
// reset current transformation matrix to the identity matrix
ctx.setTransform(1, 0, 0, 1, 0, 0);
3.放大、缩小(scale)
scale() 是 Canvas 2D API 根据 x 水平方向和 y 垂直方向,为 canvas 单位添加缩放变换的方法。
默认的,在 canvas 中一个单位实际上就是一个像素。例如,如果我们将 0.5 作为缩放因子,最终的单位会变成 0.5 像素,并且形状的尺寸会变成原来的一半。相似的方式,我们将 2.0 作为缩放因子,将会增大单位尺寸变成两个像素。形状的尺寸将会变成原来的两倍。
// Scaled rectangle
ctx.scale(9, 3);
ctx.fillStyle = 'red';
ctx.fillRect(10, 10, 8, 20);
// Reset current transformation matrix to the identity matrix
ctx.setTransform(1, 0, 0, 1, 0, 0);
同样的可以通过scale实现水平、垂直翻转
ctx.scale(-1, 1); //水平翻转上下文
ctx.scale(1, -1); //垂直翻转上下文
4.擦除(clearRect)
clearRect()通过把像素设置为透明以达到擦除一个矩形区域的目的。
// 清除一部分画布
ctx.clearRect(10, 10, 120, 100);
//清除整个画布
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
5.倾斜(skew)
垂直倾斜因子为1和水平倾斜因子为1意味着每个点的坐标将根据以下规则进行变换:
对于垂直倾斜因子为1,每个点的y坐标将增加其x坐标的值。这意味着如果你有一个点 (𝑥,𝑦)(x,y),变换后它将变为 (𝑥,𝑦+𝑥)(x,y+x)。
对于水平倾斜因子为1,每个点的x坐标将增加其y坐标的值。这意味着如果你有一个点 (𝑥,𝑦)(x,y),变换后它将变为 (𝑥+𝑦,𝑦)(x+y,y)。
然而,这两个变换是同时应用的,所以最终的变换将是这两个效果的组合。这会导致一个对角线方向的拉伸,而不是简单的垂直或水平倾斜。
为了更好地理解这一点,让我们考虑一个具体的例子。
假设我们有一个点 (2,3)(2,3):应用垂直倾斜因子为1的变换:(2,3)(2,3) 变为 (2,3+2)=(2,5)(2,3+2)=(2,5)。
应用水平倾斜因子为1的变换:(2,5)(2,5) 变为 (2+5,5)=(7,5)(2+5,5)=(7,5)。所以,点 (2,3)(2,3) 最终变为 (7,5)(7,5)。
这种变换的效果是,图形将沿着对角线方向拉伸,而不是简单的垂直或水平倾斜。这就是为什么在Canvas中,垂直倾斜因子为1和水平倾斜因子为1的组合会导致图形沿着对角线方向拉伸的原因。
Transform(矩阵变形)
transform() 是 Canvas 2D API 使用矩阵多次叠加当前变换的方法,矩阵由方法的参数进行描述。
setTransform()和 transform()方法非常相似,都可以对图形进行平移、缩放、旋转等操作,不过两者也有着本质的区别:即每次调用 transform()方法,参考的都是上一次变换后的图形状态,然后再进行变换。但是 setTransform()方法不一样,setTransform()方法会重置图形的状态,然后再进行变换。
// tansform是基于上一个状态进行改变
transform(a (水平缩放,垂直倾斜,水平倾斜,垂直缩放,水平移动,垂直移动);
//setTransform会先重置,再设置矩阵
setTransform(a (水平缩放,垂直倾斜,水平倾斜,垂直缩放,水平移动,垂直移动);
//getTransform() 方法获取当前被应用到上下文的转换矩阵,返回一个 DOMMatrix 对象
坐标点位置判断
1.isPointInStroke()
isPointInStroke()是 Canvas 2D API 用于检测某点是否在路径的描边线上的方法。
2.isPointInPath()
isPointInPath()是 Canvas 2D API 用于判断在当前路径中是否包含检测点的方法。
状态保存和恢复
Canvas 是基于「状态」来绘制图形的。每一次绘制(stroke()或 fill()),Canvas 会检测整个程序定义的所有状态,这些状态包括 strokeStyle、fillStyle、lineWidth 等。当一个状态值没有被改变时,Canvas 就会一直使用最初的值。当一个状态值被改变时,我们分两种情况考虑。
- 如果使用 beginPath()开始一个新的路径,则不同路径使用不同的值。
- 如果没有使用 beginPath()开始一个新的路径,则后面的值会覆盖前面的值(后来者居上原则)。
Canvas 状态的保存和恢复,主要用于以下三种场合。
- 图形或图片裁切。
- 图形或图片变形。
- 以下属性改变的时候:fillStyle、font、globalAlpha、globalCompositeOperation、lineCap、lineJoin、lineWidth、miterLimit、shadowBlur、shadowColor、shadowOffsetX、shadowOffsetY、strokeStyle、textAlign、textBaseline。
ctx.save(); // 保存默认的状态
ctx.fillStyle = "green";
ctx.fillRect(10, 10, 100, 100);
ctx.restore(); // 还原到上次保存的默认状态
ctx.fillRect(150, 75, 100, 100);
图片绘制
1.图形或图片剪切
在 Canvas 中,可以在图形或者图片剪切(clip())之前使用 save()方法来保持当前状态,然后在剪切(clip())之后使用 restore()方法恢复之前保存的状态。
var cnv = $$("canvas");
var cxt = cnv.getContext("2d");
//save()保存状态
cxt.save();
//使用 clip()方法指定一个圆形的剪切区域
cxt.beginPath();
cxt.arc(70, 70, 50, 0, 360 * Math.PI / 180, true);
cxt.closePath();
cxt.stroke();
cxt.clip();
//绘制一张图片
var image = new Image();
image.src = 「images/princess.png」;
image.onload = function () {
cxt.drawImage(image, 10, 20);
}
$$(「btn」).onclick = function () {
//restore()恢复状态
cxt.restore();
//清空画布
cxt.clearRect(0, 0, cnv.width, cnv.height);
//绘制一张新图片
var image = new Image();
image.src = 「images/Judy.png」;
image.onload = function () {
cxt.drawImage(image, 10, 20);
}
}
2.图像绘制
//普通
drawImage(image,x,y);
//缩放
drawImage(image,x,y,width,height);
// 切片,图像指定一部分到画布指定位置
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
组合 Compositing
globalCompositeOperation 属性设置要在绘制新形状时应用的合成操作的类型,其中 type 是用于标识要使用的合成或混合模式操作的字符串
canvas 的优化
相关文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
1.在离屏 canvas 上预渲染相似的图形或重复的对象
myEntity.offscreenCanvas = document.createElement("canvas");
myEntity.offscreenCanvas.width = myEntity.width;
myEntity.offscreenCanvas.height = myEntity.height;
myEntity.offscreenContext = myEntity.offscreenCanvas.getContext("2d");
myEntity.render(myEntity.offscreenContext);
2.避免浮点数的坐标点,用整数取而代之
当画一个没有整数坐标点的对象时会发生子像素渲染。浏览器为了达到抗锯齿的效果会做额外的运算。为了避免这种情况,请保证在调用drawImage()函数时,用Math.floor()函数对所有的坐标点取整。
3.不要在用drawImage时缩放图像
在离屏 canvas 中缓存图片的不同尺寸,而不要用drawImage()去缩放它们。
4.使用多层画布去画一个复杂的场景
某些对象需要经常移动或更改,而其他对象则保持相对静态。在这种情况下,可能的优化是使用多个<canvas>元素对您的项目进行分层。
例如,假设有一个游戏,其 UI 位于顶部,中间是游戏性动作,底部是静态背景。在这种情况下,可以将游戏分成三个<canvas>层。UI 将仅在用户输入时发生变化,游戏层随每个新框架发生变化,并且背景通常保持不变。
5.用 CSS 设置大的背景图
如果像大多数游戏那样,你有一张静态的背景图,用一个静态的<div>元素,结合background 特性,以及将它置于画布元素之后。这么做可以避免在每一帧在画布上绘制大图。
6.用 CSS transforms 特性缩放画布
CSS transforms 使用 GPU,因此速度更快。最好的情况是不直接缩放画布,或者具有较小的画布并按比例放大,而不是较大的画布并按比例缩小。
6.关闭透明度
//如果不需要透明度可以关闭透明度
var ctx = canvas.getContext('2d', { alpha: false });
globalCompositeOperation
- source-over,现有画布之上绘制图像
- destination-over,现有画布的下面绘制图形
- source-in,与现有画布重叠的地方绘制图形,其他地方透明(如单词的意思在source源的内部绘制)
- source-out,与现有画布不重叠的地方绘制图形,其他地方透明(如单词的意思在source源的外部绘制)
- source-atop,与现有画布内容重叠的地方绘制,其他地方不透明
- destination-in,现有内容保留在重叠位置
- destination-out,现有内容保留不重叠位置
- destination-atop,都保留,新图像在现有的下面绘制
1.解析
source 和 destination 是在 globalCompositeOperation 属性中经常用到的两个术语,用于描述不同的图像或图像混合模式。下面是这两个术语的具体含义:
- source (源图像)指新绘制的图像,它是将要绘制的图形数据。
- destination (目标图像)指画布上已经存在的旧图像。
图层的顺序会影响最终结果的外观。
- "atop" ,将前景图像合成到背景图像上并裁剪多余部分。
- "in" ,显示新图层与旧图层相交的部分,并且在新图层之上呈现旧图层
- "out" ,显示旧图像与新图像不相交的部分,并且在旧图像之上呈现新图片
- “over”,新添加的内容会出现在其他所有内容之上
先后顺序的问题:
- source,则代表source是前景图像
- destination,则代表destination是前景图像
over 和 atop的区别
- over 在 alpha 值为 1.0 的地方会完全覆盖目标图像,而 alpha 值小于 1.0 的地方则会显示出混合结果。
- atop 前景图像的分辨率大于背景图图像之外的部分都会被截断。
事件操作
在 Canvas 中,常见的事件共有三种,即鼠标事件、键盘事件和循环事件。
1.鼠标事件
在 Canvas 中,鼠标事件分为以下三种。
- 鼠标按下:mousedown
- 鼠标松开:mouseup
- 鼠标移动:mousemove
将鼠标当前的坐标值减去 canvas 元素的偏移位置,则 x、y 为鼠标在 canvas 中的相对坐标
2.键盘事件
在 Canvas 中,常用的键盘事件有两种。
- 键盘按下:keydown
- 键盘松开:keyup
3.循环事件
说起如何实现 Canvas 动画,大多数人想到的都是先使用 setInterval()来定时清空画布、然后重绘图形,从而达到动画的效果。事实上,这种方式不能准确地控制动画的帧率,这是因为 setInterval()本身存在一定的性能问题。
在 Canvas 中,一般使用 requestAnimationFrame()方法来实现循环,从而达到动画效果。虽然 requestAnimationFrame 这个名字很长,但其实把它分开来看就很清楚它的含义了:request animation frame,也就是「请求动画帧」的意思。
//动画循环
(function frame() {
window.requestAnimationFrame(frame);
//每次动画循环都先清空画布,再重绘新的图形
cxt.clearRect(0, 0, cnv.width, cnv.height);
//绘制圆
cxt.beginPath();
cxt.arc(x, 70, 20, 0, 360 * Math.PI / 180, true);
cxt.closePath();
cxt.fillStyle = 「#6699FF」;
cxt.fill();
//变量递增
x += 2;
})();
物理动画
物理动画,简单来说,就是模拟现实世界的一种动画效果。在物理动画中,物体会遵循牛顿运动定律,如射击游戏中打出去的炮弹会随着重力而降落。
- 三角函数
- 匀速运动
- 加速运动
- 重力
- 摩擦力
用户交互
所谓的用户交互,指的是用户可以借助鼠标或键盘参与到 Canvas 动画中去,来实现一些互动的效果。用户交互,往往是借助两个事件来实现的,一个是键盘事件,另外一个是鼠标事件。
1.捕获物体
想要拖曳一个物体或者抛掷一个物体,首先要知道怎么来捕获一个物体。只有捕获了一个物体,才可以对该物体进行相应的操作。
在 Canvas 中,对于物体的捕获,可以分为以下四种情况来考虑。
- 矩形的捕获。
- 圆的捕获。
- 多边形的捕获。
- 不规则图形的捕获。
多边形以及不规则图形的捕获非常复杂,采用的方法是分离轴定理(SAT)和最小平移向量(MTV)。
1.1矩形的捕获
如果鼠标点击坐标落在矩形上,则说明捕获了这个矩形;如果鼠标点击坐标没有落在矩形上,则说明没有捕获到这个矩形。
//判断矩形是否被点击
if (mouse.x > rect.x &&
mouse.x < rect.x + rect.width &&
mouse.y > rect.y &&
mouse.y < rect.y + rect.height) {
……
}
1.2圆的捕获
在 Canvas 中,对于圆来说,可以采用一种高精度的方法来捕获:判定鼠标与圆心之间的距离。如果距离小于圆的半径,说明鼠标落在了圆上面;如果距离大于或等于圆的半径,说明鼠标落在了圆的外面。
dx = mouse.x - ball.x;
dy = mouse.y - ball.y;
distance = Math.sqrt(dx*dx + dy*dy);
if(distance < ball.radius){
……
}
2.拖拽物体
在 Canvas 中,如果我们想要拖曳一个物体,一般情况下需要以下三个步骤。
- 捕获物体:在鼠标按下(mousedown)时,判断鼠标坐标是否落在物体上面,如果落在,就添加两个事件:mousemove 和 moveup。
- 移动物体:在鼠标移动(mousemove)中,更新物体坐标为鼠标坐标。
- 松开物体:在鼠标松开(mouseup)时,移除 mouseup 事件(自身事件也得移除)和 mousemove 事件。
cnv.addEventListener("mousedown", function () {
document.addEventListener("mousemove", onMouseMove, false);
document.addEventListener("mouseup", onMouseUp, false);
}, false);
三角函数
最近在学习canvas的路上越走越黑,canvas充分的自由度带来了无限的可能性。简单的平移、旋转、缩放还可以理解,复杂的变化没点数学基础确实啃不动🤦♂️,三角函数还有点印象,但是记得也不是很清楚了,矩阵已经没印象了....
三角函数:https://baike.baidu.com/item/%E4%B8%89%E8%A7%92%E5%87%BD%E6%95%B0/1652457
矩阵:https://baike.baidu.com/item/%E7%9F%A9%E9%98%B5/18069
反三角函数:https://baike.baidu.com/item/%E5%8F%8D%E4%B8%89%E8%A7%92%E5%87%BD%E6%95%B0/7004029
JS中的运用:https://www.jianshu.com/p/7c6a4f3021a1
- 正弦(sin) sinA = a / c ,sinθ = y / r
- 余弦(cos) cosA = b / c ,cosθ = y / r
- 正切(tan) tanA = a / b ,tanθ = y / x
- 余切(cot) cotA = b / a, cotθ = x / y
反三角函数
var asin30 = Math.round(Math.asin(sin30) * 180 / Math.PI)
console.log(asin30); //30
var acos60 = Math.round(Math.acos(cos60) * 180 / Math.PI)
console.log(acos60); //60
var atan45 = Math.round(Math.atan(tan45) * 180 / Math.PI)
console.log(atan45); //4
向量与矩阵
相关知识:https://www.modb.pro/db/418935
相关书籍:https://www.zhihu.com/pub/reader/119565272/chapter/975240522307125248