注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

用canvas实现一个大气球送给你

一、背景 近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。 二、实现 在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分: 实现球体部分; 实现气球口...
继续阅读 »

一、背景



近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。



balloon1.gif


二、实现



在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分:




  1. 实现球体部分;

  2. 实现气球口子部分;

  3. 实现气球的线部分;

  4. 进行颜色填充;

  5. 实现动画;


气球.PNG


2.1 球体部分实现



对于这样的气球的球体部分,大家都有什么好的实现思路的?相信大家肯定会有多种多样的实现方案,我也是在看到某位大佬的效果后,感受到了利用四个三次贝塞尔曲线实现这个效果的妙处。为了看懂后续代码,先了解一下三次贝塞尔曲线的原理。(注:引用了CSDN上某位大佬的文章,写的很好,下图引用于此)



三次贝塞尔曲线.gif



在上图中P0为起始点、P3为终止点,P1和P2为控制点,其最终的曲线公式如下所示:



B(t)=(1−t)^3 * P0+3t(1−t)^2 * P1+3t ^ 2(1−t) * P2+t ^ 3P3, t∈[0,1]



上述已经列出了三次贝塞尔曲线的效果图和公式,但是通过这个怎么跟我们的气球挂上钩呢?下面通过几张图就理解了:



image.png



如上图所示,就是实现整个气球球体的思路,具体解释如下所示:




  1. A图中起始点为p1,终止点为p2,控制点为c1、c2,让两个控制点重合,绘制出的效果并不是很像气球的一部分,此时就要通过改变控制点来改变其外观;

  2. 改变控制点c1、c2,c1中y值不变,减小x值;c2中x值不变,增大y值(注意canvas中坐标方向即可),改变后就得到了图B的效果,此时就跟气球外观很像了;

  3. 紧接着按照这个方法就可以实现整个的气球球体部分的外观。


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.translate(250, 250);
drawCoordiante(ctx);
ctx.save();
ctx.beginPath();
ctx.moveTo(0, -80);
ctx.bezierCurveTo(45, -80, 80, -45, 80, 0);
ctx.bezierCurveTo(80, 85, 45, 120, 0, 120);
ctx.bezierCurveTo(-45, 120, -80, 85, -80, 0);
ctx.bezierCurveTo(-80, -45, -45, -80, 0, -80);
ctx.stroke();
ctx.restore();
}

function drawCoordiante(ctx) {
ctx.beginPath();
ctx.moveTo(-120, 0);
ctx.lineTo(120, 0);
ctx.moveTo(0, -120);
ctx.lineTo(0, 120);
ctx.closePath();
ctx.stroke();
}

2.2 口子部分实现



口子部分可以简化为一个三角形,效果如下所示:



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(-5, 130);
ctx.lineTo(5, 130);
ctx.closePath();
ctx.stroke();
ctx.restore();
}

2.3 线部分实现



线实现的比较简单,就用了一段直线实现



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(0, 300);
ctx.stroke();
ctx.restore();
}

2.4 进行填充



气球部分的填充用了圆形渐变效果,相比于纯色来说更加漂亮一些。



function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = getBalloonGradient(ctx, 0, 0, 80, 210);
……

}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}

image.png


2.5 动画效果及整体代码



上述流程已经将一个静态的气球部分绘制完毕了,要想实现动画效果只需要利用requestAnimationFrame函数不断循环调用即可实现。下面直接抛出整体代码,方便同学们观察效果进行调试,整体代码如下所示:



let posX = 225;
let posY = 300;
let points = getPoints();
draw();

function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (posY < -200) {
posY = 300;
posX += 300 * (Math.random() - 0.5);
points = getPoints();
}
else {
posY -= 2;
}
ctx.save();
ctx.translate(posX, posY);
drawBalloon(ctx, points);
ctx.restore();

window.requestAnimationFrame(draw);
}

function drawBalloon(ctx, points) {
ctx.scale(points.scale, points.scale);
ctx.save();
ctx.fillStyle = getBalloonGradient(ctx, 0, 0, points.R, points.hue);
// 绘制球体部分
ctx.moveTo(points.p1.x, points.p1.y);
ctx.bezierCurveTo(points.pC1to2A.x, points.pC1to2A.y, points.pC1to2B.x, points.pC1to2B.y, points.p2.x, points.p2.y);
ctx.bezierCurveTo(points.pC2to3A.x, points.pC2to3A.y, points.pC2to3B.x, points.pC2to3B.y, points.p3.x, points.p3.y);
ctx.bezierCurveTo(points.pC3to4A.x, points.pC3to4A.y, points.pC3to4B.x, points.pC3to4B.y, points.p4.x, points.p4.y);
ctx.bezierCurveTo(points.pC4to1A.x, points.pC4to1A.y, points.pC4to1B.x, points.pC4to1B.y, points.p1.x, points.p1.y);

// 绘制气球钮部分
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.knowA.x, points.knowA.y);
ctx.lineTo(points.knowB.x, points.knowB.y);
ctx.fill();
ctx.restore();

// 绘制线部分
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.lineEnd.x, points.lineEnd.y);
ctx.stroke();
ctx.restore();
}

function getPoints() {
const offset = 35;
return {
scale: 0.3 + Math.random() / 2,
hue: Math.random() * 255,
R: 80,
p1: {
x: 0,
y: -80
},
pC1to2A: {
x: 80 - offset,
y: -80
},
pC1to2B: {
x: 80,
y: -80 + offset
},
p2: {
x: 80,
y: 0
},
pC2to3A: {
x: 80,
y: 120 - offset
},
pC2to3B: {
x: 80 - offset,
y: 120
},
p3: {
x: 0,
y: 120
},
pC3to4A: {
x: -80 + offset,
y: 120
},
pC3to4B: {
x: -80,
y: 120 - offset
},
p4: {
x: -80,
y: 0
},
pC4to1A: {
x: -80,
y: -80 + offset
},
pC4to1B: {
x: -80 + offset,
y: -80
},
knowA: {
x: -5,
y: 130
},
knowB: {
x: 5,
y: 130
},
lineEnd: {
x: 0,
y: 250
}
};
}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}


链接:https://juejin.cn/post/7006967510134161438

收起阅读 »

通过一个例子学习css层叠上下文

层叠上下文 & 层叠等级 & 层叠规则 http://www.w3.org/TR/CSS22/vi… The order in which the rendering tree is painted onto the canvas is d...
继续阅读 »

层叠上下文 & 层叠等级 & 层叠规则



http://www.w3.org/TR/CSS22/vi…


The order in which the rendering tree is painted onto the canvas is described in terms of stacking contexts. Stacking contexts can contain further stacking contexts. A stacking context is atomic from the point of view of its parent stacking context; boxes in other stacking contexts may not come between any of its boxes.


Each box belongs to one stacking context. Each positioned box in a given stacking context has an integer stack level, which is its position on the z-axis relative other stack levels within the same stacking context. Boxes with greater stack levels are always formatted in front of boxes with lower stack levels. Boxes may have negative stack levels. Boxes with the same stack level in a stacking context are stacked back-to-front according to document tree order.


The root element forms the root stacking context.



翻译一下:
渲染树被绘制到画布上的顺序是根据层叠上下文来描述的。层叠上下文可以包含更多的层叠上下文。从父层叠上下文的角度来看,层叠上下文是原子的;其他层叠上下文中的盒子可能不会出现在它的任何盒子中。


每个框都属于一个层叠上下文。给定层叠上下文中的每个定位框都有一个整数层叠等级,这是它在 z 轴上相对于同一层叠上下文中其他层叠等级的位置。具有较高层叠等级的框始终放置在具有较低层叠等级的框之前。盒子可能有负的层叠等级。层叠上下文中具有相同层叠等级的框根据文档树顺序从后到前绘制。


根元素创建根层叠上下文。


理解:
所有的元素都属于一个层叠上下文,所以所有的元素都有自己的层叠等级。
每个元素都有自己所属的层叠上下文,在当前层叠上下文中具有自己的层叠等级。



那层叠等级的规则是啥呢?



http://www.w3.org/TR/CSS22/vi…


Within each stacking context, the following layers are painted in back-to-front order:


the background and borders of the element forming the stacking context.
the child stacking contexts with negative stack levels (most negative first).
the in-flow, non-inline-level, non-positioned descendants.
the non-positioned floats.
the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
the child stacking contexts with stack level 0 and the positioned descendants with stack level 0.
the child stacking contexts with positive stack levels (least positive first).



在每一个层叠上下文中,阿照下面的顺序从后往前绘制。



  1. 创建层叠上下文元素的背景和边框

  2. 创建层叠上下文元素的具有负层叠等级子元素

  3. 非inline元素并且没有定位的后代【block后代】

  4. 非定位的浮动元素

  5. 包括inline-table / inline-block的非定位inline元素

  6. 创建层叠上下文元素的层叠等级为0的子元素【0 / auto】

  7. 创建层叠上下文元素的层叠等级为大于0的子元素



关于这个等级张鑫旭有一张图说明
image.png
这里提到了一个新增的:不依赖于z-index的层叠上下文,这里指的应该是css3会有一些元素在不通过定位来创建新的层叠上下文



  1. z-index值不为auto的flex项(父元素display:flex|inline-flex).

  2. 元素的opacity值不是1.

  3. 元素的transform值不是none.

  4. 元素mix-blend-mode值不是normal.

  5. 元素的filter值不是none.

  6. 元素的isolation值是isolate.

  7. will-change指定的属性值为上面任意一个。

  8. 元素的-webkit-overflow-scrolling设为touch.





Demo



先看parent元素


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 100px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div class="parent">
parent
<div class="child1">child1
<div class="child1-2">child1-2</div>
<div class="child1-1">child1-1</div>
<!-- <div>child1-1</div>
<div>child1-2</div> -->
</div>
<div class="child2">
child2
</div>
</div>
</body>

image.png


先从根节点看起:根节点上根层级上下文,因为只有一个子节点parent。然后parent有自己的层叠上下文。parent有两个子节点,上文说到每个盒子属于一个层叠上下文,parent属于html的层叠上下文,parent会创建自己的层叠上下文,当然这个层叠上下文的作用主要针对parent的子元素。child1,child2。


image.pngimage.png


因为child1的z-index为1,child2的z-index为-1。所以这里的child1会绘制在child2的上面。


当我们在看child1的子元素和child2的子元素就不能放在一起看了,因为child1和child2都创建了自己的层叠上下文。只能独立看了。


这里child2的绘制会在parent的上面,尽管child2的z-index为负树。这里也对应了上面说的7层关系。因为parent属于创建层叠上下文的元素。



知识点:层叠上下文



  1. 普通元素的层叠等级优先由其所在的层叠上下文决定。

  2. 层叠等级的比较只有在当前层叠上下文元素中才有意义。不同层叠上下文中比较层叠等级是没有意义的。





知识点:层叠等级



  1. 在同一个层叠上下文中,它描述定义的是该层叠上下文中的层叠上下文元素在Z轴上的上下顺序。

  2. 在其他普通元素中,它描述定义的是这些普通元素在Z轴上的上下顺序。





接下来看block层级小于float


image.png


再看具体的页面渲染,我们修改一下代码,将child1-2和child1-2的顺序调换一下:


image.pngimage.png


这里不同的顺序会有不同的效果:第二张图看得出来是我们期望的,child1-2绘制到了chil1-1下面。因为float元素没有脱离文本流,所以child1-2的文本会被挤压到下面去。那么我们看一下第一张图为什么会这样。
从float的概念当中就可以看出来了。
浮动定位作用的是当前行,当前浮动元素在绘制的时候,child1父元素第一个元素是block元素,所以。float在绘制的时候,因为child1-1的宽度和child1的宽度相同,所以float所在的当前行就是目前的位置。第二张图是我们期望的结果是因为float在绘制的时候所在的当前行就是第一行。所以会按照我们期望的体现。



接下来看float小于inline / inline-block


我们接着上面第二张图继续看。这样是看不出来效果的,需要修改一下代码再看。


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 200px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
display: inline-block;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
margin: 10px -15px 10px 10px;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div>
parent
<div>child1
<divhljs-number">1">child1-1</div>
<divhljs-number">2">child1-2</div>
</div>
<div>
child2
</div>
</div>
</body>

image.png
修改代码是需要将float元素和inline-block元素放在同一行,如果不是在同一行是没意义的。我们可以看到child1的文本节点和child1-2的inline-block元素都绘制在了child1-1的元素上面了。


论证一下css3的内容


也就是下面这个红框的内容:


image.png
继续用上面的例子:
上面看到的float元素已经放置在了inline / inline-block内容的下面。现在我们加一下:上面说的css3的样式在看一下。下面的两个例子可以看到之前放置在inline / inline-block下面的child1-1已经绘制在上面了。



opacity


image.png



tranform


image.png





概念



z-index



  1. 首先,z-index属性值并不是在任何元素上都有效果。它仅在定位元素(定义了position属性,且属性值为非static值的元素)上有效果。

  2. 判断元素在Z轴上的堆叠顺序,不仅仅是直接比较两个元素的z-index值的大小,这个堆叠顺序实际由元素的层叠上下文层叠等级共同决定。





层叠上下文的特性



  • 层叠上下文的层叠水平要比普通元素高;

  • 层叠上下文可以嵌套,内部层叠上下文及其所有子元素均受制于外部的层叠上下文。

  • 每个层叠上下文和兄弟元素独立,也就是当进行层叠变化或渲染的时候,只需要考虑后代元素。

  • 每个层叠上下文是自成体系的,当元素发生层叠的时候,整个元素被认为是在父层叠上下文的层叠顺序中



链接:https://juejin.cn/post/7006978541988347941

收起阅读 »

【中秋】纯CSS实现日地月的公转

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。 但因为我根本没咋学过前端,这两天恶补了一下重学了 flexbox 和 grid ,成...
继续阅读 »

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。


但因为我根本没咋学过前端,这两天恶补了一下重学了 flexboxgrid ,成果应该说还挺好看(如果我的审美没有问题的话)。


配色我挺喜欢的,希望你也喜欢。


源码我放到了 CodePen 上,链接 Sun Earth Moon (codepen.io)


HTML


重点是CSS,HTML放上三个 div 就🆗了。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Mancuoj</title>
<link
href="simulation.css"
rel="stylesheet"
/>
</head>

<body>
<h1>Mancuoj</h1>
<figure class="container">
<div class="sun"></div>
<div class="earth">
<div class="moon"></div>
</div>
</figure>
</body>
</html>

背景和文字


导入我最喜欢的 Lobster 字体,然后设为白色,字体细一点。


@import url("https://fonts.googleapis.com/css2?family=Lobster&display=swap");

h1 {
color: white;
font-size: 60px;
font-family: Lobster, monospace;
font-weight: 100;
}

背景随便找了一个偏黑紫色,然后把画的内容设置到中间。


body {
margin: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #2f3141;
}

.container {
font-size: 10px;
width: 40em;
height: 40em;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}

日地月动画


众所周知:地球绕着太阳转,月球绕着地球转。


我们画的是公转,太阳就直接画出来再加个阴影高光,月亮地球转就可以了。


最重要的其实是配色(文章末尾有推荐网站),我实验好长时间的配色,最终用了三个渐变色来表示日地月。


日: linear-gradient(#fcd670, #f2784b);
地: linear-gradient(#19b5fe, #7befb2);
月: linear-gradient(#8d6e63, #ffe0b2);

CSS 应该难不到大家,随便看看吧。


轨道用到了 border,用银色线条当作公转的轨迹。


动画用到了自带的 animation ,每次旋转一周。


.sun {
position: absolute;
width: 10em;
height: 10em;
background: linear-gradient(#fcd670, #f2784b);
border-radius: 50%;
box-shadow: 0 0 8px 8px rgba(242, 120, 75, 0.2);
}

.earth {
--diameter: 30;
--duration: 36.5;
}

.moon {
--diameter: 8;
--duration: 2.7;
top: 0.3em;
right: 0.3em;
}

.earth,
.moon {
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
border-width: 0.1em;
border-style: solid solid none none;
border-color: silver transparent transparent transparent;
border-radius: 50%;
animation: orbit linear infinite;
animation-duration: calc(var(--duration) * 1s);
}

@keyframes orbit {
to {
transform: rotate(1turn);
}
}

.earth::before {
--diameter: 3;
--color: linear-gradient(#19b5fe, #7befb2);
--top: 2.8;
--right: 2.8;
}

.moon::before {
--diameter: 1.2;
--color: linear-gradient(#8d6e63, #ffe0b2);
--top: 0.8;
--right: 0.2;
}

.earth::before,
.moon::before {
content: "";
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
background: var(--color);
border-radius: 50%;
top: calc(var(--top) * 1em);
right: calc(var(--right) * 1em);
}

总结


参加个活动真不容易,不过前端还是挺好玩的。


链接:https://juejin.cn/post/7006507905050492935

收起阅读 »

6年的老项目迁移vite2,提速几十倍,真香

背景 gou系统又老又大又乱,每一次的需求开发都极其难受,启动30|40几秒勉强能接受吧,毕竟一天也就这么一回,但是HMR更新也要好几秒实在是忍不了,看到了vite2就看到了曙光!盘它 先看看vue-cli3的启动编译吧... 该项目为内部运营管理系统...
继续阅读 »

vite-dev.png


背景



gou系统又老又大又乱,每一次的需求开发都极其难受,启动30|40几秒勉强能接受吧,毕竟一天也就这么一回,但是HMR更新也要好几秒实在是忍不了,看到了vite2就看到了曙光!盘它



先看看vue-cli3的启动编译吧...


编译-new-48803ms.png



  • 该项目为内部运营管理系统,年龄6岁+

  • 基于vue2+elementui,2年入职时将vue-cli2升级到了vue-cli3,2年后的今天迫不及待的的奔向vite2

  • 仅迁移开发环境(我的痛点只是开发环境,对于生产环境各位自行考虑)


痛点分析


实质上是对webpack工作原理的分析,webpack在开发环境的工作流大致如下(个人见解不喜勿喷):



查找入口文件 => 分析依赖关系 => 转化模块函数 => 打包生成bundle => node服务启动



所以随着项目越来越大,速度也就越来越慢...


至于HMR也是同理,只不过HMR是将当前文件作为入口,进行rebuild,涉及的相关依赖都需要重载


为什么是Vite



  • vite是基于esm实现的,主流浏览器已支持,所以不需要对文件进行打包编译

  • 项目启动超快(迁移后简单的概算数据是从30s 提升到 1s。30倍?3000%?一点都不夸张...)

  • 还是基于esmHMR很快,不需要编译重载,速度可以用一闪而过来形容...


vite大致工作流:



启动服务 => 查找入口文件(module script) => 浏览器发送请求 => vite劫持请求处理返回文件到浏览器



开盘,踏上迁移之路




  1. 安装相关npm包


    npm i vite vite-plugin-vue vite-plugin-html -D


    • vite-plugin-vue,用于构建vue,加载jsx

    • vite-plugin-html,用于入口文件模板注入




  2. package.json文件中,新增一个vite启动命令:


    "vite": "cross-env VITE_NODE_ENV=dev vite"



  3. 根目录新建vite.config.js文件




  4. public下的index.html复制一份到根目录



    仅迁移开发环境,public下仍然需要index.html,支持开发环境下vite和webpack两种模式





  5. 修改根目录下index.html(vite启动的入口文件,必须是根目录)


    <% if (htmlWebpackPlugin.options.isVite) { %>
    <script type="module" src="/src/main.js"></script>
    <%}%>


    htmlWebpackPlugin在vite.config.js注入,isVite用于标识是否是vite启动



    import { injectHtml } from 'vite-plugin-html';
    export default defineConfig({
     plugins:[
       injectHtml({
         injectData: {
           htmlWebpackPlugin: {
             options: {
               isVite: true
            }
          },
           title: '运营管理平台'
        }
      })
    ]
    })



  6. 完整vite.config.js 配置


    import { defineConfig } from 'vite'
    import path from 'path'
    import fs from 'fs'
    import { createVuePlugin } from 'vite-plugin-vue2'
    import { injectHtml, minifyHtml } from 'vite-plugin-html'
    import dotenv from 'dotenv'

    try {
       // 根据环境变量加载环境变量文件
       const VITE_NODE_ENV = process.env.VITE_NODE_ENV
       const envLocalSuffix = VITE_NODE_ENV === 'dev' ? '.local' : ''
       const file = dotenv.parse(fs.readFileSync(`./.env.${VITE_NODE_ENV}${envLocalSuffix}`), {
           debug: true
      })
       for (const key in file) {
           process.env[key] = file[key]
      }
    } catch (e) {
       console.error(e)
    }

    const resolve = (dir) => {
       return path.join(__dirname, './', dir)
    }
    export default defineConfig({
       root: './',
       publicDir: 'public',
       base: './',
       mode: 'development',
       optimizeDeps: {
           include: []
      },
       resolve: {
           alias: {
               'vendor': resolve('src/vendor'),
               '@': resolve('src'),
               '~component': resolve('src/components')
          },
           extensions: [
               '.mjs',
               '.js',
               '.ts',
               '.jsx',
               '.tsx',
               '.json',
               '.vue'
          ]
      },
       plugins: [
           createVuePlugin({
               jsx: true,
               jsxOptions: {
                   injectH: false
              }
          }),
           minifyHtml(),
           injectHtml({
               injectData: {
                   htmlWebpackPlugin: {
                       options: {
                           isVite: true
                      }
                  },
                   title: '运营管理平台'
              }
          })
      ],
       define: {
           'process.env': process.env
      },
       server: {
           host: '0.0.0.0',
           open: true,
           port: 3100,
           proxy: {}
      }
    })



    相关配置会在下文遇到的问题中做具体描述





迁移过程中遇到的问题




  1. Uncaught SyntaxError: The requested module 'xx.js' does not provide an export named 'xx'


    本人遇到的分以下两类情况:


    a. 一个模块只能有一个默认输出,导入默认输出时,import命令后不需要加大括号,否则会报错


    处理方式:将原先{}导入的keys,改成导入默认keyes6解构赋值


    -import { postRedeemDistUserUpdate } from '@/http-handle/api_types'

    +import api_types from '@/http-handle/api_types'
    +const { postRedeemDistUserUpdate } = api_types

    b. 浏览器仅支持 esm,不支持 cjs,需要将cjs改为esm (看了网文有通过cjs2esmodule处理的,但是本人应用有些场景是报错的,最后就去掉了)


    处理方式:不推荐使用cjs2esmodule,手动将module.exports更改为export


    -module.exports = {

    +export default {



  2. .vue文件扩展,最新版本的vite貌似已支持extensions添加.vue,不过还是推荐手动添加下后缀。(骚操作:正则匹配批量添加)




  3. Uncaught ReferenceError: require is not defined


    浏览器不支持cjs


    处理方式:require引用的文件都需要修改为import引用




  4. vite启动,页面空白


    处理方式:注意入口文件index.html,需要放置项目根目录




  5. vite环境下默认没有process.env,可通过define定义全局变量


    vue-cli模式下,环境变量都是读取根目录.env文件中的变量,那么vite模式下是否也可以读取.env文件中的变量最终注入到process.env中呢?


    这样不就可以两种模式共存了么?成本变小了么?


    处理方式:



    1. 安装环境变量加载工具:dotenv


    npm i dotenv -D




    1. 自定义全局变量process.env


      vite.config.js中配置




    define: {
    'process.env': {}
    }



    1. 加载环境变量,并添加到process.env


      vite.config.js中配置



      因为仅迁移开发环境,所以我这里默认是读取.local文件。


      VITE_NODE_ENV是在启动时通过cross-env注入的







import dotenv from 'dotenv'
try {
const VITE_NODE_ENV = process.env.VITE_NODE_ENV
const envLocalSuffix = VITE_NODE_ENV === 'dev' ? '.local' : ''
const file = dotenv.parse(fs.readFileSync(`./.env.${VITE_NODE_ENV}${envLocalSuffix}`), {
debug: true
})
console.log(file)
for (const key in file) {
process.env[key] = file[key]
}
} catch (e) {
console.error(e)
}




  1. jsx支持


    vite.config.js中配置


    plugins: [
    createVuePlugin({
      jsx: true,
      jsxOptions: {
        injectH: false
      }
    })



  2. webpack中require.context方法,在vite中使用import.meta.glob替换




现存问题


项目中导入/导出的功能,是纯前端实现的


require('script-loader!file-saver')
require('script-loader!@/vendor/Blob')

由于以上文件目前不支持import引入,webpack下是通过script-loader加载挂载到全局的,vite环境下未能解决。需要导入导出功能时只能切换到vue-cli模式启动服务...


如果各位大大有方案,麻烦指导指导~,实在是不想回到webpack开发了...


最后


总体迁移上并没有遇到什么疑难杂症,迁移成本还是不大的,实操1-2天,性价比很高哦,我这个项目按数据看就是几十倍的启动提效,几倍的HMR提效...各位可以在内部系统上做下尝试。



链接:https://juejin.cn/post/7005479358085201957

收起阅读 »

50行代码串行Promise,koa洋葱模型原来是这么实现?

1. 前言 写相对很难的源码,耗费了自己的时间和精力,也没收获多少阅读点赞,其实是一件挺受打击的事情。从阅读量和读者受益方面来看,不能促进作者持续输出文章。 所以转变思路,写一些相对通俗易懂的文章。其实源码也不是想象的那么难,至少有很多看得懂。 之前写过 ko...
继续阅读 »

1. 前言


写相对很难的源码,耗费了自己的时间和精力,也没收获多少阅读点赞,其实是一件挺受打击的事情。从阅读量和读者受益方面来看,不能促进作者持续输出文章。


所以转变思路,写一些相对通俗易懂的文章。其实源码也不是想象的那么难,至少有很多看得懂


之前写过 koa 源码文章学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理比较长,读者朋友大概率看不完,所以本文从koa-compose50行源码讲述。


本文涉及到的 koa-compose 仓库 文件,整个index.js文件代码行数虽然不到 50 行,而且测试用例test/test.js文件 300 余行,但非常值得我们学习。


歌德曾说:读一本好书,就是在和高尚的人谈话。 同理可得:读源码,也算是和作者的一种学习交流的方式。


阅读本文,你将学到:


1. 熟悉 koa-compose 中间件源码、可以应对面试官相关问题
2. 学会使用测试用例调试源码
3. 学会 jest 部分用法

2. 环境准备


2.1 克隆 koa-compose 项目


本文仓库地址 koa-compose-analysis,求个star~


# 可以直接克隆我的仓库,我的仓库保留的 compose 仓库的 git 记录
git clone https://github.com/lxchuan12/koa-compose-analysis.git
cd koa-compose/compose
npm i

顺带说下:我是怎么保留 compose 仓库的 git 记录的。


# 在 github 上新建一个仓库 `koa-compose-analysis` 克隆下来
git clone https://github.com/lxchuan12/koa-compose-analysis.git
cd koa-compose-analysis
git subtree add --prefix=compose https://github.com/koajs/compose.git main
# 这样就把 compose 文件夹克隆到自己的 git 仓库了。且保留的 git 记录

关于更多 git subtree,可以看这篇文章用 Git Subtree 在多个 Git 项目间双向同步子项目,附简明使用手册


接着我们来看怎么根据开源项目中提供的测试用例调试源码。


2.2 根据测试用例调试 compose 源码


VSCode(我的版本是 1.60 )打开项目,找到 compose/package.json,找到 scriptstest 命令。


// compose/package.json
{
"name": "koa-compose",
// debug (调试)
"scripts": {
"eslint": "standard --fix .",
"test": "jest"
},
}

scripts上方应该会有debug或者调试字样。点击debug(调试),选择 test


VSCode 调试


接着会执行测试用例test/test.js文件。终端输出如下图所示。


koa-compose 测试用例输出结果


接着我们调试 compose/test/test.js 文件。
我们可以在 45行 打上断点,重新点击 package.json => srcipts => test 进入调试模式。
如下图所示。


koa-compose 调试


接着按上方的按钮,继续调试。在compose/index.js文件中关键的地方打上断点,调试学习源码事半功倍。


更多 nodejs 调试相关 可以查看官方文档


顺便提一下几个调试相关按钮。





    1. 继续(F5)





    1. 单步跳过(F10)





    1. 单步调试(F11)





    1. 单步跳出(Shift + F11)





    1. 重启(Ctrl + Shift + F5)





    1. 断开链接(Shift + F5)




接下来,我们跟着测试用例学源码。


3. 跟着测试用例学源码


分享一个测试用例小技巧:我们可以在测试用例处加上only修饰。


// 例如
it.only('should work', async () => {})

这样我们就可以只执行当前的测试用例,不关心其他的,不会干扰调试。


3.1 正常流程


打开 compose/test/test.js 文件,看第一个测试用例。


// compose/test/test.js
'use strict'

/* eslint-env jest */

const compose = require('..')
const assert = require('assert')

function wait (ms) {
return new Promise((resolve) => setTimeout(resolve, ms || 1))
}
// 分组
describe('Koa Compose', function () {
it.only('should work', async () => {
const arr = []
const stack = []

stack.push(async (context, next) => {
arr.push(1)
await wait(1)
await next()
await wait(1)
arr.push(6)
})

stack.push(async (context, next) => {
arr.push(2)
await wait(1)
await next()
await wait(1)
arr.push(5)
})

stack.push(async (context, next) => {
arr.push(3)
await wait(1)
await next()
await wait(1)
arr.push(4)
})

await compose(stack)({})
// 最后输出数组是 [1,2,3,4,5,6]
expect(arr).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6]))
})
}

大概看完这段测试用例,context是什么,next又是什么。


koa的文档上有个非常代表性的中间件 gif 图。


中间件 gif 图


compose函数作用就是把添加进中间件数组的函数按照上面 gif 图的顺序执行。


3.1.1 compose 函数


简单来说,compose 函数主要做了两件事情。





    1. 接收一个参数,校验参数是数组,且校验数组中的每一项是函数。





    1. 返回一个函数,这个函数接收两个参数,分别是contextnext,这个函数最后返回Promise




/**
* Compose `middleware` returning
* a fully valid middleware comprised
* of all those which are passed.
*
* @param {Array} middleware
* @return {Function}
* @api public
*/
function compose (middleware) {
// 校验传入的参数是数组,校验数组中每一项是函数
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}

/**
* @param {Object} context
* @return {Promise}
* @api public
*/

return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch(i){
// 省略,下文讲述
}
}
}

接着我们来看 dispatch 函数。


3.1.2 dispatch 函数


function dispatch (i) {
// 一个函数中多次调用报错
// await next()
// await next()
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
// 取出数组里的 fn1, fn2, fn3...
let fn = middleware[i]
// 最后 相等,next 为 undefined
if (i === middleware.length) fn = next
// 直接返回 Promise.resolve()
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}

值得一提的是:bind函数是返回一个新的函数。第一个参数是函数里的this指向(如果函数不需要使用this,一般会写成null)。
这句fn(context, dispatch.bind(null, i + 1)i + 1 是为了 let fn = middleware[i]middleware中的下一个函数。
也就是 next 是下一个中间件里的函数。也就能解释上文中的 gif图函数执行顺序。
测试用例中数组的最终顺序是[1,2,3,4,5,6]


3.1.3 简化 compose 便于理解


自己动手调试之后,你会发现 compose 执行后就是类似这样的结构(省略 try catch 判断)。


// 这样就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = stack;
const fnMiddleware = function(context){
return Promise.resolve(
fn1(context, function next(){
return Promise.resolve(
fn2(context, function next(){
return Promise.resolve(
fn3(context, function next(){
return Promise.resolve();
})
)
})
)
})
);
};


也就是说koa-compose返回的是一个Promise,从中间件(传入的数组)中取出第一个函数,传入context和第一个next函数来执行。

第一个next函数里也是返回的是一个Promise,从中间件(传入的数组)中取出第二个函数,传入context和第二个next函数来执行。

第二个next函数里也是返回的是一个Promise,从中间件(传入的数组)中取出第三个函数,传入context和第三个next函数来执行。

第三个...

以此类推。最后一个中间件中有调用next函数,则返回Promise.resolve。如果没有,则不执行next函数。



这样就把所有中间件串联起来了。这也就是我们常说的洋葱模型。


洋葱模型图如下图所示:


不得不说非常惊艳,“玩还是大神会玩”


3.2 错误捕获


it('should catch downstream errors', async () => {
const arr = []
const stack = []

stack.push(async (ctx, next) => {
arr.push(1)
try {
arr.push(6)
await next()
arr.push(7)
} catch (err) {
arr.push(2)
}
arr.push(3)
})

stack.push(async (ctx, next) => {
arr.push(4)
throw new Error()
})

await compose(stack)({})
// 输出顺序 是 [ 1, 6, 4, 2, 3 ]
expect(arr).toEqual([1, 6, 4, 2, 3])
})

相信理解了第一个测试用例和 compose 函数,也是比较好理解这个测试用例了。这一部分其实就是对应的代码在这里。

try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}

3.3 next 函数不能调用多次


it('should throw if next() is called multiple times', () => {
return compose([
async (ctx, next) => {
await next()
await next()
}
])({}).then(() => {
throw new Error('boom')
}, (err) => {
assert(/multiple times/.test(err.message))
})
})

这一块对应的则是:


index = -1
dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
}

调用两次后 iindex 都为 1,所以会报错。


compose/test/test.js文件中总共 300余行,还有很多测试用例可以按照文中方法自行调试。


4. 总结


虽然koa-compose源码 50行 不到,但如果是第一次看源码调试源码,还是会有难度的。其中混杂着高阶函数、闭包、Promisebind等基础知识。


通过本文,我们熟悉了 koa-compose 中间件常说的洋葱模型,学会了部分 jest 用法,同时也学会了如何使用现成的测试用例去调试源码。


相信学会了通过测试用例调试源码后,会觉得源码也没有想象中的那么难


开源项目,一般都会有很全面的测试用例。除了可以给我们学习源码调试源码带来方便的同时,也可以给我们带来的启发:自己工作中的项目,也可以逐步引入测试工具,比如 jest


此外,读开源项目源码是我们学习业界大牛设计思想和源码实现等比较好的方式。



链接:https://juejin.cn/post/7005375860509245471

收起阅读 »

这是一个被面烂的面试题——简述 JavaScript 的事件捕获和事件冒泡

JavaScript 事件冒泡是为了捕捉和处理 DOM 内部传播的事件。但是你知道事件冒泡和事件捕获之间的区别吗? 在这篇文章中,我将用相关的示例来讨论关于这个主题你所需要了解的全部情况。 事件流的传播 在介绍事件捕获和事件冒泡之前,先来看下一个事件是如何在 ...
继续阅读 »

JavaScript 事件冒泡是为了捕捉和处理 DOM 内部传播的事件。但是你知道事件冒泡和事件捕获之间的区别吗?


在这篇文章中,我将用相关的示例来讨论关于这个主题你所需要了解的全部情况。


事件流的传播


在介绍事件捕获和事件冒泡之前,先来看下一个事件是如何在 DOM 内部传播的。


如果我们有几个嵌套的元素处理同一个事件,我们会对哪个事件处理程序会先触发的问题感到困惑。这时,理解事件传播顺序就变得很有必要。



通常,一个事件会从父元素开始向目标元素传播,然后它将被传播回父元素。



JavaScript 事件分为三个阶段:



  • 捕获阶段:事件从父元素开始向目标元素传播,从 Window 对象开始传播。

  • 目标阶段:该事件到达目标元素或开始该事件的元素。

  • 冒泡阶段:这时与捕获阶段相反,事件向父元素传播,直到 Window 对象。


下图将让你进一步了解事件传播的生命周期:


22.jpg


现在你大概了解了 DOM 内部的事件流程,让我们再来看下事件捕获和冒泡是如何出现的。


什么是事件捕获



事件捕获是事件传播的初始场景,从包装元素开始,一直到启动事件生命周期的目标元素。



如果你有一个与浏览器的 Window 对象绑定的事件,它将是第一个被执行的。所以,在下面的例子中,事件处理的顺序将是 WindowDocumentDIV 2DIV 1,最后是 button


33.gif


这里我们可以看到,事件捕获只发生在被点击的元素或目标上,该事件不会传播到子元素。


我们可以使用 addEventListener() 方法的 useCapture 参数来注册捕捉阶段的事件。


target.addEventListener(type, listener, useCapture)

你可以使用下面的代码来测试上述示例,并获得事件捕获的实践经验。


window.addEventListener("click", () => {
console.log('Window');
},true);

document.addEventListener("click", () => {
console.log('Document');
},true);

document.querySelector(".div2").addEventListener("click", () => {
console.log('DIV 2');
},true);

document.querySelector(".div1").addEventListener("click", () => {
console.log('DIV 1');
},true);

document.querySelector("button").addEventListener("click", () => {
console.log('CLICK ME!');
},true);

什么是事件冒泡


如果你知道事件捕获,事件冒泡就很容易理解,它与事件捕获是完全相反的。



事件冒泡将从一个子元素开始,在 DOM 树上传播,直到最上面的父元素事件被处理。



addEventListener() 中省略或将 useCapture 参数设置为 false,将注册冒泡阶段的事件。所以,事件监听器默认监听冒泡事件。


44.gif


在我们的示例中,我们对所有的事件使用了事件捕获或事件冒泡。但是如果我们想在两个阶段内都处理事件呢?


让我们举个例子,在冒泡阶段处理 DocumentDIV 2 的点击事件,其他事件则在捕获阶段处理。


55.gif


连接到 WindowDIV 1button 的点击事件将在捕获过程中分别触发,而 DIV 2Document 监听器则在冒泡阶段依次触发。


window.addEventListener("click", () => {
console.log('Window');
},true);

document.addEventListener("click", () => {
console.log('Document');
}); // 已注册为冒泡

document.querySelector(".div2").addEventListener("click", () => {
console.log('DIV 2');
}); // 已注册为冒泡

document.querySelector(".div1").addEventListener("click", () => {
console.log('DIV 1');
},true);

document.querySelector("button").addEventListener("click", () => {
console.log('CLICK ME!');
},true);

我想现在你已经对事件流、事件冒泡和事件捕获有了很好的理解。那么,让我们看下什么时候可以使用事件冒泡和事件捕获。


事件捕获和冒泡的应用


通常情况下,我们只需要在全局范围内执行一个函数,就可以使用事件传播。例如,我们可以注册文档范围内的监听器,如果 DOM 内有事件发生,它就会运行。



同样地,我们可以使用事件捕获和冒泡来改变用户界面。



假设我们有一个允许用户选择单元格的表格,我们需要向用户显示所选单元格。


66.gif



在这种情况下,为每个单元格分配事件处理程序将不是一个好的做法。它最终会导致代码的重复。



作为一个解决方案,我们可以使用一个单独的事件监听器,并利用事件冒泡和捕获来处理这些事件。


因此,我为 table 创建了一个单独的事件监听器,它将被用来改变单元格的样式。


document.querySelector("table").addEventListener("click", (event) =>
{
if (event.target.nodeName == 'TD')
event.target.style.background = "rgb(230, 226, 40)";
}
);

在事件监听器中,我使用 nodeName 来匹配被点击的单元格,如果匹配,单元格的颜色就会改变。


如何防止事件传播



有时,如果事件冒泡和捕捉开始不受我们控制地传播时,就会让人感到厌烦。



如果你有一个严重嵌套的元素结构,这也会导致性能问题,因为每个事件都会创建一个新的事件周期。


77.gif


在上述情况下,当我点击删除按钮时,包装元素的点击事件也被触发了。这是由于事件冒泡导致的。



我们可以使用 stopPropagation() 方法来避免这种行为,它将阻止事件沿着 DOM 树向上或向下进一步传播。



document.querySelector(".card").addEventListener("click", () => {
$("#detailsModal").modal();
});

document.querySelector("button").addEventListener("click",(event)=>{
event.stopPropagation(); // 停止冒泡
$("#deleteModal").modal();
});

88.gif


本文总结


JavaScript 事件捕获和冒泡可以用来有效地处理 Web 应用程序中的事件。了解事件流以及捕获和冒泡是如何工作的,将有助于你通过正确的事件处理来优化你的应用程序。


例如,如果你的应用程序中有任何意外的事件启动,了解事件捕获和冒泡可以节省你排查问题的时间。


因此,我希望你尝试上述示例并在评论区分享你的经验。


感谢阅读!


链接:https://juejin.cn/post/7005558885947965454

收起阅读 »

几个简单的小例子手把手带你入门webgl

各位同学们大家好,又到了周末写文章的时间,之前群里有粉丝提问, 就是shader不是很理解。 然后今天他就来了, 废话不多说,读完今天的这篇文章你可以学到以下几点: 为什么需要有shader ? shader的作用是什么???? shader 中的每个参数到...
继续阅读 »

各位同学们大家好,又到了周末写文章的时间,之前群里有粉丝提问, 就是shader不是很理解。 然后今天他就来了, 废话不多说,读完今天的这篇文章你可以学到以下几点:



  1. 为什么需要有shader ? shader的作用是什么????

  2. shader 中的每个参数到底是什么意思?? 怎么去用???


你如果会了,这篇文章你可以不用看👀,不用浪费时间,去看别的文章。 如果哪里写的有问题欢迎大家指正,我也在不断地学习当中。


WHY NEED SHADER


这里我结合自己的思考🤔,讲讲webgl的整个的一个渲染过程。


渲染管线


Webgl的渲染依赖底层GPU的渲染能力。所以WEBGL 渲染流程和 GPU 内部的渲染管线是相符的。


渲染管线的作用是将3D模型转换为2维图像。


在早期,渲染管线是不可编程的,叫做固定渲染管线,工作的细节流程已经固定,修改的话需要调整一些参数。


现代的 GPU 所包含的渲染管线为可编程渲染管线,可以通过编程 GLSL 着色器语言 来控制一些渲染阶段的细节。


简单来说: 就是使用shader,我们可以对画布中每个像素点做处理,然后就可以生成各种酷炫的效果了。


渲染过程


渲染过程大概经历了下面这么多过程, 因为本篇文章的重点其实是在着色器,所以我重点分析从顶点着色器—— 片元着色器的一个过程



  • 顶点着色器

  • 图片装配

  • 光栅化

  • 片元着色器

  • 逐片段操作(本文不会分享此内容)

  • 裁剪测试

  • 多重采样操作

  • 背面剔除

  • 模板测试

  • 深度测试

  • 融合

  • 缓存


顶点着色器


WebGL就是和GPU打交道,在GPU上运行的代码是一对着色器,一个是顶点着色器,另一个是片元着色器。每次调用着色程序都会先执行顶点着色器,再执行片元着色器。


一个顶点着色器的工作是生成裁剪空间坐标值,通常是以下的形式:


const vertexShaderSource = `
   attribute vec3 position;
   void main() {
       gl_Position = vec4(position,1);
   }

每个顶点调用一次(顶点)着色器,每次调用都需要设置一个特殊的全局变量 gl_Position。 该变量的值就是裁减空间坐标值。 这里有同学就问了, 什么是裁剪空间的坐标值???


其实我之前有讲过,我在讲一遍。


何为裁剪空间坐标?就是无论你的画布有多大,裁剪坐标的坐标范围永远是 -1 到 1 。


看下面这张图:


裁剪坐标系


如果运行一次顶点着色器, 那么gl_Position 就是 (-0.5,-0.5,0,1) 记住他永远是个 Vec4, 简单理解就是对应x、y、z、w。即使你没用其他的,也要设置默认值, 这就是所谓的 3维模型转换到我们屏幕中。


顶点着色器需要的数据,可以通过以下四种方式获得。



  1. attributes 属性(从缓冲读取数据)

  2. uniforms 全局变量 (一般用来对物体做整体变化、 旋转、缩放)

  3. textures 纹理(从像素或者纹理获得数据)

  4. varyings 变量 (将顶点着色器的变量 传给 片元着色器)


ATTRIBUTES 属性


属性可以用 float, vec2, vec3, vec4, mat2, mat3mat4 数据类型


所以它内建的数据类型例如vec2, vec3vec4分别代表两个值,三个值和四个值, 类似的还有mat2, mat3mat4 分别代表 2x2, 3x3 和 4x4 矩阵。 你可以做一些运算例如常量和矢量的乘法。看几个例子吧:


vec4 a = vec4(1, 2, 3, 4);
vec4 b = a * 2.0;
// b 现在是 vec4(2, 4, 6, 8);

向量乘法 和矩阵乘法 :


mat4 a = ???
mat4 b = ???
mat4 c = a * b;

vec4 v = ???
vec4 y = c * v;

它还支持矢量调制,意味者你可以交换或重复分量。


v.yyyy  ===  vec4(y, y, y,y )
v.bgra  ===  vec4(v.b,v.g,v.r,v.a)
vec4(v.rgb, 1) ===  vec4(v.r, v.g, v.b, 1)
vec4(1) === vec4(1, 1, 1, 1)

这样你在处理图片的时候可以轻松进行 颜色通道 对调, 发现你可以实现各种各样的滤镜了。


后面的属性在下面实战中会讲解:我们接着往下走:


图元装配和光栅化


什么是图元?



描述各种图形元素的函数叫做图元,描述几何元素的称为几何图元(点,线段或多边形)。点和线是最简单的几何图元 经过顶点着色器计算之后的坐标会被组装成组合图元



通俗解释图元就是一个点、一条线段、或者是一个多边形。


什么是图元装配呢?


简单理解就是说将我们设置的顶点、颜色、纹理等内容组装称为一个可渲染的多边形的过程。


组装的类型取决于: 你最后绘制选择的图形类型


gl.drawArrays(gl.TRIANGLES, 0, 3)

如果是三角形的话,顶点着色器就执行三次


光栅化


什么是光栅化:


通过图元装配生成的多边形,计算像素并填充,剔除不可见的部分,剪裁掉不在可视范围内的部分。最终生成可见的带有颜色数据的图形并绘制。


光栅化流程图解:


光珊化图解


剔除和剪裁




  • 剔除


    在日常生活中,对于不透明物体,背面对于观察者来说是不可见的。同样,在webgl中,我们也可以设定物体的背面不可见,那么在渲染过程中,就会将不可见的部分剔除,不参与绘制。节省渲染开销。




  • 剪裁


    日常生活中不论是在看电视还是观察物体,都会有一个可视范围,在可视范围之外的事物我们是看不到的。类似的,图形生成后,有的部分可能位于可视范围之外,这一部分会被剪裁掉,不参与绘制。以此来提高性能。这个就是视椎体, 在📷范围内能看到的东西,才进行绘制。




片元着色器


光珊化后,每一个像素点都包含了 颜色 、深度 、纹理数据, 这个我们叫做片元



小tips : 每个像素的颜色由片元着色器的gl_FragColor提供



接收光栅化阶段生成的片元,在光栅化阶段中,已经计算出每个片元的颜色信息,这一阶段会将片元做逐片元挑选的操作,处理过的片元会继续向后面的阶段传递。 片元着色器运行的次数由图形有多少个片元决定的


逐片元挑选


通过模板测试和深度测试来确定片元是否要显示,测试过程中会丢弃掉部分无用的片元内容,然后生成可绘制的二维图像绘制并显示。



  • 深度测试: 就是对 z 轴的值做测试,值比较小的片元内容会覆盖值比较大的。(类似于近处的物体会遮挡远处物体)。

  • 模板测试: 模拟观察者的观察行为,可以接为镜像观察。标记所有镜像中出现的片元,最后只绘制有标记的内容。


实战——绘制个三角形


在进行实战之前,我们先给你看一张图,让你能大概了解,用原生webgl生成一个三角形需要那些步骤:


draw


我们就跟着这个流程图一步一步去操作:


初始化CANVAS


新建一个webgl画布


<canvas id="webgl" width="500" height="500"></canvas>

创建webgl 上下文:


const gl = document.getElementById('webgl').getContext('webgl')

创建着色器程序


着色器的程序这些代码,其实是重复的,我们还是先看下图,看下我们到底需要哪些步骤:


shader


那我们就跟着这个流程图: 一步一步来好吧。


创建着色器


 const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)

gl.VERTEX_SHADER 和 gl.FRAGMENT_SHADER 这两个是全局变量 分别表示顶点着色器片元着色器


绑定数据源


顾名思义: 数据源,也就是我们的着色器 代码。


编写着色器代码有很多种方式:



  1. 用 script 标签 type notjs 这样去写

  2. 模板字符串 (比较喜欢推荐这种)


我们先写顶点着色器:


const vertexShaderSource = `
   attribute vec4 a_position;
   void main() {
       gl_Position = a_position;
   }
`

顶点着色器 必须要有 main 函数 ,他是强类型语言, 记得加分号哇 不是js 兄弟们。 我这段着色器代码非常简单 定义一个vec4 的顶点位置, 然后传给 gl_Position


这里有小伙伴会问 ? 这里a_position一定要这么搞??


这里其实是这样的哇, 就是我们一般进行变量命名的时候 都会的前缀 用来区分 他是属性 还是 全局变量 还是纹理 比如这样:


uniform mat4 u_mat;

表示个矩阵,如果不这样也可以哈。 但是要专业呗,防止bug 影响。


我们接着写片元着色器:


const fragmentShaderSource = `
   void main() {
       gl_FragColor = vec4(1.0,0.0,0.0,1.0);
   }
`

这个其实理解起来非常简单哈, 每个像素点的颜色 是红色 , gl_FragColor 其实对应的是 rgba 也就是颜色的表示。


有了数据源之后开始绑定:


// 创建着色器
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
//绑定数据源
gl.shaderSource(vertexShader, vertexShaderSource)
gl.shaderSource(fragmentShader, fragmentShaderSource)


是不是很简答哈哈哈哈,我觉得你应该会了。


后面着色器的一些操作


其实后面编译着色器绑定着色器连接着色器程序使用着色器程序 都是一个api 搞定的事不多说了 直接看代码:


// 编译着色器
gl.compileShader(vertexShader)
gl.compileShader(fragmentShader)
// 创建着色器程序
const program = gl.createProgram()
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
// 链接 并使用着色器
gl.linkProgram(program)
gl.useProgram(program)

这样我们就创建好了一个着色器程序了。


这里又有人问,我怎么知道我创建的着色器是对的还是错的呢? 我就是很粗心的人呢??? 好的他来了 如何调试:


const success = gl.getProgramParameter(program, gl.LINK_STATUS)
if (success) {
 gl.useProgram(program)
 return program
}
console.error(gl.getProgramInfoLog(program), 'test---')
gl.deleteProgram(program)


getProgramParameter 这个方法用来判断 我们着色器 glsl 语言写的是不是对的, 然后你可以通过 getProgramInfoLog这个方法 类似于打 日志 去发现❌了。


数据存入缓冲区


有了着色器,现在我们差的就是数据了对吧。


上文在写顶点着色器的时候用到了Attributes属性,说明是这个变量要从缓冲中读取数据,下面我们就来把数据存入缓冲中。


首先创建一个顶点缓冲区对象(Vertex Buffer Object, VBO)


const buffer = gl.createBuffer()

gl.createBuffer()函数创建缓冲区并返回一个标识符,接下来需要为WebGL绑定这个buffer


gl.bindBuffer(gl.ARRAY_BUFFER, buffer)

gl.bindBuffer()函数把标识符buffer设置为当前缓冲区,后面的所有的数据都会都会被放入当前缓冲区,直到bindBuffer绑定另一个当前缓冲区


我们新建一个数组 然后并把数据存入到缓冲区中。


const data = new Float32Array([0.0, 0.0, -0.3, -0.3, 0.3, -0.3])
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)

因为JavaScript与WebGL通信必须是二进制的,不能是传统的文本格式,所以这里使用了ArrayBuffer对象将数据转化为二进制,因为顶点数据是浮点数,精度不需要太高,所以使用Float32Array就可以了,这是JavaScript与GPU之间大量实时交换数据的有效方法。


gl.STATIC_DRAW 指定数据存储区的使用方法: 缓存区的内容可能会经常使用,但是不会更改


gl.DYNAMIC_DRAW 表示 缓存区的内容经常使用,也会经常更改。


gl.STREAM_DRAW 表示缓冲区的内容可能不会经常使用


从缓冲中读取数据


GLSL着色程序的唯一输入是一个属性值a_position。 我们要做的第一件事就是从刚才创建的GLSL着色程序中找到这个属性值所在的位置。


const aposlocation = gl.getAttribLocation(program, 'a_position')

接下来我们需要告诉WebGL怎么从我们之前准备的缓冲中获取数据给着色器中的属性。 首先我们需要启用对应属性


gl.enableVertexAttribArray(aposlocation)

最后是从缓冲中读取数据绑定给被激活的aposlocation的位置


gl.vertexAttribPointer(aposlocation, 2, gl.FLOAT, false, 0, 0)

gl.vertexAttribPointer()函数有六个参数:



  1. 读取的数据要绑定到哪

  2. 表示每次从缓存取几个数据,也可以表示每个顶点有几个单位的数据,取值范围是1-4。这里每次取2个数据,之前vertices声明的6个数据,正好是3个顶点的二维坐标。

  3. 表示数据类型,可选参数有gl.BYTE有符号的8位整数,gl.SHORT有符号的16位整数,gl.UNSIGNED_BYTE无符号的8位整数,gl.UNSIGNED_SHORT无符号的16位整数,gl.FLOAT32位IEEE标准的浮点数。

  4. 表示是否应该将整数数值归一化到特定的范围,对于类型gl.FLOAT此参数无效。

  5. 表示每次取数据与上次隔了多少位,0表示每次取数据连续紧挨上次数据的位置,WebGL会自己计算之间的间隔。

  6. 表示首次取数据时的偏移量,必须是字节大小的倍数。0表示从头开始取。


渲染


现在着色器程序 和数据都已经ready 了, 现在就差渲染了。 渲染之前和2d canvas 一样做一个清除画布的动作:


// 清除canvas
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)

我们用0、0、0、0清空画布,分别对应 r, g, b, alpha (红,绿,蓝,阿尔法)值, 所以在这个例子中我们让画布变透明了。


开启绘制三角形:


gl.drawArrays(gl.TRIANGLES, 0, 3)


  1. 第一个参数表示绘制的类型

  2. 第二个参数表示从第几个顶点开始绘制

  3. 第三个参数表示绘制多少个点,缓冲中一共6个数据,每次取2个,共3个点


绘制类型共有下列几种 看图:


drawtype


这里我们看下画面是不是一个红色的三角形 :


三角形截图


我们创建的数据是这样的:


画布的宽度是 500 * 500 转换出来的实际数据其实是这样的


0,0  ====>  0,0 
-0.3, -0.3 ====> 175, 325
0.3, -0.3 ====>  325, 325

矩阵的使用


有了静态的图形我们开始着色器,对三角形做一个缩放。


改写顶点着色器: 其实在顶点着色器上加一个全局变量 这就用到了 着色器的第二个属性 uniform


 const vertexShaderSource = `
 attribute vec4 a_position;
 // 添加矩阵代码
 uniform mat4 u_mat;
 void main() {
     gl_Position = u_mat * a_position;
 }
`

然后和属性一样,我们需要找到 uniform 对应的位置:


const matlocation = gl.getUniformLocation(program, 'u_mat')

然后初始化一个缩放举证:


// 初始化一个旋转矩阵。
 const mat = new Float32Array([
   Tx,  0.0, 0.0, 0.0,
   0.0,  Ty, 0.0, 0.0,
   0.0, 0.0,  Tz, 0.0,
   0.0, 0.0, 0.0, 1.0,
]);

Tx, Ty, Tz 对应的其实就是 x y z 轴缩放的比例。


最后一步, 将矩阵应用到着色器上, 在画之前, 这样每个点 就可以✖️ 这个缩放矩阵了 ,所以整体图形 也就进行了缩放。


gl.uniformMatrix4fv(matlocation, false, mat)

三个参数分别代表什么意思:



  1. 全局变量的位置

  2. 是否为转置矩阵

  3. 矩阵数据


OK 我写了三角形缩放的动画:


  let Tx = 0.1 //x坐标的位置
 let Ty = 0.1 //y坐标的位置
 let Tz = 1.0 //z坐标的位置
 let Tw = 1.0 //差值
 let isOver = true
 let step = 0.08
 function run() {
   if (Tx >= 3) {
     isOver = false
  }
   if (Tx <= 0) {
     isOver = true
  }
   if (isOver) {
     Tx += step
     Ty += step
  } else {
     Tx -= step
     Ty -= step
  }
   const mat = new Float32Array([
     Tx,  0.0, 0.0, 0.0,
     0.0,  Ty, 0.0, 0.0,
     0.0, 0.0,  Tz, 0.0,
     0.0, 0.0, 0.0, 1.0,
  ]);
   gl.uniformMatrix4fv(matlocation, false, mat)
   gl.drawArrays(gl.TRIANGLES, 0, 3)

   // 使用此方法实现一个动画
   requestAnimationFrame(run)
}

效果图如下:


缩放动画


最后 给大家看一下webgl 内部是怎么搞的 一张gif 动画 :


vertex-shader-anim


原始的数据通过 顶点着色器 生成一系列 新的点。


变量的使用


说完矩阵了下面👇,我们开始说下着色器中的varying 这个变量 是如何和片元着色器进行联动的。


我们还是继续改造顶点着色器:


const vertexShaderSource = `
 attribute vec4 a_position;
 uniform mat4 u_mat;
 // 变量
 varying vec4 v_color;
 void main() {
     gl_Position = u_mat * a_position;
     v_color = gl_Position * 0.5 + 0.5;
 }
`

这里有一个小知识 , gl_Position 他的值范围是 -1 -1 但是片元着色 他是颜色 他的范围是 0 - 1 , 所以呢这时候呢,我们就要 做一个范围转换 所以为什么要 乘 0.5 在加上 0.5 了, 希望你们明白。


改造下片元着色器:


const fragmentShaderSource = `
   precision lowp float;
   varying vec4 v_color;
   void main() {
       gl_FragColor = v_color;
   }
`

只要没一个像素点 改为由顶点着色器传过来的就好了。


我们看下这时候的三角形 变成啥样子了。


彩色三角形


是不是变成彩色三角形了, 这里很多人就会问, 这到底是怎么形成呢, 本质是在三角形的三个顶点, 做线性插值的过程:


插值过程


总结


本篇文章大概是对webgl 做了一个基本的介绍, 和带你用几个简单的小例子 带你入门了glsl 语言, 你以为webgl 就这样嘛 那你就错了,其实有一个texture 我是没有讲的, 后面我去专门写一篇文章去将纹理贴图 , 漫反射贴图、 法线贴图。 希望你关注下我,不然找不到我了, 如果你觉得本篇文章对你有帮助的话,欢迎 点赞 、评论、收藏。 我们下期再见👋。




链接:https://juejin.cn/post/7004386540843434020

收起阅读 »

完美解决macOS Homebrew安装JDK的一些问题

自从Oracle接手JDK之后,更新变快了,之前的“旧版本”也不容易下载了。最近一段时间Oracle一直不安生, 搞出来一堆幺蛾子, 所以安装方式也一直在变, 之前的方法已经不能用了, 网上找各种办法都不好使,下面针对各个版本给出了不同建议, 安装结束后, 可...
继续阅读 »

自从Oracle接手JDK之后,更新变快了,之前的“旧版本”也不容易下载了。最近一段时间Oracle一直不安生, 搞出来一堆幺蛾子, 所以安装方式也一直在变, 之前的方法已经不能用了, 网上找各种办法都不好使,下面针对各个版本给出了不同建议, 安装结束后, 可输入java -version确认是否安装成功。


一、JDK8~JDK12、OpenJDK及AdoptOpenJDK


这些都是比较主流的JDK版本, 目前大多数企业还在使用,但想要通过 Homebrew 却并不容易,网上查询的90%Homebrew安装JDK8的方式都是不能用的, 必须要寻求开源世界的帮助, 对于 JDK8 ~ JDK12, 这时会推荐 AdoptOpenJDK.


AdoptOpenJDK 是免费的、完全无品牌的 OpenJDK 版本,基于 GPL 开源协议(+Classpath Extension),以免费软件的形式提供社区版的 OpenJDK 二进制包,公司也可安全且放心使用。与由 Oracle 的 OpenJDK 构建版本不同,这些版本会提供更长的支持,像 Java 11 一样,至少提供 4 年的免费长期支持(LTS)计划。


通过 AdoptOpenJDK 可以安装最多版本的 JDK.


brew cask install AdoptOpenJDK/openjdk/adoptopenjdk8
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk9
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk10
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk11
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk12
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk

二、JDK12、JDK13及OracleJDK


如果你想在电脑上装最新版的 JDK, 那么 Oracle 或许是你最想要的选择, 而 Oracle 家的最新版 JDK 也有两款, 一个是 Oracle 提供的 OpenJDK, 一个是商业版本 Oracle JDK, 但请注意 Oracle JDK 并不比 OpenJDK “更好”, 大家需要理性看待.


# 运行以下命令会安装 Oracle 提供的 Oracle JDK12
brew cask install oracle-jdk

# 在2019年5月
## 该命令会安装由 Oracle 提供的 OpenJDK12
brew cask install java
## 而该命令则安装由 Oracle 提供的 OpenJDK11
brew cask install java11

三、JDK7和Zulu


JDK7 甚至 AdoptOpenJDK 都不提供了, 这时候需要的是有商业背景的 Azul Zulu, zulu 是 OpenJDK 的免费版本, 在提供商业付费支持之外, Azul 也有为 zulu 提供免费的社区技术支持.


通过安装 zulu7 我们可以安装 OpenJDK7.


# Azul Zulu 也提供其他版本的 OpenJDK 像 zulu8 zulu11 和最新版的 zulu 均可使用
brew cask install homebrew/cask-versions/zulu7
brew cask install homebrew/cask-versions/zulu8
brew cask install homebrew/cask-versions/zulu11
brew cask install homebrew/cask-versions/zulu

四、JDK6


估计现在连好多企业都不用了吧,所以放到左后。 JDK6 主要由 Apple 自身提供。


brew cask install homebrew/cask-versions/java6



遇到问题耐心解决,如果还没解决,那肯定是时间没花够



  • 9月21日心心念想、梦寐以求的MacBook Pro (13-inch, 2019, Four Thunderbolt 3 ports)入手的第一天晚上,我拆机用了多半个小时,就是边拆边拍照,拆个盒子都要洗个手😂那种。在这之前,我平时抽空就提前“上手macOS”

  • 依稀还记得初一自己捣鼓的那台组装机,主板:华硕,CPU:英特尔奔腾E5400,硬盘512G HDD,金士顿2G,现在确实已经很卡了,尽管装了最新版的Windows v1903专业版。不过作为我的第一台电脑,好多东西都是在它上面学的,感谢下老爸将它作为考了年级第一奖励台电脑送给我。

  • 9月21日那天晚上,emmm,是晚上,大家应该和我有同感,一到晚上一些站点就特别慢,比如GitHub之类的,但强迫症的我肯定得在第一时间部署好我的开发环境吧,毕竟大二那会儿我快成专业的运维了~~同学电脑有毛病就找我,开发环境(Java/Android/Python/Node~)有问题还是找我。所以就先装Git、JDK、Maven吧。

  • Git简单,现在macOS 虽然不自带Git,但是安装Homebrew之前安装的Command Line Tools里面包括了Git、gcc等工具,很方便,也比较快(前提是切换了Homebrew的源

  • JDK确实费了老大劲才装好,还是第二天早晨6点10分起来干的,因为早晨访问GitHub这种站点确实比下午快很多。最后诞生了此文。

  • Maven不说了,可以Homebrew安装,也可以在Maven官网下载包之后解压,然后使用,好多人可能会想着配Mavne的环境变量,其实我个人认为没必要。直接说下我是怎么使用的吧:IDEA现在是Java开发的主流IDE工具,里面的终端可以自己配置,结合zsh加上oh my zsh简直无可挑剔!我是使用了“两个Maven”,一个是公司用(公司有自己的Maven仓库),另一个是自己玩(配置的阿里云镜像),在同一个IDEA切换不同的settings.xml便可以实现多Maven切换,之间互不影响,主要是IDEA确实智能,智能在部分配置是每个项目单独的,多个项目可以完全配置不同的Maven,公司项目和自己项目随意切换,Maven也跟着换,很方便。至于不配置Maven环境mvn命令不能使用的问题我想说:IDEA里面依然可以执行你手敲的mvn -U clean package -Dmaven.test.skip=true,所以我不推荐配置Maven的环境变量。用多个Maven的原因我想大家也没明白,如果公司项目里修改了某个包的源码或者重写了某个方法,而你自己项目同样使用了该包的,那么这种情况下极易出错,使用Maven或多或少可能会遇到Maven存在一些Bug,这种迭代了N个版本依然存在的Bug,也许这就是包统一管理的缺陷,如果至今你还没碰过这种情况,说明你的项目比较稳定或者emmmm...你平时自己不捣鼓学习一些东西。


链接:https://juejin.cn/post/6896353939277496327

收起阅读 »

如何将react-native的style样式转换成css样式

背景: 我们总是倾向于一套代码走天下,正所谓一招鲜,吃遍天。刚接触RN项目的时候,常常为RN style样式的写法而头痛,等到熟悉了RN样式写法时,一个web端项目从天而降,于是,你又不得不操练起日渐陌生的css写法。更过分的是,有时你还得在RN样式和css样...
继续阅读 »

背景: 我们总是倾向于一套代码走天下,正所谓一招鲜,吃遍天。刚接触RN项目的时候,常常为RN style样式的写法而头痛,等到熟悉了RN样式写法时,一个web端项目从天而降,于是,你又不得不操练起日渐陌生的css写法。更过分的是,有时你还得在RN样式和css样式之间来回切换,时刻处于水深火热之中。抬首间,不禁叹息一声:人间不值得。


一、准备工作


本文中详细讲解sass样式的转换,其它诸如less、css、PostCss的转换请参考:(https://github.com/kristerkari/react-native-css-modules)
这里面有较为详细说明。


我们需要准备四个依赖:

react-native-sass-transformer 将 Sass 转换为与 React Native 兼容的样式对象并处理实时重新加载

babel-plugin-react-native-platform-specific-extensions 如果磁盘上存在特定于平台的文件,则将 ES6 导入语句转换为特定于平台的 require 语句

babel-plugin-react-native-classname-to-style 将 className 属性转换为 style 属性

node-sass


二、 创建一个React-Native APP


参考官方文档创建即可。


三、安装依赖


yarn add babel-plugin-react-native-classname-to-style babel-plugin-react-native-platform-specific-extensions react-native-sass-transformer node-sass --dev

四、设置babel配置


对于React Native v0.57 或者更新版本


.babelrc (or babel.config.js)


{
"presets": ["module:metro-react-native-babel-preset"],
"plugins": [
"react-native-classname-to-style",
[
"react-native-platform-specific-extensions",
{
"extensions": ["scss", "sass"]
}
]
]
}

对于React Native v0.57以下版本


{
"presets": ["react-native"],
"plugins": [
"react-native-classname-to-style",
[
"react-native-platform-specific-extensions",
{
"extensions": ["scss", "sass"]
}
]
]
}

五、设置Metro配置


在项目根目录下新增一个metro.config.js的文件


const { getDefaultConfig } = require("metro-config");

module.exports = (async () => {
const {
resolver: { sourceExts }
} = await getDefaultConfig();
return {
transformer: {
babelTransformerPath: require.resolve("react-native-sass-transformer")
},
resolver: {
sourceExts: [...sourceExts, "scss", "sass"]
}
};
})()
;


对于React Native v0.57以下版本,在根目录下新增rn-cli.config.js文件


module.exports = {
getTransformModulePath() {
return require.resolve("react-native-sass-transformer");
},
getSourceExts() {
return ["js", "jsx", "scss", "sass"];
}
};

六、接下来你就可以愉快的使用sass来写样式


style.scss


.container {
flex: 1;
justify-content: center;
align-items: center;
background-color: #f5fcff;
}

.blue {
color: blue;
font-size: 30px;
}


你既可以使用className来写样式,也可以使用style


import React, { Component } from "react";
import { Text, View } from "react-native";
import styles from "./styles.scss";

const BlueText = () => {
return Blue Text;
};

export default class App extends Component<{}> {
render() {
return (



);
}
}

七、为sass配置TypeScript


在ts项目中,为sass配置类型提示很有必要。首先我们需要把在第三步第五步中把react-native-sass-transformer依赖替换成react-native-typed-sass-transformer


为了让className 属性正常工作,我们还需要安装下面的依赖包:


对于React Native v0.57 或者更新版本


yarn add typescript --dev

老版本:


yarn add react-native-typescript-transformer typescript --dev

在package.json中添加下面依赖,然后运行yarn命令


"@types/react-native": "^0.57.55",

如果版本versions >=0.52.4


"@types/react-native": "kristerkari/react-native-types-for-css-modules#v0.57.55",

你也可以删掉版本号,但是不建议这样做


"@types/react-native": "kristerkari/react-native-types-for-css-modules",

如果你使用的rn版本>=0.57,这样就OK了,如果不是,请参照文档:github.com/kristerkari…


八、原生提供的属性和方法如何添加到scss文件中,如何做不同机型的适配?


我们需要自定义一个transform用于sass文件的转换。


metro.config.js文件中,修改如下:


const { getDefaultConfig } = require("metro-config");

module.exports = (async () => {
const {
resolver: { sourceExts }
} = await getDefaultConfig();
return {
transformer: {
babelTransformerPath: require.resolve("./transformer.js")
},
resolver: {
sourceExts: [...sourceExts, "scss", "sass"]
}
};
})()
;


metro.config.js


const upstreamTransformer = require("metro-react-native-babel-transformer");
const sassTransformer = require("react-native-typed-sass-transformer");
const DtsCreator = require("typed-css-modules");
const css2rn = require("css-to-react-native-transform").default;

const creator = new DtsCreator();

/** 引入原生的属性和方法 */
const preImport = `
import { PixelRatio, Dimensions, StatusBar, Platform } from 'react-native';
let DEVICE_WIDTH = Dimensions.get('window').width;
let DEVICE_HEIGHT = Dimensions.get('window').height;
let S=(designPx) => {
return PixelRatio.roundToNearestPixel((designPx / 750) * DEVICE_WIDTH);
}
`

function renderCSSToReactNative(css) {
return css2rn(css, { parseMediaQueries: true });
}

/** px转换成pt,做一个标记 */
function pxToPtForMark(code){
let newCode=code;
try {
newCode=code.replace(/([0-9]+)px/g,(...arg)=>{
const px=Number(arg[1]);
return `${px}pt`;
})
} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

/** px 或者 pt单位的适配 需要注意正负值 */
function unitAdaption(code){
let newCode=code;
try {
newCode=code.replace(/"([-+]{0,1})([0-9]+)pt"/g,(...arg)=>{
const px=arg[1]+arg[2];
return `S(${px})`;
})
} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

/** vh和vw的适配 */
function vhAndVwAdaption(code){
let newCode=code;
try {
newCode=code.replace(/"([0-9]+)vw"/g,(...arg)=>{
const vw=Number(arg[1]);
return `${vw/100} * DEVICE_WIDTH`;
}).replace(/"([0-9]+)vh"/g,(...arg)=>{
const vh=Number(arg[1]);
return `${vh/100} * DEVICE_HEIGHT`;
});

} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

function isPlatformSpecific(filename) {
var platformSpecific = [".native.", ".ios.", ".android."];
return platformSpecific.some(name => filename.includes(name));
}

module.exports ={
transform:async function({ src, filename, options }) {
if (filename.endsWith(".scss") || filename.endsWith(".sass")) {

let newSrc=pxToPtForMark(src);

let css =await sassTransformer.renderToCSS({ src:newSrc, filename, options });
let cssObject = renderCSSToReactNative(css);
let cssObjectStr=JSON.stringify(cssObject);

cssObjectStr=unitAdaption(cssObjectStr);

cssObjectStr=vhAndVwAdaption(cssObjectStr);

//特殊文件直接return
if (isPlatformSpecific(filename)) {
return upstreamTransformer.transform({
src: preImport+";module.exports = " + cssObjectStr,
filename,
options
});
}

//一般文件创建types文件之后再return
return creator.create(filename, css).then(content => {
return content.writeFile().then(() => {
return upstreamTransformer.transform({
src: preImport+";module.exports = " + cssObjectStr,
filename,
options
});
});
});
} else {
return upstreamTransformer.transform({ src, filename, options });
}
}
}

在scss文件中,px单位转换成style对象时,会自动去掉,如下:


.unpaidRemind {
position: absolute;
bottom: 56px;
right: 28px;
background-color: #999;
padding: 20px;
border-radius: 16px;
}
.unpaidRemindText {
color: rgba(255, 255, 255, 0.9);
font-size: 28px;
}

转换之后变成


{
unpaidRemind: {
position: 'absolute',
bottom: 56,
right: 28,
backgroundColor: '#999',
padding: 20,
borderRadius: 16,
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: 28,
},
}

我们的目标是在在转后之后把所有px都换成我们的适配方法:


{
unpaidRemind: {
position: 'absolute',
bottom: S(55),
right: S(28),
backgroundColor: '#999',
padding: S(20),
borderRadius: S(16),
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: S(28),
},
}

最终拿到的代码类似于这样,它是可以直接执行的,同样的道理,我们可以注入更多的RN属性到我们的文件中,这取决于我们是否需要这些属性。


import { PixelRatio, Dimensions, StatusBar, Platform } from 'react-native'; 
let DEVICE_WIDTH = Dimensions.get('window').width;
let DEVICE_HEIGHT = Dimensions.get('window').height;
let S=(designPx) => { return PixelRatio.roundToNearestPixel((designPx / 750) * DEVICE_WIDTH); }
module.exports ={
unpaidRemind: {
position: 'absolute',
bottom: S(55),
right: S(28),
backgroundColor: '#999',
padding: S(20),
borderRadius: S(16),
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: S(28),
},
}

pxToPtForMark方法将px转换成pt,这一步主要是方便我们后续把pt转成S(28)这种形式,unitAdaption方法就是实现这一功能。为什么不是直接把px转成S(28)这种形式?renderCSSToReactNative会把px转没掉,我们无法区分flex:1这种属性和fontSize:28的区别,但是它不会吧pt转没,而是变成fontSize:"28pt".


为了使vhvw这两个单位能够生效,我们使用vhAndVwAdaption方法做了处理,width:100vw最后会变成width:100/100 * DEVICE_WIDTH,其中DEVICE_WIDTH就是我们前面注入的设备宽度这个变量。


九、referenceError:'xx' is not defined 报错


const Button=(props)=>{ 
const {style}=props;
return
}
const Page=()=>{
return
}

//以上写法会导致报referenceError:'xx' is not defined,而且是非必现,偶尔会报
//要这样写
const Button=(props)=>{
const {style}=props;
return
}
const Page=()=>{
return
}

链接:https://juejin.cn/post/6995883216695459870

收起阅读 »

聊聊 RN 中 Android 提供 View 的那些坑

最近笔者研究 Android 中使用自定义 View 提供原生组件给 React Native(下面统一写成 RN ) 端的时候,遇到一些实际问题,在这里从 RN 的一些...
继续阅读 »


最近笔者研究 Android 中使用自定义 View 提供原生组件给 React Native(下面统一写成 RN ) 端的时候,遇到一些实际问题,在这里从 RN 的一些工作机制入手,分享一下问题的原因和解决方案。

自定义 View 内容不生效

原因

在给 RN 提供自定义 View 的时候发现自定义 View 内部很多 UI 逻辑没有生效。
例如下图,根据逻辑隐藏/展示了一些控件,但是应显示控件的位置没有变化。被隐藏控件的位置还是空出来的。很明显整个自定义 View 的 requestLayout 没有执行。


问题的答案就在 RN 根布局 ReactRootView 的 measure 方法里面。


在这个View的测量过程中,会判断 measureSpec 是否有更新。


当 measureSpec 有变化,或者宽高有变化的时候,才会触发 updateRootLayoutSpecs 的逻辑。
继续看下 updateRootLayoutSpecs 里做了一些什么事情,跟着源码最后会执行到 UIImplementation 的 dispatchViewUpdates 方法:


最终执行:


这里会从根节点往下一直更新子 View ,执行 View的 measure 和 layout
所以 ReactRootView 在宽高和测量模式都没有变化的情况下,就相当于把子 View 发出的 requestLayout 请求都拦截了。

解决方案

知道了原因就非常好解决了,既然你不让我通知我的根控件需要重新布局,那我就自己给自己重新布局好了。参考了 RN 一些自带的自定义 View 的实现,我们可以在这个自定义 View 重新布局的时候,注册一个 FrameCallback 去执行自己的 measure 和 layout 方法。

RN 自定义View 必须在JS端设置宽高

实现了自定义 View 之后,在 JSX 里面指定标签之后,会发现这个原生组件并没有显示。通过 IDE 的 Layout Inspect 可以发现此时这个自定义 View 的 width 和 height 都是 0 。如果设置了 width 和height 的话就可以展示了。
这时候就很奇怪了, 为什么我的自定义 View 里面的内容明明是 WRAP_CONTENT 的,很多自定义 View 又是直接继承的 ConstraintLayout 、 RelativeLayout 这种 Android 的 ViewGroup ,但还是要指定宽高才能在 RN 中渲染出来呢?
要解决这个疑惑,就需要了解一下 RN 的渲染流程。

RN 是怎么确定 Native View的宽高的

我们顺着 RN 更新 View 结构的 UIImplementation#updateViewHierarchy 方法,发现有两处关键的逻辑:


calculateRootLayout 中调用了 cssRoot 的布局计算逻辑:


接下来就是 applyUpdatesRecursive,顾名思义就是递归的更新根节点的所有子节点,在我们的场景中即整个页面的布局。


需要更新的节点则调用了 dispatchUpdates 方法,执行 enqueueUpdateLayout, 调用 NativeViewHierarchyManager#updateLayout 逻辑。


updateLayout 的核心流程如下:

  • 调用 resolveView 方法获取到真实的控件对象。
  • 调用这个控件的 measure 方法。


  • 调用updateLayout,执行这个控件的 layout方法



发现了没有?这里的 widthheight 已经是固定的值分别传给了 meausre 和 layout, 也就是说,这些 View 的宽高根本不是 Android 的绘制流程决定的,那么这个 width 和 height 的值是从哪里来的呢?
回头看看就发现了答案:


宽高是 lefttoprightbottom坐标相减得到的,而这些坐标则是通过
getLayoutWidth 和 getLayoutHeight 得到的:


而这个 layoutWidth 和 layoutHeight,则都是 Yoga 帮我们计算好,存放在 YogoNode里面的。
关于 Yoga

Yoga 是 Facebook 实现的一个高性能、易用、 Flex 的跨端布局引擎。
React Native 内部则是使用 Yoga 来布局的。
具体内容可以看 Yoga 的官网:https://yogalayout.com/

这里也就解释了为什么自定义 View 需要在 jsx 中指定了 width 和 height 才会渲染出来。因为这些自定义 View 原本在 Android系统的 measure layout 流程都已经被 RN 给控制住了。
这里可以总结成一句话:
RN 中最终渲染出来的控件的宽高,都由 Yoga 引擎来计算决定,系统自身的布局流程无法直接决定这些控件的宽高
但是这时候还是有一个疑问,为什么RN自己的一些组件,例如  ,没有指定
宽高也可以正常自适应显示呢?

为什么 RN 自己的 Text 是有自己的宽高的

我们来看一下RN是怎么定义渲染出来的 TextView 的,找到对应的 TextView 的 ViewManager,
com.facebook.react.views.text.ReactTextViewManager
我们关注两个方法:

  1. createViewInstance


  1. createShadowNodeInstance



其中,ReactTextView 其实就是实现了一个普通的 Android TextViewReactTextShadowNode 则表示了这个 TextView 对应的 YogaNode 的实现。


在它的实现中,我们可以看到一个成员变量,从名字上看是负责这个 YogaNode 的 measure 工作。


YogaNodeJNIBase 会调用这个JNI的方法,给JNI的逻辑注册这样一个回调函数。


这个 YogaMeasureFunction 的具体实现:


这里截个图,可以看到这里调用了 Android 中 Text 绘制的 API 来确定的文本的宽高。函数返回的是


这里是使用了 YogaMeasureOutput.make 把 Layout 算出来的宽高转成一定格式的二进制回调给 Yoga 引擎,这也是为什么 RN 自己的 Text 标签是可以自适应宽高展示的。
这里我们也可以得到一个结论:如果 Android 端封装的自定义 View 可以是确定宽高或者内部的控件是非常固定可以通过 measure 和 layout 就能算出宽高的,我们可以通过注册 measureFunction 回调的方式告诉 Yoga 我们 View 的宽高。
但是在实际业务中,我们很多业务组件是封装在 ConstraintLayout 、RelativeLayout 等 ViewGroup 中,所以我们还需要其他的方法来解决组件宽高设置的问题。

解决方案

那么这个问题可以重写 View 的 onMeasure 和 layout 方法来解决吗?看起来是这个做法是可以解决 View 宽高为 0 渲染不出来的问题。但是如果 jsx 这样描述布局的时候:


这时候 AndroidView 和 Text 会同时显示,并且 AndroidView 被 Text 遮住。
稍微思考一下就能得到原因:对于 Yoga 引擎来说,AndroidView 所代表的的节点仍然是没有宽高的,YogaNode 里面的 widthheight 仍然是 0,那么当重写 onMeasure 和 onLayout 的逻辑生效后,View 显示的左上方顶点是 (0,0) 的坐标。
而 Yoga 引擎自己计算出 Text 的宽高后, Text 的左上方顶点坐标肯定也是 (0,0) ,所以这时候2个 View 会显示在同一个位置(重叠或者覆盖)。
所以这时候问题就变成了,我们想通过 Android 自己的布局流程来确定并刷新这个自定义控件,但是 Yoga 引擎并不知道。
所以想要解决这个问题,可行的有两条路:

  • 改变 UI 层级和自定义 View 的粒度
  • Native 测量出实际需要的宽高后同步给Yoga 引擎
增加自定义控件的粒度

举一个自定义控件的例子:


我们希望把这个图上第一行的控件拆分成粒度较低的自定义 View 交给 RN 来布局实现布局动态配置的能力。但是这类场景的左右两边控件都是自适应宽度。这时候在 JS 端其实没有办法提供一个合适的宽度。考虑到更多场景下同一个方向轴上的自适应宽度控件是有位置上的依赖性的,所以可以不拆分这两个部分,直接都定义在同一个自定义 View 内:


提供给 JS 端使用,没有宽高的话,就把整个 SingHeaderView 的宽度设置成


这时候内部的两个控件会自己去进行布局。最终展示出来的就是左右都是 Wrap_Content 的。

Native 测量出实际需要的宽高后同步给Yoga引擎

但是控制自定义 View 的粒度的方式总归是不够灵活,开发的时候也往往会让人犹豫是否拆分。接着之前的内容,既然这个问题的矛盾点在于 Yoga 不知道 Android 可以自己再次调用 measure 来确定宽高,那如果能把最新的宽高传给 Yoga,不就可以解决我们的问题吗?
具体怎么触发 YogaNode 的刷新呢?通过阅读源码可以找到解决方法。在 UIManage里面,有一个叫做 updateNodeSize 的 api:


这个 api 会更新 View 对应的 cssNode 的大小,然后分发刷新 View 的逻辑。这个逻辑是需要保证在后台消息队列里面执行的,所以需要把这个刷新的消息发送到 nativeModulesQueueThread 里面去执行。
我们在 ViewManager 里面保存这个 Manager 对应的 View 和 ReactNodeImpl 实例。例如 Android 端封装了一个 LinearLayout , 对应的 node 是 MyLinearLayoutNode


重写自定义 View 的 onMeasure, 让自己是 wrap_content 的布局:


在 requestLayout 中根据自己真实的宽高布局并触发以下逻辑:




不过上面这个方案虽然可以解决 View 的 wrap_content 显示的问题,但是存在一些缺点:
刷新 YogaNode 实际是在 requestLayout 的时候触发的,这就相当于 requestLayout 这种比较耗费性能的操作会双倍的执行。对于一些可能会频繁触发 requestLayout 的业务场景来说需要慎重考虑。如果遇到这种场景,还是需要根据自己的需求来灵活选择解决方式。

收起阅读 »

巧用CSS filter,让你的网站更加酷炫!

前言 我们在处理图片时,经常使用的一个功能就是滤镜,它能使一张图像呈现各种不同的视觉效果。 在 CSS 中,也有一个filter属性,让我们能用 CSS 代码为元素指定各种滤镜效果,比如模糊、灰度、明暗度、颜色偏移等。 CSS filter的基础使用非常简单...
继续阅读 »

前言


我们在处理图片时,经常使用的一个功能就是滤镜,它能使一张图像呈现各种不同的视觉效果。


image.png


在 CSS 中,也有一个filter属性,让我们能用 CSS 代码为元素指定各种滤镜效果,比如模糊、灰度、明暗度、颜色偏移等。


CSS filter的基础使用非常简单,CSS 标准里包含了一些已实现预定义效果的函数(下面blur、brightness、contrast等),我们可以通过指定这些函数的值来实现想要的效果:


/* 使用单个滤镜 (如果传入的参数是百分数,那么也可以传入对应的小数:40% --> 0.4)*/
filter: blur(5px);
filter: brightness(40%);
filter: contrast(200%);
filter: drop-shadow(16px 16px 20px blue);
filter: grayscale(50%);
filter: hue-rotate(90deg);
filter: invert(75%);
filter: opacity(25%);
filter: saturate(30%);
filter: sepia(60%);

/* 使用多个滤镜 */
filter: contrast(175%) brightness(3%);

/* 不使用任何滤镜 */
filter: none;

官方demo:MDN


filter-demo.gif


滤镜在日常开发中是很常见的,比如使用drop-shadow给不规则形状添加阴影;使用blur来实现背景模糊,以及毛玻璃效果等。


下面我们将进一步使用CSS filter实现一些动画效果,让网站交互更加酷炫,同时也加深对CSS filter的理解。一起开始吧!


( 下面要使用到的 动画 和 伪类 知识,在 CSS的N个编码技巧 中都有详细的介绍,这里就不重复了,有需要的朋友可以前往查看哦。 )


电影效果


滤镜中的brightness用于调整图像的明暗度。默认值是1;小于1时图像变暗,为0时显示为全黑图像;大于1时图像显示比原图更明亮。


我们可以通过调整 背景图的明暗度文字的透明度 ,来模拟电影谢幕的效果。


movie.gif


<div>
<div></div>
<div>
<p>如果生活中有什么使你感到快乐,那就去做吧</p>
<br>
<p>不要管别人说什么</p>
</div>
</div>

.pic{
height: 100%;
width: 100%;
position: absolute;
background: url('./images/movie.webp') no-repeat;
background-size: cover;
animation: fade-away 2.5s linear forwards; //forwards当动画完成后,保持最后一帧的状态
}
.text{
position: absolute;
line-height: 55px;
color: #fff;
font-size: 36px;
text-align: center;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
opacity: 0;
animation: show 2s cubic-bezier(.74,-0.1,.86,.83) forwards;
}

@keyframes fade-away { //背景图的明暗度动画
30%{
filter: brightness(1);
}
100%{
filter: brightness(0);
}
}
@keyframes show{ //文字的透明度动画
20%{
opacity: 0;
}
100%{
opacity: 1;
}
}

模糊效果


在下面的单词卡片中,当鼠标hover到某一张卡片上时,其他卡片背景模糊,使用户焦点集中到当前卡片。


card-blur.gif


html结构:


<ul>
<li>
<p>Flower</p>
<p>The flowers mingle to form a blaze of color.</p>
</li>
<li>
<p>Sunset</p>
<p>The sunset glow tinted the sky red.</p>
</li>
<li>
<p>Plain</p>
<p>The winds came from the north, across the plains, funnelling down the valley. </p>
</li>
</ul>

实现的方式,是将背景加在.card元素的伪类上,当元素不是焦点时,为该元素的伪类加上滤镜。


.card:before{
z-index: -1;
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 20px;
filter: blur(0px) opacity(1);
transition: filter 200ms linear, transform 200ms linear;
}
/*
这里不能将滤镜直接加在.card元素,而是将背景和滤镜都加在伪类上。
因为,父元素加了滤镜,它的子元素都会一起由该滤镜改变。
如果滤镜直接加在.card元素上,会导致上面的文字也变模糊。
*/

//通过css选择器选出非hover的.card元素,给其伪类添加模糊、透明度和明暗度的滤镜 

.cards:hover > .card:not(:hover):before{
filter: blur(5px) opacity(0.8) brightness(0.8);
}

//对于hover的元素,其伪类增强饱和度,尺寸放大

.card:hover:before{
filter: saturate(1.2);
transform: scale(1.05);
}

褪色效果


褪色效果可以打造出一种怀旧的风格。下面这组照片墙,我们通过sepia滤镜将图像基调转换为深褐色,再通过降低 饱和度saturate 和 色相旋转hue-rotate 微调,模拟老照片的效果。


old-photo-s.gif


.pic{
border: 3px solid #fff;
box-shadow: 0 10px 50px #5f2f1182;
filter: sepia(30%) saturate(40%) hue-rotate(5deg);
transition: transform 1s;
}
.pic:hover{
filter: none;
transform: scale(1.2) translateX(10px);
z-index: 1;
}

灰度效果


怎样让网站变成灰色?在html元素上加上filter: grayscale(100%)即可。


grayscale(amount)函数将改变输入图像灰度。amount 的值定义了灰度转换的比例。值为 100% 则完全转为灰度图像,值为 0% 图像无变化。若未设置值,默认值是 0


gray-scale.gif


融合效果


要使两个相交的元素产生下面这种融合的效果,需要用到的滤镜是blurcontrast


merge.gif


<div>
<div></div>
<div></div>
</div>

.container{
margin: 50px auto;
height: 140px;
width: 400px;
background: #fff; //给融合元素的父元素设置背景色
display: flex;
align-items: center;
justify-content: center;
filter: contrast(30); //给融合元素的父元素设置contrast
}
.circle{
border-radius: 50%;
position: absolute;
filter: blur(10px); //给融合元素设置blur
}
.circle-1{
height: 90px;
width: 90px;
background: #03a9f4;
transform: translate(-50px);
animation: 2s moving linear infinite alternate-reverse;
}
.circle-2{
height: 60px;
width: 60px;
background: #0000ff;
transform: translate(50px);
animation: 2s moving linear infinite alternate;
}
@keyframes moving { //两个元素的移动
0%{
transform: translate(50px)
}
100%{
transform: translate(-50px)
}
}

实现融合效果的技术要点:



  1. contrast滤镜应用在融合元素的父元素(.container)上,且父元素必须设置background

  2. blur滤镜应用在融合元素(.circle)上。


blur设置图像的模糊程度,contrast设置图像的对比度。当两者像上面那样组合时,就会产生神奇的融合效果,你可以像使用公式一样使用这种写法。


在这种融合效果的基础上,我们可以做一些有趣的交互设计。



  • 加载动画:


loading-l.gif
htmlcss如下所示,这个动画主要通过控制子元素.circle的尺寸和位移来实现,但是由于父元素和子元素都满足 “融合公式” ,所以当子元素相交时,就出现了融合的效果。


<div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>

.container {
margin: 10px auto;
height: 140px;
width: 300px;
background: #fff; //父元素设置背景色
display: flex;
align-items: center;
filter: contrast(30); //父元素设置contrast
}
.circle {
height: 50px;
width: 60px;
background: #1aa7ff;
border-radius: 50%;
position: absolute;
filter: blur(20px); //子元素设置blur
transform: scale(0.1);
transform-origin: left top;
}
.circle{
animation: move 4s cubic-bezier(.44,.79,.83,.96) infinite;
}
.circle:nth-child(2) {
animation-delay: .4s;
}
.circle:nth-child(3) {
animation-delay: .8s;
}
.circle:nth-child(4) {
animation-delay: 1.2s;
}
.circle:nth-child(5) {
animation-delay: 1.6s;
}
@keyframes move{ //子元素的位移和尺寸动画
0%{
transform: translateX(10px) scale(0.3);
}
45%{
transform: translateX(135px) scale(0.8);
}
85%{
transform: translateX(270px) scale(0.1);
}
}


  • 酷炫的文字出场方式:


gooey-text.gif
主要通过不断改变letter-spacingblur的值,使文字从融合到分开:


<div>
<span>fantastic</span>
</div>

.container{
margin-top: 50px;
text-align: center;
background-color: #000;
filter: contrast(30);
}
.text{
font-size: 100px;
font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
letter-spacing: -40px;
color: #fff;
animation: move-letter 4s linear forwards; //forwards当动画完成后,保持最后一帧的状态
}
@keyframes move-letter{
0% {
opacity: 0;
letter-spacing: -40px;
filter: blur(10px);
}
25% {
opacity: 1;
}
50% {
filter: blur(5px);
}
100% {
letter-spacing: 20px;
filter: blur(2px);
}
}

水波效果


filter还可以通过 URL 链接到 SVG 滤镜元素,SVG滤镜元素MDN 。


下面的水波纹效果就是基于 SVG 的feTurbulence滤镜实现的,原理参考了 说说SVG的feTurbulence滤镜

SVG feTurbulence滤镜深入介绍,有兴趣的朋友可以深入阅读。



feTurbulence滤镜借助Perlin噪声算法模拟自然界真实事物那样的随机样式。它接收下面5个属性:



  • baseFrequency表示噪声的基本频率参数,频率越高,噪声越密集。

  • numOctaves就表示倍频的数量,倍频的数量越多,噪声看起来越自然。

  • seed属性表示feTurbulence滤镜效果中伪随机数生成的起始值,不同数量的seed不会改变噪声的频率和密度,改变的是噪声的形状和位置。

  • stitchTiles定义了Perlin噪声在边框处的行为表现。

  • type属性值有fractalNoiseturbulence,模拟随机样式使用turbulence



wave.gif


在这个例子,两个img标签使用同一张图片,将第二个img标签使用scaleY(-1)实现垂直方向的镜像翻转,模拟倒影。


并且,对倒影图片使用feTurbulence滤镜,通过动画不断改变feTurbulence滤镜的baseFrequency值实现水纹波动的效果。


<div>
<img src="images/moon.jpg">
<img src="images/moon.jpg">
</div>

<!--定义svg滤镜,这里使用的是feTurbulence滤镜-->
<svg width="0" height="0">
<filter id="displacement-wave-filter">

<!--baseFrequency设置0.01 0.09两个值,代表x轴和y轴的噪声频率-->
<feTurbulence baseFrequency="0.01 0.09">

<!--这是svg动画的定义方式,通过动画不断改变baseFrequency的值,从而形成波动效果-->
<animate attributeName="baseFrequency"
dur="20s" keyTimes="0;0.5;1" values="0.01 0.09;0.02 0.13;0.01 0.09"
repeatCount="indefinite" ></animate>

</feTurbulence>
<feDisplacementMap in="SourceGraphic" scale="10" />
</filter>
</svg>

.container{
height: 520px;
width: 400px;
display: flex;
clip-path: inset(10px);
flex-direction: column;
}
img{
height: 50%;
width: 100%;
}
.reflect {
transform: translateY(-2px) scaleY(-1);
//对模拟倒影的元素应用svg filter
//url中对应的是上面svg filter的id
filter: url(#displacement-wave-filter);
}

抖动效果


在上面的水波动画中改变的是baseFrequency值,我们也通过改变seed的值,实现文字的抖动效果。
text-shaking.gif


<div>
<p>Such a joyful night!</p>
</div>
<svg width="0" height="0">
<filter id="displacement-text-filter">

<!--定义feTurbulence滤镜-->
<feTurbulence baseFrequency="0.02" seed="0">

<!--这是svg动画的定义方式,通过动画不断改变seed的值,形成抖动效果-->
<animate attributeName="seed"
dur="1s" keyTimes="0;0.5;1" values="1;2;3"
repeatCount="indefinite" ></animate>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" scale="10" />
</filter>
</svg>

.shaky{
font-size: 60px;
filter: url(#displacement-text-filter); //url中对应的是上面svg filter的id
}

链接:https://juejin.cn/post/7002829486806794276

收起阅读 »

用 JavaScript 做数独

最近看到老婆天天在手机上玩数独,突然想起 N 年前刷 LeetCode 的时候,有个类似的算法题(37.解数独),是不是可以把这个算法进行可视化。 说干就干,经过一个小时的实践,最终效果如下: 怎么解数独 解数独之前,我们先了解一下数独的规则: 数字 1-...
继续阅读 »

最近看到老婆天天在手机上玩数独,突然想起 N 年前刷 LeetCode 的时候,有个类似的算法题(37.解数独),是不是可以把这个算法进行可视化。


说干就干,经过一个小时的实践,最终效果如下:



怎么解数独


解数独之前,我们先了解一下数独的规则:



  1. 数字 1-9 在每一行只能出现一次。

  2. 数字 1-9 在每一列只能出现一次。

  3. 数字 1-9 在每一个以粗实线分隔的九宫格( 3x3 )内只能出现一次。



接下来,我们要做的就是在每个格子里面填一个数字,然后判断这个数字是否违反规定。


填第一个格子


首先,在第一个格子填 1,发现在第一列里面已经存在一个 1,此时就需要擦掉前面填的数字 1,然后在格子里填上 2,发现数字在行、列、九宫格内均无重复。那么这个格子就填成功了。



填第二个格子


下面看第二个格子,和前面一样,先试试填 1,发现在行、列、九宫格内的数字均无重复,那这个格子也填成功了。



填第三个格子


下面看看第三个格子,由于前面两个格子,我们已经填过数字 12,所以,我们直接从数字 3 开始填。填 3 后,发现在第一行里面已经存在一个 3,然后在格子里填上 4,发现数字 4 在行和九宫格内均出现重复,依旧不成功,然后尝试填上数字 5,终于没有了重复数字,表示填充成功。



一直填,直到填到第九个格子


照这个思路,一直填到第九个格子,这个时候,会发现,最后一个数字 9 在九宫格内冲突了。而 9 已经是最后一个数字了,这里没办法填其他数字了,只能返回上一个格子,把第七个格子的数字从 8 换到 9,发现在九宫格内依然冲突。


此时需要替换上上个格子的数字(第六个格子)。直到没有冲突为止,所以在这个过程中,不仅要往后填数字,还要回过头看看前面的数字有没有问题,不停地尝试。



综上所述


解数独就是一个不断尝试的过程,每个格子把数字 1-9 都尝试一遍,如果出现冲突就擦掉这个数字,直到所有的格子都填完。



通过代码来实现


把上面的解法反映到代码上,就需要通过 递归 + 回溯 的思路来实现。


在写代码之前,先看看怎么把数独表示出来,这里参考 leetcode 上的题目:37. 解数独



前面的这个题目,可以使用一个二维数组来表示。最外层数组内一共有 9 个数组,表示数独的 9 行,内部的每个数组内 9 字符分别对应数组的列,未填充的空格通过字符('.' )来表示。


const sudoku = [
['.', '.', '.', '4', '.', '.', '.', '3', '.'],
['7', '.', '4', '8', '.', '.', '1', '.', '2'],
['.', '.', '.', '2', '3', '.', '4', '.', '9'],
['.', '4', '.', '5', '.', '9', '.', '8', '.'],
['5', '.', '.', '.', '.', '.', '9', '1', '3'],
['1', '.', '.', '.', '8', '.', '2', '.', '4'],
['.', '.', '.', '.', '.', '.', '3', '4', '5'],
['.', '5', '1', '9', '4', '.', '7', '2', '.'],
['4', '7', '3', '.', '5', '.', '.', '9', '1'],
]

知道如何表示数组后,我们再来写代码。


const sudoku = [……]
// 方法接受行、列两个参数,用于定位数独的格子
function solve(row, col) {
if (col >= 9) {
// 超过第九列,表示这一行已经结束了,需要另起一行
col = 0
row += 1
if (row >= 9) {
// 另起一行后,超过第九行,则整个数独已经做完
return true
}
}
if (sudoku[row][col] !== '.') {
// 如果该格子已经填过了,填后面的格子
return solve(row, col + 1)
}
// 尝试在该格子中填入数字 1-9
for (let num = 1; num <= 9; num++) {
if (!isValid(row, col, num)) {
// 如果是无效数字,跳过该数字
continue
}
// 填入数字
sudoku[row][col] = num.toString()
// 继续填后面的格子
if (solve(row, col + 1)) {
// 如果一直到最后都没问题,则这个格子的数字没问题
return true
}
// 如果出现了问题,solve 返回了 false
// 说明这个地方要重填
sudoku[row][col] = '.' // 擦除数字
}
// 数字 1-9 都填失败了,说明前面的数字有问题
// 返回 FALSE,进行回溯,前面数字要进行重填
return false
}

上面的代码只是实现了递归、回溯的部分,还有一个 isValid 方法没有实现。该方法主要就是按照数独的规则进行一次校验。


const sudoku = [……]
function isValid(row, col, num) {
// 判断行里是否重复
for (let i = 0; i < 9; i++) {
if (sudoku[row][i] === num) {
return false
}
}
// 判断列里是否重复
for (let i = 0; i < 9; i++) {
if (sudoku[i][col] === num) {
return false
}
}
// 判断九宫格里是否重复
const startRow = parseInt(row / 3) * 3
const startCol = parseInt(col / 3) * 3
for (let i = startRow; i < startRow + 3; i++) {
for (let j = startCol; j < startCol + 3; j++) {
if (sudoku[i][j] === num) {
return false
}
}
}
return true
}

通过上面的代码,我们就能解出一个数独了。


const sudoku = [
['.', '.', '.', '4', '.', '.', '.', '3', '.'],
['7', '.', '4', '8', '.', '.', '1', '.', '2'],
['.', '.', '.', '2', '3', '.', '4', '.', '9'],
['.', '4', '.', '5', '.', '9', '.', '8', '.'],
['5', '.', '.', '.', '.', '.', '9', '1', '3'],
['1', '.', '.', '.', '8', '.', '2', '.', '4'],
['.', '.', '.', '.', '.', '.', '3', '4', '5'],
['.', '5', '1', '9', '4', '.', '7', '2', '.'],
['4', '7', '3', '.', '5', '.', '.', '9', '1']
]
function isValid(row, col, num) {……}
function solve(row, col) {……}
solve(0, 0) // 从第一个格子开始解
console.log(sudoku) // 输出结果

输出结果


动态展示做题过程


有了上面的理论知识,我们就可以把这个做题的过程套到 react 中,动态的展示做题的过程,也就是文章最开始的 Gif 中的那个样子。


这里直接使用 create-react-app 脚手架快速启动一个项目。


npx create-react-app sudoku
cd sudoku

打开 App.jsx ,开始写代码。


import React from 'react';
import './App.css';

class App extends React.Component {
state = {
// 在 state 中配置一个数独二维数组
sudoku: [
['.', '.', '.', '4', '.', '.', '.', '3', '.'],
['7', '.', '4', '8', '.', '.', '1', '.', '2'],
['.', '.', '.', '2', '3', '.', '4', '.', '9'],
['.', '4', '.', '5', '.', '9', '.', '8', '.'],
['5', '.', '.', '.', '.', '.', '9', '1', '3'],
['1', '.', '.', '.', '8', '.', '2', '.', '4'],
['.', '.', '.', '.', '.', '.', '3', '4', '5'],
['.', '5', '1', '9', '4', '.', '7', '2', '.'],
['4', '7', '3', '.', '5', '.', '.', '9', '1']
]
}

// TODO:解数独
solveSudoku = async () => {
const { sudoku } = this.state
}

render() {
const { sudoku } = this.state
return (
<div className="container">
<div className="wrapper">
{/* 遍历二维数组,生成九宫格 */}
{sudoku.map((list, row) => (
{/* div.row 对应数独的行 */}
<div className="row" key={`row-${row}`}>
{list.map((item, col) => (
{/* span 对应数独的每个格子 */}
<span key={`box-${col}`}>{ item !== '.' && item }</span>
))}
</div>
))}
<button onClick={this.solveSudoku}>开始做题</button>
</div>
</div>
);
}
}

九宫格样式


给每个格子加上一个虚线的边框,先让它有一点九宫格的样子。


.row {
display: flex;
direction: row;
/* 行内元素居中 */
justify-content: center;
align-content: center;
}
.row span {
/* 每个格子宽高一致 */
width: 30px;
min-height: 30px;
line-height: 30px;
text-align: center;
/* 设置虚线边框 */
border: 1px dashed #999;
}

可以得到一个这样的图形:



接下来,需要给外边框和每个九宫格加上实线的边框,具体代码如下:


/* 第 1 行顶部加上实现边框 */
.row:nth-child(1) span {
border-top: 3px solid #333;
}
/* 第 3、6、9 行底部加上实现边框 */
.row:nth-child(3n) span {
border-bottom: 3px solid #333;
}
/* 第 1 列左边加上实现边框 */
.row span:first-child {
border-left: 3px solid #333;
}

/* 第 3、6、9 列右边加上实现边框 */
.row span:nth-child(3n) {
border-right: 3px solid #333;
}

这里会发现第三、六列的右边边框和第四、七列的左边边框会有点重叠,第三、六行的底部边框和第四、七行的顶部边框也会有这个问题,所以,我们还需要将第四、七列的左边边框和第三、六行的底部边框进行隐藏。



.row:nth-child(3n + 1) span {
border-top: none;
}
.row span:nth-child(3n + 1) {
border-left: none;
}

做题逻辑


样式写好后,就可以继续完善做题的逻辑了。


class App extends React.Component {
state = {
// 在 state 中配置一个数独二维数组
sudoku: [……]
}

solveSudoku = async () => {
const { sudoku } = this.state
// 判断填入的数字是否有效,参考上面的代码,这里不再重复
const isValid = (row, col, num) => {
……
}
// 递归+回溯的方式进行解题
const solve = async (row, col) => {
if (col >= 9) {
col = 0
row += 1
if (row >= 9) return true
}
if (sudoku[row][col] !== '.') {
return solve(row, col + 1)
}
for (let num = 1; num <= 9; num++) {
if (!isValid(row, col, num)) {
continue
}

sudoku[row][col] = num.toString()
this.setState({ sudoku }) // 填了格子之后,需要同步到 state

if (solve(row, col + 1)) {
return true
}

sudoku[row][col] = '.'
this.setState({ sudoku }) // 填了格子之后,需要同步到 state
}
return false
}
// 进行解题
solve(0, 0)
}

render() {
const { sudoku } = this.state
return (……)
}
}

对比之前的逻辑,这里只是在对数独的二维数组填空后,调用了 this.setStatesudoku 同步到了 state 中。


function solve(row, col) {   ……   sudoku[row][col] = num.toString()+  this.setState({ sudoku })	 ……   sudoku[row][col] = '.'+  this.setState({ sudoku }) // 填了格子之后,需要同步到 state}

在调用 solveSudoku 后,发现并没有出现动态的效果,而是直接一步到位的将结果同步到了视图中。



这是因为 setState 是一个伪异步调用,在一个事件任务中,所有的 setState 都会被合并成一次,需要看到动态的做题过程,我们需要将每一次 setState 操作放到该事件流之外,也就是放到 setTimeout 中。更多关于 setState 异步的问题,可以参考我之前的文章:React 中 setState 是一个宏任务还是微任务?


solveSudoku = async () => {
const { sudoku } = this.state
// 判断填入的数字是否有效,参考上面的代码,这里不再重复
const isValid = (row, col, num) => {
……
}
// 脱离事件流,调用 setState
const setSudoku = async (row, col, value) => {
sudoku[row][col] = value
return new Promise(resolve => {
setTimeout(() => {
this.setState({
sudoku
}, () => resolve())
})
})
}
// 递归+回溯的方式进行解题
const solve = async (row, col) => {
……
for (let num = 1; num <= 9; num++) {
if (!isValid(row, col, num)) {
continue
}

await setSudoku(row, col, num.toString())

if (await solve(row, col + 1)) {
return true
}

await setSudoku(row, col, '.')
}
return false
}
// 进行解题
solve(0, 0)
}

最后效果如下:



作者:Shenfq
链接:https://juejin.cn/post/7004616375591239711

收起阅读 »

JS中this的指向原理

前言 在JS中,每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。与声明的位置无关。 调用位置 理解调用位置:调用位置就是函数在执行时被调用的位置(而不是声明的位置)。 要找到函数的调用位置,最重要是找到函数的调用...
继续阅读 »

前言


在JS中,每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。与声明的位置无关。


调用位置



理解调用位置:调用位置就是函数在执行时被调用的位置(而不是声明的位置)。



要找到函数的调用位置,最重要是找到函数的调用栈(就是为了到达当前执行位置所调用的所有函数),而函数的调用位置就是当前所在栈顶的前一个位置。


举个栗子


function baz() { 
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域,浏览器下位window,node下为global
console.log( "baz" );
bar(); // <-- bar 的调用位置
}
function bar() {
// 当前调用栈是 baz -> bar
// 因此,当前调用位置在 baz 中
console.log( "bar" );
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用栈是 baz -> bar -> foo
// 因此,当前调用位置在 bar 中
console.log( "foo" );
}
baz(); // <-- baz 的调用位置

this绑定规则


函数的this在js引擎执行时,会根据一些规则去绑定到上下文中。


默认绑定


默认绑定应用在最常用的函数调用类型:独立函数调用上。可以把这条规则看作是无法应用其他规则时的默认规则。


function foo() {
//默认规则下,this指向全局对象,即顶层作用域
console.log( this.a );
}

var a = 2;
foo()//2

怎么知道这里应用了默认绑定呢?可以通过分析调用位置来看看 foo()是如何调用的。在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用
默认绑定,无法应用其他规则。



严格模式下,不能将全局对象用于默认绑定,因此 this 会绑定到undefined,在浏览器和node中是一样的。



这里有一个微妙但是非常重要的细节,虽然this的绑定规则完全取决于调用位置,但是只有 foo() 运行在非严格模式下时,默认绑定才能绑定到全局对象;在严格模式下调用
foo() 则不影响默认绑定:


function foo() { 
//在非严格模式下运行
console.log( this.a );
}
var a = 2;
(function(){
//在严格模式下调用
"use strict";
foo(); // 2
})();

以上代码混合使用了严格模式和非严格模式,因此foothis不受严格模式影响,但混合使用严格模式是不提倡的,幸运的是es6默认是严格模式


隐式绑定


当一个函数的引用被一个对象持有时(作为该对象的方法),那么该函数的this就绑定在了这个对象上。通常这在声明一个对象,并将一个已声明的函数作为该对象属性时触发。


function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2

obj对象声明时,foo作为obj的一个属性,因此其this被隐式绑定到了obj上,因为obj持有对foo的引用。



在对象属性引用链中,只有上一层或者说最后一层在调用位置中起作用。



举个栗子


function foo() { 
console.log( this.a );
}

// obj2.foo引用了foo函数
var obj2 = {
a: 42,
foo: foo
};

//obj1.obj2 引用了obj1对象
var obj1 = {
a: 2,
obj2: obj2
};

//但是foo中的this永远指向直接持有它的引用的那个对象,即obj2
obj1.obj2.foo(); // 42

一个函数的引用被一个对象持有,而这个对象的引用又被另一个对象持有,另一个对象的引用再被另一个对象持有...,这就像一条项链,但是不管层次有多深,这个函数的this永远指向直接持有它的引用的那个对象。


隐式丢失



一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。



function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!

var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"

虽然barobj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此==此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定==。


再看一个栗子,发生在传入回调函数时


function foo() { 
console.log( this.a );
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!,很明显,这是个默认绑定
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一
个例子一样。


如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一
样的,没有区别:


function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
setTimeout( obj.foo, 100 ); // "oops, global"

以上的栗子再次向我们证明了,函数this是在运行时绑定的,与声明位置无关。



除此之外,还有一种情



况 this 的行为会出乎我们意料:调用回调函数的函数可能会修改 this。在一些流行的
JavaScript 库中事件处理器常会把回调函数的 this 强制绑定到触发事件的 DOM 元素上。如onclick,addEventListener,会将this绑定在dom元素 上。


显式绑定


显示绑定就是利用js提供的一些内置函数,将this绑定到指定的上下文中。



具体点说,可以使用函数的 call(..) 和



apply(..) 方法。严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们
并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及你自
己创建的所有函数都可以使用 call(..) 和 apply(..) 方法。


function foo() { 
console.log( this.a );
}
var obj = {
a:2
};
//执行时,foo的this就是obj了
foo.call( obj ); // 2


如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..)、new Boolean(..) 或者



new Number(..))。这通常被称为“装箱”。



显式绑定仍然无法解决我们之前提出的丢失绑定问题。



硬绑定


硬绑定是显式绑定的一个变种。


function foo() { 
console.log( this.a );
}
var obj = {
a:2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬绑定的 bar 不可能再修改它的 this
bar.call( window ); // 2

很好理解,就是在函数运行时再把这个函数绑定到我们制定的this上。



硬绑定的典型应用场景就是创建一个包裹函数,负责接收参数并返回值



function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5


另一种使用方法是创建一个可以重复使用的辅助函数



function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

由于硬绑定是一种非常常用的模式,所以 ES5 提供了内置的方法 Function.prototype.bind,bind(..) 会返回一个硬编码的新函数,它会把你指定的参数设置为 this 的上下文并调用原始函数


function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

API中可选的调用“上下文”



第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一



个可选的参数,通常被称为"上下文"(context),其作用和 bind(..) 一样,确保你的回调函数使用指定的 this。这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定。


function foo(el) { 
console.log( el, this.id );
}
var obj = {
id: "awesome"
};
// 调用 foo(..) 时把 this 绑定到 obj
[1, 2, 3].forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome

new 绑定


使用new来调用函数时(函数也是对象),或者说发生构造函数调用时,会自动执行下面的操作:



  1. 创建(或者说构造)一个全新的对象。

  2. 这个新对象会被执行原型链[[Prototype]] 连接。

  3. 这个新对象会绑定到函数调用的 this

  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。


function foo(a) { 
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2


使用 new 来调用foo(..)时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this上。ne是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。



规则的优先级


实际判断时,一个场景可能存在多个规则,因此判定时需要由高优先级往下判定。


可以按照下面的顺序来进行判断:




  1. 函数是否在new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。




  2. 函数是否通过callapply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是




指定的对象。



  1. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上


下文对象。



  1. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定


到全局对象。


规则例外



在某些场景下this的绑定行为会出乎意料,你认为应当应用其他绑定规则时,实际上应用的可能是默认绑定规则。



1. 将null或undefined作为this进行显式绑定


2. 赋值表达式的返回值


function foo() { 
console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是
p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。


3.软绑定


硬绑定很好地解决了隐式绑定可能会无意间将this绑定在顶级作用对象(严格模式下,为undefined)上的问题,但降低了其灵活性,我们要的结果是,保留其灵活性,既能绑定到指定的this上,但又不想让它默认绑定到全局对象上,解决方法就是软绑定。


通俗的说,就是有一个默认值,指定了绑定对象的话就绑定到指定的对象上,否则就绑定到默认对象。


//实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。
if (!Function.prototype.softBind) {
Function.prototype.softBind = function(obj) {
var fn = this;
// 捕获所有 curried 参数
var curried = [].slice.call( arguments, 1 );
var bound = function() {
return fn.apply(
(!this || this === (window || global)) ?
obj : this,
curried.concat.apply( curried, arguments )
);
};
bound.prototype = Object.create( fn.prototype );
return bound;
};
}

总结


如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。


找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。



  1. new 调用?绑定到新创建的对象。

  2. call 或者 apply(或者 bind)调用?绑定到指定的对象。

  3. 由上下文对象调用?绑定到那个上下文对象。

  4. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。


箭头函数不会以上四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论this绑定到什么)。这和我们创建一个变量来保存当前的this的效果是一样的。


链接:https://juejin.cn/post/7000756069244862477

收起阅读 »

一道看似简单的阿里前端算法题

题目描述 题目分析 我们以下面这个数组为例,我们首先要明白题目中的第2大的元素指的是4,第3大的元素指的是3,也就是说指的是去重后的数组中的排序。我们之所以要建立一个哈希表是因为我们需要知道第k大和第m大的元素总共出现了几次,因为最后需要进行求和。 [1...
继续阅读 »

题目描述


image.png


题目分析



我们以下面这个数组为例,我们首先要明白题目中的第2大的元素指的是4,第3大的元素指的是3,也就是说指的是去重后的数组中的排序。我们之所以要建立一个哈希表是因为我们需要知道第k大和第m大的元素总共出现了几次,因为最后需要进行求和。



[1, 2, 4, 4, 3, 5]

解题思路



本题博主采用的是哈希表 + 堆排序的方式来求解。



第一步:构建哈希表,键为目标元素,值为目标元素出现的次数


const map = new Map();
for (let v of arr) {
if (!map.get(v)) {
map.set(v,1);
} else {
map.set(v,map.get(v) + 1)
}
}

第二步:对数组去重


const singleNums = [...new Set(arr)]

第三步:构建大顶堆


// 堆的尺寸指的是去重后的数组
let heapSize = singleNums.length;
buildMaxHeap(singleNums, heapSize);
function buildMaxHeap(arr, heapSize) {
// 从最后一个叶子节点开始进行堆化
for (let i = Math.floor(heapSize / 2) - 1; i >= 0; i--) {
// 进行堆化
maxHeapify(arr, i, heapSize);
}
}
function maxHeapify(arr, i, heapSize) {
// 首先假定第i个是最大的
let max = i;
let leftChild = 2 * i + 1;
let rightChild = 2 * i + 2;
// 如果下标不越界,并且左孩子的比最大值大则更新最大值
if (leftChild < heapSize && arr[leftChild] > arr[max]) {
max = leftChild;
}
if (rightChild < heapSize && arr[rightChild] > arr[max]) {
max = rightChild;
}
if (max !== i) {
swap(arr, i, max);
// 上来的元素的位置往下要接着堆化
maxHeapify(arr, max, heapSize);
}
}
// 交换数组中两个元素
function swap(nums, a, b) {
let temp = nums[a];
nums[a] = nums[b];
nums[b] = temp;
}

第四步:求第k大的元素和第m大元素


function target(arr, x) {
for (let i = 0; i < x - 1; i++) {
// 交换不需要进行堆化的元素
if (i === min - 1) result.push(arr[0]);
swap(arr, 0, arr.length - 1 - i);
arr
heapSize--;
maxHeapify(arr, 0, heapSize)
}
}
target(singleNums, max)
result.push(singleNums[0]);

第五步:根据哈希表出现的次数计算并返回结果


return result.reduce((pre,cur) => pre + cur * map.get(cur),0)

AC代码


/*
* @Author: FaithPassion
* @Date: 2021-07-09 10:06:00
* @LastEditTime: 2021-08-28 11:09:30
* @Description: 找出数组中第k大和第m大的数字相加之和
* let arr = [1,2,4,4,3,5], k = 2, m = 4
* findTopSum(arr, k, m); // 第2大的数是4,出现2次,第4大的是2,出现1次,所以结果为10
*/

/**
* @description: 采用堆排序求解
* @param {*} arr 接收一个未排序的数组
* @param {*} k 数组中第k大的元素
* @param {*} m 数组中第m大的元素
* @return {*} 返回数组中第k大和第m大的数字相加之和
*/
function findTopSum(arr, k, m) {


function buildMaxHeap(arr, heapSize) {
// 从最后一个叶子节点开始进行堆化
for (let i = Math.floor(heapSize / 2) - 1; i >= 0; i--) {
// 进行堆化
maxHeapify(arr, i, heapSize);
}
}
// 最大堆化函数
function maxHeapify(arr, i, heapSize) {
// 首先假定第i个是最大的
let max = i;
let leftChild = 2 * i + 1;
let rightChild = 2 * i + 2;
// 如果下标不越界,并且左孩子的比最大值大则更新最大值
if (leftChild < heapSize && arr[leftChild] > arr[max]) {
max = leftChild;
}
if (rightChild < heapSize && arr[rightChild] > arr[max]) {
max = rightChild;
}
if (max !== i) {
swap(arr, i, max);
// 上来的元素的位置往下要接着堆化
maxHeapify(arr, max, heapSize);
}
}

// 交换数组中两个元素
function swap(nums, a, b) {
let temp = nums[a];
nums[a] = nums[b];
nums[b] = temp;
}
let result = []
// k和m中较大的
let max = Math.max(k, m);
// k和m中较小的
let min = Math.min(k, m);
const map = new Map();

for (let v of arr) {
if (!map.get(v)) {
map.set(v,1);
} else {
map.set(v,map.get(v) + 1)
}
}
// 求第x大的元素
function target(arr, x) {
for (let i = 0; i < x - 1; i++) {
// 交换不需要进行堆化的元素
if (i === min - 1) result.push(arr[0]);
swap(arr, 0, arr.length - 1 - i);
arr
heapSize--;
maxHeapify(arr, 0, heapSize)
}
}
const singleNums = [...new Set(arr)]
// 堆的大小
let heapSize = singleNums.length;
// 构建大顶堆
buildMaxHeap(singleNums, heapSize);

target(singleNums, max)
result.push(singleNums[0]);
return result.reduce((pre,cur) => pre + cur * map.get(cur),0)

}

findTopSum([1, 2, 4, 4, 3, 5], 2, 4)

题目反思



  • 学会通过堆排序的方式来求解Top K问题。

  • 学会对数组进行去重。

  • 学会使用reduce Api。


链接:https://juejin.cn/post/7001397295912583198

收起阅读 »

cookie和session、localStorage和sessionStorage、IndexedDB、JWT汇总

cookie和session HTTP协议是一种无状态协议,即每次服务端接收到客户端的请求时,都是一个全新的请求,服务器并不知道客户端的历史请求记录;Session和Cookie的主要目的就是为了弥补HTTP的无状态特性。 cookie是什么? cookie是...
继续阅读 »

cookie和session


HTTP协议是一种无状态协议,即每次服务端接收到客户端的请求时,都是一个全新的请求,服务器并不知道客户端的历史请求记录;SessionCookie的主要目的就是为了弥补HTTP的无状态特性。


cookie是什么?


cookie是服务器发送到Web浏览器的一小块数据,服务器发送到浏览器的Cookie,浏览器会进行存储,并与下一个请求一起发送到服务器,用于判断请求是否来自同一个浏览器,例如用户保持登录状态。


cookie的属性




  • name:表示cookie的名称




  • valuecookie对应的值。




  • domain:该字段为可以访问此cookie的域名,即cookie在哪个域有效




  • path: cookie的有效路径。DomainPath标识共同定义了Cookie的作用域:即 Cookie应该发送给哪些URL




  • sizecookie的大小(不超过4kb)




  • expires/Max-Age:有效期。expirescookie被删除的时间戳;Max-Age有效期的时间戳(服务器返回的时间,和客户端可能存在误差),默认为-1,页面关闭立即失效。




  • HttpOnly: 设置为true时不允许通过脚本document.cookie去更改cookie值,也不可获取,能有效的防止xss攻击。但发送请求仍会携带cookie。




  • secure: 标记为SecureCookie只应通过被HTTPS协议加密过的请求发送给服务端,可以保护Cookie在浏览器和Web服务器间的传输过程中不被窃取和篡改。




  • SameSite: 该属性可以让Cookie在跨站请求时不会被发送,用来防止CSRF攻击和用户追踪




    • Strict:完全禁止第三方cookie,跨站点时,任何情况下都不会发送cookie。也就是说,只有当前网页的URL与请求目标一致,才会带上cookie




    • Lax: 大多数情况不发送第三方cookie,但导航到目标网址的get请求(链接,预加载请求,GET表单)除外。




    • None: 网站可以选择显式关闭SameSite属性,将其设为None。不过,前提是必须同时设置Secure属性(Cookie 只能通过 HTTPS 协议发送),否则无效。


      浏览器查看cookie






cookie安全


安全问题可以看我总结的这篇:前端安全—常见的攻击方式及防御方法


session是什么?


Session是保存在服务器记录客户状态的机制。客户端浏览器访问服务器的时候,服务器会为这次请求开辟一块内存空间,这个对象便是Session 对象,存储结构为 ConcurrentHashMap。Session 弥补了 HTTP 无状态特性,服务器可以利用 Session 存储客户端在同一个会话期间的一些操作记录。


session的创建



  • 用户向服务器发送用户名和密码

  • 服务器通过验证后,在当前对话(session)里面保存相关数据(比如用户角色,登录时间等)

  • 服务器向用户返回一个session_id,写入要不干湖的cookie

  • 用户随后的每一次请求都会通过cookie,将session_id传回服务器

  • 服务器收到session_id,找到前期保存的数据,由此得知用户的身份。


cookie和session的区别



  • 储存方式:cookie是服务端产生,储存在客户端;session储存在服务端

  • 储存大小:单个cookie不超过4kb;session没有大小限制

  • 安全性:session更安全

  • 储存内容:cookie只能保存字符串,以文本的方式;session通过类似hashtable的数据结构来储存,能支持任何类型的对象

  • 使用方式

    • cookie机制:如果不在浏览器设置过期时间,cookie被保存在内存中,cookie生命周期随浏览器的关闭而结束。如果在浏览器中设置了cookie的过期时间,cookie被保存在硬盘中,关闭浏览器后,cookie数据仍然存在,知道过期时间才消失。

    • session机制:当服务器收到请求需要创建session对象时,首先会检查客户端请求中是否包含session_id,如果有,服务器将根据id返回对应的session对象。如果没有session_id,服务器会创建新的session对象,并把session_id在本次响应中返回给客户端。




cookie、localStorage和sessionStorage


HTML5提供了两种在客户端存储数据的新方法:localStorage和sessionStorage,挂载在window对象下。


webStorage是本地存储,数据不是由服务器请求传递的。从而它可以存储大量的数据,而不影响网站的性能。


Web Storage的目的是为了克服由cookie带来的一些限制,当数据需要被严格控制在客户端上时,无须持续地将数据发回服务器。比如客户端需要保存的一些用户行为或数据,或从接口获取的一些短期内不会更新的数据,我们就可以利用Web Storage来存储。


localStorage


生命周期是永久性的。localStorage存储的数据,以“键值对”的形式存在。即使关闭浏览器,也不会让数据消失,除非主动的去删除数据。如果想设置失效时间,需自行封装。localStorage 在所有同源窗口中都是共享的。


sessionStorage


sessionStorage保存的数据用于浏览器的一次会话,当会话结束(关闭浏览器或者页面),数据被清空;SessionStorage的属性和方法与LocalStorage完全一样。


sessionStorage特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 sessionStorage 内容便无法共享
localStorage 在所有同源窗口中都是共享的; cookie也是在所有同源窗口中都是共享的。除了保存期限的长短不同,


cookie、localStorage和sessionStorage的区别



  • 共同点:都是保存在浏览器端,且都遵循同源策略。

  • 不同点:在于生命周期与作用域等不同


image.png


IndexedDB


IndexedDB是一个运行在浏览器上的非关系型数据库,储存空间大,用于客户端存储大量结构化数据(包括文件和blobs) 。可以存字符串,也可以存二进制数据,数据以"键值对"的形式保存,不能有重复,否则会报错。除非被清理,否则一直存在。



  • 键值对储存

  • 异步

  • 支持事务

  • 同源策略

  • 支持二进制储存


JWT(JSON Web Token)


互联网服务离不开用户认证。一般流程看上面session的创建


什么是Token?




  • Token的定义


    Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。token其实说的更通俗点可以叫暗号,在一些数据传输之前,要先进行暗号的核对,不同的暗号被授权不同的数据操作。




  • 简单 token 的组成



    • uid(用户唯一的身份标识)

    • time(当前时间戳)

    • sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)




  • token 的身份验证流程



    • 客户端使用用户名跟密码请求登录

    • 服务端收到请求,去验证用户名与密码

    • 客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage

    • 客户端每次向服务端请求资源的时候需要带着服务端签发的 token

    • 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据
      image.png




  • 使用Token的目的


    Token的目的是为了减少频繁的查询数据库,减轻服务器的压力。基于Token用户认证是一种服务器无状态的认证方式,服务器不存放数据,所有数据都保存在客户端,每次请求都发回服务器,用解析token的时间来换取session的储存空间,从而减轻服务器的压力,减少频繁的查询数据库。token 完全由应用管理,所以它可以避开同源策略。




什么是 JWT?


JWT的原理


JWT的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。


JWT的数据结构




  • Header(头部)


    Header部分是一个JSON对象,描述JWT的元数据,使用Base64编码转成字符串。




  • Payload(负载)


    Payload是一个JSON对象,用来存放实际需要传递的数据,使用Base64编码转成字符串。



    • iss (issuer):签发人

    • exp (expiration time):过期时间

    • sub (subject):主题

    • aud (audience):受众

    • nbf (Not Before):生效时间

    • iat (Issued At):签发时间

    • jti (JWT ID):编号




  • Signature(签名)


    Signature是对前两部分的签名,防止数据篡改。首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用Header里面指定的签名算法(默认是 HMAC SHA256)产生签名。用"点"(.)分隔拼接成字符串后返回给用户。




JWT的特点



  • 默认不加密,也可以加密

  • 可以用于认证,也可以用于交换信息。降低服务器查询数据库的次数,减小服务器压力

  • 服务器无状态,因此无法在使用过程中废除某个Token,或者更改Token的权限。即一旦JWT签发了,在到期之前始终有效,除非服务器部署额外的逻辑

  • JWT本身包含了认证信息,为保证安全性,有效期应设置得比较短

  • 为了减少盗用,JWT应使用HTTPS协议传输

作者:你阿呆呀
链接:https://juejin.cn/post/7002520994434777119
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter 系列 - 环境搭建

Flutter 作为火热的跨端工具包,在 github 上超过 120k 的关注量,可见一斑。 基于目前本人正在学习 Flutter 的路上,会将整个学习的过程记录下来。 本博文主要讲解环境的搭建,先把项目搭建好,跑通 demo 才有玩下去的必要和成就感,你说...
继续阅读 »

Flutter 作为火热的跨端工具包,在 github 上超过 120k 的关注量,可见一斑。


基于目前本人正在学习 Flutter 的路上,会将整个学习的过程记录下来。


本博文主要讲解环境的搭建,先把项目搭建好,跑通 demo 才有玩下去的必要和成就感,你说是吧?


本人开发环境




  • macOS Big Sur 版本 11.2 芯片 Apple M1




  • 磁盘空间:> 2.8 GB (要求的最小的空间)




  • $SHELL




echo $SHELL
/bin/bash


⚠️ 之后出现并解决的问题都是基于本人的环境



安装 Flutter


通过官网下载安装包。


将安装包放到自己想存放的地方。这里,我放在 文稿 -> sdk 方便管理,然后解压下载包。


配置 flutterPATH 环境变量,格式如下:


export PATH=$PATH:${pwd}/flutter/bin

export PATH=${pwd}/flutter/bin:$PATH

这里我需要编辑 ~/.bash_profile 文件,添加下面这行内容:


export PATH=/Users/jimmy/Documents/sdk/flutter/bin:$PATH

安装 IDE


作为一个前端开发者,比较偏向 VS code,直接安装其稳定版即可。


因为需要调试安卓平台,还需要安装编辑器 Android StudioAndroid StudioFlutter 提供了一个完整的集成开发环境。


不管 VS code 还是 Android Studio 都需要安装 Flutter 插件。



Android Studio 我还是安装在 文稿 -> sdk



注意安装android studio的路径,也许会报sdk的错误。类似错误 ❌


# [Flutter-Unable to find bundled Java version(flutter doctor), after updated android studio Arctic Fox(2020.3.1) on M1 Apple Silicon](https://stackoverflow.com/questions/68569430/flutter-unable-to-find-bundled-java-versionflutter-doctor-after-updated-andro)

对应的解决方法:flutter-unable-to-find-bundled-java-versionflutter-doctor-after-updated-andro


验证


之后,运行 flutter doctor 或者 flutter doctor -v 来检查是否安装了必要的安装包。


下面是自己搭建环境的情况flutter doctor -v


[✓] Flutter (Channel stable, 2.2.3, on macOS 11.2 20D64 darwin-arm, locale

    zh-Hans-CN)

    • Flutter version 2.2.3 at /Users/jimmy/Documents/sdk/flutter

    • Framework revision f4abaa0735 (9 weeks ago), 2021-07-01 12:46:11 -0700

    • Engine revision 241c87ad80

    • Dart version 2.13.4

[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0)

    • Android SDK at /Users/jimmy/Library/Android/sdk

    • Platform android-31, build-tools 31.0.0

    • Java binary at: /Users/jimmy/Documents/sdk/Android

      Studio.app/Contents/jre/jdk/Contents/Home/bin/java

    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS

    • Xcode at /Applications/Xcode.app/Contents/Developer

    • Xcode 12.5.1, Build version 12E507

    • CocoaPods version 1.10.2

[✓] Chrome - develop for the web

    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2020.3)

    • Android Studio at /Users/jimmy/Documents/sdk/Android Studio.app/Contents # 留意 Android Studio 路径

    • Flutter plugin can be installed from:

      🔨 https://plugins.jetbrains.com/plugin/9212-flutter

    • Dart plugin can be installed from:

      🔨 https://plugins.jetbrains.com/plugin/6351-dart

    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

[✓] VS Code (version 1.59.1)

    • VS Code at /Applications/Visual Studio Code.app/Contents

    • Flutter extension version 3.25.0

[✓] Connected device (1 available)

    • Chrome (web) • chrome • web-javascript • Google Chrome 92.0.4515.159

• No issues found!

出现 No issues found! 的提示,说明你捣鼓成功了~


运行 Demo


我们在 VS code 上新建一个项目:


查看 -> 命令面板 -> Flutter: New Application Project

初始化项目之后,运行 -> 启动调试,然后按照下图运行应用:


vscode_demo.png


如果选中 Chrome web 会直接调起你安装好的谷歌浏览器。


如果选中 Start iOS Simulator 会调起 xCode 的模拟器。


如果选中 Start Pixel 2 API 31 会调起 Android Studio 的模拟器。



当然你得在 Android Studio 上预设手机型号是哪个,不然初次在 VS code 上调不起来。



effect_result.png


【完】~ 下次可以更加愉快玩耍了


作者:Jimmy
链接:https://juejin.cn/post/7002401225270362143

收起阅读 »

面试官问:我们聊聊原型和继承?我:这里边水深,我把握不住。。。

前言 原型和继承一直是js中非常基础和重要的部分,我们来看看日常开发中经常会用到的原型和继承。 class Person extends React.Component { componentDidMount() {} render() {...
继续阅读 »

前言


原型和继承一直是js中非常基础和重要的部分,我们来看看日常开发中经常会用到的原型和继承。


  class Person extends React.Component {
componentDidMount() {}
render() {}
}

这行代码代码大家都很熟悉,Person通过extends关键字继承了React的特性,componentDidMount和render在class类中的是一个普通定义好的函数。特殊的是,它们也是在Component中提前定义好的钩子函数,用于在某个固定的时机触发。


看完了基本的使用,下面我们一起来深入探索下class和extends。


class只是一个语法糖


class是ES6中引入的概念,我们也称它为类。class的用途是作为对象模版,用来创建对象。但需要明确的是,class只是一个语法糖,它内部实现上还是和ES5创建对象是相同的。由于class的写法更加符合面向对象编程的习惯,所以被推广使用,逐步替代了ES5中的对象创建。


   console.log(typeof React.Component); // function

ES5是通过构造函数函数来创建对象,React.Component的类型同样是一个function,所以想要完全搞清楚对象和原型,还是要去学习下ES5中对象的创建。后面有一篇文章是关于ES5中对象的创建和继承,有需要的大家可以自己去看,这里就不展开说了。


class与构造函数的对比


class的本质还是构造函数,但是与构造函数又有些许使用上的不同。


相同点


定义方式


class与构造函数都有两种定义方式,声明和表达式,这两种写法完全等价。且名称都必须大写,以区别于它创建的实例.


  // 函数声明
function Person() {};
// 函数表达式
let Person = function () {};

// 类声明
class Person {}
// 类表达式
let Person = class {};

// 创建实例(函数和类)
let person = new Person();

通过name访问原表达式。


表达式赋值时,可通过name访问原表达式。


  let Student = function Person() {};
// 通过name属性获取类表达式的名称
console.log(Student.name); // Person

let Student = class Person {};
// 通过name属性获取类表达式的名称
console.log(Student.name); // Person

表达式外部,无法访问原表达式


  let Student = function Person() {};
// 外部无法访问类表达式
console.log(Person); // Person is not defined

let Student = class Person {};
// 外部无法访问类表达式
console.log(Person); // Person is not defined

不同点


类不可以变量提升


函数可以变量提升,而类不可以。


  // 声明
console.log(Person); // 报错
console.log(Student); // ƒ Student() {}

class Person {}
function Student() {}

console.log(Person); // class Person {}
console.log(Student); // ƒ Student() {}

// 表达式定义
console.log(Person); // undefined
console.log(Student); // undefined

var Person = class {};
var Student = function () {};

console.log(Person); // class {}
console.log(Student); // ƒ () {}

类受块级作用域限制


  {
class Person {}
function Student() {}
}
console.log(Person); // 报错,Person is not defined
console.log(Student); // ƒ Student() {}

类必须通过new来调用


类必须通过new来调用,否则会报错。构造函数不使用new调用也可以,就会把全局的this作为内部对象。


  function Person() {}
class Animal {}

let p = Person(); // Person内部this指向window
let a = Animal(); // TypeError: class constructor Animal cannot be invoked without 'new'

class的实例化


class实例化的时候,会调用class中的constructor函数。constructor是类的默认方法,如果没有定义,constructor方法会被默认添加。


  class Bar {}
等同于
class Bar {
constructor() {}
}

constructor方法会默认返回一个实例对象(即this),也可以完全返回另一个对象。但返回另一个对象,会导致返回的对象不是Bar的实例(因为它的原型指针没有被更改,具体的原因后面分析)。


  // 返回一个对象
class Bar {
constructor() {
return {
name: 1,
};
}
}

let bar = new Bar();
console.log(bar); // {name: 1}
console.log(bar instanceof Bar); // false

// 返回默认对象
class Bar {
constructor() {}
}

let bar = new Bar();
console.log(bar); // Bar {}
console.log(bar instanceof Bar); // true

前面说到了,如果手动返回了一个对象,会导致返回的对象不是class的实例。那么我们看看生成一个对象的过程是什么样的,为什么手动返回一个对象,这个对象就不是类的实例了。


实例化的过程:



  1. 在内存中创建一个对象

  2. 新对象的__proto__赋值为构造函数的prototype

  3. 构造函数内部的this指向新对象

  4. 执行构造函数内部代码(给新对象添加属性)

  5. 如果构造函数返回非空对象,则返回该对象。否则,则返回新创建的对象。


通过上面的第二步可以看到,原型的赋值作用在新对象上,只有新对象与原型有关系,人为的在constructor返回的对象,与原型毫无关联,自然不是class的实例。


数据共享


定义在constructor中的属性,是每个实例独有的,不会在原型上共享。


  class Person {
constructor() {
this.name = new String("Jack");
// 定义在constructor中的函数是不被原型共享的
this.sayName = () => console.log(this.name);
this.nicknames = ["Jake", "J-Dog"];
}
}
let p1 = new Person();
let p2 = new Person();
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); //false

实例化的时候相当于复制了一个新函数


  class Person {
constructor() {
this.name = new String("Jack");
this.sayName = new Function();
this.nicknames = new Array(["Jake", "J-Dog"]);
}
}

如果想在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。


  class Person {
constructor() {
// 定义在constructor中的方法是属于每个实例的
this.locate = () => console.log("instance");
}
// 定义在类块中的方法是所有实例共享的
test() {
console.log("test");
}
}

let person1 = new Person();
let person2 = new Person();
console.log(person1.locate === person2.locate); // false
console.log(person1.test === person2.test); // true
// 实例中有该属性
console.log(person1.hasOwnProperty("locate")); // true
// 实例中没有该属性
console.log(person1.hasOwnProperty("test")); // false

类的静态方法


类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。


作用


在日常开发中,我会通过类的静态方法去处理一些名称管理和接口,如下面所示:


  class Home {
static getData() {
return [];
}
}
console.log(Home.getData());

静态方法中的this至与类有关


需要注意的是,静态方法不要求存在类的实例,所以this引用类自身,而不是实例。


  class Bar {
static test() {
console.log(this);
}
}
// 类可以直接调用静态方法
Bar.test(); // class Bar {}

var bar = new Bar();
// 实例与静态方法无关
bar.test(); // 报错,bar.test is not a function

静态方法也可被继承


  class Bar {
static test() {
console.log(this);
}
}

class Foo extends Bar {}
Foo.test(); // class Foo extends Bar {}

静态方法也是可以从super对象上调用的


  class Bar {
static test() {
return "test1";
}
}

class Foo extends Bar {
static test2() {
return super.test() + " test2";
}
}
console.log(Foo.test2()); // test1 test2

类中this指向



  1. this存在于类的构造函数中,this指向实例

  2. this存在于类的原型对象上,this指向类的原型

  3. this存在于类的静态方法中,this指向当前类


类的继承


类的继承使用的是新语法,但它的本质依旧是原型链。


ES6中,使用extends关键字,就可以继承任何拥有constructor和原型的对象。所以它不仅可以继承一个类,还可以继承普通的构造函数。


  class Vehicle {}
// 继承类
class Bus extends Vehicle {}
let b = new Bus();
console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true
function Person() {}
// 继承普通构造函数
class Engineer extends Person {}
let e = new Engineer();
console.log(e instanceof Engineer); // true
console.log(e instanceof Person); // true

super


派生类的方法可以通过 super 关键字引用它们的原型。这个关键字只能在派生类中使用,在类构造函数中使用 super 可以调用父类构造函数。


提炼几个要点:



  1. super关键字只能在派生类的构造函数和静态方法上使用,如下所示,Vehicle不是派生类


  class Vehicle {
constructor() {
// SyntaxError: 'super' keyword unexpected
super();
}
}


  1. 在类构造函数中,不能在调用 super()之前引用 this。


  class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(this);
}
}
new Bus();
// Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor


  1. 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象。


  class Vehicle {}

class Car extends Vehicle {}
console.log(new Car()); // Car {}

class Bus extends Vehicle {
constructor() {
super();
}
}
console.log(new Bus()); // Bus {}

class Van extends Vehicle {
constructor() {
return {};
}
}
console.log(new Van()); // {}

class Test extends Vehicle {
constructor() {}
}
console.log(new Test());
// Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

链接:https://juejin.cn/post/7001502812261580836

收起阅读 »

二进制都不了解?也配做什么程序员???

最近在学习一些计算机专业课,学习的过程中二进制的基础是必须要有的,不管是计算机网络,还是组成原理,还是操作系统,都是以二进制为基础的,所以本文总结一些二进制基础。今天,2021年8月30日,写下第一版,后面会陆续增加一些内容,增加一些应用便于更多人理解。 本文...
继续阅读 »

最近在学习一些计算机专业课,学习的过程中二进制的基础是必须要有的,不管是计算机网络,还是组成原理,还是操作系统,都是以二进制为基础的,所以本文总结一些二进制基础。今天,2021年8月30日,写下第一版,后面会陆续增加一些内容,增加一些应用便于更多人理解。


本文目标:



  • 理解的概念

  • 熟记常见的2的次幂,例如128是2的几次幂(2的几次幂就需要多少个二进制位)

  • 理解字节,对于1个字节能存储多少数据做到理性认知

  • 熟记16进制0-16,对应的2进制


带着问题阅读:



  1. 一个ip地址 192.168.1.1共有几位

  2. CSS中的颜色表示 #ffaaff,需要占用多大的存储空间存储

  3. 为什么计算机专业书籍中,表示内存地址大部分都是用16进制表示的,而不是10进制或者2进制

  4. javascirpt中的数字类型在计算机内存储为多少Byte

  5. 宽带的带宽是200M,为什么下载的时候怎么都达不到200M呢


如果所有的问题,你都会,就不用读了,直接退出。


进制


10进制,一位数可以是0-9,共10种可能,如果要表示第11种可能,就要进位。


类比一下,2进制,一位数只能是0或1,有2种可能。


16进制,一位数可以是0-15,有16种可能


10进制的进位规则如下:满10进一位


0  10  20
1 11
2 12
3 13
4 14
5 15
6 16
7 17
8 18
9 19

2进制的进位规则如下:满两位进一位,10进制的0是2进制0,10进制的1是2进制的1,如果要表示10进制的2,就要用两位2进制数,10


0  10  100  1000
1 11 101 1001
110 1010
111 1011
1100
1101
1110
1111


16进制的规则,满16进一位(a表示10进制的10,b:11,c:12...)


0  10(10进制的16)
1 11(10进制的17)
2 12
3
4
5
6
7
8
9
a
b
c
d
e
f

2进制与16进制


一位二进制数,称为1bit。


image.png


1位二进制数,也就是1bit,有2种可能,可以表示数0,1


2位二进制数,2bit,有4种可能(2x2),可以表示数0,1,2,3


3位二进制数,3bit,有8种可能(2x2x2),可以表示数0,1,2,3,4,5,6,7


...


n位二进制数,有 2^n -1 种可能。


有一些常用的2的次幂需要记住,必须记在脑子里,例如看到10进制的128,就想起来是2的7次方,就想起来有7位,0000000


image.png


2进制是计算机用的,人用起来写起来并不方便,所以就有了16进制。


一个16进制,可以表示16种可能性,也就是2的4次方,就是4位2进制数,就是4bit


举个栗子,


16进制是f,表示为2进制就是1111


16进制的ff,表示为2进制就是1111 1111


规律就是,一位16进制,可以用4位2进制来表示。2位16进制,用8位2进制数来表示。


那么16进制的ffffff表示为2进制是多少位呢


字节



字节(英语:Byte),通常用作计算机信息计量单位,不分数据类型。是通信和数据存储的概念。



一个字节能存储8位2进制数据(这个是规范,需要刻在DNA里面)


1Byte =8bit

2^8是256,1个字节能表示的数就是0-255,共256种可能性。


1位16进制数能表示为4位2进制,所以一个字节能表示2个16进制。


总结如下:


1Byte
8bit 1111 1111
2个16进制位 f f

KB,MB,GB,Kb,Mb,Gb


KB(Kilobyte) 千字节,国际单位法一般以1000来定义千,例如1千米=1000米,但是在信息领域,尤其是表示主存储容量时,千字节一般表示1024(2^10)个字节


1KB = 1024 B   2^10 Byte
1MB = 1024 KB 2^20 Byte
1GB = 1024 MB 2^30 Byte

Kb与KB是不同的,Kb是 Kilobit,


1Kb = 1024bit

我们的宽带的带宽是200M每秒,其实是200Mb/s,但是文件是以Byte为单位的,而不是bit,所以需要换算一下


200Mb / 8 = 25 MB

其实能够达到的最高下载速度是25MB/s


简单应用


一个ip地址 192.168.1.1,共32位,why?


因为ip地址是10进制表示的,ip地址用.分开,每一段的范围是0-255,就是2^8,共8位,4*8=32,一共32位。


CSS中的颜色表示 #ffaaff,需要占用多大的存储空间存储


1个Byte存储8位2进制,


1个16进制相当于4位2进制,


所以1个Byte存储2位16进制


#ffaaff存储需要 3Byte


本文就先到这里,后续要有一些内容需要补充,比如按位&``|``!左移右移以及更多的应用(在内存层面的应用,在计算机网络中的应用,在字符编码中的应用等)等我学会了,整理了,补充在这篇文章的后面。


有问题请在评论区提出。


链接:https://juejin.cn/post/7002088412903637022

收起阅读 »

一个"剑气"加载?️

🙇 前言 我们在网页加载的时候总会加上一些过渡效果来引导用户,减少白屏时间,而加载的效果可以用svg也可以使用一些我们封装好的组件,今天就来分享一种"剑气"加载效果。 相信大家看封面都知道效果了,那我们就直接开干吧。 🏋️‍♂️ToDoList 剑气...
继续阅读 »

🙇 前言



  • 我们在网页加载的时候总会加上一些过渡效果来引导用户,减少白屏时间,而加载的效果可以用svg也可以使用一些我们封装好的组件,今天就来分享一种"剑气"加载效果。

  • 相信大家看封面都知道效果了,那我们就直接开干吧。


src=http___image.17173.com_bbs_v1_2012_12_01_1354372326576.gif&refer=http___image.17173.gif


🏋️‍♂️ToDoList



  • 剑气形状

  • 剑气转动

  • 组合剑气


🚴 Just Do It



  • 其实做一个这样的效果仔细看就是有三个类似圆环状的元素进行循环转动,我们只需要拆解出一个圆环来做效果即可,最后再将三个圆环组合起来。


剑气形状



  • 仔细看一道剑气,它的形状是不是很像一把圆圆的镰刀分成一半,而这个镰刀我们可以通过边框和圆角来做。

  • 首先准备一个剑气雏形。


  <div class="sword">
<span>
</div>


  • 我们只需要对一个圆加上一个方向的边框就可以做成半圆的形状,这样类似剑气的半圆环形状就完成了🌪️。


.sword {
position: relative;
margin: 200px auto;
width: 64px;
height: 64px;
border-radius: 50%;
}
.sword span{
position: absolute;
box-sizing: border-box;
width: 100%;
height: 100%;
border-radius: 50%;
}
.sword :first-child{
left: 0%;
top: 0%;
border-bottom: 3px solid #EFEFFA;
}

image.png


剑气转动



  • 因为我们需要剑气一直不停的循环转动,所以我们可以借助cssanimation动画属性就可以自己给它添加一个动画了。

  • animation属性是一个简写属性,可以用于设置以下动画属性分别是:

    • animation-name:指定要绑定到选择器的关键帧的名称

    • animation-duration:动画指定需要多少秒或毫秒完成

    • animation-timing-function:设置动画将如何完成一个周期

    • animation-delay:设置动画在启动前的延迟间隔

    • animation-iteration-count:定义动画的播放次数

    • animation-direction:指定是否应该轮流反向播放动画

    • animation-fill-mode:规定当动画不播放时,要应用到元素的样式

    • animation-play-state:指定动画是否正在运行或已暂停



  • 更多的动画学习可以参考MDN


...
.sword :first-child{
...
animation: sword-one 1s linear infinite;
...
}
@keyframes sword-one {
0% {
transform: rotateZ(0deg);
}
100% {
transform: rotateZ(360deg);
}
}
...


  • 我们可以给定一个不断绕z0deg360deg转动的动画,设定为一秒完成一次一直无限循环,我们来看看效果:


剑气1.gif



  • 接下来让这个半圆弧分别绕x轴和y轴也转动一定角度即可完成一个剑气的转动。


...
@keyframes sword-one {
0% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(360deg);
}
}
...


  • 我们来看看完成后的效果:


剑气2.gif


组合剑气



  • 最后我们只需要再制作两个剑气在组装起来就好了。


<div class="sword">
<span></span>
<span></span>
<span></span>
</div>


  • 给新添的两个span添加动画和样式。


...
.sword :nth-child(2){
right: 0%;
top: 0%;
animation: sword-two 1s linear infinite;
border-right: 3px solid #EFEFFA;
}

.sword :last-child{
right: 0%;
bottom: 0%;
animation: sword-three 1s linear infinite;
border-top: 3px solid #EFEFFA;
}

@keyframes sword-two {
0% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(0deg);
}
100% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(360deg);
}
}

@keyframes sword-three {
0% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(360deg);
}
}
...


  • 这样我们的剑气加载效果就制作好了,以上就是全部代码了,喜欢的可以拿去用哟。

  • 我们来看看最终的效果吧~


剑气3.gif



链接:https://juejin.cn/post/7001779766852321287

收起阅读 »

学会这个,我的http加载速度更快了!

1. 前言 说到 HTTP 怎么提升网络加载速度,就不得不聊一聊 HTTP/2 了。 HTTP/2 的主要目标是通过支持完整的请求与响应复用来减少延迟,通过有效压缩 HTTP 标头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。 HTTP/2 ...
继续阅读 »

1. 前言


说到 HTTP 怎么提升网络加载速度,就不得不聊一聊 HTTP/2 了。


HTTP/2 的主要目标是通过支持完整的请求与响应复用来减少延迟,通过有效压缩 HTTP 标头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。


HTTP/2 没有改动 HTTP 的应用语义。 HTTP 方法、状态代码、URI 和标头字段等核心概念一如往常。 不过,HTTP/2 修改了数据格式化(分帧)以及在客户端与服务器间传输的方式。这两点统帅全局,通过新的分帧层向我们的应用隐藏了所有复杂性。 因此,所有现有的应用都可以不必修改而在新协议下运行。


2. 二进制分帧层


HTTP/2 所有性能增强的核心在于新的二进制分帧层,它定义了如何封装 HTTP 消息并在客户端与服务器之间传输。


image.png


这里所谓的“层”,指的是位于套接字接口与应用可见的高级 HTTP API 之间一个经过优化的新编码机制: HTTP 的语义(包括各种动词、方法、标头)都不受影响,不同的是传输期间对它们的编码方式变了。 HTTP/1.x 协议以换行符作为纯文本的分隔符,而 HTTP/2 将所有传输的信息分割为更小的消息和帧,并采用二进制格式对它们编码。


3. 数据流、消息和帧


新的二进制分帧机制改变了客户端与服务器之间交换数据的方式。 为了说明这个过程,我们需要了解 HTTP/2 的三个概念:



  • 数据流: 已建立的连接内的双向字节流,可以承载一条或多条消息。

  • 消息: 与逻辑请求或响应消息对应的完整的一系列帧。

  • : HTTP/2 通信的最小单位,每个帧都包含帧头,至少也会标识出当前帧所属的数据流。


这些概念的关系总结如下:



  • 所有通信都在一个 TCP 连接上完成,此连接可以承载任意数量的双向数据流。

  • 每个数据流都有一个唯一的标识符和可选的优先级信息,用于承载双向消息。

  • 每条消息都是一条逻辑 HTTP 消息(例如请求或响应),包含一个或多个帧。

  • 帧是最小的通信单位,承载着特定类型的数据,例如 HTTP 标头、消息负载等等。 来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。


image.png


简言之,HTTP/2 将 HTTP 协议通信分解为二进制编码帧的交换,这些帧对应着特定数据流中的消息。所有这些都在一个 TCP 连接内复用。 这是 HTTP/2 协议所有其他功能和性能优化的基础。


4. 请求与响应复用


在 HTTP/1.x 中,如果客户端要想发起多个并行请求以提升性能,则必须使用多个 TCP 连接(请参阅使用多个 TCP 连接)。 这是 HTTP/1.x 交付模型的直接结果,该模型可以保证每个连接每次只交付一个响应(响应排队)。 更糟糕的是,这种模型也会导致队首阻塞,从而造成底层 TCP 连接的效率低下。


HTTP/2 中新的二进制分帧层突破了这些限制,实现了完整的请求和响应复用: 客户端和服务器可以将 HTTP 消息分解为互不依赖的帧,然后交错发送,最后再在另一端把它们重新组装起来。


image.png


快照捕捉了同一个连接内并行的多个数据流。 客户端正在向服务器传输一个 DATA 帧(数据流 5),与此同时,服务器正向客户端交错发送数据流 1 和数据流 3 的一系列帧。因此,一个连接上同时有三个并行数据流。


将 HTTP 消息分解为独立的帧,交错发送,然后在另一端重新组装是 HTTP 2 最重要的一项增强。事实上,这个机制会在整个网络技术栈中引发一系列连锁反应,从而带来巨大的性能提升,让我们可以:



  • 并行交错地发送多个请求,请求之间互不影响。

  • 并行交错地发送多个响应,响应之间互不干扰。

  • 使用一个连接并行发送多个请求和响应。

  • 不必再为绕过 HTTP/1.x 限制而做很多工作(请参阅针对 HTTP/1.x 进行优化,例如级联文件、image sprites 和域名分片。

  • 消除不必要的延迟和提高现有网络容量的利用率,从而减少页面加载时间。

  • 等等…


HTTP/2 中的新二进制分帧层解决了 HTTP/1.x 中存在的队首阻塞问题,也消除了并行处理和发送请求及响应时对多个连接的依赖。 结果,应用速度更快、开发更简单、部署成本更低。


5. 数据流优先级


将 HTTP 消息分解为很多独立的帧之后,我们就可以复用多个数据流中的帧,客户端和服务器交错发送和传输这些帧的顺序就成为关键的性能决定因素。 为了做到这一点,HTTP/2 标准允许每个数据流都有一个关联的权重和依赖关系:



  • 可以向每个数据流分配一个介于 1 至 256 之间的整数。

  • 每个数据流与其他数据流之间可以存在显式依赖关系。


数据流依赖关系和权重的组合让客户端可以构建和传递“优先级树”,表明它倾向于如何接收响应。 反过来,服务器可以使用此信息通过控制 CPU、内存和其他资源的分配设定数据流处理的优先级,在资源数据可用之后,带宽分配可以确保将高优先级响应以最优方式传输至客户端。


image.png


HTTP/2 内的数据流依赖关系通过将另一个数据流的唯一标识符作为父项引用进行声明;如果忽略标识符,相应数据流将依赖于“根数据流”。 声明数据流依赖关系指出,应尽可能先向父数据流分配资源,然后再向其依赖项分配资源。 换句话说,“请先处理和传输响应 D,然后再处理和传输响应 C”。


共享相同父项的数据流(即,同级数据流)应按其权重比例分配资源。 例如,如果数据流 A 的权重为 12,其同级数据流 B 的权重为 4,那么要确定每个数据流应接收的资源比例,请执行以下操作:



  1. 将所有权重求和: 4 + 12 = 16

  2. 将每个数据流权重除以总权重: A = 12/16, B = 4/16


因此,数据流 A 应获得四分之三的可用资源,数据流 B 应获得四分之一的可用资源;数据流 B 获得的资源是数据流 A 所获资源的三分之一。


我们来看一下上图中的其他几个操作示例。 从左到右依次为:



  1. 数据流 A 和数据流 B 都没有指定父依赖项,依赖于隐式“根数据流”;A 的权重为 12,B 的权重为 4。因此,根据比例权重: 数据流 B 获得的资源是 A 所获资源的三分之一。

  2. 数据流 D 依赖于根数据流;C 依赖于 D。 因此,D 应先于 C 获得完整资源分配。 权重不重要,因为 C 的依赖关系拥有更高的优先级。

  3. 数据流 D 应先于 C 获得完整资源分配;C 应先于 A 和 B 获得完整资源分配;数据流 B 获得的资源是 A 所获资源的三分之一。

  4. 数据流 D 应先于 E 和 C 获得完整资源分配;E 和 C 应先于 A 和 B 获得相同的资源分配;A 和 B 应基于其权重获得比例分配。


如上面的示例所示,数据流依赖关系和权重的组合明确表达了资源优先级,这是一种用于提升浏览性能的关键功能,网络中拥有多种资源类型,它们的依赖关系和权重各不相同。 不仅如此,HTTP/2 协议还允许客户端随时更新这些优先级,进一步优化了浏览器性能。 换句话说,我们可以根据用户互动和其他信号更改依赖关系和重新分配权重。


注: 数据流依赖关系和权重表示传输优先级,而不是要求,因此不能保证特定的处理或传输顺序。 即,客户端无法强制服务器通过数据流优先级以特定顺序处理数据流。 尽管这看起来违反直觉,但却是一种必要行为。 我们不希望在优先级较高的资源受到阻止时,还阻止服务器处理优先级较低的资源。


6. 每个来源一个连接


有了新的分帧机制后,HTTP/2 不再依赖多个 TCP 连接去并行复用数据流;每个数据流都拆分成很多帧,而这些帧可以交错,还可以分别设定优先级。 因此,所有 HTTP/2 连接都是永久的,而且仅需要每个来源一个连接,随之带来诸多性能优势。



SPDY 和 HTTP/2 的杀手级功能是,可以在一个拥塞受到良好控制的通道上任意进行复用。 这一功能的重要性和良好运行状况让我吃惊。 我喜欢的一个非常不错的指标是连接拆分,这些拆分仅承载一个 HTTP 事务(并因此让该事务承担所有开销)。 对于 HTTP/1,我们 74% 的活动连接仅承载一个事务 - 永久连接并不如我们所有人希望的那般有用。 但是在 HTTP/2 中,这一比例锐减至 25%。 这是在减少开销方面获得的巨大成效。  (HTTP/2 登陆 Firefox,Patrick McManus)



大多数 HTTP 传输都是短暂且急促的,而 TCP 则针对长时间的批量数据传输进行了优化。 通过重用相同的连接,HTTP/2 既可以更有效地利用每个 TCP 连接,也可以显著降低整体协议开销。 不仅如此,使用更少的连接还可以减少占用的内存和处理空间,也可以缩短完整连接路径(即,客户端、可信中介和源服务器之间的路径) 这降低了整体运行成本并提高了网络利用率和容量。 因此,迁移到 HTTP/2 不仅可以减少网络延迟,还有助于提高通量和降低运行成本。


注: 连接数量减少对提升 HTTPS 部署的性能来说是一项特别重要的功能: 可以减少开销较大的 TLS 连接数、提升会话重用率,以及从整体上减少所需的客户端和服务器资源。


7. 流控制


流控制是一种阻止发送方向接收方发送大量数据的机制,以免超出后者的需求或处理能力: 发送方可能非常繁忙、处于较高的负载之下,也可能仅仅希望为特定数据流分配固定量的资源。 例如,客户端可能请求了一个具有较高优先级的大型视频流,但是用户已经暂停视频,客户端现在希望暂停或限制从服务器的传输,以免提取和缓冲不必要的数据。 再比如,一个代理服务器可能具有较快的下游连接和较慢的上游连接,并且也希望调节下游连接传输数据的速度以匹配上游连接的速度来控制其资源利用率;等等。


上述要求会让您想到 TCP 流控制吗?您应当想到这一点;因为问题基本相同(请参阅流控制)。 不过,由于 HTTP/2 数据流在一个 TCP 连接内复用,TCP 流控制既不够精细,也无法提供必要的应用级 API 来调节各个数据流的传输。 为了解决这一问题,HTTP/2 提供了一组简单的构建块,这些构建块允许客户端和服务器实现其自己的数据流和连接级流控制:



  • 流控制具有方向性。 每个接收方都可以根据自身需要选择为每个数据流和整个连接设置任意的窗口大小。

  • 流控制基于信用。 每个接收方都可以公布其初始连接和数据流流控制窗口(以字节为单位),每当发送方发出 DATA 帧时都会减小,在接收方发出 WINDOW_UPDATE 帧时增大。

  • 流控制无法停用。 建立 HTTP/2 连接后,客户端将与服务器交换 SETTINGS 帧,这会在两个方向上设置流控制窗口。 流控制窗口的默认值设为 65,535 字节,但是接收方可以设置一个较大的最大窗口大小(2^31-1 字节),并在接收到任意数据时通过发送 WINDOW_UPDATE 帧来维持这一大小。

  • 流控制为逐跃点控制,而非端到端控制。 即,可信中介可以使用它来控制资源使用,以及基于自身条件和启发式算法实现资源分配机制。


HTTP/2 未指定任何特定算法来实现流控制。 不过,它提供了简单的构建块并推迟了客户端和服务器实现,可以实现自定义策略来调节资源使用和分配,以及实现新传输能力,同时提升网页应用的实际性能和感知性能(请参阅速度、性能和人类感知)。


例如,应用层流控制允许浏览器仅提取一部分特定资源,通过将数据流流控制窗口减小为零来暂停提取,稍后再行恢复。 换句话说,它允许浏览器提取图像预览或首次扫描结果,进行显示并允许其他高优先级提取继续,然后在更关键的资源完成加载后恢复提取。


8. 服务器推送


HTTP/2 新增的另一个强大的新功能是,服务器可以对一个客户端请求发送多个响应。 换句话说,除了对最初请求的响应外,服务器还可以向客户端推送额外资源(图 12-5),而无需客户端明确地请求。


image.png


注: HTTP/2 打破了严格的请求-响应语义,支持一对多和服务器发起的推送工作流,在浏览器内外开启了全新的互动可能性。 这是一项使能功能,对我们思考协议、协议用途和使用方式具有重要的长期影响。


为什么在浏览器中需要一种此类机制呢?一个典型的网络应用包含多种资源,客户端需要检查服务器提供的文档才能逐个找到它们。 那为什么不让服务器提前推送这些资源,从而减少额外的延迟时间呢? 服务器已经知道客户端下一步要请求什么资源,这时候服务器推送即可派上用场。


事实上,如果您在网页中内联过 CSS、JavaScript,或者通过数据 URI 内联过其他资产(请参阅资源内联),那么您就已经亲身体验过服务器推送了。 对于将资源手动内联到文档中的过程,我们实际上是在将资源推送给客户端,而不是等待客户端请求。 使用 HTTP/2,我们不仅可以实现相同结果,还会获得其他性能优势。 推送资源可以进行以下处理:



  • 由客户端缓存

  • 在不同页面之间重用

  • 与其他资源一起复用

  • 由服务器设定优先级

  • 被客户端拒绝


PUSH_PROMISE 101


所有服务器推送数据流都由 PUSH_PROMISE 帧发起,表明了服务器向客户端推送所述资源的意图,并且需要先于请求推送资源的响应数据传输。 这种传输顺序非常重要: 客户端需要了解服务器打算推送哪些资源,以免为这些资源创建重复请求。 满足此要求的最简单策略是先于父响应(即,DATA 帧)发送所有 PUSH_PROMISE 帧,其中包含所承诺资源的 HTTP 标头。


在客户端接收到 PUSH_PROMISE 帧后,它可以根据自身情况选择拒绝数据流(通过 RST_STREAM 帧)。 (例如,如果资源已经位于缓存中,便可能会发生这种情况。) 这是一个相对于 HTTP/1.x 的重要提升。 相比之下,使用资源内联(一种受欢迎的 HTTP/1.x“优化”)等同于“强制推送”: 客户端无法选择拒绝、取消或单独处理内联的资源。


使用 HTTP/2,客户端仍然完全掌控服务器推送的使用方式。 客户端可以限制并行推送的数据流数量;调整初始的流控制窗口以控制在数据流首次打开时推送的数据量;或完全停用服务器推送。 这些优先级在 HTTP/2 连接开始时通过 SETTINGS 帧传输,可能随时更新。


推送的每个资源都是一个数据流,与内嵌资源不同,客户端可以对推送的资源逐一复用、设定优先级和处理。 浏览器强制执行的唯一安全限制是,推送的资源必须符合原点相同这一政策: 服务器对所提供内容必须具有权威性。


9. 标头压缩


每个 HTTP 传输都承载一组标头,这些标头说明了传输的资源及其属性。 在 HTTP/1.x 中,此元数据始终以纯文本形式,通常会给每个传输增加 500–800 字节的开销。如果使用 HTTP Cookie,增加的开销有时会达到上千字节。 (请参阅测量和控制协议开销。) 为了减少此开销和提升性能,HTTP/2 使用 HPACK 压缩格式压缩请求和响应标头元数据,这种格式采用两种简单但是强大的技术:



  1. 这种格式支持通过静态霍夫曼代码对传输的标头字段进行编码,从而减小了各个传输的大小。

  2. 这种格式要求客户端和服务器同时维护和更新一个包含之前见过的标头字段的索引列表(换句话说,它可以建立一个共享的压缩上下文),此列表随后会用作参考,对之前传输的值进行有效编码。


利用霍夫曼编码,可以在传输时对各个值进行压缩,而利用之前传输值的索引列表,我们可以通过传输索引值的方式对重复值进行编码,索引值可用于有效查询和重构完整的标头键值对。


image.png


作为一种进一步优化方式,HPACK 压缩上下文包含一个静态表和一个动态表: 静态表在规范中定义,并提供了一个包含所有连接都可能使用的常用 HTTP 标头字段(例如,有效标头名称)的列表;动态表最初为空,将根据在特定连接内交换的值进行更新。 因此,为之前未见过的值采用静态 Huffman 编码,并替换每一侧静态表或动态表中已存在值的索引,可以减小每个请求的大小。


注: 在 HTTP/2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异: 所有标头字段名称均为小写,请求行现在拆分成各个 :method:scheme:authority 和 :path 伪标头字段。


HPACK 的安全性和性能


早期版本的 HTTP/2 和 SPDY 使用 zlib(带有一个自定义字典)压缩所有 HTTP 标头。 这种方式可以将所传输标头数据的大小减小 85% - 88%,显著减少了页面加载时间延迟:



在带宽较低的 DSL 链路中,上行链路速度仅有 375 Kbps,仅压缩请求标头就显著减少了特定网站(即,发出大量资源请求的网站)的页面加载时间。 我们发现,仅仅由于标头压缩,页面加载时间就减少了 45 - 1142 毫秒。  (SPDY 白皮书, chromium.org)



10. 相关阅读



链接:https://juejin.cn/post/7002025354542415902

收起阅读 »

我是如何用这3个小工具,助力小姐姐提升100%开发效率的

前言 简单的知识点搭配合适的业务场景,往往能起到意想不到的效果。这篇文章会用三个最基础人人都知道的前端知识来说明如何助力运营小姐姐、公司48+前端开发同学的日常工作,让他们的工作效率得到极大地提升。 看完您可以会收获:用vue从零开始写一个chrome插件&n...
继续阅读 »

前言


简单的知识点搭配合适的业务场景,往往能起到意想不到的效果。这篇文章会用三个最基础人人都知道的前端知识来说明如何助力运营小姐姐、公司48+前端开发同学的日常工作,让他们的工作效率得到极大地提升。


看完您可以会收获:用vue从零开始写一个chrome插件 如何用Object.defineProperty拦截fetch请求`  如何使用油猴脚本开发一个扩展程序  日常提效的一些思考


油猴脚本入门示例



因为接下来的两个小工具都是基于油猴脚本来实现的,所以我们提前先了解一下它



油猴脚本是什么?



油猴脚本(Tampermonkey)是一个流行的浏览器扩展,可以运行用户编写的扩展脚本,来实现各式各样的功能,比如去广告、修改样式、下载视频等。



如何写一个油猴脚本?


1. 安装油猴


以chrome浏览器扩展为例,点击这里先安装


安装完成之后可以看到右上角多了这个


image.png


2. 新增示例脚本 hello world



// ==UserScript==
// @name hello world // 脚本名称
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://juejin.cn/* // 表示怎样的url才执行下面的代码
// @icon https://www.google.com/s2/favicons?domain=juejin.cn
// @grant none
// ==/UserScript==

(function() {
'use strict';
alert('hello world')
// Your code here...
})();

没错当打开任意一个https://juejin.cn/*掘金的页面时,都会弹出hello world,而其他的网页如https://baidu.com则不会。


到此你就完成了一个最简单的油猴脚本,接下来我们看一下用同样简单的代码,来解决一个实际问题吧!O(∩_∩)O


3行代码让SSO自动登录


问题是什么?


1. 有一天运营小姐姐要在几个系统之间配置点东西


一顿操作,终于把事情搞定了,心情美美的。


但是她心想,为啥每个系统都要我登录一次,不开心 o( ̄ヘ ̄o#)


1.gif


2. 下午一觉醒来,领导让把上午的配置重新改一下(尽职的小姐姐马上开始操作)


但是让她没想到的是:上午的登录页面仿佛许久没有见到她一样,又和小姐姐来了一次亲密接触😭


此时,她的内心已经开始崩溃了


2.gif


3. 但是这不是结束,以后的每一天她都是这种状态😭😭😭


3.gif


痛点在哪里?



看完上面的动图,我猜你已经在替小姐姐一起骂娘了,这做的什么玩意,太垃圾了。SSO是统一登录,你们这搞的是什么东西。



是的,我的内心和你一样愤愤不平, 一样有一万个草泥马在奔腾,这是哪个sb设计的方案,简直不配做人,一天啥事也不干,尽是跳登录页,输入用户名密码点登录按钮了,久而久之,朋友间见面说的第一句话不是“你吃了吗?”,而是“你登录了吗?”。


不过吐槽完,我们还是要想想如何通过技术手段解决这两个痛点,达到只需要登录一次的目的


1. 在A系统登录之后,跑到其他系统需要重新登录。


2. 登录时效只有2小时,2小时后,需要重新登录


该如何解决?


根本原因还是公司的SSO统一登录方案设计的有问题,所以需要推动他们修改,但是这是一个相对长期的过程,短期内有没有什么办法能让我们愉快的登录呢?


痛点1: 1. 在A系统登录之后,跑到其他系统需要重新登录。已无力回天


痛点2: 2. 登录时效只有2小时,2小时后,需要重新登录已无力回天


我们不好直接侵入各个系统去改造登录逻辑,改造其登录时效,但是却可以对登录页面(示例)做点手脚


image.png


最关键的是:




  1. 用户名输入框




  2. 密码输入框




  3. 点击按钮




所以可以借助油猴脚本,在DOMContentLoaded的时候,插入一下代码,来实现自动登录,减少手动操作的过程,大概原理如下。


结构图.jpg


// ==UserScript==
// @name SSO自动登录
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://*.xxx.com/login* // 这里是SSO登录页面地址,表示只有符合这个规则的才注入这段代码
// @grant none
// ==/UserScript==

document.querySelector('#username').value = 'xxx' // 用户名
document.querySelector('#password').value = 'yyy' // 密码
document.querySelector('#login-submit').click() // 自动提交登录

是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


image.png


是的,就这 😄,第一次帮小姐姐解决了困扰她许久的问题,晚上就请我吃了麻辣烫,还夸我"技术"好(此处不是开车


试试效果


gif中前半部分没有开启自动登录的脚本需要手动登录,后半部开启了就可以自动登录了。


autoLogin.gif


拦截fetch请求,只留你想要的页面


问题是什么?


前端常见的调试方式



  1. chrome inspect

  2. vconsole

  3. weinre

  4. 等等


这些方式都有各自的优缺点,比如chrome inspect第一次需要翻墙才能使用,只适用于安卓; vconsole不方便直接调试样式; weinre只适用于调试样式等。


基于这些原因,公司很久之前搞了一个远程调试工具,可以很方便的增删DOM结构、调试样式、查看请求、查看application 修改后手机上立即生效。


autoLogin2.gif


远程调试平台使用流程


他的使用流程大概是这样的




  1. 打开远程调试页面列表


    此页面包含测试环境所有人打开的调试页面链接, 多的时候有上百个




image.png



  1. 点击你要调试的页面,就可以进入像chrome控制台一样调试了


image.png


看完流程你应该大概知道问题在哪里了, 远程调试页面列表不仅仅包含我自己的页面,还包括很多其他人的,导致很难快速找到自己想要调试的页面


该如何解决?


问题解析


有什么办法能让我快速找到自己想要调试的页面呢?其实观察解析这个页面会发现列表是



  1. 通过发送一个请求获取的

  2. 响应中包含设备关键字


image.png


拦截请求


所以聪明的你已经猜到了,我们可以通过Object.defineProperty拦截fetch请求,过滤设备让列表中只存在我们指定的设备(毕竟平时开发时调试的设备基本是固定的,而设备完全相同的概率是很低的,所以指定了设备其实就是唯一标识了自己)页面。


具体如何做呢?



// ==UserScript==
// @name 前端远程调试设备过滤
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://chii-fe.xxx.com/ // 指定脚本生效的页面
// @grant none
// @run-at document-start // 注意这里,脚本注入的时机是document-start
// ==/UserScript==

;(() => {
const replaceRe = /\s*/g
// 在这里设置设备白名单
const DEVICE_WHITE_LIST = [
'Xiaomi MI 8',
'iPhone9,2',
].map(
(it) => it.replace(replaceRe, '').toLowerCase())

const originFetch = window.fetch
const recordListUrl = 'record-list'
const filterData = (source) => {
// 数据过滤,返回DEVICE_WHITE_LIST指定的设备的数据
// 详细过程省略
return data
}
// 拦截fetch请求
Object.defineProperty(window, 'fetch', {
configurable:
true,
enumerable:
true,
get () {
return function (url, options) {
return originFetch(url, options).then((response) => {
// 只处理指定的url
if (url.includes(recordListUrl)) {
if (response.clone) {
const cloneRes = response.clone()

return new Promise((resolve, reject) => {
resolve(
{
text: (
) => {
return cloneRes.json().then(json => {
return filterData(JSON.stringify(json))
}
);
}
}
)
}
)
}
}

return response
}
)
}
}
}
)
}
)()


试试效果


通过下图可以看出,过滤前有37个页面,过滤后只剩3个,瞬间就找到你要调试页面,再也不用从几百个页面中寻找你自己的那个啦!


image.png


助力全公司45+前端开发 - chrome插件的始与终



通过插件一键设置ua,模拟用户登录状态,提高开发效率。



先看结果


插件使用方式


new.gif


插件使用结果



团队48+小伙伴也使用起来了



image.png


image.png


背景和问题



日常c端业务中有很多场景都需要用户登录后才能正常进行,而开发阶段基本都是通过chrome模拟手机设备来开发,所以往往会涉及到在chrome浏览器中模拟用户登录,其涉及以下三步(这个步骤比较繁琐)。



备注:保持用户的登录态一般是通过cookie,但也有通过header来做,比如我们公司是改写ua来做的



  1. 获取ua: 前往公司UA生成平台输入手机号生成ua

  2. 添加ua: 将ua复制到chrome devtool设置/修改device

  3. 使用ua: 选择新添加的ua,刷新页面,重新开发调试


ua.gif


来看一段对话



隔壁98年刚毕业妹子:



又过期了,谁又把我挤下去了嘛


好的,稍等一会哈,我换个账号测测


好麻烦哎!模拟一个用户信息,要这么多步骤,好烦呀!!!



我,好奇的大叔:



“细心”了解下,她正在做一个h5活动项目,场景复杂,涉及的状态很多,需要用不同的账号来做测试。


模拟一两个用户还好,但是此刻小姐姐测这么多场景,已经模拟了好多个(谁都会烦啊)


公司的登录体系是单点登录,一个好不容易模拟的账号,有可能别人也在用,结果又被顶掉了,得重新生成,我TM


看着她快气哭的小眼神,作为隔壁桌友好的邻居,此刻我心里只想着一件事...!帮她解决这个恼人的问题。


分析和解决问题



通过上面的介绍您应该可以感觉到我们开发阶段遇到需要频繁切换账号做测试时的烦恼,相对繁琐的ua生成过程导致了它一定是个费时费力的麻烦事。



有没有什么办法让我们的开发效率得到提升,别浪费在这种事情上呢?一起一步步做起来


需求有哪些



提供一种便捷地模拟ua的方式,助力开发效率提升。




  1. 基本诉求:本地开发阶段,希望有更便捷的方式来模拟用户登录

  2. 多账号: 一个项目需要多个账号,不同项目间的账号可以共享也可以不同

  3. 指定域: 只有指定的下才需要模拟ua,不能影响浏览器正常使用

  4. 过期处理: 账号过期后,可以主动生成,无需手动重新获取


如何解决




  1. 需求1:结合前面生成ua阶段,我们可以通过某种方式让用户能直接在当前页面生成ua,无需跳出,一键设置省略手动过程




  2. 需求2:提供多账号管理功能,能直接选中切换ua




  3. 需求3:限定指定域,该ua才生效




  4. 需求4:当使用到过期账号时,可一键重新生成即可




为什么是chrome插件




  1. 浏览器中发送ajax请求的ua无法直接修改,但是chrome插件可以修改请求的ua(很重要的一点




  2. chrome插件popup模式可直接在当前页面打开,无需跳出开发页面,减少跳出过程




用vue从零开始写一个chrome插件



篇幅原因,这里只做示例级别的简单介绍,如果您希望详细了解chrome插件的编写可以参考这里



从一个小例子开始



接下来我们会以下页面为例,说明用vue如何写出来。



ua3.gif


基本功能




  1. 底部tab切换区域viewAviewBviewC




  2. 中间内容区域:切换viewA、B、C分别展示对应的页面




content部分


借助chrome浏览器可以向网页插入脚本的特性,我们会演示如何插入脚本并且在网页加载的时候弹一个hello world


popup与background通信部分


popup完成用户的主要交互,在viewA页面点击获取自定义的ua信息


修改ajax请求ua部分


会演示如果通过chrome插件修改请求header


1. 了解一个chrome插件的构成



  1. manifest.json

  2. background script

  3. content script

  4. popup


1. manifest.json



几乎所有的东西都要在这里进行声明、权限资源页面等等




{
"manifest_version": 2, // 清单文件的版本,这个必须写
"name": "hello vue extend", // 插件的名称,等会我们写的插件名字就叫hello vue extend
"description": "hello vue extend", // 插件描述
"version": "0.0.1", // 插件的版本
// 图标,写一个也行
"icons": {
"48": "img/logo.png"
},
// 浏览器右上角图标设置,browser_action、page_action、app必须三选一
"browser_action": {
"default_icon": "img/logo.png",
"default_title": "hello vue extend",
"default_popup": "popup.html"
},
// 一些常驻的后台JS或后台页面
"background": {
"scripts": [
"js/hot-reload.js",
"js/background.js"
]
},
// 需要直接注入页面的JS
"content_scripts": [{
"matches": [""],
"js": ["js/content.js"],
"run_at": "document_start"
}],
// devtools页面入口,注意只能指向一个HTML文件
"devtools_page": "devcreate.html",
// Chrome40以前的插件配置页写法
"options_page": "options.html",
// 权限申请
"permissions": [
"storage",
"webRequest",
"tabs",
"webRequestBlocking",
""
]
}

2. background script



后台,可以认为是一个常驻的页面,权限很高,几乎可以调用所有的API,可以与popup、content script等通信



3. content script



chrome插件向页面注入脚本的一种形式(js和css都可以)



4. popup



popup是点击browser_action或者page_action图标时打开的一个小窗口网页,焦点离开网页就立即关闭。



比如我们要用vue做的页面。


image.png


2. 改写vue.config.js



manifest.json对文件引用的结构基本决定了打包后的文件路径



打包后的路径


// dist目录用来chrome扩展导入

├── dist
│ ├── favicon.ico
│ ├── img
│ │ └── logo.png
│ ├── js
│ │ ├── background.js
│ │ ├── chunk-vendors.js
│ │ ├── content.js
│ │ ├── hot-reload.js
│ │ └── popup.js
│ ├── manifest.json
│ └── popup.html


源码目录



├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── js
│ └── hot-reload.js
├── src
│ ├── assets
│ │ ├── 01.png
│ │ ├── disabled.png
│ │ └── logo.png
│ ├── background
│ │ └── background.js
│ ├── content
│ │ └── content.js
│ ├── manifest.json
│ ├── popup
│ │ ├── App.vue
│ │ ├── main.js
│ │ ├── router.js
│ │ └── views
│ │ ├── viewA.vue
│ │ ├── viewB.vue
│ │ └── viewC.vue
│ └── utils
│ ├── base.js
│ ├── fixCaton.js
│ └── storage.js
└── vue.config.js



修改vue.config.js



主需要稍微改造变成可以多页打包,注意输出的目录结构就可以了




const CopyWebpackPlugin = require('copy-webpack-plugin')
const path = require('path')
// 这里考虑可以添加多页
const pagesObj = {}
const chromeName = ['popup']
const plugins = [
{
from: path.resolve('src/manifest.json'),
to: `${path.resolve('dist')}/manifest.json`
},
{
from: path.resolve('src/assets/logo.png'),
to: `${path.resolve('dist')}/img/logo.png`
},
{
from: path.resolve('src/background/background.js'),
to: `${path.resolve('dist')}/js/background.js`
},
{
from: path.resolve('src/content/content.js'),
to: `${path.resolve('dist')}/js/content.js`
},
]

chromeName.forEach(name => {
pagesObj[name] = {
css: {
loaderOptions: {
less: {
modifyVars: {},
javascriptEnabled: true
}
}
},
entry: `src/${name}/main.js`,
filename: `${name}.html`
}
})

const vueConfig = {
lintOnSave:false, //关闭eslint检查
pages: pagesObj,
configureWebpack: {
entry: {},
output: {
filename: 'js/[name].js'
},
plugins: [new CopyWebpackPlugin(plugins)]
},
filenameHashing: false,
productionSourceMap: false
}

module.exports = vueConfig



3. 热刷新



我们希望修改插件源代码进行打包之后,chrome插件对应的页面能主动更新。为什么叫热刷新而不是热更新呢?因为它其实是全局刷新页面,并不会保存状态。



这里推荐一个github上的解决方案crx-hotreload


4. 完成小例子编写


new.gif


文件目录结构



├── popup
│ ├── App.vue
│ ├── main.js
│ ├── router.js
│ └── views
│ ├── viewA.vue
│ ├── viewB.vue
│ └── viewC.vue



main.js



import Vue from 'vue'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

new Vue({
router,
render: h => h(App)
}).$mount('#app')



router.js


import Vue from 'vue'
import Router from 'vue-router'

import ViewA from './views/viewA.vue'
import ViewB from './views/viewB.vue'
import ViewC from './views/viewC.vue'

Vue.use(Router)

export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
redirect: '/view/a'
},
{
path: '/view/a',
name: 'viewA',
component: ViewA,
},
{
path: '/view/b',
name: 'viewB',
component: ViewB,
},
{
path: '/view/c',
name: 'viewC',
component: ViewC,
},
]
})

App.vue









viewA、viewB、viewC



三个页面基本长得是一样的,只有背景色和文案内容不一样,这里我就只贴viewA的代码了。



需要注意的是这里会演示popup与background,通过sendMessage方法获取background后台数据










background.js


const customUa = 'hello world ua'
// 请求发送前拦截
const onBeforeSendCallback = (details) => {
for (var i = 0; i < details.requestHeaders.length; ++i) {
if (details.requestHeaders[i].name === 'User-Agent') {
details.requestHeaders.splice(i, 1);
break;
}
}
// 修改请求UA为hello world ua
details.requestHeaders.push({
name: 'User-Agent',
value: customUa
});

return { requestHeaders: details.requestHeaders };
}

// 前面的sendMessage获取getCustomUserAgent,会被这里监听
const onRuntimeMessageListener = () => {
chrome.runtime.onMessage.addListener(function (msg, sender, callback) {
if (msg.type === 'getCustomUserAgent') {
callback({
customUa
});
}
});
}

const init = () => {
onRuntimeMessageListener()
onBeforeSendHeadersListener()
}

init()


content.js



演示如何往网页中插入代码




function setScript({ code = '', needRemove = true } = params) {
let textNode = document.createTextNode(code)
let script = document.createElement('script')

script.appendChild(textNode)
script.remove()

let parentNode = document.head || document.documentElement

parentNode.appendChild(script)
needRemove && parentNode.removeChild(script)
}

setScript({
code: `alert ('hello world')`,
})

ua3.gif


关于一键设置ua插件



大体上和小例子差不都,只是功能相对复杂一些,会涉及到





  1. 数据本地存储chrome.storage.sync.get|setchrome.tabs.query等API




  2. popup与background通信、content与background通信




  3. 拦截请求修改UA




  4. 其他的大体就是常规的vue代码编写啦!




这里就不贴详细的代码实现了。



链接:https://juejin.cn/post/7001998089938534437

收起阅读 »

跨浏览器窗口通讯 ,7种方式,你还知道几种呢?

前言 为什么会扯到这个话题,最初是源于听 y.qq.com/ QQ音乐, 播放器处于单独的一个页面 当你在另外的一个页面搜索到你满意的歌曲的时候,点击播放或添加到播放队列 你会发现,播放器页面做出了响应的响应 这里我又联想到了商城的购物车的场景,体验确实有...
继续阅读 »

前言


为什么会扯到这个话题,最初是源于听 y.qq.com/ QQ音乐,



  • 播放器处于单独的一个页面

  • 当你在另外的一个页面搜索到你满意的歌曲的时候,点击播放或添加到播放队列

  • 你会发现,播放器页面做出了响应的响应


这里我又联想到了商城的购物车的场景,体验确实有提升。

刚开始,我怀疑的是Web Socket作妖,结果通过分析网络请求和看源码,并没有。 最后发现是localStore的storage事件作妖,哈哈。




回归正题,其实在一般正常的知识储备的情况下,我们会想到哪些方案呢?


先抛开如下方式:



  1. 各自对服务器进行轮询或者长轮询

  2. 同源策略下,一方是另一方的 opener


演示和源码


多页面通讯的demo, 为了正常运行,请用最新的chrome浏览器打开。

demo的源码地址



两个浏览器窗口间通信


WebSocket


这个没有太多解释,WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。当然是有代价的,需要服务器来支持。

js语言,现在比较成熟稳定当然是 socket.iows. 也还有轻量级的ClusterWS


你可以在The WebSocket API (WebSockets)
看到更多的关于Web Socket的信息。


定时器 + 客户端存储


定时器:setTimeout/setInterval/requestAnimationFrame

客户端存储: cookie/localStorage/sessionStorage/indexDB/chrome的FileSystem


定时器没啥好说的,关于客户端存储。



  • cookie: 每次会带到服务端,并且能存的并不大,4kb?,记得不太清楚

  • localStorage/sessionStorage 应该是5MB, sessionStorage关闭浏览器就和你说拜拜。

  • indexDB 这玩意就强大了,不过读取都是异步的,还能存 Blob文件,真的是很high。

  • chrome的FileSystem ,Filesystem & FileWriter API,主要是chrome和opera支持。这玩意就是文件系统。


postMessage


Cross-document messaging 这玩意的支持率98.9%。 好像还能发送文件,哈哈,强大。

不过仔细一看 window.postMessage(),就注定了你首先得拿到window这个对象。 也注定他使用的限制, 两个窗体必须建立起联系。 常见建立联系的方式:



  • window.open

  • window.opener

  • iframe


提到上面的window.open, open后你能获得被打开窗体的句柄,当然也可以直接操作窗体了。




到这里,我觉得一般的前端人员能想到的比较正经的方案应该是上面三种啦。

当然,我们接下来说说可能不是那么常见的另外三种方式。


StorageEvent


Page 1


localStorage.setItem('message',JSON.stringify({
message: '消息',
from: 'Page 1',
date: Date.now()
}))

Page 2


window.addEventListener("storage", function(e) {
console.log(e.key, e.newValue, e.oldValue)
});

如上, Page 1设置消息, Page 2注册storage事件,就能监听到数据的变化啦。


上面的e就是StorageEvent,有下面特有的属性(都是只读):



  • key :代表属性名发生变化.当被clear()方法清除之后所有属性名变为null

  • newValue:新添加进的值.当被clear()方法执行过或者键名已被删除时值为null

  • oldValue:原始值.而被clear()方法执行过,或在设置新值之前并没有设置初始值时则返回null

  • storageArea:被操作的storage对象

  • url:key发生改变的对象所在文档的URL地址


Broadcast Channel


这玩意主要就是给多窗口用的,Service Woker也可以使用。 firefox,chrome, Opera均支持,有时候真的是很讨厌Safari,浏览器支持77%左右。


使用起来也很简单, 创建BroadcastChannel, 然后监听事件。 只需要注意一点,渠道名称一致就可以。

Page 1


    var channel = new BroadcastChannel("channel-BroadcastChannel");
channel.postMessage('Hello, BroadcastChannel!')

Page 2


    var channel = new BroadcastChannel("channel-BroadcastChannel");
channel.addEventListener("message", function(ev) {
console.log(ev.data)
});

SharedWorker


这是Web Worker之后出来的共享的Worker,不通页面可以共享这个Worker。

MDN这里给了一个比较完整的例子simple-shared-worker


这里来个插曲,Safari有几个版本支持这个特性,后来又不支持啦,还是你Safari,真是6。


虽然,SharedWorker本身的资源是共享的,但是要想达到多页面的互相通讯,那还是要做一些手脚的。
先看看MDN给出的例子的ShareWoker本身的代码:


onconnect = function(e) {
var port = e.ports[0];

port.onmessage = function(e) {
var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
port.postMessage(workerResult);
}

}

上面的代码其实很简单,port是关键,这个port就是和各个页面通讯的主宰者,既然SharedWorker资源是共享的,那好办,把port存起来就是啦。

看一下,如下改造的代码:

SharedWorker就成为一个纯粹的订阅发布者啦,哈哈。


var portList = [];

onconnect = function(e) {
var port = e.ports[0];
ensurePorts(port);
port.onmessage = function(e) {
var data = e.data;
disptach(port, data);
};
port.start();
};

function ensurePorts(port) {
if (portList.indexOf(port) < 0) {
portList.push(port);
}
}

function disptach(selfPort, data) {
portList
.filter(port => selfPort !== port)
.forEach(port => port.postMessage(data));
}


MessageChannel


Channel Messaging API的 MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个MessagePort 属性发送数据。


其需要先通过 postMessage先建立联系。


MessageChannel的基本使用:


var channel = new MessageChannel();
var para = document.querySelector('p');

var ifr = document.querySelector('iframe');
var otherWindow = ifr.contentWindow;

ifr.addEventListener("load", iframeLoaded, false);

function iframeLoaded() {
otherWindow.postMessage('Hello from the main page!', '*', [channel.port2]);
}

channel.port1.onmessage = handleMessage;
function handleMessage(e) {
para.innerHTML = e.data;
}

至于在线的例子,MDN官方有一个版本 MessageChannel 通讯



链接:https://juejin.cn/post/7002012595200720927

收起阅读 »

更新需要提示用户,需要控制应用是否更新

更新需要提示用户,需要控制应用是否更新1. 方案一在检测到更新后提示用户,让用户选择更新。设置autoDownload参数为false,让应用检测到更新不自动下载,改成手动下载更新包。通过在钩子update-available中,加入对话框提示用户,让用户选择...
继续阅读 »

更新需要提示用户,需要控制应用是否更新

1. 方案一

在检测到更新后提示用户,让用户选择更新。

设置autoDownload参数为false,让应用检测到更新不自动下载,改成手动下载更新包。

通过在钩子update-available中,加入对话框提示用户,让用户选择。

response为0用户选择确定,触发downloadUpdate方法下载应用更新包进行后续更新操作。否则,不下载更新包。

如果我们不配置autoDownload为false,那么问题来了:在弹出对话框的同时,用户还来不及选择,应用自动下载并且更新完成,做不到阻塞。

本文首发于公众号「全栈大佬的修炼之路」,欢迎关注。

重要代码如下:

autoUpdater.autoDownload = false

update-available钩子中弹出对话框

autoUpdater.on('update-available', (ev, info) => {
// // 不可逆过程
const options = {
type: 'info',
buttons: ['确定', '取消'],
title: '更新提示',
// ${info.version} Cannot read property 'version' of undefined
message: '发现有新版本,是否更新?',
cancelId: 1
}
dialog.showMessageBox(options).then(res => {
if (res.response === 0) {
autoUpdater.downloadUpdate()
logger.info('下载更新包成功')
sendStatusToWindow('下载更新包成功');
} else {
return;
}
})
})

2. 方案二

在更新下载完后提示用户,让用户选择更新。

先配置参数autoInstallOnAppQuit为false,阻止应用在检测到更新包后自动更新。

在钩子update-downloaded中加入对话框提示用户,让用户选择。

response为0用户选择确定,更新应用。否则,当前应用不更新。

如果我们不配置autoInstallOnAppQuit为false,那么问题是:虽然第一次应用不更新,但是第二次打开应用,应用马上关闭,还没让我们看到主界面,应用暗自更新,重点是更新完后不重启应用。

重要代码如下:

// 表示下载包不自动更新
autoUpdater.autoInstallOnAppQuit = false
在update-downloaded钩子中弹出对话框
autoUpdater.on('update-downloaded', (ev, releaseNotes, releaseName) => {
logger.info('下载完成,更新开始')
sendStatusToWindow('下载完成,更新开始');
// Wait 5 seconds, then quit and install
// In your application, you don't need to wait 5 seconds.
// You could call autoUpdater.quitAndInstall(); immediately
const options = {
type: 'info',
buttons: ['确定', '取消'],
title: '应用更新',
message: process.platform === 'win32' ? releaseNotes : releaseName,
detail: '发现有新版本,是否更新?'
}
dialog.showMessageBox(options).then(returnVal => {
if (returnVal.response === 0) {
logger.info('开始更新')
setTimeout(() => {
autoUpdater.quitAndInstall()
}, 5000);
} else {
logger.info('取消更新')
return
}
})
});

3. 源码分析

未打包目录位于: electron-builder/packages/electron-updater/src/AppUpdater.ts中。 打包后在electron-updater\out\AppUpdater.d.ts中

  1. 首先进入checkForUpdates()方法,开始检测更新
  2. 正在更新不需要进入
  3. 开始更新前判断autoDownload,为true自动下载,为false不下载等待应用通知。
export declare abstract class AppUpdater extends EventEmitter {
/**
* 当被发现有更新时,是否要自动下载更新
* 场景:可以适用于electron检查更新包提示,用户操作是否需要更新
*/
autoDownload: boolean;
/**
* 在app.quit()后,是否自动将下载下载的更新包更新
* 场景:可以适用于electron下载完更新包提示,用户操作是否需要更新。在第二次打开应用,应用不会自动更新。
*/
autoInstallOnAppQuit: boolean;
}


/**
* 检测是否需要更新
*/
checkForUpdates(): Promise < UpdateCheckResult > {
let checkForUpdatesPromise = this.checkForUpdatesPromise
// 正在检测更新跳过
if (checkForUpdatesPromise != null) {
this._logger.info("Checking for update (already in progress)")
return checkForUpdatesPromise
}

const nullizePromise = () => this.checkForUpdatesPromise = null
// 开始检测更新
this._logger.info("Checking for update")
checkForUpdatesPromise = this.doCheckForUpdates()
.then(it => {
nullizePromise()
return it
})
.catch(e => {
nullizePromise()
this.emit("error", e, `Cannot check for updates: ${(e.stack || e).toString()}`)
throw e
})

this.checkForUpdatesPromise = checkForUpdatesPromise
return checkForUpdatesPromise
}
// 检测更新具体函数
private async doCheckForUpdates(): Promise < UpdateCheckResult > {
// 触发 checking-for-update 钩子
this.emit("checking-for-update")
// 取更新信息
const result = await this.getUpdateInfoAndProvider()
const updateInfo = result.info
// 判断更新信息是否有效
if (!await this.isUpdateAvailable(updateInfo)) {
this._logger.info(`Update for version ${this.currentVersion} is not available (latest version: ${updateInfo.version}, downgrade is ${this.allowDowngrade ? "allowed" : "disallowed"}).`)
this.emit("update-not-available", updateInfo)
return {
versionInfo: updateInfo,
updateInfo,
}
}

this.updateInfoAndProvider = result
this.onUpdateAvailable(updateInfo)

const cancellationToken = new CancellationToken()
//noinspection ES6MissingAwait
// 如果设置autoDownload为true,则开始自动下载更新包,否则不下载
return {
versionInfo: updateInfo,
updateInfo,
cancellationToken,
downloadPromise: this.autoDownload ? this.downloadUpdate(cancellationToken) : null
}
}

如果需要配置updater中的其他参数达到某种功能,我们可以仔细查看其中的配置项。

export abstract class AppUpdater extends EventEmitter {
/**
* 当被发现有更新时,是否要自动下载更新
* 场景:可以适用于electron检查更新包提示,用户操作是否需要更新
*/
autoDownload: boolean;
/**
* 在app.quit()后,是否自动将下载下载的更新包更新
* 场景:可以适用于electron下载完更新包提示,用户操作是否需要更新。在第二次打开应用,应用不会自动更新。
*/
autoInstallOnAppQuit: boolean;
/**
* GitHub提供者。
是否允许升级到预发布版本。
如果应用程序版本包含预发布组件,默认为“true”。0.12.1-alpha.1,这里alpha是预发布组件),否则“false”。
allowDowngrade设置为true,则应用允许降级。
*/
allowPrerelease: boolean;
/**
* GitHub提供者。
获取所有发布说明(从当前版本到最新版本),而不仅仅是最新版本。
@default false
*/
fullChangelog: boolean;
/**
*是否允许版本降级(当用户从测试通道想要回到稳定通道时)。
*仅当渠道不同时考虑(根据语义版本控制的预发布版本组件)。
* @default false
*/
allowDowngrade: boolean;
/**
* 当前应用的版本
*/
readonly currentVersion: SemVer;
private _channel;
protected downloadedUpdateHelper: DownloadedUpdateHelper | null;
/**
* 获取更新通道。
不适用于GitHub。
从更新配置不返回“channel”,仅在之前设置的情况下。
*/
get channel(): string | null;
/**
* 设置更新通道。
不适用于GitHub。
覆盖更新配置中的“channel”。
“allowDowngrade”将自动设置为“true”。
如果这个行为不适合你,明确后简单设置“allowDowngrade”。
*/
set channel(value: string | null);
/**
* 请求头
*/
requestHeaders: OutgoingHttpHeaders | null;
protected _logger: Logger;
get netSession(): Session;
/**
* The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`.
* Set it to `null` if you would like to disable a logging feature.
* 日志,类型有:info、warn、error
*/
get logger(): Logger | null;
set logger(value: Logger | null);
/**
* For type safety you can use signals, e.g.
为了类型安全,可以使用signals。
例如:
`autoUpdater.signals.updateDownloaded(() => {})` instead of `autoUpdater.on('update-available', () => {})`
*/
readonly signals: UpdaterSignal;
private _appUpdateConfigPath;
/**
* test only
* @private
*/
set updateConfigPath(value: string | null);
private clientPromise;
protected readonly stagingUserIdPromise: Lazy<string>;
private checkForUpdatesPromise;
protected readonly app: AppAdapter;
protected updateInfoAndProvider: UpdateInfoAndProvider | null;
protected constructor(
options: AllPublishOptions | null | undefined,
app?: AppAdapter
);
/**
* 获取当前更新的url
*/
getFeedURL(): string | null | undefined;
/**
* Configure update provider. If value is `string`, [GenericServerOptions](/configuration/publish#genericserveroptions) will be set with value as `url`.
* @param options If you want to override configuration in the `app-update.yml`.
*
* 配置更新提供者。通过提供url
* @param options 如果你想覆盖' app-update.yml '中的配置。
*/
setFeedURL(options: PublishConfiguration | AllPublishOptions | string): void;
/**
* 检查服务其是否有更新
*/
checkForUpdates(): Promise<UpdateCheckResult>;
isUpdaterActive(): boolean;
/**
*
* @param downloadNotification 询问服务器是否有更新,下载并通知更新是否可用
*/
checkForUpdatesAndNotify(
downloadNotification?: DownloadNotification
): Promise<UpdateCheckResult | null>;
private static formatDownloadNotification;
private isStagingMatch;
private computeFinalHeaders;
private isUpdateAvailable;
protected getUpdateInfoAndProvider(): Promise<UpdateInfoAndProvider>;
private createProviderRuntimeOptions;
private doCheckForUpdates;
protected onUpdateAvailable(updateInfo: UpdateInfo): void;
/**
*
* 作用:开始下载更新包
*
* 如果将`autoDownload`选项设置为false,就可以使用这个方法。
*
* @returns {Promise<string>} Path to downloaded file.
*/
downloadUpdate(cancellationToken?: CancellationToken): Promise<any>;
protected dispatchError(e: Error): void;
protected dispatchUpdateDownloaded(event: UpdateDownloadedEvent): void;
protected abstract doDownloadUpdate(
downloadUpdateOptions: DownloadUpdateOptions
): Promise<Array<string>>;
/**
* 作用:下载后重新启动应用程序并安装更新。
*只有在' update- downloads '被触发后才会调用。
*
* 注意:如果在update-downloaded钩子中,让用户选择是否更新应用,选择不更新,那就是没有执行autoUpdater.quitAndInstall()方法。
* 虽然应用没有更新,但是当第二次打开应用的时候,应用检测到本地有更新包,他就会直接更新,最后不会重启更新后的应用。
*
* 为了解决这个问题,需要设置`autoInstallOnAppQuit`为false。关闭应用自动更新。
*
* **Note:** ' autoUpdater.quitAndInstall() '将首先关闭所有的应用程序窗口,然后只在' app '上发出' before-quit '事件。
*这与正常的退出事件序列不同。
*
* @param isSilent 仅Windows以静默模式运行安装程序。默认为false。
* @param isForceRunAfter 即使无提示安装也可以在完成后运行应用程序。不适用于macOS。忽略是否isSilent设置为false。
*/
abstract quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void;
private loadUpdateConfig;
private computeRequestHeaders;
private getOrCreateStagingUserId;
private getOrCreateDownloadHelper;
protected executeDownload(
taskOptions: DownloadExecutorTask
): Promise<Array<string>>;
}

最后,希望大家一定要点赞三连。


链接:https://juejin.cn/post/7001682043104919565

收起阅读 »

JS数字之旅——Number

首先来一段神奇的数字比较的代码 23333333333333333 === 23333333333333332 // output: true 233333333333333330000000000 === 23333333333333333999999999...
继续阅读 »

首先来一段神奇的数字比较的代码


23333333333333333 === 23333333333333332
// output: true
233333333333333330000000000 === 233333333333333339999999999
// output: true

咦?明明不一样的两个数字,为啥是相等的呢?


Number


众所周知,每一种编程语言,都有自己的数字类型,像Java里面,有intfloatlongdouble等,不同的类型有不同的可表示的数字范围。


同理,JavaScript也有Number表示数字,但是没有像C、Java等语言那样有表示不同精度的类型,Number可以用来表示整数也可以表示浮点数。由于Number是在内部被表示为64位的浮点数,所以是有边界值,而这个边界值如下:


Number.MAX_VALUE
// output: 1.7976931348623157e+308
Number.MIN_VALUE
// output: 5e-324

最大正数和最小正数



Number.MAX_VALUE代表的是可表示的最大正数,Number.MIN_VALUE代表的是可表示的最小正数。它们的值分别大约是1.79E+3085e-324



这时很容易想到一个问题,那超过MAX_VALUE会发生什么呢?通过下面的代码和输出可以发现,当超过MAX_VALUE,无论什么数字,都一律认为与MAX_VALUE相等,直到超过一定值之后,就会等于Infinity


Number.MAX_VALUE
// output: 1.7976931348623157e+308
Number.MAX_VALUE+1
// output: 1.7976931348623157e+308
Number.MAX_VALUE+1e291
// output: 1.7976931348623157e+308
Number.MAX_VALUE === Number.MAX_VALUE+1e291
// output: true
Number.MAX_VALUE+1e292
// output: Infinity

很明显,最开始的那段代码里面的数字,是在这两个Number.MAX_VALUENumber.MIN_VALUE,没有出现越界的情况,但为什么会发生比较上错误呢?


安全整数


其实,Number还有另一个概念,叫做安全整数,其中MAX_SAFE_INTEGER的定义是最大的整数n,使得n和n+1都能准确表示。而MIN_SAFE_INTEGER的定义则是最小的整数n,使得n和n-1都能准确表示。如下面代码所示,9007199254740991(2^53 - 1) 和 -9007199254740991(-(2^53 - 1)) 就是符合定义的最大和最小整数。


Number.MAX_SAFE_INTEGER
// output: 9007199254740991
Number.MAX_SAFE_INTEGER+1
// output: 9007199254740992
Number.MAX_SAFE_INTEGER+2
// output: 9007199254740992

Number.MIN_SAFE_INTEGER
// output: -9007199254740991
Number.MIN_SAFE_INTEGER-1
// output: -9007199254740992
Number.MIN_SAFE_INTEGER-2
// output: -9007199254740992

这意味着,在这个范围内的整数,进行计算或比较都是精确的。而超过这个区域的整数,则是不安全、有较大误差的。


现在回头看最开始的代码,很明显这些数字都已经超过MAX_SAFE_INTEGER。另外,也可以使用Number.isSafeInteger方法去判断是否是安全整数


Number.isSafeInteger(23333333333333333)
// output: false

Infinity


关于Infinity的一个有趣的地方是,它是一个数字类型,但既不是NaN,也不是整数。


typeof Infinity
// output: "number"
Number.isNaN(Infinity)
// output: false
Number.isInteger(Infinity)
// output: false

进制


JavaScript里面,除了支持十进制以外,也支持二进制、八进制和十六进制的字面量。



  • 二进制:以0b开头

  • 八进制:以0开头

  • 十六进制:以0x开头


0b11001
// output: 25
0234
// output: 156
0x1A2B
// output: 6699

里面出现的字母是不区分大小写,可以放心使用。那当我们遇到上面的进制以字符串形式出现的时候,如何解决呢?答案是使用parseInt


parseInt('11001', 2)
// output: 25
parseInt('0234', 8)
// output: 156
parseInt('0x1A2B', 16)
// output: 6699

parseInt这个方法实际上支持2-36进制的转换,虽然平时绝大多数情况,它通常被用来转十进制格式的字符串,而不会特别声明第二个参数。需要注意的一个特别的点是,部分浏览器,如果字符串是0开头的话,不带第二个参数的话,会默认以八进制进行换算,从而导致一些意想不到的bug。所以,保险起见,还是应该声明第二个参数。


浮点数计算


前面基本上都是在讨论整数,Number在浮点数计算方面,就显得有些力不从心,经常出现摸不着头脑的情况,最著名的莫过于0.1 + 0.2不等于0.3的精度问题。


0.1 + 0.2 === 0.3
// output: false
0.1 + 0.2
// output: 0.30000000000000004
1.5 * 1.2
// output: 1.7999999999999998

但其实这并非JavaScript特有的现象,像Java等语言也是有这种问题存在。究其原因,是因为我们以十进制的角度去计算,但计算机本身是以二进制运行和计算的,这就会导致在浮点数类型的表示和计算上,会存在一定的偏差,当这种偏差累计足够大的时候,就会导致精度问题。


正所谓解决办法总比困难多,仔细想想还是能找到一些解决方案。


有一种思路是,既然尽管出现误差也只有一点点,那就通过toFixed()强行限制小数位数,让存在误差的数值进行转换从而消除误差,但有一定的局限性,当遇上乘法和除法的时候,需要确认限制小数位数具体要多少个才合适。


另一种思路是,浮点数计算有误差,但是整数计算是准确的,那就把浮点数放大转换为整数,然后进行计算后再转换为浮点数。跟上面的方案类似,同样需要解决放大多少倍,以及后面转换为浮点数时的额外计算。


链接:https://juejin.cn/post/7001183062792863774

收起阅读 »

前端动画lottie-web

lottie是一个跨平台的动画库,通过AE(After Effects)制作动画,再通过AE插件Bodymovin导出Json文件,最终各个终端解析这个Json文件,还原动画。本文中我只介绍前端用到的库lottie-web。 对比三种常规的制作动画方式 Pn...
继续阅读 »

lottie是一个跨平台的动画库,通过AE(After Effects)制作动画,再通过AE插件Bodymovin导出Json文件,最终各个终端解析这个Json文件,还原动画。本文中我只介绍前端用到的库lottie-web。


对比三种常规的制作动画方式



  1. Png序列帧

  2. 2.Gif图

  3. 前端Svg API


先对位图与矢量图有一个基本的概念。



矢量图就是使用直线和曲线来描述的图形,构成这些图形的元素是一些点、线、矩形、多边形、圆和弧线等,它们都是通过数学公式计算获得的,具有编辑后不失真的特点。

位图是由称作像素(图片元素)的单个点组成的,放大会失真。\



Png序列帧


用Png序列帧是也容易理解,用css keyframes操作每一帧需要展示的图片,缺点也很明显,每一帧都是一张图片,占比较大的体积。当然也可以将图片合并成精灵图(Sprites Map),可参考这个方案,使用 gka 一键生成帧动画。Png也是位图,放大会失真,不过可以通过增大图片尺寸,避免模糊。


Gif图


如果之前没有用过动画,用Gif图是最简单的,只需要引入一张图。但是Gif图是位图,不是矢量图,放大会虚。


前端Svg API


Svg API对于动画初学者不太友好,你要实现一个自定义的动画,需要了解Svg的所有的API,虽然它的属性与css的动画有一些相似。它是矢量图,不失真。


lottie


而lottie是一个不太占体积,还原度高,对于初学者友好的库。设计师制作好动画,并且利用Bodymovin插件导出Json文件。而前端直接引用lottie-web库即可,它默认的渲染方式是svg,原理就是用JS操作Svg API。但是前端完全不需要关心动画的过程,Json文件里有每一帧动画的信息,而库会帮我们执行每一帧。


前端安装lottie-web插件


npm install lottie-web

代码调用


import lottie from 'lottie-web';

this.animation = lottie.loadAnimation({
container: this.animationRef.current,
renderer: 'svg',
loop: false,
autoplay: false,
animationData: dataJson,
assetsPath: CDN_URL,
});

介绍一个每个属性的意思。



  • container 当前需要渲染的DOM

  • renderer,渲染方式,默认是Svg,还有Html和Canvas方案。

  • loop 是否循环播放

  • autoplay 是否自动播放

  • animationData AE导出的Json,注意,这里不是路径

  • assetsPath Json文件里资源的绝对路径,webpack项目需要配合这个参数。


动画的播放与暂停,如果动画需要用户触发与暂停,需要有一个切换操作(toggle)


this.animation.play();
this.animation.pause();

动画执行过程中的钩子,可以对动画有一定的控制权



  • complete

  • loopComplete

  • enterFrame

  • segmentStart

  • config_ready(初始配置完成)

  • data_ready(所有动画数据加载完成)

  • DOMLoaded(元素已添加到DOM节点)

  • destroy


// 动画播放完成触发
anm.addEventListener('complete', anmLoaded);

// 当前循环播放完成触发
anm.addEventListener('loopComplete', anmComplete);

// 播放一帧动画的时候触发
anm.addEventListener('enterFrame', enterFrame);

打包时图片资源路径


webpack工程需要注意Json文件如果有图片资源(Png或者Svg),需要将文件放在项目的根目录的static下。这样打包的时候,图片会被打包,并且后缀名不会被改变,当然需要配合assetsPath这个参数,设置图片的绝对路径。而CDN的路径可以通过process.env.CDN_URL从webpack传到前端代码中。


关于源码


关于lottie源码解析,这位老哥已经分析的挺到位了,Lottie原理与源码解析。尽管lottie也一直在迭代,但是顺着这篇解析应该也能理清源码。以及Svg动画的介绍,SVG 动画精髓



链接:https://juejin.cn/post/7001312313953222670

收起阅读 »

居然不知道CSS能做3D?天空盒子了解一下,颠覆想象?

大家好,这次给大家换换口味,我们来点不一样的东西。请不要理解歪了🐶。 上周六和昊神的一聊,然后就有了这篇文章。 通过H5实现3D全景是挺平常的事情了,通过three.js可以很容易实现一个全景图。 可以这个链接来查看,three.js来实现的,戳👇thre...
继续阅读 »

大家好,这次给大家换换口味,我们来点不一样的东西。请不要理解歪了🐶。


image.png


上周六和昊神的一聊,然后就有了这篇文章。


通过H5实现3D全景是挺平常的事情了,通过three.js可以很容易实现一个全景图。


image.png
可以这个链接来查看,three.js来实现的,戳👇three.js全景图DEMO链接


其实我们通过CSS3也能实现类似的效果,而且性能上更好,兼容性更好,支持低端机型。


是不是很惊讶,CSS居然也能做这种事情?


image.png


好了,放放手上的事情,花10多分钟专心致志🐶,羽飞老师的课开始了。


注意⚠️:建议PC端观摩,因为有挺多例子需要查看后理解更好,不过也不太影响,为手机同学准备了比较多的gif图,准备地好疲乏🥱。


由于本文重点在最后章,文中借用了一些DEMO方便快速带入,可能有所纰漏,欢迎各位大佬拍砖🧱、吐槽💬。


〇 背景


17年双十一前夕,其实也前不了多少天(大家都懂),产品找到我,说要做它,赶在双十一前上线,然后就有了它🐶。


开门见山,直接甩上成品给大家看看。


image.png


那......我就开动啦。我们先看看成品是长啥样的。



可以查看这个,👇CSS全景图DEMO链接


image.png


或者通过如上CSS全景图DEMO二维码进行尝试。


如果是“尊贵”的苹果手机用户🐶,在iOS13以上需要允许陀螺仪才可,如下图,得点击屏幕授权通过。iOS13之前都是默认开启的,苹果真的是一点不考虑向下兼容🥲,有点霸道呀。


image.png


扯远了扯远了,收。


这个时候大家就可以通过旋转手机或拖拽来查看整个全景图了。


image.png


是不是还挺神奇的?不是?


image.png


还是不是?🐶。🦢🦢🦢,不能向苹果学习🐶。


回来回来,接下来讲讲原理,先看看前置知识点。


〇 前置知识


看问题先看全貌,我们先来了解下如题中所提的天空盒子是什么概念。


天空盒子


天空盒子其实通俗的理解,可以理解如果把你放到天空中,上下前后左右都是蓝色的天空。而这个天空可以简单的用六边形来实现。


如下图所示,六边组成了一个封闭空间。



如果把你放到这个空间里,然后把每个空间的墙壁弄成天蓝色,而且每面都是纯蓝天色,这样你就分辨不出自己是不是在天上,还是只是在一个封闭的天空盒子里。



细思极恐,让人想到了缸中之脑,没听过的同学可以看看百度百科的缸中之脑解释


好了,回归主题👻。这样一个天空盒子就形成了一个全景空间图。


那CSS是要怎么才能实现一个天空盒子呢?我们继续。


image.png


CSS 3D坐标系


先来了解一下坐标系的概念。


从二维“反降维”到三维,需要理解下这个坐标系。


image.png


我们可以看到增加一个Z纬度的线,平面就变3D了。


这里需要注意的是CSS3D中,上下轴是Y轴,左右轴是X轴,前后轴是Z轴。可以简单理解为在原有竖着的面对我们的平面中,在X和Y轴中间强行插入一根直线,与Y轴和X轴都成90度,这根直线就是Z轴。


通过上面的处理,这样就形成了一个空间坐标系。


这有什么用呢?


image.png


大家可能有点懵逼,感觉二维都没搞定,突然要搞三维了。


可以先看看这个3D坐标系的DEMO,👇链接在此,可以先随意把玩把玩。



可以看到途中绿色线就是Z轴,红色就是X轴,蓝色就是Z轴。


多玩一玩就有点感觉啦,是不是感觉逐渐有了3D空间的感觉。


没有?


image.png


其他同学们,不要他了,我们继续。


image.png


不管你了,辛苦做了好久的DEMO🐶。继续继续。


如果想深入了解此CSS 3D坐标系演示的DEMO,源码可以查看这里,👇链接在此


说到CSS 3D,肯定离不开CSS3D transform,下面开始学习。


CSS 3D transform


3D transform字面意思翻译过来为三维变换。


3D rotate


我们先从rotate 3d(旋转)开始,这个能辅助我们理解3D坐标系。


rotate X


单杠运动员,如果正面对着我们,就是可以理解为围着X转。


image.png


rotate Y


围着钢管转,就可以理解为围着Y轴在转。



rotate Z


如果我们正面对着摩天轮,其实摩天轮就在围着Z轴在做运动,中间那个白点,可以理解为Z轴从这个圆圈穿透过去的点。



如果还没理解的同学,可以通过之前的CSS3D DEMO,👇链接在此,辅助理解3D rotate。


理解了3D rotate后,可以辅助我们理解三维坐标系。下面我们开始讲解perspective,有一些理解的难度哦。


image.png


perspective


perspective是做什么用的呢?字面意思是视角、透视的意思。


有一种我们从小到大看到的想象,可能我们都并不在意了,就是现实生活中的透视。比如同样的电线杆,会进高远低。其实这个现象是有一些规律的:近大远小、近实远虚、近宽远窄。


image.png


因此在素描、建筑的行业,都会通过一种透视的方式来表达现实世界的3D模型。


image.png


而我们在计算机世界怎么表达3D呢?


image.png


上方图可以辅助大家理解3D的透视perspective,黄色的是电脑或手机屏幕,红色是屏幕里的方块。


image.png


再看看上面这个二维图,可以看到,perspective: 800,代表3D物体距离屏幕(中间那个平面)是800px。


这里还有个概念,perspective-origin,可以看到上面perspective-origin是50% 50%,可以理解为眼睛视角的中心点,分别在x轴、y轴(x轴50%,y轴50%)交叉处。


image.png


没事没事,如果上面这些还不够你理解的,可以看看下面这张图。再不懂就不管你了🐶。


「下图来自:CSS 3D - Scrolling on the z-axis | CSS | devNotes
image.png


上图里的Z就是Z轴的值。Z轴如果是正数的离屏幕更近,如果是负数离屏幕更远。


而Z轴的远近和translateZ分不开,下面来讲解translateZ。


image.png


translateZ


这个属性可以帮助我们理解perspective。


可以通过translate的DEMO进行把玩把玩,有助于理解,戳👇DEMO链接在此



translateZ实现了CSS3D世界空间的近大远小。


看一下这个例子,平面上的translateZ的变换,戳👇DEMO链接在此


Kapture 2021-08-18 at 14.06.30.gif


比如,我们设置元素perspective为201px,则其子元素的translateZ值越小,则看着越小;如果translateZ值越大,则看着越大。当translateZ为200px的时候,该元素会撑满屏幕,当超过201px时候,该元素消失了,跑到我们眼睛后面了。


平面上的translateZ感受完了,来试试三维下的,看看这个DEMO,戳👇链接在此



上图中,如果把perspective往左拖,可以发现front面会离我们越来越远,如果往右拖,反之。


通过这么一节,基本translateZ的作用,大家应该都能理解到位了,还没有?回头看看🐶。


image.png


模拟现实3D空间


其实计算机的3D世界就是现实3D世界的模拟。而和计算机的3D世界中,构建3D空间概念很相近的现实场景,是摄像。我们可以考虑一下如果你去拍照,会有几个要素?


第一个:镜头,第二个:拍摄的环境的空间,第三个:要拍摄的物件。


「下图来自搞懂 CSS 3D,你必须理解 perspective(视域)


image.png


而在CSS的3D世界,我们也需要去模仿这三要素。我们用三层div来表示,第一层是摄像镜头、第二层是立体空间或也可叫舞台,第三层是立体空间内的元素。


大致的HTML代码如下。


<div class="camera">
<div class="space">
<div class="box">
</div>
</div>
</div>

下面就是真枪实弹地干了。


image.png


〇 实现天空盒子


已经知道了足够的前置知识,我们来简单实现一下天空盒子。


六面盒子


需要生成前后、左右、上下六个面。首先我们想一下第一面前面应该怎么放?


前面墙


假设我们在天空盒子(是一个正方体1024px*1024px),我们在正方体里面的中心点,那我们要往前面的墙上贴一张图,需要做什么?


我们回顾下坐标系。


image.png


你可以想象自己站在x轴和y轴交叉的中心点,即你在正方体的中心点。则你的前面的墙就是在z为-512px处,因为是前面,我们无需对这个墙进行旋转。


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
margin: 0;
overflow: hidden;
background-color: #ccc;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;
}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
</style>
</head>

<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
</div>
</div>
</body>
</html>

生成如下页面,演示代码地址:。
image.png


可以看到第一张图被放在了前面。


左面墙


从前面墙放上一张图,然后转向左面墙,需要几步走?


image.png


第一步,需要让平面与前面的墙垂直,这个时候我们需要把左面的图绕着Y轴旋转90度。


左面墙的图本应该放在X轴的-512px位置,但由于做了旋转,所以左面墙对应的坐标系也做了绕着Y轴向下旋转了90度。如果我们想把左侧的图放到对应的位置,我们需要让其在Z轴的-512px位置。


因此代码如下。


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
margin: 0;
overflow: hidden;
background-color: #ccc;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;
}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
.space .left {
transform: rotateY(90deg) translateZ(-512px);
}
</style>
</head>
<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
<img class="left" src="//yun.dui88.com/tuia/junhehe/skybox/left.jpg" alt="" />
</div>
</div>
</body>
</html>

生成的页面如下,演示代码地址


image.png


可以看到左面墙确实生成在了前面墙的左侧。


底面


类似前面墙、左面墙,我们把底面,做了绕着X轴旋转90度,然后沿着Y轴走-512px。


代码如下。


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
margin: 0;
overflow: hidden;
background-color: #ccc;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;
}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
.space .left {
transform: rotateY(90deg) translateZ(-512px);
}
.space .bottom {
transform: rotateX(90deg) translateZ(-512px);
}
</style>
</head>
<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
<img class="left" src="//yun.dui88.com/tuia/junhehe/skybox/left.jpg" alt="" />
<img class="bottom" src="//yun.dui88.com/tuia/junhehe/skybox/bottom.jpg" alt="" />
</div>
</div>
</body>
</html>

生成页面如下,演示代码地址


image.png


可以看到我们底部也有了,看看所有面集成后是什么样。


image.png


所有面


类似上面的操作,我们把六个面补全,下面我们就把六个面都集合起来。


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
overflow: hidden;
margin: 0;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;
}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
.space .back {
transform: rotateY(180deg) translateZ(-512px);
}
.space .left {
transform: rotateY(90deg) translateZ(-512px);
}
.space .right {
transform: rotateY(-90deg) translateZ(-512px);
}
.space .bottom {
transform: rotateX(90deg) translateZ(-512px);
}
.space .top {
transform: rotateX(-90deg) translateZ(-512px);
}
</style>
</head>
<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
<img class="back" src="//yun.dui88.com/tuia/junhehe/skybox/back.jpg" alt="" />
<img class="left" src="//yun.dui88.com/tuia/junhehe/skybox/left.jpg" alt="" />
<img class="right" src="//yun.dui88.com/tuia/junhehe/skybox/right.jpg" alt="" />
<img class="bottom" src="//yun.dui88.com/tuia/junhehe/skybox/bottom.jpg" alt="" />
<img class="top" src="//yun.dui88.com/tuia/junhehe/skybox/top.jpg" alt="" />
</div>
</div>
</body>
</html>

生成页面如下,演示代码地址


image.png


我们发现看不到后方墙(背面墙)。所以我们打算把整个场景转起来。


image.png


盒子旋转


怎么才能把盒子进行旋转?这里需要对六面墙所在的场景,也即是它们上一层的元素。


我们给.cube加上一个动画效果,绕着Y轴钢管舞🐶,回忆起前置知识里的钢管舞没?


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
overflow: hidden;
margin: 0;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;

}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
.space .back {
transform: rotateY(180deg) translateZ(-512px);
}
.space .left {
transform: rotateY(90deg) translateZ(-512px);
}
.space .right {
transform: rotateY(-90deg) translateZ(-512px);
}
.space .bottom {
transform: rotateX(90deg) translateZ(-512px);
}
.space .top {
transform: rotateX(-90deg) translateZ(-512px);
}
@keyframes rot {
0% {
transform: rotateY(0deg)
}

10% {
transform: rotateY(90deg)
}

25% {
transform: rotateY(90deg)
}

35% {
transform: rotateY(180deg)
}

50% {
transform: rotateY(180deg)
}

60% {
transform: rotateY(270deg)
}

75% {
transform: rotateY(270deg)
}

85% {
transform: rotateY(360deg)
}

100% {
transform: rotateY(360deg)
}
}
/*为立方体加上帧动画*/
.space {
animation: rot 8s ease-out 0s infinite forwards;
}
</style>
</head>
<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
<img class="back" src="//yun.dui88.com/tuia/junhehe/skybox/back.jpg" alt="" />
<img class="left" src="//yun.dui88.com/tuia/junhehe/skybox/left.jpg" alt="" />
<img class="right" src="//yun.dui88.com/tuia/junhehe/skybox/right.jpg" alt="" />
<img class="bottom" src="//yun.dui88.com/tuia/junhehe/skybox/bottom.jpg" alt="" />
<img class="top" src="//yun.dui88.com/tuia/junhehe/skybox/top.jpg" alt="" />
</div>
</div>
</body>
</html>

生成页面动画效果如下,这次用的手机拍摄的更真实一些😂,虽然有点糊,演示代码地址


gif (1).gif


既然能自动旋转,我们是不是可以考虑用手动旋转呢?


image.png


手动旋转


大概原理,就是手动拖拽(手机是touchmove,PC是mousemove),拖拽过去走的多少路程,计算出角度,然后把这个角度通过DOM设置(这个过程通过requestAnimationFrame不停地轮询设置)。


启动手动拖拽的代码。


var curMouseX = 0;
var curMouseY = 0;
var lastMouseX = 0;
var lastMouseY = 0;

if (isAndroid || isiOS) {
document.addEventListener('touchstart', mouseDownHandler);
document.addEventListener('touchmove', mouseMoveHandler);
} else {
document.addEventListener('mousedown', mouseDownHandler);
document.addEventListener('mousemove', mouseMoveHandler);
}

function mouseDownHandler(evt) {
lastMouseX = evt.pageX || evt.targetTouches[0].pageX;
lastMouseY = evt.pageY || evt.targetTouches[0].pageY;
}

function mouseMoveHandler(evt) {
curMouseX = evt.pageX || evt.targetTouches[0].pageX;
curMouseY = evt.pageY || evt.targetTouches[0].pageY;
}

具体的不分析了,不是本次的重点。有兴趣的可以直接看代码深入。


且由于我们想使用在手机上,因此做了rem的适配,适配在手机端。


生成页面动画效果如下,演示代码地址



上面是手机录制的旋转视频。既然我们能通过手触旋转,那我们肯定也可以进行陀螺仪旋转。


陀螺仪旋转


大致原理也是如上,把手动拖拽换成了陀螺仪旋转,然后计算旋转角度。


启动陀螺仪的代码。


window.addEventListener('deviceorientation', motionHandler, false)
function motionHandler(event) {
var x = event.beta;
var y = event.gamma;
}

自开头所说,陀螺仪在IOS13+下需要授权。


var isiOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); // ios??
if (isiOS) {
permission()
}

function permission () {
if ( typeof( DeviceMotionEvent ) !== "undefined" && typeof( DeviceMotionEvent.requestPermission ) === "function" ) {
// (optional) Do something before API request prompt.
DeviceMotionEvent.requestPermission()
.then( response => {
// (optional) Do something after API prompt dismissed.
if ( response == "granted" ) {
window.addEventListener( "devicemotion", (e) => {
// do something for 'e' here.
})
}
})
.catch( console.error )
} else {
alert( "请使用手机浏览器" );
}
}

下面是手机录制展示陀螺仪的例子,生成页面动画效果如下,演示代码地址



这里想深入的同学,可以看一下代码,和上面一样不是本文的重点就不分析了。


有没有感觉写了这么多代码,感觉跟写纯JS操作DOM似的,有没有类似JQuery之类的库呢?


image.png


css3d-engine


上面只是实现了平行旋转,要实现任意角度旋转,我们是基于css3d-engine做了实现。


这一节只是带过,理解了大概的原理后,结合例子去学习这个库还是非常快的。


部分示例代码


文章第一个DEMO就是以这个库为基础进行实践的,地址在这里:github.com/shrekshrek/…


创建stage,stage是舞台,是整个场景的根。


var s = new C3D.Stage();  

创建一个天空盒子的例子,控制各面的素材。


//创建1个立方体放入场景
var c = new C3D.Skybox();
c.size(1024).position(0, 0, 0).material({
front: {image: "images/cube_FR.jpg"},
back: {image: "images/cube_BK.jpg"},
left: {image: "images/cube_LF.jpg"},
right: {image: "images/cube_RT.jpg"},
up: {image: "images/cube_UP.jpg"},
down: {image: "images/cube_DN.jpg"},
}).update();
s.addChild(c);

Tween制作动效


第一个DEMO中动效,是通过Tween.js实现的,地址在这里:github.com/sole/tween.…


为什么DOM元素会有动效,也是因为属性值的变化,而Tween可以控制属性值在一段时间内按规定的规律变化。


下面是一个Tween的示例。


var coords = { x: 0, y: 0 };
var tween = new TWEEN.Tween(coords)
.to({ x: 100, y: 100 }, 1000)
.onUpdate(function() {
console.log(this.x, this.y);
})
.start();

requestAnimationFrame(animate);

function animate(time) {
requestAnimationFrame(animate);
TWEEN.update(time);
}

在最后再体验一下整个处理好后的DEMO,重新感受一下。


image.png


具体的完整版DEMO的源码在此,有兴趣的可以深入研究,由于是之前早几年做的DEMO,代码比较乱,还请见谅,地址在此:github.com/fly0o0/css3…



作者:羽飞
链接:https://juejin.cn/post/6997697496176820255

收起阅读 »

奇思妙想 CSS 3D 动画 | 仅使用 CSS 能制作出多惊艳的动画?

本文将从比较多的方面详细阐述如何利用 CSS 3D 的特性,实现各类有趣、酷炫的动画效果。认真读完,你将会收获到: 了解 CSS 3D 的各种用途 激发你新的灵感,感受动画之美 对于提升 CSS 动画制作水平会有所帮助 CSS 3D 基础知识 本文默认读者...
继续阅读 »

本文将从比较多的方面详细阐述如何利用 CSS 3D 的特性,实现各类有趣、酷炫的动画效果。认真读完,你将会收获到:



  • 了解 CSS 3D 的各种用途

  • 激发你新的灵感,感受动画之美

  • 对于提升 CSS 动画制作水平会有所帮助


CSS 3D 基础知识


本文默认读者掌握一定的 CSS 3D 知识,能够绘制初步的 3D 动画效果。当然这里会再简单过一下 CSS 3D 的基础知识。


使用 transform-style 启用 3D 模式


要利用 CSS3 实现 3D 的效果,最主要的就是借助 transform-style 属性。transform-style 只有两个值可以选择:


// 语法:
transform-style: flat|preserve-3d;

transform-style: flat; // 默认,子元素将不保留其 3D 位置
transform-style: preserve-3d; // 子元素将保留其 3D 位置。

当我们指定一个容器的 transform-style 的属性值为 preserve-3d 时,容器的后代元素便会具有 3D 效果,这样说有点抽象,也就是当前父容器设置了 preserve-3d 值后,它的子元素就可以相对于父元素所在的平面,进行 3D 变形操作。


利用 perspective & perspective-origin 设置 3D视距,实现透视/景深效果


perspective 为一个元素设置三维透视的距离,仅作用于元素的后代,而不是其元素本身。


简单来说,当元素没有设置 perspective 时,也就是当 perspective:none/0 时所有后代元素被压缩在同一个二维平面上,不存在景深的效果。


而如果设置 perspective 后,将会看到三维的效果。


// 语法
perspective: number|none;

// 语法
perspective-origin: x-axis y-axis;
// x-axis : 定义该视图在 x 轴上的位置。默认值:50%
// y-axis : 定义该视图在 y 轴上的位置。默认值:50%

perspective-origin 表示 3D 元素透视视角的基点位置,默认的透视视角中心在容器是 perspective 所在的元素,而不是他的后代元素的中点,也就是 perspective-origin: 50% 50%


通过绘制 Webpack Logo 熟悉 CSS 3D


对于初次接触 CSS 3D 的同学而言,可以通过绘制正方体快速熟悉语法,了解规则。


而 Webpack 的 Logo,正是由 2 个 立方体组成:



以其中一个正方体而言,实现它其实非常容易:



  1. 一个正方体由 6 个面组成,所以首先设定一个父元素 div,然后这个 div 再包含 6 个子 div,同时,父元素设置 transform-style: preserve-3d

  2. 6 个子元素,依次首先旋转不同角度,再通过 translateZ 位移正方体长度的一半距离即可

  3. 父元素可以通过 transformperspective 调整视觉角度


以一个正方体为例子,简单的伪代码如下:


<ul class="cube-inner">
<li class="top"></li>
<li class="bottom"></li>
<li class="front"></li>
<li class="back"></li>
<li class="right"></li>
<li class="left"></li>
</ul>

.cube {
width: 100px;
height: 100px;
transform-style: preserve-3d;
transform-origin: 50px 50px;
transform: rotateX(-33.5deg) rotateY(45deg);

li {
position: absolute;
top: 0;
left: 0;
width: 100px;
height: 100px;
background: rgba(141, 214, 249);
border: 1px solid #fff;
}
.top {
transform: rotateX(90deg) translateZ(50px);
}
.bottom {
transform: rotateX(-90deg) translateZ(50px);
}
.front {
transform: translateZ(50px);
}
.back {
transform: rotateX(-180deg) translateZ(50px);
}
.left {
transform: rotateY(-90deg) translateZ(50px);
}
.right {
transform: rotateY(90deg) translateZ(50px);
}
}

叠加两个,调整颜色和透明度,我们可以非常轻松的实现 Webpack 的 LOGO:



当然,这里的 LOGO 为了保证每条线条视觉上的一致性,其实是没有设置景深效果 perspective 的,我们可以尝试给顶层父容器添加一下如下代码,通过 transformperspective 调整视觉角度,设置景深效果:


.father {
transform-style: preserve-3d;
perspective: 200px;
transform: rotateX(10deg);
}

就可以得到真正的 3D 效果,感受很不一样:



完整的代码,你可以戳这里:CodePen Demo -- Webpack LOGO




OK,热身完毕,接下来,让我们插上想象的翅膀,走进 CSS 3D 的世界。


实现文字的 3D 效果


首先,看看一些有意思的 CSS 3D 文字特效。


要实现文字的 3D 效果,看起来是立体的,通常的方式就是叠加多层。


下面有一些实现一个文字的 3D 效果的方式。


假设我们有如下结构:


<div class="g-container">
<p>Lorem ipsum</p>
</div>

如果什么都不加,文字的展示可能是这样的:



我们可以通过叠加阴影多层,营造 3D 的感觉,主要是合理控制阴影的距离及颜色,核心 CSS 代码如下:


p {
text-shadow:
4px 4px 0 rgba(0, 0, 0, .8),
8px 8px 0 rgba(0, 0, 0, .6),
12px 12px 0 rgba(0, 0, 0, .4),
16px 16px 0 rgba(0, 0, 0, .2),
20px 20px 0 rgba(0, 0, 0, .05);
}


这样,就有了基础的 3D 视觉效果。


3D 氖灯文字效果


基于此,我们可以实现一些 3D 文字效果,来看一个 3D 氖灯文字效果,核心就是:



  • 利用 text-shadow 叠加多层文字阴影

  • 利用 animation 动态改变阴影颜色


<div class="container">
<p class="a">CSS 3D</p>
<p class="b">NEON</p>
<p class="a">EFFECT</p>
</div>

核心 CSS 代码:


.container {
transform: rotateX(25deg) rotateY(-25deg);
}
.a {
color: #88e;
text-shadow: 0 0 0.3em rgba(200, 200, 255, 0.3), 0.04em 0.04em 0 #112,
0.045em 0.045em 0 #88e, 0.09em 0.09em 0 #112, 0.095em 0.095em 0 #66c,
0.14em 0.14em 0 #112, 0.145em 0.145em 0 #44a;
animation: pulsea 300ms ease infinite alternate;
}
.b {
color: #f99;
text-shadow: 0 0 0.3em rgba(255, 100, 200, 0.3), 0.04em 0.04em 0 #112,
0.045em 0.045em 0 #f99, 0.09em 0.09em 0 #112, 0.095em 0.095em 0 #b66,
0.14em 0.14em 0 #112, 0.145em 0.145em 0 #a44;
animation: pulseb 300ms ease infinite alternate;
}
@keyframes pulsea {
// ... 阴影颜色变化
}
@keyframes pulseb {
// ... 阴影颜色变化
}

可以得到如下效果:


4


完整的代码,你可以猛击这里 CSS 灵感 -- 使用阴影实现文字的 3D 氖灯效果


利用 CSS 3D 配合 translateZ 实现真正的文字 3D 效果


当然,上述第一种技巧其实没有运用 CSS 3D。下面我们使用 CSS 3D 配合 translateZ 再进一步。


假设有如下结构:


<div>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
</div>我们通过给父元素 div 设置 transform-style: preserve-3d,给每个 <h1> 设定不同的 translateZ() 来达到文字的 3D 效果:

div {
transform-style: preserve-3d;
}
h1:nth-child(2) {
transform: translateZ(5px);
}
h1:nth-child(3) {
transform: translateZ(10px);
}
h1:nth-child(4) {
transform: translateZ(15px);
}
h1:nth-child(5) {
transform: translateZ(20px);
}
h1:nth-child(6) {
transform: translateZ(25px);
}
h1:nth-child(7) {
transform: translateZ(30px);
}
h1:nth-child(8) {
transform: translateZ(35px);
}
h1:nth-child(9) {
transform: translateZ(40px);
}
h1:nth-child(10) {
transform: translateZ(45px);
}

当然,辅助一些旋转,色彩变化,就可以得到更纯粹一些 3D 文字效果:



完整的代码,你可以猛击这里 CSS 灵感 -- 3D 光影变换文字效果


利用距离、角度及光影构建不一样的 3D 效果


还有一种很有意思的技巧,制作的过程需要比较多的调试。


合理的利用距离、角度及光影构建出不一样的 3D 效果。看看下面这个例子,只是简单是设置了三层字符,让它们在 Z 轴上相距一定的距离。


简单的伪代码如下:


<div>
<span class='C'>C</span>
<span class='S'>S</span>
<span class='S'>S</span>
<span></span>
<span class='3'>3</span>
<span class='D'>D</span>
</div>

$bright : #AFA695;
$gold : #867862;
$dark : #746853;
$duration : 10s;
div {
perspective: 2000px;
transform-style: preserve-3d;
animation: fade $duration infinite;
}
span {
transform-style: preserve-3d;
transform: rotateY(25deg);
animation: rotate $duration infinite ease-in;

&:after, &:before {
content: attr(class);
color: $gold;
z-index: -1;
animation: shadow $duration infinite;
}
&:after{
transform: translateZ(-16px);
}
&:before {
transform: translateZ(-8px);
}
}
@keyframes fade {
// 透明度变化
}
@keyframes rotate {
// 字体旋转
}
@keyframes shadow {
// 字体颜色变化
}

简单捋一下,上述代码的核心就是:



  1. 父元素、子元素设置 transform-style: preserve-3d

  2. span 元素的两个伪元素复制两个相同的字,利用 translateZ() 让它们在 Z 轴间隔一定距离

  3. 添加简单的旋转、透明度、字体颜色变化


可以得到这样一种类似电影开片的标题 3D 动画,其实只有 3 层元素,但是由于角度恰当,视觉上的衔接比较完美,看上去就非常的 3D。



为什么上面说需要合理的利用距离、角度及光影呢?


还是同一个动画效果,如果动画的初始旋转角度设置的稍微大一点,整个效果就会穿帮:



可以看到,在前几帧,能看出来简单的分层结构。又或者,简单调整一下 perspective,设置父容器的 perspective2000px 改为 500px,穿帮效果更为明显:


8


也就是说,在恰当的距离,合适的角度,我们仅仅通过很少的元素,就能在视觉上形成比较不错的 3D 效果。


上述的完整代码,你可以猛击这里:CSS 灵感 -- 3D 文字出场动画


3D 计数器


当然,发挥想象,我们还可以利用 3D 文字效果,制作出非常多有意思的效果。


譬如这个,我之前运用在我们业务的可视化看板项目中的 3D 计数器:



代码比较长,就不贴出来了,但是也是使用纯 CSS 可以实现的效果。


完整的代码,你可以猛击这里 CSS 灵感 -- 3D 数字计数动画


空间效果


嗯,上述章节主要是关于文字的 3D 效果,下面我们继续探寻 3D 在营造空间效果上的神奇之处。


优秀的 3D 效果,能让人有一种身临其境的感觉,都说 CSS 3D 其实作用有限,能做的不多,但是不代表它不能实现酷炫逼真的效果。


要营造逼真的 3D 效果,关键是恰当好处的运用 perspective 属性。


简单掌握原理,我们也可以很轻松的利用 CSS 3D 绘制一些非常有空间美感的效果。


这里我带领大家快速绘制一副具有空间美感的 CSS 3D 作品。


空间 3D 效果热身


首先,我们借助 Grid/Flex 等布局,在屏幕上布满格子(item),随意点就好:


<ul class="g-container">
<li></li>
<li></li>
// ... 很多子 li
<li></li>
</ul>


初始背景色为黑色,每个 item 填充为白色




接着,改变下每个 item 的形状,让他变成长条形的,可以改变通过改变 item 宽度,使用渐变填充部分等等方式:



接下来,父容器设置 transform-style: preserve-3dperspective,子元素设置 transform: rotateX(45deg),神奇的事情就发生了:



Wow,仅仅 3 步,我们就初步得到了一副具有空间美感的图形,让我们再回到每个子 item 的颜色设置,给它们随机填充不同的颜色,并且加上一个 transform: translate3d() 的动画,一个简单的 CSS 3D 作品就绘制完成了:



基于这个技巧的变形和延伸,我们就可以绘制非常多类似的效果。


在这里,我再次推荐 CSS-Doodle 这个工具,它可以帮助我们快速的创造复杂 CSS 效果。



CSS-doodle 是一个基于 Web-Component 的库。允许我们快速的创建基于 CSS Grid 布局的页面,以实现各种 CSS 效果(或许可以称之为 CSS 艺术)。



我们可以把上述的线条切换成圆弧:



完整的代码可以戳这里,利用 CSS-Doodle 也就几十行:CodePen Demo - CSS-Doodle Random Circle


又譬如袁川老师创作的 Seeding



利用图片素材


当然,基于上述技巧,有的时候会认为利用 CSS 绘制一些线条、圆弧、方块比较麻烦。可以进一步尝试利用现有的素材基于 CSS 3D 进行二次创作,这里有一个非常有意思的技巧。


假设我们有这样一张图形:



这张图先放着备用。在使用这张图之前,我们会先绘制这样一个图形:


<div class="g-container">
<div class="g-group">
<div class="item item-right"></div>
<div class="item item-left"></div>
<div class="item item-top"></div>
<div class="item item-bottom"></div>
<div class="item item-middle"></div>
</div>
</div>

body {
background: #000;
}
.g-container {
position: relative;
}
.g-group {
position: absolute;
width: 100px;
height: 100px;
left: -50px;
top: -50px;
transform-style: preserve-3d;
}
.item {
position: absolute;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, .5);
}
.item-right {
background: red;
transform: rotateY(90deg) translateZ(50px);
}
.item-left {
background: green;
transform: rotateY(-90deg) translateZ(50px);
}
.item-top {
background: blue;
transform: rotateX(90deg) translateZ(50px);
}
.item-bottom {
background: deeppink;
transform: rotateX(-90deg) translateZ(50px);
}
.item-middle {
background: rgba(255, 255, 255, 0.5);
transform: rotateX(180deg) translateZ(50px);
}

一共设置了 5 个子元素,不过仔细看 CSS 代码,其中 4 个子元素都设置了 rotateX/Y(90deg/-90deg),也就是绕 X 轴或者 Y 轴旋转了 90°,在视觉上是垂直屏幕的一张平面,所以直观视觉上我们是不到的,只能看到一个平面 .item-middle


我将 5 个子 item 设置了不同的背景色,结果如下:



现在看来,好像平平无奇,确实也是。


不过,见证奇迹的时候来了,此时,我们给父元素 .g-container 设置一个极小的 perspective,譬如,设置一个 perspective: 4px,看看效果:


.g-container {
position: relative;
+ perspective: 4px;
}
// ...其余样式保持不变

此时,画风骤变,整个效果就变成了这样:



由于 perspective 生效,原本的平面效果变成了 3D 的效果。接下来,我们使用上面准备好的星空图,替换一下上面的背景颜色,全部都换成同一张图,神奇的事情发生了:



由于设置的 perspective 非常之下,而每个 item 的 transform: translateZ(50px) 设置的又比较大,所以图片在视觉上被拉伸的非常厉害。但是整体是充满整个屏幕的。


接下来,我们只需要让视角动起来,给父元素增加一个动画,通过控制父元素的 translateZ() 进行变化即可:


.g-container{
position: relative;
perspective: 4px;
perspective-origin: 50% 50%;
}

.g-group{
position: absolute;
// ... 一些定位高宽代码
transform-style: preserve-3d;
+ animation: move 8s infinite linear;
}

@keyframes move {
0%{
transform: translateZ(-50px) rotate(0deg);
}
100%{
transform: translateZ(50px) rotate(0deg);
}
}

看看,神奇美妙的星空穿梭的效果就出来了,Amazing:



美中不足之处在于,动画没能无限衔接上,开头和结尾都有很大的问题。


当然,这难不倒我们,我们可以:



  1. 通过叠加两组同样的效果,一组比另一组通过负的 animation-delay 提前行进,使两组动画衔接起来(一组结束的时候另外一组还在行进中)

  2. 再通过透明度的变化,隐藏掉 item-middle 迎面飞来的突兀感

  3. 最后,可以通过父元素的滤镜 hue-rotate 控制图片的颜色变化


我们尝试修改 HTML 结构如下:


<div class="g-container">
<div class="g-group">
<div class="item item-right"></div>
<div class="item item-left"></div>
<div class="item item-top"></div>
<div class="item item-bottom"></div>
<div class="item item-middle"></div>
</div>
<!-- 增加一组动画 -->
<div class="g-group">
<div class="item item-right"></div>
<div class="item item-left"></div>
<div class="item item-top"></div>
<div class="item item-bottom"></div>
<div class="item item-middle"></div>
</div>
</div>

修改后的核心 CSS 如下:


.g-container{
perspective: 4px;
position: relative;
// hue-rotate 变化动画,可以让图片颜色一直变换
animation: hueRotate 21s infinite linear;
}

.g-group{
transform-style: preserve-3d;
animation: move 12s infinite linear;
}
// 设置负的 animation-delay,让第二组动画提前进行
.g-group:nth-child(2){
animation: move 12s infinite linear;
animation-delay: -6s;
}
.item {
background: url(https://z3.ax1x.com/2021/08/20/fLwuMd.jpg);
background-size: cover;
opacity: 1;
// 子元素的透明度变化,减少动画衔接时候的突兀感
animation: fade 12s infinite linear;
animation-delay: 0;
}
.g-group:nth-child(2) .item {
animation-delay: -6s;
}
@keyframes move {
0%{
transform: translateZ(-500px) rotate(0deg);
}
100%{
transform: translateZ(500px) rotate(0deg);
}
}
@keyframes fade {
0%{
opacity: 0;
}
25%,
60%{
opacity: 1;
}
100%{
opacity: 0;
}
}
@keyframes hueRotate {
0% {
filter: hue-rotate(0);
}
100% {
filter: hue-rotate(360deg);
}
}

最终完整的效果如下,星空穿梭的效果,整个动画首尾相连,可以一直无限下去,几乎没有破绽,非常的赞:



上述的完整代码,你可以猛击这里:CSS 灵感 -- 3D 宇宙时空穿梭效果


3D 无限延伸视角动画


OK,当掌握了上述技巧之后,我们可以很容易的对其继续变形发散,实现各种各样的无限延伸的 3D 视角动画。


这里还有一个非常有意思的运用了类似技巧的动画:



原理与上述的星空穿梭大致相同,4 面墙的背景图使用 CSS 渐变可以很轻松的绘制出来,接下来就只是需要考虑如何让动画能无限循环下去,控制好首尾的衔接。


该效果最早见于 jkantner 的 CodePen,在此基础上我对其进行了完善和丰富,完整代码,你可以猛击这里:CSS 灵感 -- 3D 无限延伸视角动画



作者:chokcoco
链接:https://juejin.cn/post/6999801808637919239

收起阅读 »

想了解到底啥是个Web Socket?猛戳这里!!!

什么是 Web Socket WebSocket 协议在2008年诞生,2011年成为国际标准,所有浏览器都已经支持了。其是基于TCP的一种新的网络协议,是 HTML5 开始提供的一种在单个TCP连接上进行全双工通讯的协议,它实现了浏览器与服务器全双工(ful...
继续阅读 »

什么是 Web Socket


WebSocket 协议在2008年诞生,2011年成为国际标准,所有浏览器都已经支持了。其是基于TCP的一种新的网络协议,是 HTML5 开始提供的一种在单个TCP连接上进行全双工通讯的协议,它实现了浏览器与服务器全双工(full-duplex)通信——允许服务器主动发送信息给客户端。


都有http协议了,为什么要用Web Socket


WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后浏览器和服务器之间就形成了一条快速通道,两者之间就直接可以数据互相传送。


HTTP协议是一种无状态、单向的应用层协议,其采用的是请求/响应模型,通信请求只能由客户端发起,服务端对请求做出应答响应,无法实现服务器主动向客户端发起消息,这就注定如果服务端有连续的状态变化,客户端想要获知就非常的麻烦。而大多数Web应用程序通过频繁的异步JavaScript 和 aJax 请求实现长轮询,其效率很低,而且非常的浪费很多的带宽等资源。


HTML5定义的WebSocket协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。WebSocket 连接允许客户端和服务器之间进行全双工通信,以便任一方都可以通过建立的连接将数据推送到另一端。WebSocket 只需要建立一次连接,就可以一直保持连接状态,这相比于轮询方式的不停建立连接显然效率要大大提高。


特点




  • 服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话




  • 建立在 TCP 协议之上,服务器端的实现比较容易。




  • 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。




  • 数据格式比较轻量,性能开销小,通信高效。




  • 可以发送文本,也可以发送二进制数据。




  • 没有同源限制,客户端可以与任意服务器通信。




  • 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。




怎样使用?


执行流程



  • 1 连接建立:客户端向服务端请求建立连接并完成连接建立

  • 2 数据上行:客户端通过已经建立的连接向服务端发送数据

  • 3 数据下行:服务端通过已经建立的连接向客户端发送数据

  • 4 客户端断开:客户端要求断开已经建立的连接

  • 5 服务端断开:服务端要求断开已经建立的连接


客户端


连接建立


连接成功后,会触发 onopen 事件


var ws = new WebSocket("wss://ws.iwhao.top");
ws.onopen = function(evt) {
console.log("Connection open ...");
};

数据上行


  ws.send("Hello WebSockets!");

数据下行


ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
ws.close();
};

客户端断开


ws.close();

服务端断开


ws.onclose = function(evt) {
console.log("closed.");
};

异常报错


如果连接失败,发送、接收数据失败或者处理数据出现错误,browser 会触发 onerror 消息;


ws.onerror = function(evt) {
};

服务端 node


参考



api/浏览器版本兼容性



链接:https://juejin.cn/post/7000579006386929672

收起阅读 »

我写的页面打开才用了10秒,产品居然说我是腊鸡!!!

背景 产品:你看看这页面加载的如此之慢,怎么会有用户用呢?(并甩给了我一个录屏) 我: (抛出前端应对之策)前端需要加载vue,js,html,css这些都需要时间呀,是不是,别说还需要接口请求,数据库查询,js执行,这些都需要时间是不是,所以加载慢很正常,...
继续阅读 »

背景



  • 产品:你看看这页面加载的如此之慢,怎么会有用户用呢?(并甩给了我一个录屏)

  • : (抛出前端应对之策)前端需要加载vue,js,html,css这些都需要时间呀,是不是,别说还需要接口请求,数据库查询,js执行,这些都需要时间是不是,所以加载慢很正常,让用户用wifi嘛。(嗯。。。心安理得,就是这样。。)

  • 产品: 你上一家公司就是因为有你这样的优秀员工才倒闭的吧?!




这么说我就不服了,先看看视频:


我的影片我.gif
掐指一算,也就10s,还。。。。。。。。。。。。好吧,行吧,我编不下去。




前戏


欲练此功,必先自宫。额。。不对。欲解性能,必先分析。
市面上的体检套餐有很多种, 但其实都是换汤不换药. 那药 (标准) 是什么呢? 我们会在下面说明. 这里我选择了谷歌亲儿子 " 灯塔 "(LightHouse) 进行性能体检.


640.webp
从上面中我们可以看到灯塔是通过几种性能指标及不同权重来进行计分的. 这几种指标主要是根据 PerformanceTiming 和 PerformanceEntry API 标准进行定义. 市面上大多体检套餐也是基于这些指标定制的. 接下来我们来了解下这些指标的含义吧.


具体含义


FCP (First Contentful Paint)



First Contentful Paint (FCP) 指标衡量从页面开始加载到页面内容的任何部分在屏幕上呈现的时间。对于此指标,“内容”是指文本、图像(包括背景图像)、<svg> 元素或非白色 <canvas> 元素。



SI (Speed Index)



速度指数衡量页面加载期间内容的视觉显示速度。



LCP (Largest Contentful Paint)



LCP 测量视口中最大的内容元素何时呈现到屏幕上。这大约是页面的主要内容对用户可见的时间.



TTI (Time to Interactive)



TTI 衡量一个页面需要多长时间才能完全交互。在以下情况下,页面被认为是完全交互的:




  • 页面显示有用的内容,这是由 First Contentful Paint 衡量的,

  • 为大多数可见的页面元素注册了事件处理程序

  • 并且该页面会在 50 毫秒内响应用户交互。


TBT (Total Blocking Time)



FCP 到 TTI 之间, 主线程被 long task(超过 50ms) 阻塞的时间之和



TBT 衡量页面被阻止响应用户输入(例如鼠标点击、屏幕点击或键盘按下)的总时间。总和是通过将所有长任务的阻塞部分相加来计算的,即首次内容绘制和交互时间。任何执行时间超过 50 毫秒的任务都是长任务。 50 毫秒后的时间量是阻塞部分。例如,如果 Lighthouse 检测到 70 毫秒长的任务,则阻塞部分将为 20 毫秒。


CLS (Cumulative Layout Shift)



累计布局偏移值



FID (First Input Delay)



衡量您的用户可能遇到的最坏情况的首次输入延迟。首次输入延迟测量从用户第一次与您的网站交互(例如单击按钮)到浏览器实际能够响应该交互的时间。



体检结果


WechatIMG55139.png


哈哈哈,不愧是优秀的前端工程师。。。6项性能指标挂了5个。




手术方案


优化建议


1629886726026_C607FFC4-676D-4245-86DE-385AE0087581.png
那好,我们一个一个的逐个攻破。


减少初始服务器响应时间


下面是我和后端友好的对话:



  • : 你这首页接口2.39s,你是闭着眼睛写的接口吗?

  • 后端大佬: xxx哔哔哔哔哔哔xxxx,想死吗?!******xxxxx哔哔哔哔哔哔哔哔哔哔

  • : 我也觉得是前端的问题,嗯,打扰了。。。


行,下一个优化点。


减少未使用的 JavaScript


经过分析,我发现首页仅涉及到资源请求,并不需要请求库(我们内部封装)的加载,同时依赖的第三方的库也不需要长时间的版本更新,所以并不需要单独打包到chunk-vendors中。
查看基于 webpack-bundle-analyzer 生成的体积分析报告我发现有两个可优化的大产物:



内部封装的请求库需要md5和sha256加密请求,导致包打包出来多了600kb,于是在和领导商议之后决定用axios重写封装。




vue,vuex,vue-router,clipboard,vue-i18n,axios等三方的库上传cdn,首页预加载。



经过优化, bundle 体积 (gizp 前) 由原来的 841kb 减小至 278kb.


WechatIMG55140.png


避免向现代浏览器提供旧版 JavaScript


WechatIMG55141.png
没有想到太好的代替方案,暂时搁置。


视觉稳定性


优化未设置尺寸的图片元素



改善建议里提到了一项优先级很高的优化就是为图片元素设置显式的宽度和高度, 从而减少布局偏移和改善 CLS.



<img src="hello.png" width="640" height="320" alt="Hello World" />


避免页面布局发生偏移



我们产品中header是可配置的, 这个header会导致网站整体布局下移. 从而造成了较大的布局偏移. 跟产品 'qs'交易后, 讲页面拉长,header脱离文本流固定定位在上方。



最大的内容元素绘制


替换最大内容绘制元素



在改善建议中, 我发现首页的最大内容绘制元素是一段文本, 这也难怪 LCP 指标的数据表现不理想了, 原因: 链路过长 - 首页加载js -> 加载语言包 -> 显示文本内用.




于是, 我决定对最大内容绘制元素进行修改, 从而提升 LCP 时间. 我喵了一眼 Largest Contentful Paint API 关于该元素类型的定义, 将 "目标" 锁定到了一个 loading 元素 (绘制成本低: 默认渲染, 不依赖任何条件和判断). 经过我对该元素的尺寸动了手脚后 (变大), 该元素成功 "上位".



其他


除了针对上面几个指标维度进行优化外, 我还做了几点优化, 这里简单提一下:



  • 优化 DOM 嵌套层级及数量

  • 减少不必要的接口请求

  • 使用 translate 替换 top 做位移 / 动画


优化结果


WechatIMG55142.png


哎,优秀呀,还是优秀的前端工程师呀~~~~~hahahhahaha


链接:https://juejin.cn/post/7000330596043997198

收起阅读 »

这里是一个让你为所欲为,欲罢不能的抽奖demo

寒暄 抽奖系统有很多,各式各样的,不知道大伙都抽中过什么,还是像我这样经历了绝望,看破红尘,存起来留给下一代。 这种抽奖场景在活动中很常见,为了更好的摸鱼,决定自己去写一个插件来解决重复劳动。接下来为大伙介绍一个不错的宫格抽奖组件,请看官往下挪步 关于gri...
继续阅读 »

寒暄


抽奖系统有很多,各式各样的,不知道大伙都抽中过什么,还是像我这样经历了绝望,看破红尘,存起来留给下一代。


image.png


这种抽奖场景在活动中很常见,为了更好的摸鱼,决定自己去写一个插件来解决重复劳动。接下来为大伙介绍一个不错的宫格抽奖组件,请看官往下挪步


关于grid-roll


grid-roll是一个vue的宫格组件,它让ui和逻辑分离,封装了逻辑和宫格布局,让开发者只关注奖品和按钮的ui部分。



  • 自定义宫格数量,经典的3x3还是10x100都不在话下

  • 多抽功能,一次点击多次抽奖,谷底梭哈,就问你刺不刺激


安装


npm i grid-roll -S
yarn add grid-roll

引入


/** 引入 */
import { gridRoll, gridStart, gridPrize } from 'grid-roll'
import 'grid-roll/dist/grid-roll.min.css'

实践


通过vuecli搭起新项目,这边我们可以直接用掘金抽奖的图片链接,拿过来吧你。


图片上的奖品我都打上了数字记号,这些记号其实就奖品数组的下标,它们对应着奖品位置,布局从左到右一行一行排列,所以我们的奖品数组元素排序要注意下


image.png


通过使用grid-roll,我们只需要定义里面8个奖品和1个按钮的样式就行,用gridStart和gridPrize去包装这些物料,塞进gridRoll里面,gridRoll会帮我们自动调整成九宫格布局。这里,我更喜欢把奖品写成数据去循环生成gridPrize。然后样式布局基本是打开开发者工具复制掘金的样式,所以就不细说了


image.png


介绍下这3个组件:



  • gridRoll:interval这个属性用来定义宫格之前的间隔,默认是没有间隔的,这里我看感觉定义了6px。并且接受两个插槽button和prize

  • gridStart:专门用来做button插槽的组件

  • gridPrize:专门用来做prize插槽的组件









// 这里引入组件和样式
import { gridRoll, gridStart, gridPrize } from "grid-roll";
import "grid-roll/dist/grid-roll.min.css";
expoet default {
data () {
return {
prizes: [
{
id: 1,
text: "66矿石",
img: "https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/32ed6a7619934144882d841761b63d3c~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 2,
text: "随机限量徽章",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/71c68de6368548bd9bd6c8888542f911~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 3,
text: "掘金新款T恤",
img: "https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5bf91038a6384fc3927dee294a38006b~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 4,
text: "Bug",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0a4ce25d48b8405cbf5444b6195928d4~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 5,
text: "再抽2次解锁",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/aabe49b0d5c741fa8d92ff94cd17cb90~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 6,
text: "掘金限量桌垫",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c78f363f41a741ffa11dcc8a92b72407~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 7,
text: "Yoyo抱枕",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/33f4d465a6a9462f9b1b19b3104c8f91~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 8,
text: "再抽3次解锁",
img: "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4decbd721b2b48098a1ecf879cfca677~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
],
}
}
components: {
gridRoll,
gridStart,
gridPrize,
},
}

从上面可以看到,我们只需要通过gridStart和gridPrize定义好按钮和奖品的样式,放进gridRoll就行,不用再去管其他乱七八糟的操作。


disabled的使用


从官方的图看起来,这边还缺少一个“锁”样式,需要通过抽奖次数进行解锁,除了奖品样式的不同,在滚动的时候还会直接跳过未解锁的奖品。这边gridPrize也有一个对应的prop做这件事。


首先在prizes需要用到“锁”的元素中添加一个字段disabled: true,传给gridPrize,当抽奖开始的时候,滚动会直接跳过disabled为true的奖品,其次我们用disabled来做一些样式区分,这里样式也是照抄掘金




image.png


这里我们基本就完成静态样式啦,接下来就是说说怎么触发这个抽奖


抽奖


抽奖的行为是由gridPrize的startRoll函数提供的,这里通过ref获取gridRoll的实例,定义一个handleLottery方法用来触发startRoll函数。再把handleLottery绑定的抽奖按钮上







methods: {
async handleLottery() {
const value = 1;
/**
* 这里的value为1是指抽取id为1的奖品
* 返回一个Promise实例,内部为了防止多次触发抽奖逻辑,
* resolve会传递一个Boolean,进行是false,抽奖结束返回true
*/

const b = await this.$refs.dial.startRoll(value);
if (b) {
alert(
`🎉你抽到${this.prizes.find((prize) => prize.id === value).text}`
);
} else {
console.warn("稍安勿躁");
}
},
},

同时别忘记了,抽奖滚动的时候,有一个选中的样式,这里gridPrize作用域插槽提供了一个isSelect值用来判断是否滚动到当前奖品,用来做一些样式切换





收起阅读 »

vue、react函数式编程

函数式编程 JavaScript 语言从一诞生,就具有函数式编程的烙印。它将函数作为一种独立的数据类型,与其他数据类型处于完全平等的地位。在 JavaScript 语言中,你可以采用面向对象编程,也可以采用函数式编程。有人甚至说,JavaScript 是有史以...
继续阅读 »

函数式编程


JavaScript 语言从一诞生,就具有函数式编程的烙印。它将函数作为一种独立的数据类型,与其他数据类型处于完全平等的地位。在 JavaScript 语言中,你可以采用面向对象编程,也可以采用函数式编程。有人甚至说,JavaScript 是有史以来第一种被大规模采用的函数式编程语言。


ES6 的种种新增功能,使得函数式编程变得更方便、更强大。本章介绍 ES6 如何进行函数式编程。


柯里化


柯里化(currying)指的是将一个多参数的函数拆分成一系列函数,每个拆分后的函数都只接受一个参数(unary)。


function add (a, b) {
return a + b;
}

add(1, 1) // 2

上面代码中,函数add接受两个参数ab


柯里化就是将上面的函数拆分成两个函数,每个函数都只接受一个参数。


function add (a) {
return function (b) {
return a + b;
}
}
// 或者采用箭头函数写法
const add = x => y => x + y;

const f = add(1);
f(1) // 2

上面代码中,函数add只接受一个参数a,返回一个函数f。函数f也只接受一个参数b


函数合成


函数合成(function composition)指的是,将多个函数合成一个函数。


const compose = f => g => x => f(g(x));

const f = compose (x => x * 4) (x => x + 3);
f(2) // 20

上面代码中,compose就是一个函数合成器,用于将两个函数合成一个函数。


可以发现,柯里化与函数合成有着密切的联系。前者用于将一个函数拆成多个函数,后者用于将多个函数合并成一个函数。


参数倒置


参数倒置(flip)指的是改变函数前两个参数的顺序。


var divide = (a, b) => a / b;
var flip = f.flip(divide);

flip(10, 5) // 0.5
flip(1, 10) // 10

var three = (a, b, c) => [a, b, c];
var flip = f.flip(three);
flip(1, 2, 3); // => [2, 1, 3]

上面代码中,如果按照正常的参数顺序,10 除以 5 等于 2。但是,参数倒置以后得到的新函数,结果就是 5 除以 10,结果得到 0.5。如果原函数有 3 个参数,则只颠倒前两个参数的位置。


参数倒置的代码非常简单。


let f = {};
f.flip =
fn =>
(a, b, ...args) => fn(b, a, ...args.reverse());

执行边界


执行边界(until)指的是函数执行到满足条件为止。


let condition = x => x > 100;
let inc = x => x + 1;
let until = f.until(condition, inc);

until(0) // 101

condition = x => x === 5;
until = f.until(condition, inc);

until(3) // 5

上面代码中,第一段的条件是执行到x大于 100 为止,所以x初值为 0 时,会一直执行到 101。第二段的条件是执行到等于 5 为止,所以x最后的值是 5。


执行边界的实现如下。


let f = {};
f.until = (condition, f) =>
(...args) => {
var r = f.apply(null, args);
return condition(r) ? r : f.until(condition, f)(r);
};

上面代码的关键就是,如果满足条件就返回结果,否则不断递归执行。


队列操作


队列(list)操作包括以下几种。



  • head: 取出队列的第一个非空成员。

  • last: 取出有限队列的最后一个非空成员。

  • tail: 取出除了“队列头”以外的其他非空成员。

  • init: 取出除了“队列尾”以外的其他非空成员。


下面是例子。


f.head(5, 27, 3, 1) // 5
f.last(5, 27, 3, 1) // 1
f.tail(5, 27, 3, 1) // [27, 3, 1]
f.init(5, 27, 3, 1) // [5, 27, 3]

这些方法的实现如下。


let f = {};
f.head = (...xs) => xs[0];
f.last = (...xs) => xs.slice(-1);
f.tail = (...xs) => Array.prototype.slice.call(xs, 1);
f.init = (...xs) => xs.slice(0, -1);

合并操作


合并操作分为concatconcatMap两种。前者就是将多个数组合成一个,后者则是先处理一下参数,然后再将处理结果合成一个数组。


f.concat([5], [27], [3]) // [5, 27, 3]
f.concatMap(x => 'hi ' + x, 1, [[2]], 3) // ['hi 1', 'hi 2', 'hi 3']

这两种方法的实现代码如下。


let f = {};
f.concat =
(...xs) => xs.reduce((a, b) => a.concat(b));
f.concatMap =
(f, ...xs) => f.concat(xs.map(f));

配对操作


配对操作分为zipzipWith两种方法。zip操作将两个队列的成员,一一配对,合成一个新的队列。如果两个队列不等长,较长的那个队列多出来的成员,会被忽略。zipWith操作的第一个参数是一个函数,然后会将后面的队列成员一一配对,输入该函数,返回值就组成一个新的队列。


下面是例子。


let a = [0, 1, 2];
let b = [3, 4, 5];
let c = [6, 7, 8];

f.zip(a, b) // [[0, 3], [1, 4], [2, 5]]
f.zipWith((a, b) => a + b, a, b, c) // [9, 12, 15]

上面代码中,zipWith方法的第一个参数是一个求和函数,它将后面三个队列的成员,一一配对进行相加。


这两个方法的实现如下。


let f = {};

f.zip = (...xs) => {
let r = [];
let nple = [];
let length = Math.min.apply(null, xs.map(x => x.length));

for (var i = 0; i < length; i++) {
xs.forEach(
x => nple.push(x[i])
);

r.push(nple);
nple = [];
}

return r;
};

f.zipWith = (op, ...xs) =>
f.zip.apply(null, xs).map(
(x) => x.reduce(op)
);


链接:https://juejin.cn/post/7000530780057239565

收起阅读 »

深入理解 Class 和 extends 原理

准备工作 在开始之前,我们需要一个 babel 的环境,方便查看 babel 后的代码,这里我推荐两种方式。chrome 插件 —— ScratchJS,可以设置 babel 来转换代码,通过点击 Toggle output 就能看到 babel 后的代码。b...
继续阅读 »

准备工作


在开始之前,我们需要一个 babel 的环境,方便查看 babel 后的代码,这里我推荐两种方式。

chrome 插件 —— ScratchJS,可以设置 babel 来转换代码,通过点击 Toggle output 就能看到 babel 后的代码。babel 官网推荐的在线编译工具 试一试,可以实时看到转换前后的代码。


本文将以 ScratchJS 转换后的代码为例进行代码分析。


1. class 实现


先从最简单的 class 开始看,下面这段代码涵盖了使用 class 时所有会出现的情况(静态属性、构造函数、箭头函数)。


class Person {
static instance = null;
static getInstance() {
return super.instance;
}
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHi() {
console.log('hi');
}
sayHello = () => {
console.log('hello');
}
sayBye = function() {
console.log('bye');
}
}

而经过 babel 处理后的代码是这样的:


'use strict';

var _createClass = function () {
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor)
descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
return function (Constructor, protoProps, staticProps) {
if (protoProps)
defineProperties(Constructor.prototype, protoProps);
if (staticProps)
defineProperties(Constructor, staticProps);
return Constructor;
};
}();

function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}

var Person = function () {
function Person(name, age) {
_classCallCheck(this, Person);

this.sayHello = function () {
console.log('hello');
};

this.sayBye = function () {
console.log('bye');
};

this.name = name;
this.age = age;
}

_createClass(Person, [{
key: 'sayHi',
value: function sayHi() {
console.log('hi');
}
}]);

return Person;
}();

Person.instance = null;

最外层的 Person 变量被赋值给了一个立即执行函数,立即执行函数里面返回的是里面的 Person 构造函数,实际上最外层的 Person 就是里面的 Person 构造函数。


在 Person 类上用 static 设置的静态属性instance,在这里也被直接挂载到了 Person 构造函数上。


1.1 挂载属性方法


Person 类上各个属性的关系是这样的:


image_1dmjbel2cfvdls41h2e1hcmpn39.png-30.9kB


你是不是很好奇,为什么在 Person 类上面设置的 sayHisayHellosayBye 三个方法,编译后被放到了不同的地方处理?


从编译后的代码中可以看到 sayHellosayBye 被放到了 Person 构造函数中定义,而 sayHi_createClass 来处理(_createClasssayHi 添加到了 Person 的原型上面)。


曾经我也以为是 sayHello 使用了箭头函数的缘故才让它最终被绑定到了构造函数里面,后来我看到 sayBye 这种用法才知道这和箭头函数无关。


实际上 class 中定义属性还有一种写法,这种写法和 sayBye 如出一辙,在 babel 编译后会将其属性放到构造函数中,而非原型上面。


class Person {
name = 'tom';
age = 23;
}
// 等价于
class Person {
constructor() {
this.name = 'tom';
this.age = 23;
}
}

如果我们将 name 后面的 'tom' 换成函数呢?甚至箭头函数呢?这不就是 sayByesayHello 了吗?


因此,在 class 中不直接使用 = 来定义的方法,最终都会被挂载到原型上,使用 = 定义的属性和方法,最终都会被放到构造函数中。


1.2 _classCallCheck


Person 构造函数中调用了 _classCallCheck 函数,并将 this 和自身传入进去。
_classCallCheck 中通过 instanceof 来进行判断,instance 是否在 Constructor 的原型链上面,如果不在上面则抛出错误。这一步主要是为了避免直接将 Person 类当做函数来调用。
因此,在ES5中构造函数是可以当做普通函数来调用的,但在ES6中的类是无法直接当普通函数来调用的。



注意:为什么通过 instanceof 可以判断是否将 Person 类当函数来调用呢?
因为如果使用 new 操作符实例化 Person 的时候,那么 instance 就是当前的实例,指向 Person.prototypeinstance instanceof Constructor 必然为true。反之,直接调用 Person 构造函数,那么 instance 就不会指向 Person.prototype



1.3 _createClass


我们再来看 _createClass 函数,这个函数在 Person 原型上面添加了 sayHi 方法。


// 创建原型方法
_createClass(Person, [{
key: 'sayHi',
value: function sayHi() {
console.log('hi');
}
}]);

// _createClass也是一个立即执行函数
var _createClass = function () {
// 将props属性挂载到目标target上面
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor)
descriptor.writable = true;
// 通过defineProperty来挂载属性
Object.defineProperty(target, descriptor.key, descriptor);
}
}
// 这个才是“真正的”_createClass
return function (Constructor, protoProps, staticProps) {
// 如果传入了需要挂载的原型方法
if (protoProps)
defineProperties(Constructor.prototype, protoProps);
// 如果传入了需要挂载的静态方法
if (staticProps)
defineProperties(Constructor, staticProps);
return Constructor;
};
}();

_createClass 函数接收三个参数,分别是 Constructor (构造函数)、protoProps(需要挂载到原型上的方法)、staticProps(需要挂载到类上的静态方法)。
在接收到参数之后,_createClass 会进行判断如果有 staticProps,则挂载到 Constructor 构造函数上;如果有 protoProps ,那么挂载到 Constructor 原型上面。
这里的挂载函数 defineProperties 是关键,它对传入的 props 进行了遍历,并设置了其 enumerable(是否可枚举) 和 configurable(是否可配置)、writable(是否可修改)等数据属性。
最后使用了 Object.defineProperty 函数来给设置当前对象的属性描述符。


2. extends 实现


通过上文对 Person 的分析,相信你已经知道了 ES6 中类的实现,这与ES5中的实现大同小异,接下来我们来具体看一下 extends 的实现。
以下面的 ES6 代码为例:


class Child extends Parent {
constructor(name, age) {
super(name, age);
this.name = name;
this.age = age;
}
getName() {
return this.name;
}
}

class Parent {
constructor(name, age) {
this.name = name;
this.age = age;
}
getName() {
return this.name;
}
getAge() {
return this.age;
}
}

babel后的代码则是这样的:


"use strict";

// 省略 _createClass
// 省略 _classCallCheck

function _possibleConstructorReturn(self, call) {
if (!self) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return call
&& (typeof call === "object" || typeof call === "function") ? call : self;
}

function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass)
Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

var Child = function (_Parent) {
_inherits(Child, _Parent);

function Child(name, age) {
_classCallCheck(this, Child);

var _this = _possibleConstructorReturn(this, Object.getPrototypeOf(Child).call(this, name, age));

_this.name = name;
_this.age = age;
return _this;
}

_createClass(Child, [{
key: "getName",
value: function getName() {
return this.name;
}
}]);

return Child;
}(Parent);

// 省略 Parent(类似上面的 Person 代码)

我们可以清楚地看到,继承是通过_inherits实现的。
为了方便理解,我这里整理了一下原型链的关系:


image_1dmec296p60q11bp1f8c1rid1rc52a.png-43.1kB


除去一些无关紧要的代码,最终的核心实现代码就只有这么多:


var Child = function (_Parent) {

_inherits(Child, _Parent);

function Child(name, age) {

var _this = _possibleConstructorReturn(this, Object.getPrototypeOf(Child).call(this, name, age));

_this.name = name;
_this.age = age;
return _this;
}

return Child;
}(Parent);

和前面的 Person 类实现有所不同的地方是,在 Child 方法中增加调用了 _inherits,还有在设置 nameage 属性的时候,使用的是执行 _possibleConstructorReturn 后返回的 _this,而非自身的 this,我们就重点分析这两步。


2.1 _inherits


先来看_inherits函数的实现代码:


function _inherits(subClass, superClass) { 
// 如果有一个不是函数,则抛出报错
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}
// 将 subClass.prototype 设置为 superClass.prototype 的实例
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
// 将 subClass 设置为 superClass 的实例(优先使用 Object.setPrototypeOf)
if (superClass)
Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

_inherits 函数接收两个参数,分别是 subClass (子构造函数)和 subClass (父构造函数),将这个函数做的事情稍微做一下梳理。



  1. 设置 subClass.prototype[[Prototype]]指向 superClass.prototype[[Prototype]]

  2. 设置 subClass[[Prototype]] 指向 superClass


在《深入理解类和继承》一文中,曾经提到过 ES5 中的寄生组合式继承,extends 的实现与寄生组合式继承实则大同小异,仅仅只增加了第二步操作。


2.2 _possibleConstructorReturn


Child 中调用了 _possibleConstructorReturn 函数,将 thisObject.getPrototypeOf(Child).call(this, name, age)) 传了进去。
这个 this 我们很容易理解,就是构造函数的 this,但后面这么长的一串又是什么意思呢?
刚刚在 _inherits 中设置了 Child[[Prototype]] 指向了 Parent,因此可以将后面这串代码简化为 Parent.call(this, name, age)
这样你是不是就很熟悉了?这不就是组合继承中的执行一遍父构造函数吗?
那么 Parent.call(this, name, age) 执行后返回了什么呢?
正常情况下,应该会返回 undefined,但不排除 Parent 构造函数中直接返回一个对象或者函数的可能性。
*** 小课堂:**
在构造函数中,如果什么也没有返回或者返回了原始值,那么默认会返回当前的 this;而如果返回的是引用类型,那么最终实例化后的实例依然是这个引用类型(仅相当于对这个引用类型进行了扩展)。


const obj = {};
function Parent(name) {
this.name = name;
return obj;
}
const p = new Parent('tom');
obj.name; // 'tom'
p === obj; // true

如果没有 self,这里就会直接抛出错误,提示 super 函数还没有被调用。
最后会对 call 进行判断,如果 call 为引用类型,那么返回 call,否则返回 self



注意:call 就是 Parent.call(this, name, age) 执行后返回的结果。



function _possibleConstructorReturn(self, call) { 
if (!self) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return call
&& (typeof call === "object" || typeof call === "function") ? call : self;
}

Child 方法中,最终拿到 _possibleConstructorReturn 执行后的结果作为新的 this 来设置构造函数里面的属性。



思考题:如果直接用 this,而不是 _this,会出现什么问题?



总结


ES6 中提供的 classextends 本质上只是语法糖,底层的实现原理依然是构造函数和寄生组合式继承。
所以对于一个合格的前端工程师来说,即使 ES6 已经到来,对于 ES5 中的这些基础原理我们依然需要好好掌握。


作者:sh22n
链接:https://juejin.cn/post/7001025002287923207

收起阅读 »

一个"水"按钮(滑水的水)

🐳 前言 不知道大家平时有没有留意水滴落下的瞬间。 仔细去听,仔细去看,每一滴滴水珠落下泛起的涟漪都让人意向连篇。 一个个显现而消失的涟漪就像时光仿佛带走了什么,还是留下了什么,又似乎一切都没有变,却又感觉多了些什么,让人情不自禁想要点一个赞~~ 好了不开玩...
继续阅读 »

🐳 前言



  • 不知道大家平时有没有留意水滴落下的瞬间。

  • 仔细去听,仔细去看,每一滴滴水珠落下泛起的涟漪都让人意向连篇。

  • 一个个显现而消失的涟漪就像时光仿佛带走了什么,还是留下了什么,又似乎一切都没有变,却又感觉多了些什么,让人情不自禁想要点一个~~

  • 好了不开玩笑了我们来试试做这个涟漪按钮。


water.gif


🤽‍♂️ ToDoList



  • 一片静好

  • 蜻蜓点水

  • 阵阵微波


🚿 Just Do It



  • 其实做一个这样的效果无非就是中间的按钮旁边会有两个渐渐变大的阴影,而当时间的推移,随着阴影范围变大也渐渐消失。


🌱 一片静好



  • 我们先做一个平静的湖面,也就是我们的按钮。


/** index.html **/
<div class="waterButton">
<div class="good">
<div class="good_btn" id="waterButton">
<img src="./good.png" alt="">
</div>
<span id="water1"></span>
<span id="water2"></span>
</div>
</div>


  • 在基本布局中我们需要一个div包裹住一个点赞图片来表示一个按钮,另外还需要两个span标签来表示即将泛起涟漪,这个到后面会用到。


/** button.css **/
.waterButton {
height: 27rem;
display: flex;
justify-content: center;
align-items: center;
}
.good {
width: 6rem;
height: 6rem;
position: relative;
}
.good_btn {
width: 6rem;
height: 6rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
z-index: 3;
cursor: pointer;
box-shadow: .4rem .4rem .8rem #c8d0e7,-.4rem -.4rem .8rem #fff;
}
img{
width: 50%;
height: 50%;
z-index: 4;
}


  • 因为是模拟在水中的效果所以如果按钮的阴影特别单一相同就不好了,这时候我们可以让按钮上面白色阴影下面灰色阴影,在这里推荐一个网站给大家如果需要制作这些阴影可以在这里调试 Neumorphism.io


image.png


🍃 蜻蜓点水



  • 因为是按钮我们需要一个点击事件来模拟水滴滴入湖中的感觉。

  • 而水波荡漾的感觉其实可以做成一个动画,让一个跟按钮一样的元素逐渐缩放到两倍后慢慢消失,我们可以使用两个这样的元素来在视觉上产生水波一个接一个的感觉。


.good_water-1, .good_water-2 {
width: 6rem;
height: 6rem;
border-radius: 50%;
z-index: -1;
position: absolute;
top: 0;
left: 0 ;
filter: blur(1px);
}
.good_water-1 {
box-shadow: .4rem .4rem .8rem #c8d0e7,
-.4rem -.4rem .8rem #fff;
background: linear-gradient(to bottom right, #c8d0e7 0%, #fff 100%);
animation: waves 2s linear;
}
.good_water-2 {
box-shadow: .4rem .4rem .8rem #c8d0e7,
-.4rem -.4rem .8rem #fff;
animation: waves 2s linear 1s;
}
@keyframes waves {
0% {
transform: scale(1);
opacity: 1;
}
50% {
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}


  • 跟按钮一样我们给两个水波元素也加上不同的阴影,这样的感觉会更有立体感,而为了营造水波逐渐消失的感觉,我们需要给一个过渡属性filter: blur(1px)


/** JS **/
<script>
let btn=document.getElementById('waterButton')
let water1=document.getElementById('water1')
let water2=document.getElementById('water2')
let timer=''
btn.addEventListener('click', ()=>{
window.clearTimeout(timer)
water1.classList.add("good_water-1");
water2.classList.add("good_water-2");
setTimeout(()=>{
water1.classList.remove("good_water-1");
water2.classList.remove("good_water-2");
}, 3000)
})
</script>


  • 接下来我们设定点击事件来动态添加样式并在动画结束后移除样式,这样我们来看看效果吧~


water1.gif


💦 阵阵微波



  • 如果我们不希望水波这么快停下的话,我们也可以设置水波动画为无限循环,这样的话我们就不需要点击按钮的时候再加样式了,我们之间把样式加到水波上,然后给animation设置无限循环播放infinite


.good_water-1 {
...
animation: waves 2s linear infinite;

}
.good_water-2 {
...
animation: waves 2s linear 1s infinite;
}


  • 接下来我们来看看效果吧~是不是还不错呢。


water2.gif


👋 写在最后



  • 首先感谢大家看到这里,这次分享的只是学习css中的一些乐趣,对于业务上可能不太实用,但是图个乐嘛~上班这么累,多用前端做点好玩的事情。

  • 前端世界太过奇妙,只有细心的人才能发现其乐趣,希望可以帮到有需要的人。

  • 如果您觉得这篇文章有帮助到您的的话不妨🍉关注+点赞+收藏+评论+转发🍉支持一下哟~~😛您的支持就是我更新的最大动力。

链接:https://juejin.cn/post/7000652451435003918

收起阅读 »

【前端可视化】如何在React中优雅的使用ECharts

这片文章由最近公司的一个可视化项目有感而发,随着前端的飞速发展,近年来数据可视化越来越火,有些公司的业务跟地图、位置、大数据等脱离不开关系,所以数据可视化甚至成了单独的一门前端行业,比如在杭州地区的前端可视化职位不但有一定的需求量且高薪, 至今为止,已经有很...
继续阅读 »

这片文章由最近公司的一个可视化项目有感而发,随着前端的飞速发展,近年来数据可视化越来越火,有些公司的业务跟地图、位置、大数据等脱离不开关系,所以数据可视化甚至成了单独的一门前端行业,比如在杭州地区的前端可视化职位不但有一定的需求量且高薪,


截屏2021-08-25 下午10.57.37.png


至今为止,已经有很多的可视化框架供我们选择,比如D3EChartsLeaflet....等等。


本文使用的可视化框架为ECharts


看完本文你可以学到什么?



  • 如何搭建react+ts+echarts项目

  • typescript基础使用

  • eCharts如何在react中更安全高效的使用

  • eCharts的使用

  • eCharts图表尺寸自适应

  • 启蒙可视化项目思想


本文的源码地址:github.com/Gexle-Tuy/e…


项目准备


技术栈为:React+TypeScript+ECharts。既然提到优雅那肯定跟TS逃离不开关系,毕竟强大的类型系统能给我的🐶💩代码保驾护航,什么?我不会TS,我不看了,别急,本文不做过于复杂的类型检查,只在组件状态(state)、属性(props)上做基本使用,不会TS也能看的懂,废话不多说,咱们开始吧。


使用的为react官方脚手架create-react-app,但是默认启动的为正常的js项目,如果想加上typescript类型检查,我们可以去它的仓库地址查看使用语法。在github上找到facebook/create-react-app。找到目录packages/cra-template-typescript。 在README中就可以看见启动命令create-react-app my-app --template typescript。
image


项目搭建完成之后看看src下的index文件的后缀名是否为tsx而不是jsx,为tsx就说明ts项目搭建成功了,就可以开始咱们的高雅之旅了~





初探


前面瞎吧啦半天完全跟我们本文的主角ECharts没有关系呀,伞兵作者?别急,这就开始,首先安装ECharts。


npm i echarts

安装好之后该干什么?当然是来个官方的入门例子感受一下了啦,打开官网->快速入手->绘制一个简单的图表。


可以看到,每一个图表都需要一个DOM当作容器,在React中我们可以用ref来获取到DOM实例。


image


发现平时正常写的ref竟然报错了,这就是强大的ts发挥了作用,我们把鼠标放上去可以发现提示框有一大堆东西。


不能将类型“RefObject<unknown>”分配给类型“LegacyRef<HTMLDivElement> | undefined”。
不能将类型“RefObject<unknown>”分配给类型“RefObject<HTMLDivElement>”。
不能将类型“unknown”分配给类型“HTMLDivElement”。
.....

可以根据它的提示来解决这个问题,将ref加上类型检查,本文不对ts做过多介绍,只使用简单的基础类型检查,我们直接给它加上一个:any。


eChartsRef:any= React.createRef();

这样报错就消失了,可以理解为any类型就是没有类型检查,跟普通的js一样没有区别。真正的重点不在这里,所以就直接使用any,其实应该按照它的提示加上真正的类型检查RefObject<HTMLDivElement>





拿到实例之后,直接copy官方的配置项例子过来看看效果。


import React, { PureComponent } from "react";
import * as eCharts from "echarts";

export default class App extends PureComponent {

eChartsRef: any = React.createRef();

componentDidMount() {
const myChart = eCharts.init(this.eChartsRef.current);

let option = {
title: {
text: "ECharts 入门示例",
},
tooltip: {},
legend: {
data: ["销量"],
},
xAxis: {
data: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"],
},
yAxis: {},
series: [
{
name: "销量",
type: "bar",
data: [5, 20, 36, 10, 10, 20],
},
],
};

myChart.setOption(option);
}

render() {
return <div ref={this.eChartsRef} style={{
width: 600,
height: 400,
margin: 100
}}></div>;
}
}

gif


当图标的动态效果呈现在你眼前的时候是不是心动了,原来可视化这么简单,到这里你就会了最基本的使用了。





接下来就开始本文的重点!如何在react里封装图表组件动态渲染并自适应移动端


正文


首先确定项目中我们要用到的图表,这里我选了四个最基本且常用的图表(折线图趋势图饼状图柱状图)。


所有的图表都由无状态组件写(函数组件、Hooks),因为它们只负责拿到数据并渲染。并无自己维护的状态。


接下来就是封装图表组件,这里就不把四个表的代码都贴出来了,只拿一个折线图举例子。可以把拉下源码看下其他的图。


折线图:src/components/LineChart


import React, { useEffect, useRef } from 'react';
import { IProps } from "./type";
import * as echarts from "echarts";

const Index: React.FC<IProps> = (props) => {

const chartRef:any = useRef(); //拿到DOM容器

// 每当props改变的时候就会实时重新渲染
useEffect(()=>{
const chart = echarts.init(chartRef.current); //echart初始化容器
let option = { //配置项(数据都来自于props)
title: {
text: props.title ? props.title : "暂无数据",
},
xAxis: {
type: 'category',
data: props.xData,
},
yAxis: {
type: 'value'
},
series: [{
data: props.seriesData,
type: 'line'
}]
};

chart.setOption(option);
}, [props]);

return <div ref={chartRef} className="chart"></div>
}

export default Index;

同文件下新建一个type.ts,将要约束的props类型检查单独抽离出去,当然也可以直接写在index.tsx文件里面,看个人喜好。
type.ts


// 给props添加类型检查
export interface IProps {
title: string, //图表的标题(为string类型)
xData: string[], //图表x轴数据的数组(数字里面每一项都为string类型)
seriesData: number[], //跟x轴每个坐标点对应的数据(数字里面每一项都为number类型)
}

根据每张图表对应的配置项,选出你想要动态配置的属性,就可以写成props作为属性传递过来。(比如,一个项目里需要用到很多张折线图,但是每个图表的线条颜色是不一样的,就可以把color写成一个props作为属性值传递进来。)





封装好之后,我们在App.tsx中引入使用一下。


App.tsx


import React, { PureComponent } from "react";
import LineChart from "./components/LineChart/Index";
import "./App.css";
export default class App extends PureComponent {
eChartsRef: any = React.createRef();

state = {
lineChartData: {
//折线图模拟数据
xData: [
"2021/08/13",
"2021/08/14",
"2021/08/15",
"2021/08/16",
"2021/08/17",
"2021/08/18",
],
seriesData: [22, 19, 88, 66, 5, 90],
},
};

componentDidMount() {}

render() {
return (
<div className="homeWrapper">
{/* 折线图 */}
<div className="chartWrapper">
<LineChart
title="折线图模拟数据"
xData={this.state.lineChartData.xData}
seriesData={this.state.lineChartData.seriesData}
/>
</div>
</div>
);
}
}

如果使用LineChart组件的时候少传了任何一个属性,或者说属性传递的类型不对,那么就会直接报错,将报错扼杀在开发阶段,而不是运行代码阶段,而且还有一个好处就是,加上类型检查后会有强大的智能提示,普通的js项目写一个组件根本就不会提示你需要传递某些属性。


忘记传递某个属性
image


传递的类型不符合类型检查
image


效果如下:


gif


这样一个基本的图表组件就完成了,但是都是我们模拟的数据,在真实的开发中数据都是来自于后端返回给我们,而且格式还不是我们想要的,那时候就需要我们自己处理下数据包装成需要的数据格式再传递。


这样封装成函数组件还有一个好处就是每当props改变的时候就会进行重新渲染。比如我在componentDidMount中开启一个定时器定时添加数据来模拟实时数据。


componentDidMount() {
setInterval(() => {
this.setState({
lineChartData: {
xData: [...this.state.lineChartData.xData, "2000/01/01"],
seriesData: [...this.state.lineChartData.seriesData, Math.floor(Math.random() * 100)],
}
})
}, 1500 );
}

gif


这样就可以实现展示实时数据了,比如每秒的pv、uv数等等。我们把四个图表组件全部封装好之后的效果是这样的。


gif


前三个图表的数据都来自实时数据模拟,最后一张饼状图直接在组件中写死数据了,有兴趣的小伙伴可以拉下源码自行把它实现成实时的,可以看option中的配置哪些需要配置的,单独抽离出来写在type.ts文件中。





移动端适配


啥?echarts没做移动端适配?当然不是,echarts的官网中就介绍了移动端的相关优化:echarts.apache.org/zh/feature.… 当然也有跨平台使用。


gif


好像是那么回事,但感觉好像少了些什么,好像没有根据屏幕尺寸大小变化而自动发生调整尺寸。每次都要刷新一下也就是重新进入页面。


别着急,在它的API文档中,有这么一个方法,echarts创建的实例也就是通过echarts.init()之后的对象会有一个resize的方法。


我们可以监听窗口的变化,只要窗口尺寸变化了就调用resize方法。监听窗口的变化的方法很简单window.onresize可以在创建组件对象的时候都添加上一个window.onresize方法。





注意:如果网页只有一个图表那么这么写是可以的,如果项目中图表不只一个的话,每个图表组件难道在后面都写一个window.onresize方法吗?这样写的话只有最后创建的组件会自适应屏幕尺寸大小,因为每创建一个组件都重新将window.onresize赋予为新的函数体了。





解决:我们可以写一个公用方法,每一次创建组件的时候都加入到一个数组中,当屏幕尺寸变化的时候,都去循环遍历这个数组中的每一项,然后调用resize方法。


src/util.js


const echartsDom = [];  //所有echarts图表的数组
/**
* 当屏幕尺寸变化时,循环数组里的每一项调用resize方法来实现自适应。
* @param {*} eDom
*/
export function echartsResize(eDom) {
echartsDom.push(eDom);
window.onresize = () => {
echartsDom.forEach((it)=>{
it.resize();
})
};
}

写好方法之后,在每个图表组件设置好option之后将他添加到此数组内,然后当屏幕尺寸变化后就可以将每个图表变成自适应的了。





这样之后每个图表就都可以自适应屏幕尺寸啦~


gif


结语


本文主要介绍了如何在react中更安全高效的使用eCharts,所涉及的ts都为最基础的类型检查(有兴趣的同学可以自行拓展),只是为了给各位提供一个我在写一个eCharts项目的时候如何去做和管理项目,文章有错误的地方欢迎指出,大佬勿喷,大家伙儿有更好的思路和想法欢迎大家积极留言。感谢观看~




链接:https://juejin.cn/post/7000551946029858830

收起阅读 »

DIff算法看不懂就一起来砍我(带图)

前言 面试官:"你了解虚拟DOM(Virtual DOM)跟Diff算法吗,请描述一下它们"; 我:"额,...鹅,那个",完了😰,突然智商不在线,没组织好语言没答好或者压根就答不出来; 所以这次我总结一下相关的知识点,让你可以有一个清晰的认知之余也会让你在今...
继续阅读 »

前言


面试官:"你了解虚拟DOM(Virtual DOM)Diff算法吗,请描述一下它们";


我:"额,...鹅,那个",完了😰,突然智商不在线,没组织好语言没答好或者压根就答不出来;


所以这次我总结一下相关的知识点,让你可以有一个清晰的认知之余也会让你在今后遇到这种情况可以坦然自若,应付自如,游刃有余:




相关知识点:



  • 虚拟DOM(Virtual DOM):


    • 什么是虚拟dom




    • 为什么要使用虚拟dom




    • 虚拟DOM库





  • DIFF算法:

    • snabbDom源码

      • init函数

      • h函数

      • patch函数

      • patchVnode函数

      • updateChildren函数








虚拟DOM(Virtual DOM)


什么是虚拟DOM


一句话总结虚拟DOM就是一个用来描述真实DOM的javaScript对象,这样说可能不够形象,那我们来举个🌰:分别用代码来描述真实DOM以及虚拟DOM


真实DOM:


<ul class="list">
<li>a</li>
<li>b</li>
<li>c</li>
</ul>

对应的虚拟DOM:



let vnode = h('ul.list', [
h('li','a'),
h('li','b'),
h('li','c'),
])

console.log(vnode)

控制台打印出来的Vnode:


image.png


h函数生成的虚拟DOM这个JS对象(Vnode)的源码:


export interface VNodeData {
props?: Props
attrs?: Attrs
class?: Classes
style?: VNodeStyle
dataset?: Dataset
on?: On
hero?: Hero
attachData?: AttachData
hook?: Hooks
key?: Key
ns?: string // for SVGs
fn?: () => VNode // for thunks
args?: any[] // for thunks
[key: string]: any // for any other 3rd party module
}

export type Key = string | number

const interface VNode = {
sel: string | undefined, // 选择器
data: VNodeData | undefined, // VNodeData上面定义的VNodeData
children: Array<VNode | string> | undefined, //子节点,与text互斥
text: string | undefined, // 标签中间的文本内容
elm: Node | undefined, // 转换而成的真实DOM
key: Key | undefined // 字符串或者数字
}


补充:

上面的h函数大家可能有点熟悉的感觉但是一时间也没想起来,没关系我来帮大伙回忆;
开发中常见的现实场景,render函数渲染:


// 案例1 vue项目中的main.js的创建vue实例
new Vue({
router,
store,
render: h => h(App)
}).$mount("#app");

//案例2 列表中使用render渲染
columns: [
{
title: "操作",
key: "action",
width: 150,
render: (h, params) => {
return h('div', [
h('Button', {
props: {
size: 'small'
},
style: {
marginRight: '5px',
marginBottom: '5px',
},
on: {
click: () => {
this.toEdit(params.row.uuid);
}
}
}, '编辑')
]);
}
}
]



为什么要使用虚拟DOM



  • MVVM框架解决视图和状态同步问题

  • 模板引擎可以简化视图操作,没办法跟踪状态

  • 虚拟DOM跟踪状态变化

  • 参考github上virtual-dom的动机描述

    • 虚拟DOM可以维护程序的状态,跟踪上一次的状态

    • 通过比较前后两次状态差异更新真实DOM



  • 跨平台使用

    • 浏览器平台渲染DOM

    • 服务端渲染SSR(Nuxt.js/Next.js),前端是vue向,后者是react向

    • 原生应用(Weex/React Native)

    • 小程序(mpvue/uni-app)等



  • 真实DOM的属性很多,创建DOM节点开销很大

  • 虚拟DOM只是普通JavaScript对象,描述属性并不需要很多,创建开销很小

  • 复杂视图情况下提升渲染性能(操作dom性能消耗大,减少操作dom的范围可以提升性能)


灵魂发问:使用了虚拟DOM就一定会比直接渲染真实DOM快吗?答案当然是否定的,且听我说:
2c3559e204c5aae6a1c6bfdc8557efcd.jpeg


举例:当一个节点变更时DOMA->DOMB


image.png
上述情况:
示例1是创建一个DOMB然后替换掉DOMA;
示例2创建虚拟DOM+DIFF算法比对发现DOMBDOMA不是相同的节点,最后还是创建一个DOMB然后替换掉DOMA;
可以明显看出1是更快的,同样的结果,2还要去创建虚拟DOM+DIFF算啊对比
所以说使用虚拟DOM比直接操作真实DOM就一定要快这个说法是错误的,不严谨的


举例:当DOM树里面的某个子节点的内容变更时:


image.png
当一些复杂的节点,比如说一个父节点里面有多个子节点,当只是一个子节点的内容发生了改变,那么我们没有必要像示例1重新去渲染这个DOM树,这个时候虚拟DOM+DIFF算法就能够得到很好的体现,我们通过示例2使用虚拟DOM+Diff算法去找出改变了的子节点更新它的内容就可以了


总结:复杂视图情况下提升渲染性能,因为虚拟DOM+Diff算法可以精准找到DOM树变更的地方,减少DOM的操作(重排重绘)




虚拟dom库



  • Snabbdom

    • Vue.js2.x内部使用的虚拟DOM就是改造的Snabbdom

    • 大约200SLOC(single line of code)

    • 通过模块可扩展

    • 源码使用TypeScript开发

    • 最快的Virtual DOM之一



  • virtual-dom




Diff算法


在看完上述的文章之后相信大家已经对Diff算法有一个初步的概念,没错,Diff算法其实就是找出两者之间的差异;



diff 算法首先要明确一个概念就是 Diff 的对象是虚拟DOM(virtual dom),更新真实 DOM 是 Diff 算法的结果。



下面我将会手撕snabbdom源码核心部分为大家打开Diff的心,给点耐心,别关网页,我知道你们都是这样:


src=http___img.wxcha.com_file_201905_17_f5a4d33d48.jpg&refer=http___img.wxcha.jpeg




snabbdom的核心



  • init()设置模块.创建patch()函数

  • 使用h()函数创建JavaScript对象(Vnode)描述真实DOM

  • patch()比较新旧两个Vnode

  • 把变化的内容更新到真实DOM树


init函数


init函数时设置模块,然后创建patch()函数,我们先通过场景案例来有一个直观的体现:


import {init} from 'snabbdom/build/package/init.js'
import {h} from 'snabbdom/build/package/h.js'

// 1.导入模块
import {styleModule} from "snabbdom/build/package/modules/style";
import {eventListenersModule} from "snabbdom/build/package/modules/eventListeners";

// 2.注册模块
const patch = init([
styleModule,
eventListenersModule
])

// 3.使用h()函数的第二个参数传入模块中使用的数据(对象)
let vnode = h('div', [
h('h1', {style: {backgroundColor: 'red'}}, 'Hello world'),
h('p', {on: {click: eventHandler}}, 'Hello P')
])

function eventHandler() {
alert('疼,别摸我')
}

const app = document.querySelector('#app')

patch(app,vnode)

当init使用了导入的模块就能够在h函数中用这些模块提供的api去创建虚拟DOM(Vnode)对象;在上文中就使用了样式模块以及事件模块让创建的这个虚拟DOM具备样式属性以及事件属性,最终通过patch函数对比两个虚拟dom(会先把app转换成虚拟dom),更新视图;


image.png


我们再简单看看init的源码部分:


// src/package/init.ts
/* 第一参数就是各个模块
第二参数就是DOMAPI,可以把DOM转换成别的平台的API,
也就是说支持跨平台使用,当不传的时候默认是htmlDOMApi,见下文
init是一个高阶函数,一个函数返回另外一个函数,可以缓存modules,与domApi两个参数,
那么以后直接只传oldValue跟newValue(vnode)就可以了*/
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {

...

return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {}
}



h函数


些地方也会用createElement来命名,它们是一样的东西,都是创建虚拟DOM的,在上述文章中相信大伙已经对h函数有一个初步的了解并且已经联想了使用场景,就不作场景案例介绍了,直接上源码部分:


// h函数
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}
var children: any
var text: any
var i: number
...
return vnode(sel, data, children, text, undefined) //最终返回一个vnode函数
};

// vnode函数
export function vnode (sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
const key = data === undefined ? undefined : data.key
return { sel, data, children, text, elm, key } //最终生成Vnode对象
}

总结:h函数先生成一个vnode函数,然后vnode函数再生成一个Vnode对象(虚拟DOM对象)


补充:


在h函数源码部分涉及一个函数重载的概念,简单说明一下:



  • 参数个数或参数类型不同的函数()

  • JavaScript中没有重载的概念

  • TypeScript中有重载,不过重载的实现还是通过代码调整参数



重载这个概念个参数相关,和返回值无关




  • 实例1(函数重载-参数个数)



function add(a:number,b:number){

console.log(a+b)

}

function add(a:number,b:number,c:number){

console.log(a+b+c)

}

add(1,2)

add(1,2,3)



  • 实例2(函数重载-参数类型)



function add(a:number,b:number){

console.log(a+b)

}

function add(a:number,b:string){

console.log(a+b)

}

add(1,2)

add(1,'2')




patch函数(核心)


src=http___shp.qpic.cn_qqvideo_ori_0_e3012t7v643_496_280_0&refer=http___shp.qpic.jpeg


要是看完前面的铺垫,看到这里你可能走神了,醒醒啊,这是核心啊,上高地了兄弟;



  • pactch(oldVnode,newVnode)

  • 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点(核心)

  • 对比新旧VNode是否相同节点(节点的key和sel相同)

  • 如果不是相同节点,删除之前的内容,重新渲染

  • 如果是相同节点,再判断新的VNode是否有text,如果有并且和oldVnodetext不同直接更新文本内容(patchVnode)

  • 如果新的VNode有children,判断子节点是否有变化(updateChildren,最麻烦,最难实现)


源码:


return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {    
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
// cbs.pre就是所有模块的pre钩子函数集合
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
// isVnode函数时判断oldVnode是否是一个虚拟DOM对象
if (!isVnode(oldVnode)) {
// 若不是即把Element转换成一个虚拟DOM对象
oldVnode = emptyNodeAt(oldVnode)
}
// sameVnode函数用于判断两个虚拟DOM是否是相同的,源码见补充1;
if (sameVnode(oldVnode, vnode)) {
// 相同则运行patchVnode对比两个节点,关于patchVnode后面会重点说明(核心)
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
elm = oldVnode.elm! // !是ts的一种写法代码oldVnode.elm肯定有值
// parentNode就是获取父元素
parent = api.parentNode(elm) as Node

// createElm是用于创建一个dom元素插入到vnode中(新的虚拟DOM)
createElm(vnode, insertedVnodeQueue)

if (parent !== null) {
// 把dom元素插入到父元素中,并且把旧的dom删除
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))// 把新创建的元素放在旧的dom后面
removeVnodes(parent, [oldVnode], 0, 0)
}
}

for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}

补充1: sameVnode函数


function sameVnode(vnode1: VNode, vnode2: VNode): boolean { 通过key和sel选择器判断是否是相同节点
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}



patchVnode



  • 第一阶段触发prepatch函数以及update函数(都会触发prepatch函数,两者不完全相同才会触发update函数)

  • 第二阶段,真正对比新旧vnode差异的地方

  • 第三阶段,触发postpatch函数更新节点


源码:


function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
if (oldVnode === vnode) return
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
if (isUndef(vnode.text)) { // 新节点的text属性是undefined
if (isDef(oldCh) && isDef(ch)) { // 当新旧节点都存在子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) //并且他们的子节点不相同执行updateChildren函数,后续会重点说明(核心)
} else if (isDef(ch)) { // 只有新节点有子节点
// 当旧节点有text属性就会把''赋予给真实dom的text属性
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
// 并且把新节点的所有子节点插入到真实dom中
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) { // 清除真实dom的所有子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) { // 把''赋予给真实dom的text属性
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) { //若旧节点的text与新节点的text不相同
if (isDef(oldCh)) { // 若旧节点有子节点,就把所有的子节点删除
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
api.setTextContent(elm, vnode.text!) // 把新节点的text赋予给真实dom
}
hook?.postpatch?.(oldVnode, vnode) // 更新视图
}

看得可能有点蒙蔽,下面再上一副思维导图:


image.png




题外话:diff算法简介


传统diff算法



  • 虚拟DOM中的Diff算法

  • 传统算法查找两颗树每一个节点的差异

  • 会运行n1(dom1的节点数)*n2(dom2的节点数)次方去对比,找到差异的部分再去更新


image.png


snabbdom的diff算法优化



  • Snbbdom根据DOM的特点对传统的diff算法做了优化

  • DOM操作时候很少会跨级别操作节点

  • 只比较同级别的节点


image.png


src=http___img.wxcha.com_file_202004_03_1ed2e19e4f.jpg&refer=http___img.wxcha.jpeg


下面我们就会介绍updateChildren函数怎么去对比子节点的异同,也是Diff算法里面的一个核心以及难点;




updateChildren(核中核:判断子节点的差异)



  • 这个函数我分为三个部分,部分1:声明变量,部分2:同级别节点比较,部分3:循环结束的收尾工作(见下图);


image.png



  • 同级别节点比较五种情况:

    1. oldStartVnode/newStartVnode(旧开始节点/新开始节点)相同

    2. oldEndVnode/newEndVnode(旧结束节点/新结束节点)相同

    3. oldStartVnode/newEndVnode(旧开始节点/新结束节点)相同

    4. oldEndVnode/newStartVnode(旧结束节点/新开始节点)相同

    5. 特殊情况当1,2,3,4的情况都不符合的时候就会执行,在oldVnodes里面寻找跟newStartVnode一样的节点然后位移到oldStartVnode,若没有找到在就oldStartVnode创建一个



  • 执行过程是一个循环,在每次循环里,只要执行了上述的情况的五种之一就会结束一次循环

  • 循环结束的收尾工作:直到oldStartIdx>oldEndIdx || newStartIdx>newEndIdx(代表旧节点或者新节点已经遍历完)


为了更加直观的了解,我们再来看看同级别节点比较五种情况的实现细节:


新开始节点和旧开始节点(情况1)


image.png



  • 情况1符合:(从新旧节点的开始节点开始对比,oldCh[oldStartIdx]和newCh[newStartIdx]进行sameVnode(key和sel相同)判断是否相同节点)

  • 则执行patchVnode找出两者之间的差异,更新图;如没有差异则什么都不操作,结束一次循环

  • oldStartIdx++/newStartIdx++


新结束节点和旧结束节点(情况2)


image.png



  • 情况1不符合就判断情况2,若符合:(从新旧节点的结束节点开始对比,oldCh[oldEndIdx]和newCh[newEndIdx]对比,执行sameVnode(key和sel相同)判断是否相同节点)

  • 执行patchVnode找出两者之间的差异,更新视图,;如没有差异则什么都不操作,结束一次循环

  • oldEndIdx--/newEndIdx--


旧开始节点/新结束节点(情况3)


image.png



  • 情况1,2都不符合,就会尝试情况3:(旧节点的开始节点与新节点的结束节点开始对比,oldCh[oldStartIdx]和newCh[newEndIdx]对比,执行sameVnode(key和sel相同)判断是否相同节点)

  • 执行patchVnode找出两者之间的差异,更新视图,如没有差异则什么都不操作,结束一次循环

  • oldCh[oldStartIdx]对应的真实dom位移到oldCh[oldEndIdx]对应的真实dom

  • oldStartIdx++/newEndIdx--;


旧结束节点/新开始节点(情况4)


image.png



  • 情况1,2,3都不符合,就会尝试情况4:(旧节点的结束节点与新节点的开始节点开始对比,oldCh[oldEndIdx]和newCh[newStartIdx]对比,执行sameVnode(key和sel相同)判断是否相同节点)

  • 执行patchVnode找出两者之间的差异,更新视图,如没有差异则什么都不操作,结束一次循环

  • oldCh[oldEndIdx]对应的真实dom位移到oldCh[oldStartIdx]对应的真实dom

  • oldEndIdx--/newStartIdx++;


新开始节点/旧节点数组中寻找节点(情况5)


image.png



  • 从旧节点里面寻找,若寻找到与newCh[newStartIdx]相同的节点(且叫对应节点[1]),执行patchVnode找出两者之间的差异,更新视图,如没有差异则什么都不操作,结束一次循环

  • 对应节点[1]对应的真实dom位移到oldCh[oldStartIdx]对应的真实dom


image.png



  • 若没有寻找到相同的节点,则创建一个与newCh[newStartIdx]节点对应的真实dom插入到oldCh[oldStartIdx]对应的真实dom

  • newStartIdx++


379426071b8130075b11ba142f9468e2.jpeg




下面我们再介绍一下结束循环的收尾工作(oldStartIdx>oldEndIdx || newStartIdx>newEndIdx):


image.png



  • 新节点的所有子节点先遍历完(newStartIdx>newEndIdx),循环结束

  • 新节点的所有子节点遍历结束就是把没有对应相同节点的子节点删除


image.png



  • 旧节点的所有子节点先遍历完(oldStartIdx>oldEndIdx),循环结束

  • 旧节点的所有子节点遍历结束就是在多出来的子节点插入到旧节点结束节点前;(源码:newCh[newEndIdx + 1].elm),就是对应的旧结束节点的真实dom,newEndIdx+1是因为在匹配到相同的节点需要-1,所以需要加回来就是结束节点


最后附上源码:


function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
let oldStartIdx = 0; // 旧节点开始节点索引
let newStartIdx = 0; // 新节点开始节点索引
let oldEndIdx = oldCh.length - 1; // 旧节点结束节点索引
let oldStartVnode = oldCh[0]; // 旧节点开始节点
let oldEndVnode = oldCh[oldEndIdx]; // 旧节点结束节点
let newEndIdx = newCh.length - 1; // 新节点结束节点索引
let newStartVnode = newCh[0]; // 新节点开始节点
let newEndVnode = newCh[newEndIdx]; // 新节点结束节点
let oldKeyToIdx; // 节点移动相关
let idxInOld; // 节点移动相关
let elmToMove; // 节点移动相关
let before;


// 同级别节点比较
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
}
else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
}
else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
}
else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldStartVnode, newStartVnode)) { // 判断情况1
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
else if (sameVnode(oldEndVnode, newEndVnode)) { // 情况2
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right情况3
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left情况4
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
else { // 情况5
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key];
if (isUndef(idxInOld)) { // New element // 创建新的节点在旧节点的新节点前
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
}
else {
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) { // 创建新的节点在旧节点的新节点前
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
}
else {
// 在旧节点数组中找到相同的节点就对比差异更新视图,然后移动位置
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined;
api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
// 循环结束的收尾工作
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// newCh[newEndIdx + 1].elm就是旧节点数组中的结束节点对应的dom元素
// newEndIdx+1是因为在之前成功匹配了newEndIdx需要-1
// newCh[newEndIdx + 1].elm,因为已经匹配过有相同的节点了,它就是等于旧节点数组中的结束节点对应的dom元素(oldCh[oldEndIdx + 1].elm)
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
// 把新节点数组中多出来的节点插入到before前
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
}
else {
// 这里就是把没有匹配到相同节点的节点删除掉
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}



key的作用



  • Diff操作可以更加快速;

  • Diff操作可以更加准确;(避免渲染错误)

  • 不推荐使用索引作为key


以下我们看看这些作用的实例:


Diff操作可以更加准确;(避免渲染错误):

实例:a,b,c三个dom元素中的b,c间插入一个z元素


没有设置key
image.png
当设置了key:


image.png


Diff操作可以更加准确;(避免渲染错误)

实例:a,b,c三个dom元素,修改了a元素的某个属性再去在a元素前新增一个z元素


没有设置key:


image.png


image.png


因为没有设置key,默认都是undefined,所以节点都是相同的,更新了text的内容但还是沿用了之前的dom,所以实际上a->z(a原本打勾的状态保留了,只改变了text),b->a,c->b,d->c,遍历完毕发现还要增加一个dom,在最后新增一个text为d的dom元素


设置了key:


image.png


image.png


当设置了key,a,b,c,d都有对应的key,a->a,b->b,c->c,d->d,内容相同无需更新,遍历结束,新增一个text为z的dom元素


不推荐使用索引作为key:

设置索引为key:


image.png


这明显效率不高,我们只希望找出不同的节点更新,而使用索引作为key会增加运算时间,我们可以把key设置为与节点text为一致就可以解决这个问题:


image.png




最后


如有描述错误或者不明的地方请在下方评论联系我,我会立刻更新,如有收获,请为我点个赞👍这是对我的莫大的支持,谢谢各位


链接:https://juejin.cn/post/7000266544181674014

收起阅读 »

产品经理说你能不能让词云动起来?我觉得配得上!!!

☀️ 前言 事情是这样的,前段时间拿到公司的数据大屏原型图让我一天内把一整个页面做出来。 简单看了看,就是一个3840 * 1840的大屏然后几个列表几个图例看起来也没有多复杂。 唰!很快啊加了一会班把整个页面调整好了信心十足拿给产品经理看。 产品经理皱了皱...
继续阅读 »

☀️ 前言



  • 事情是这样的,前段时间拿到公司的数据大屏原型图让我一天内把一整个页面做出来。

  • 简单看了看,就是一个3840 * 1840的大屏然后几个列表几个图例看起来也没有多复杂。

  • 唰!很快啊加了一会班把整个页面调整好了信心十足拿给产品经理看。

  • 产品经理皱了皱眉头:你这词云不会动啊??


🌤️ 之前的效果



  • 听到这话我发现情况不对,我寻思着这原型图的词云也看不出他要没要求我动啊,而且明明我做的是会动的呀!


🎢 关系图



  • 一开始我用的是echartsgraph关系图,这种图的特点是一开始会因为每个词的斥力会互相分开,在一开始会有一些动态效果,但是因为力引导布局会在多次迭代后才会稳定,所以到后面就不会继续运动了。


ciyun1.gif



  • 我:是吧我没骗人吧?确实是会动的。

  • 产品经理:这样效果不好,没有科技感,而且我要字体大小每个都不同的,明天要拿给客户看一版,比较急,算了你别做动的了就让他词云填满然后每个词的大小要不一样。


WPS图片编辑.png


🎠 词云图



  • 做不动词云的那不就简单了,直接使用echartswordCloud图啊,直接唰唰配置一下就好了。


image.png



  • 产品经理:客户看完了,整体还不错,但是词云这块我还是想它动起来,这样吧,你想个办法整整。


src=http___5b0988e595225.cdn.sohucs.com_images_20181108_0b031f4213f4403ca4cfca30c2b369ca.jpeg&refer=http___5b0988e595225.cdn.sohucs.jpg


🚄 自己手写



  • 对于这个词云,我一开始真的是死脑筋了,认定要用echarts来做,但实际上wordCloud官网也没有提供资料了,好像确实也没有办法让它动起来。

  • 思量片刻....等会,词云要不同大小不同颜色然后要在区域内随机移动,既然我不熟canvas,那我是不是可以用jscss来写一个2d的呢,说白了就是一个词语在一个容器内随机运动然后每个词语都动起来撒,好像能行....开干。


🚅 ToDoList



  • 准备容器和需要的配置项

  • 生成所有静态词云

  • 让词云动起来


🚈 Just Do It



  • 由于我这边的技术栈是vue 2.x的所以接下来会用vue 2.x的语法来分享,但实际上换成原生js也没有什么难度,相信大家可以接受的。


🚎 准备容器和需要的配置项



  • 首先建立一个容器来包裹我们要装的词云,我们接下来的所有操作都围绕这个容器进行。


<template>
<div class="wordCloud" ref="wordCloud">
</div>
</template>

image.png



  • 因为我们的词云需要有不同的颜色我们需要实现准备一个词语列表和颜色列表,再准备一个空数组来存储之后生成的词语。


...
data () {
return {
hotWord: ['万事如意', '事事如意 ', '万事亨通', '一帆风顺', '万事大吉', '吉祥如意', '步步高升', '步步登高', '三羊开泰', '得心应手', '财源广进', '陶未媲美', '阖家安康', '龙马精神'],
color: [
'#a18cd1', '#fad0c4', '#ff8177',
'#fecfef', '#fda085', '#f5576c',
'#330867', '#30cfd0', '#38f9d7'
],
wordArr: []
};
}
...


  • 准备的这些词语都是想对现在在读文章的你说的~如果觉得我说得对的不妨读完文章后给一个 ~

  • 好了不开玩笑,现在准备工作完成了,开始生成我们的词云。


🚒 生成所有静态词云



  • 我们如果想让一个容器里面充满词语,按照正常我们切图的逻辑来说,每个词语占一个span,那么就相当于一个div里面有n(hotWord数量)个词语,也就是容器里面有对应数量的span标签即可。

  • 如果需要不同的颜色和大小,再分别对span标签分别加不同样式即可。


...
mounted () {
this.init();
},
methods: {
init () {
this.dealSpan();
},
dealSpan () {
const wordArr = [];
this.hotWord.forEach((value) => {
// 根据词云数量生成span数量设置字体颜色和大小
const spanDom = document.createElement('span');
spanDom.style.position = 'relative';
spanDom.style.display = "inline-block";
spanDom.style.color = this.randomColor();
spanDom.style.fontSize = this.randomNumber(15, 25) + 'px';
spanDom.innerHTML = value;
this.$refs.wordCloud.appendChild(spanDom);
wordArr.push(spanDom);
});
this.wordArr = wordArr;
},
randomColor () {
// 获取随机颜色
var colorIndex = Math.floor(this.color.length * Math.random());
return this.color[colorIndex];
},
randomNumber (lowerInteger, upperInteger) {
// 获得一个包含最小值和最大值之间的随机数。
const choices = upperInteger - lowerInteger + 1;
return Math.floor(Math.random() * choices + lowerInteger);
}
}
...


  • 我们对hotWord热词列表进行遍历,每当有一个词语就生成一个span标签,分别使用randomColor()randomSize()设置不同的随机颜色和大小。

  • 最后再将这些span都依次加入div容器中,那么完成后是这样的。


image.png


🚓 让词云动起来



  • 词语是添加完了,接下来我们需要让他们动起来,那么该怎么动呢,我们自然而然会想到transformtranslateXtranslateY属性,我们首先要让一个词语先动起来,接下来所有的都应用这种方式就可以了。


先动一下x轴


  • 怎么动呢?我们现在要做的是一件无限循环的事情,就是一个元素无限的移动,既然是无限,在js中用定时器可不可以实现呢?确实是可以的,但是会巨卡,万一词语一多你的电脑会爆炸,在另一方面编写动画循环的关键是要知道延迟时间多长合适,如果太长或者太短都不合适所以不用定时器。

  • 然后一不小心发现了window.requestAnimationFrame这个APIrequestAnimationFrame不需要设置时间间隔。



requestAnimationFrame 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒60帧。




  • 也就是说当我们循环无限的让一个元素在x轴或者y轴移动,假设每秒向右移动10px那么它的translateX就是累加10px,每个元素都是如此那么我们需要给span元素新增一个属性来代表它的位置。


data () {
return {
...
timer: null,
resetTime: 0
...
};
}
methods: {
init () {
this.dealSpan();
this.render();
},
dealSpan () {
const wordArr = [];
this.hotWord.forEach((value) => {
...
spanDom.local = {
position: {
x: 0,
y: 0
}
};
...
});
this.wordArr = wordArr;
},
render () {
if (this.resetTime < 100) {
//防止“栈溢出”
this.resetTime = this.resetTime + 1;
this.timer = requestAnimationFrame(this.render.bind(this));
this.resetTime = 0;
}
this.wordFly();
},
wordFly () {
this.wordArr.forEach((value) => {
//每次循环加1
value.local.position.x += 1;
// 给每个词云加动画过渡
value.style.transform = 'translateX(' + value.local.position.x + 'px)';
});
},
},
destroyed () {
// 组件销毁,关闭定时执行
cancelAnimationFrame(this.timer);
},


  • 这时候我们给每个元素加了个local属性里面有它的初始位置,每当我们执行一次requestAnimationFrame的时候它的初始位置+1,再把这个值给到translateX这样我们每次循环都相当于移动了1px,现在我们来看看效果。


ciyun2.gif


调整范围


  • 嘿!好家伙,动是动起来了,但是怎么还过头了呢?

  • 我们发现每次translateX+1了但是没有给一个停止的范围给他,所以我们需要给一个让他到容器的边缘就开始掉头的步骤。

  • 那怎么样让他掉头呢?既然我们可以让他每次往右移动1px那么我们是不是可以检测到当它的x轴位置大于这个容器的位置时x轴位置小于这个容器的位置时并且换个方向就好换个方向我们只需要用正负数来判断即可。


init () {
this.dealSpan();
this.initWordPos();
this.render();
},
dealSpan () {
const wordArr = [];
this.hotWord.forEach((value) => {
...
spanDom.local = {
position: {
// 位置
x: 0,
y: 0
},
direction: {
// 方向 正数往右 负数往左
x: 1,
y: 1
}
};
...
});
this.wordArr = wordArr;
},
wordFly () {
this.wordArr.forEach((value) => {
// 设置运动方向 大于边界或者小于边界的时候换方向
if (value.local.realPos.minx + value.local.position.x < this.ContainerSize.leftPos.x) {
value.local.direction.x = -value.local.direction.x;
}
if (value.local.realPos.maxx + value.local.position.x > this.ContainerSize.rightPos.x) {
value.local.direction.x = -value.local.direction.x;
}
//每次右移1个单位 当方向为负数时就是-1个单位也就是向左移1个单位
value.local.position.x += 1 * value.local.direction.x;
// 给每个词云加动画过渡
value.style.transform = 'translateX(' + value.local.position.x + 'px)';
});
},
initWordPos () {
// 计算每个词的真实位置和容器的位置
this.wordArr.forEach((value) => {
value.local.realPos = {
minx: value.offsetLeft,
maxx: value.offsetLeft + value.offsetWidth
};
});
this.ContainerSize = this.getContainerSize();
},
getContainerSize () {
// 判断容器大小控制词云位置
const el = this.$refs.wordCloud;
return {
leftPos: {
// 容器左侧的位置和顶部位置
x: el.offsetLeft,
y: el.offsetTop
},
rightPos: {
// 容器右侧的位置和底部位置
x: el.offsetLeft + el.offsetWidth,
y: el.offsetTop + el.offsetHeight
}
};
}


  • 我们一开始先用initWordPos来计算每个词语现在处于的位置并把它的位置保存起来,再使用getContainerSize获取我们的外部容器的最左侧最右侧最上最下的位置保存起来。

  • 给我们每个span添加一个属性direction 方向,当方向为负数则往左,方向为正则往右,注释我写在代码上了,大家如果不清除可以看一下。

  • 也就是说我们的词云会在容器里面反复横跳,那我们来看看效果。


ciyun3.gif


随机位移


  • 很不错,是我们想要的效果!!!

  • 当然我们每次位移不可能写死只位移1px我们要做到那种凌乱美,那就需要做一个随机位移。

  • 那怎么来做随机位移呢?可以看出我们的词语其实是在做匀速直线运动而匀速直线运动的公式大家还记得吗?

  • 如果不记得的话这边建议回去翻一下物理书~ 匀速直线运动的位移公式是 x=vt

  • 这个x就是我们需要的位移,而这个t我们就不用管了因为我上面也说了这个requestAnimationFrame会帮助我们设置时间,那我们只需要控制这个v初速度是随机的就可以了。


dealSpan () {
const wordArr = [];
this.hotWord.forEach((value) => {
...
spanDom.local = {
velocity: {
// 每次位移初速度
x: -0.5 + Math.random(),
y: -0.5 + Math.random()
},
};
...
});
this.wordArr = wordArr;
},
wordFly () {
this.wordArr.forEach((value) => {
...
//利用公式 x=vt
value.local.position.x += value.local.velocity.x * value.local.direction.x;
...
});
},


  • 我们给每个词语span元素一个初速度,这个初速度可以为- 也可以为+代表向左或者向右移动,当我们处理这个translateX的时候他就会随机处理了,现在我们来看看效果。


ciyun4.gif


完善y轴


  • 现在x轴已经按照我们想的所完成了,想让词云们上下左右都动起来那么我们需要按照x轴的方法来配一下y轴即可。

  • 由于代码长度问题我就不放出来啦,我下面会给出源码,大家有兴趣可以去下载看看~我们直接来看看成品!小卢感谢您的阅读,那我就在这里祝您


ciyun5.gif



  • 至此一个简单的词云动画就完啦,具体源码我放在这里。

链接:https://juejin.cn/post/7000300247947673630

收起阅读 »

贝塞尔曲线在前端,走近她,然后爱上她

贝塞尔曲线在前端 css3的动画主要是 transition animation transition有transition-timing-function animation有animation-timing-function 以transition-t...
继续阅读 »

贝塞尔曲线在前端


css3的动画主要是



  • transition

  • animation


transition有transition-timing-function

animation有animation-timing-function


transition-timing-function为例


image.png


其内置 ease,linear,ease-in,ease-out,ease-in-out就是贝塞尔曲线函数, 作用是控制属性变化的速度。

也可以自定义cubic-bizier(x1,y1,x2,y2), 这个嘛玩意呢,三阶贝塞尔曲线, x1,y1x2,y2是两个控制点。


如图:
x1, y1对应 P1点, x2,y2 对应P2点。

要点:



  1. 曲线越陡峭,速度越快,反之,速度越慢!

  2. 控制点的位置会影响曲线形状


image.png




说道这里, 回想一下我们前端在哪些地方还会贝塞尔呢。



  • svg

  • canvas/webgl

  • css3 动画

  • animation Web API

    千万别以为JS就不能操作CSS3动画了


这样说可能有些空洞,我们一起来看看曲线和实际的动画效果:

红色ease和ease-out曲线前期比较陡峭,加速度明显比较快


图片.png


贝塞尔曲线运动-演示地址
6af390fc619a4f1f8758a437d03e37c4~tplv-k3u1fbpfcp-watermark.image.gif




什么是贝赛尔曲线


贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。


公式怎么理解呢?这里你可以假定



  • P0的坐标(0,0), 最终的点的坐标为(1,1)


t从0不断的增长到1

t的值和控制点的x坐标套入公式,得到一个新的x坐标值

t的值和控制点的y坐标套入公式,得到一个新的y坐标值


(新的x坐标值 , 新的y坐标值)坐标就是t时刻曲线的点的坐标。


通用公式


image.png


线性公式


无控制点,直线


image.png


二次方公式


一个控制点


image.png


三次方公式


两个控制点


image.png


这是我们的重点,因为css动画都是三次方程式


P0作为起点,P3作为终点, 控制点是P1与P2, 因为我们一般会假定 P0 为 (0,0), 而 P3为(1,1)。


控制点的变化,会影响整个曲线,我们一起来简单封装一下并进行实例操作。


一阶二阶三阶封装


我们基于上面公式的进行简单的封装,

你传入需要的点数量和相应的控制点就能获得相应一组点的信息。


class Bezier {
getPoints(count = 100, ...points) {
const len = points.length;
if (len < 2 || len > 4) {
throw new Error("参数points的长度应该大于等于2小于5");
}
const fn =
len === 2
? this.firstOrder
: len === 3
? this.secondOrder
: this.thirdOrder;
const retPoints = [];
for (let i = 0; i < count; i++) {
retPoints.push(fn.call(null, i / count, ...points));
}
return retPoints;
}

firstOrder(t, p0, p1) {
const { x: x0, y: y0 } = p0;
const { x: x1, y: y1 } = p1;
const x = (x1 - x0) * t;
const y = (y1 - y0) * t;
return { x, y };
}

secondOrder(t, p0, p1, p2) {
const { x: x0, y: y0 } = p0;
const { x: x1, y: y1 } = p1;
const { x: x2, x: y2 } = p2;
const x = (1 - t) * (1 - t) * x0 + 2 * t * (1 - t) * x1 + t * t * x2;
const y = (1 - t) * (1 - t) * y0 + 2 * t * (1 - t) * y1 + t * t * y2;
return { x, y };
}

thirdOrder(t, p0, p1, p2, p3) {
const { x: x0, y: y0 } = p0;
const { x: x1, y: y1 } = p1;
const { x: x2, y: y2 } = p2;
const { x: x3, y: y3 } = p3;
let x =
x0 * Math.pow(1 - t, 3) +
3 * x1 * t * (1 - t) * (1 - t) +
3 * x2 * t * t * (1 - t) +
x3 * t * t * t;
let y =
y0 * (1 - t) * (1 - t) * (1 - t) +
3 * y1 * t * (1 - t) * (1 - t) +
3 * y2 * t * t * (1 - t) +
y3 * t * t * t;
return { x, y };
}
}

export default new Bezier();


演示地址: xiangwenhu.github.io/juejinBlogs…


一阶贝塞尔是一条直线:

image.png


二阶贝塞尔一个控制点:


image.png


三阶贝塞尔两个控制点:


image.png


贝塞尔曲线控制点


回到最开始, animation和 transition都可以自定义三阶贝塞尔函数, 而需要的就是两个控制点的信息怎么通过测试曲线获得控制点呢?


在线取三阶贝塞尔关键的方案早就有了。



在线贝塞尔

在线贝塞尔2



但是不妨碍我自己去实现一个简单,加强理解。

大致的实现思路



  • canvas 绘制效果

    canvas有bezierCurveTo方法,直接可以绘制贝塞尔曲线

  • 两个控制点用dom元素来显示


逻辑



  • 点击时计算最近的点,同时修改最近点的坐标

  • 重绘


当然这只是一个简单的版本。


演示地址: xiangwenhu.github.io/juejinBlogs…

截图:


有了这个,你就可以通过曲线获得控制点了, 之前提到过,曲线的陡峭决定了速度的快慢,是不是很有用呢?


当然,你可以自己加个贝塞尔的直线运动,查看实际的运动效果,其实都不难,难的是你不肯动手!!!


链接:https://juejin.cn/post/7000525748578549774

收起阅读 »

我用index作为key也没啥问题啊,为什么面试还有人diao我???

所有熟悉 Vue 技术栈的小伙伴,都知道在列表渲染的场景下,不能使用 index 或 random 作为 key。 也有很多小伙伴在面试的时候会被面试官比较详细的追问,假如使用 index 作为 key 会有什么问题?假如使用 random 作为 key 会有...
继续阅读 »

所有熟悉 Vue 技术栈的小伙伴,都知道在列表渲染的场景下,不能使用 indexrandom 作为 key


也有很多小伙伴在面试的时候会被面试官比较详细的追问,假如使用 index 作为 key 会有什么问题?假如使用 random 作为 key 会有什么问题?假如使用一个唯一不变的 id 作为 key 有什么好处呢?


这道题目,表面上看起来是考察我们对同级比较过程中 diff 算法的理解,唯一不变的 key 可以帮助我们更快的找到可复用的 VNode,节省性能开销,使用 index 作为 key 有可能造成 VNode 错误的复用,从而产生 bug ,而使用 random 作为 key 会导致VNode 始终无法复用,极大的影响性能。


这么回答有问题么?没有问题。


但是假如这道题目满分100,我只能给你99分。


还有 1分,涉及到 Vue 更新流程中的一点点细节,若不理清,可能在实际的业务场景中给我们造成困扰。


啥困扰呢?


举个栗子


直奔主题,看一段代码,index 作为 key ,假如我们删除某一条,结果会是啥呢?


<template>
 <div id="app">
   <div v-for="(item, index) in data" :key="index">
     <Child />
     <button @click="handleDelete(index)">删除这一行</button>
   </div>
 </div>
</template>

<script>

export default {
 name: "App",
 components: {
   Child: {
     template: '<span>{{name}}{{Math.floor(Math.random() * 1000)}}</span>',
     props: ['name']
  }
},
 data() {
   return {
     data: [
      { name: "小明" },
      { name: "小红" },
      { name: "小蓝" },
      { name: "小紫" },
    ]
  };
},
 methods: {
   handleDelete(index) {
     this.data.splice(index, 1);
  },
}
};
</script>

看结果



可以观察到,虽然我们删除的不是最后一条,但最终却是最后一条被删除了,看起来很奇怪,但是假如你了解过 Vuediff 流程,这个结果应该是可以符合你的预期的。


diff


大段的列源码,会增加我们的理解负担,所以我把 Vue更新流程简化成一张图:



通常来讲,我们说 Vuediff 流程,指的就是 patchVnode ,其中 updateChildren 就是我们说的同层比较,其实就是比较新旧两个 Vnode 数组。


Vue 会声明四个指针变量,分别记录新旧 Vnode 数组的首尾索引,通过首尾索引指针的移动,根据新头旧头、新尾旧尾、旧头新尾、旧尾新头的顺序,依次比较新旧 Vnode ,若不能命中 sameVnode,则将oldVnode.key 维护成一个 map, 继续查询是否包含newVnode.key ,若命中 sameVnode ,则递归执行 patchVnode。若最终无法命中,说明无可复用的 Vnode ,创建新的 dom 节点。


newVnode 的首尾指针先相遇,说明 newVnode 已经遍历完成,直接移除 oldVnode 多余部分,若 oldVnode 的首尾指针先相遇,说明 oldVnode 已经遍历完成,直接新增 newVnode 的多余部分。


这种直接的文字描述会显得比较苍白,所以我给大家准备了个动画


第一步:



第二步:



第三步:



第四步:



第五步:



第六步:



理论上,只要你滑动的足够快,这几张图就可以动起来😊



上面描述updateChildren过程的图片均摘自 Vue技术揭秘 组件更新章节,建议大家翻阅原文


我尝试了半天实在做不出来动画,同时感觉这几张图已经可以带给我们足够直观的感受了,所以直接搬运了


侵删



使用 index 作为 key 会有什么问题


上面我们讲,判断新旧 Vnode 是否可以复用,取决于 sameNode 方法,这个方法非常简单,就是比对 Vnode 的部分属性,其中 key 是最关键的因素


function sameVnode (a, b) {
   return (
     a.key === b.key &&
     a.asyncFactory === b.asyncFactory && (
      (
         a.tag === b.tag &&
         a.isComment === b.isComment &&
         isDef(a.data) === isDef(b.data) &&
         sameInputType(a, b)
      ) || (
         isTrue(a.isAsyncPlaceholder) &&
         isUndef(b.asyncFactory.error)
      )
    )
  )
}

我们再回到上面的栗子,看看是哪里出了问题


上面代码生成的 VNode 大约是这样的:


[
{
   tag: 'div',
key: 0,
children: [
{
tag: VueComponent,
       elm: 408, // 这个Vnode对应的真实dom是408
},
{
tag: 'button'
}
]
},
{
   tag: 'div',
key: 1,
children: [
{
tag: VueComponent,
       elm: 227, // 这个Vnode对应的真实dom是227
},
{
tag: 'button'
}
]
}
 ...
]

我们删除第一条数据,新的 VNode 大约是这样的:


[
{
   tag: 'div',
key: 0,
children: [
{
tag: VueComponent,
       elm: 227, // 这个Vnode对应的真实dom是227
},
{
tag: 'button'
}
]
},
{
   tag: 'div',
key: 1,
children: [
{
tag: VueComponent,
       elm: 324, // 这个Vnode对应的真实dom是324
},
{
tag: 'button'
}
]
}
 ...
]

我们人肉逻辑 一下这两个 Vnode 数组,由于 key 都是0,所以比较第一条的时候,就会命中 sameNode ,导致错误复用,然后 updateChildren ,子节点的 Vnode 依然会命中 sameVnode ,同理,第二、三条均会命中 sameVnode ,而直接错误复用其关联的真实 dom 节点,所以我们明明删除的是第一条,UI表现却是最后一条被删除了。


那么到这里就结束了么?


当然没有,因为很多小伙伴在刚接触 Vue 的时候,也用过 index 作为 key ,部分牛逼的项目甚至已经上线了,似乎也没人来找麻烦


why?


为什么我用 index 作为 key 没出现问题


如果我把代码改成这样,再删除某一条,会是什么结果呢?


<template>
 <div id="app">
   <div v-for="(item, index) in data" :key="index">
     <Child :name="`${item.name}`" />
     <button @click="handleDelete(index)">删除这一行</button>
   </div>
 </div>
</template>

看结果



法克,我们明明把 Vue更新流程捋清楚了,用 index 作为 key 会导致 Vnode 错误复用啊,怎么这里表现却正常了呢?


我们再看一下更新流程简化图:



组件类型的 Vnode ,在 patchVnode 的过程中会执行 prePatch 钩子函数,给组件的 propsData 重新赋值,从而触发 setter ,假如 propsData 的值有变化,则会触发 update ,重新渲染组件


我们可以再人肉逻辑 一下,这次我们删除的是第二条,因为key 一致,新的 Vnode 数组依然会复用旧的 Vnode 数组的前三条,第一条 Vnode 是正确复用,组件的 propsData 未发生变化,不会触发 update ,直接复用其关联的真实 dom 节点,但是第二条 Vnode 是错误复用,但是组件的 propsData 发生变化,由小红变成了小蓝,触发了 update ,组件重新渲染,因此我们看到其实连 random 都发生了变化,第三条同理。


呼~


到这里,总算是搞明白了,我可真是个小机灵鬼


那么到这里就结束了么?


其实还没有,比如我们再改一下代码


<template>
 <div id="app">
   <div v-for="(item, index) in data" :key="index">
     <span>{{item.name}}</span>
     <button @click="handleDelete(index)">删除这一行</button>
   </div>
 </div>
</template>

看结果



这次我们没有组件类型 Vnode ,不会执行 prePatch,为啥表现还是正常的呢?


再观察一下上面的更新流程图,文本类型的 Vnode ,新旧文本不同的时候是会直接覆盖的。


到这里,我们已经完全明白,列表渲染的场景下,为什么推荐使用唯一不变的 id 作为 key了。抛开代码规范不谈,即使某些场景下,问题并未以 bug 的形式暴露出来,但是不能复用、或者错误复用 Vnode ,都会导致组件重新渲染,这部分的性能包袱还是非常沉重的!


最后的1分


纸上得来终觉浅,绝知此事要躬行


我第一次读完 Vue2 源码的时候,以为自己已经清晰的明白了这部分知识,直到团队里的小伙伴拿着一个纯文本类型的列表来质问我


不得已仔细 debug 了一遍更新流程,才算解开了心中疑惑,补上了这 1分 的缺口



链接:https://juejin.cn/post/6999932053466644517

收起阅读 »

call, call.call, call.call.call, 你也许还不懂这疯狂的call

Function.prototype.call 我想大家都觉得自己很熟悉了,手写也没问题!! 你确认这个问题之前, 首先看看 三千文字,也没写好 Function.prototype.call, 看完,你感觉还OK,那么再看一道题: 请问如下的输出结果 fun...
继续阅读 »

Function.prototype.call 我想大家都觉得自己很熟悉了,手写也没问题!!

你确认这个问题之前, 首先看看 三千文字,也没写好 Function.prototype.call,


看完,你感觉还OK,那么再看一道题:

请问如下的输出结果


function a(){ 
console.log(this,'a')
};
function b(){
console.log(this,'b')
}
a.call.call(b,'b')

如果,你也清晰的知道,结果,对不起,大佬, 打扰了,我错了!


本文起源:

一个掘友加我微信,私聊问我这个问题,研究后,又请教了 阿宝哥

觉得甚有意思,遂与大家分享!


结果


结果如下: 惊喜还是意外,还是淡定呢?


String {"b"} "b"

再看看如下的代码:2个,3个,4个,更多个的call,输出都会是String {"b"} "b"


function a(){ 
console.log(this,'a')
};
function b(){
console.log(this,'b')
}
a.call.call(b,'b') // String {"b"} "b"
a.call.call.call(b,'b') // String {"b"} "b"
a.call.call.call.call(b,'b') // String {"b"} "b"

看完上面,应该有三个疑问?



  1. 为什么被调用的是b函数

  2. 为什么thisString {"b"}

  3. 为什么 2, 3, 4个call的结果一样


结论:

两个以上的call,比如call.call(b, 'b'),你就简单理解为用 b.call('b')


分析


为什么 2, 3, 4个call的结果一样


a.call(b) 最终被调用的是a,

a.call.call(b), 最终被调用的 a.call

a.call.call.call(b), 最终被执行的 a.call.call


看一下引用关系


a.call === Function.protype.call  // true
a.call === a.call.call // true
a.call === a.call.call.call // true

基于上述执行分析:

a.call 被调用的是a

a.call.calla.call.call.call 本质没啥区别, 被调用的都是Function.prototype.call


为什么 2, 3, 4个call的结果一样,到此已经真相


为什么被调用的是b函数


看本质就要返璞归真,ES 标准对 Funtion.prototye.call 的描述



Function.prototype.call (thisArg , ...args)


When the call method is called on an object func with argument, thisArg and zero or more args, the following steps are taken:



  1. If IsCallable(func) is false, throw a TypeError exception.

  2. Let argList be an empty List.

  3. If this method was called with more than one argument then in left to right order, starting with the second argument, append each argument as the last element of argList.

  4. Perform PrepareForTailCall().

  5. Return Call(functhisArgargList).



中文翻译一下



  1. 如果不可调用,抛出异常

  2. 准备一个argList空数组变量

  3. 把第一个之后的变量按照顺序添加到argList

  4. 返回 Call(functhisArgargList)的结果


这里的Call只不是是一个抽象的定义, 实际上是调用函数内部 [[Call]] 的方法, 其也没有暴露更多的有用的信息。


实际上在这里,我已经停止了思考:


a is a function, then what a.call.call really do? 一文的解释,有提到 Bound Function Exotic Objects , MDN的 Function.prototype.bind 也有提到:



The bind() function creates a new bound function, which is an exotic function object (a term from ECMAScript 2015) that wraps the original function object. Calling the bound function generally results in the execution of its wrapped function.



Function.prototype.call 相反,并没有提及!!! 但不排查在调用过程中有生成。


Difference between Function.call, Function.prototype.call, Function.prototype.call.call and Function.prototype.call.call.call 一文的解释,我觉得是比较合理的


function my(p) { console.log(p) }
Function.prototype.call.call(my, this, "Hello"); // output 'Hello'


Function.prototype.call.call(my, this, "Hello"); means:


Use my as this argument (the function context) for the function that was called. In this case Function.prototype.call was called.


So, Function.prototype.call would be called with my as its context. Which basically means - it would be the function to be invoked.


It would be called with the following arguments: (this, "Hello"), where this is the context to be set inside the function to be called (in this case it's my), and the only argument to be passed is "Hello" string.



重点标出:

So, Function.prototype.call would be called with my as its context. Which basically means - it would be the function to be invoked.


It would be called with the following arguments: (this, "Hello"), where this is the context to be set inside the function to be called (in this case it's my), and the only argument to be passed is "Hello" string


翻译一下:

Function.prototype.call.call(my, this, "Hello")表示: 用my作为上下文调用Function.prototype.call,也就是说my是最终被调用的函数。


my带着这些 (this, "Hello") 被调用, this 作为被调用函数的上下文,此处是作为my函数的上下文, 唯一被传递的参数是 "hello"字符串。


基于这个理解, 我们简单验证一下, 确实是这样的表象


// case 1:
function my(p) { console.log(p) }
Function.prototype.call.call(my, this, "Hello"); // output 'Hello'

// case 2:
function a(){
console.log(this,'a')
};
function b(){
console.log(this,'b')
}
a.call.call(b,'b') // String {"b"} "b"

为什么被调用的是b函数, 到此也真相了。


其实我依旧不能太释怀, 但是这个解释可以接受,表象也是正确的, 期望掘友们有更合理,更详细的解答。


为什么thisString {"b"}


在上一节的分析中,我故意遗漏了Function.prototype.call的两个note



NOTE 1: The thisArg value is passed without modification as the this value. This is a change from Edition 3, where an undefined or null thisArg is replaced with the global object and ToObject is applied to all other values and that result is passed as the this value. Even though the thisArg is passed without modification, non-strict functions still perform these transformations upon entry to the function.




NOTE 2: If func is an arrow function or a bound function then the thisArg will be ignored by the function [[Call]] in step 5.



注意这一句:



This is a change from Edition 3, where an undefined or null thisArg is replaced with the global object and ToObject is applied to all other values and that result is passed as the this value



两点:



  1. 如果thisArgundefined 或者null, 会用global object替换


这里的前提是 非严格模式


"use strict"

function a(m){
console.log(this, m); // undefined, 1
}

a.call(undefined, 1)


  1. 其他的所有类型,都会调用 ToObject进行转换


所以非严格模式下, this肯定是个对象, 看下面的代码:


Object('b') // String {"b"}

note2的 ToObject 就是答案


到此, 为什么thisSting(b) 这个也真相了


万能的函数调用方法


基于Function.prototype.call.call的特性,我们可以封装一个万能函数调用方法


var call = Function.prototype.call.call.bind(Function.prototype.call);

示例


var person = {
hello() {
console.log('hello', this.name)
}
}

call(person.hello, {"name": "tom"}) // hello tom

写在最后


如果你觉得不错,你的一赞一评就是我前行的最大动力。




作者:云的世界
链接:https://juejin.cn/post/6999781802923524132

收起阅读 »

Vue3的7种和Vue2的12种组件通信,年轻人?还不收藏在等什么!!!

Vue2.x组件通信12种方式写在后面了,先来 Vue3 的 奥力给! Vue3 组件通信方式 props $emit expose / ref $attrs v-model provide / inject Vuex Vue3 通信使用写法 props ...
继续阅读 »

Vue2.x组件通信12种方式写在后面了,先来 Vue3 的


奥力给!


Vue3 组件通信方式



  • props

  • $emit

  • expose / ref

  • $attrs

  • v-model

  • provide / inject

  • Vuex


Vue3 通信使用写法


props


用 props 传数据给子组件有两种方法,如下


方法一,混合写法


// Parent.vue 传送
<child :msg1="msg1" :msg2="msg2"></child>
<script>
import child from "./child.vue"
import { ref, reactive } from "vue"
export default {
data(){
return {
msg1:"这是传级子组件的信息1"
}
},
setup(){
// 创建一个响应式数据

// 写法一 适用于基础类型 ref 还有其他用处,下面章节有介绍
const msg2 = ref("这是传级子组件的信息2")

// 写法二 适用于复杂类型,如数组、对象
const msg2 = reactive(["这是传级子组件的信息2"])

return {
msg2
}
}
}
</script>

// Child.vue 接收
<script>
export default {
props: ["msg1", "msg2"],// 如果这行不写,下面就接收不到
setup(props) {
console.log(props) // { msg1:"这是传给子组件的信息1", msg2:"这是传给子组件的信息2" }
},
}
</script>

方法二,纯 Vue3 写法


// Parent.vue 传送
<child :msg2="msg2"></child>
<script setup>
import child from "./child.vue"
import { ref, reactive } from "vue"
const msg2 = ref("这是传给子组件的信息2")
// 或者复杂类型
const msg2 = reactive(["这是传级子组件的信息2"])
</script>

// Child.vue 接收
<script setup>
// 不需要引入 直接使用
// import { defineProps } from "vue"
const props = defineProps({
// 写法一
msg2: String
// 写法二
msg2:{
type:String,
default:""
}
})
console.log(props) // { msg2:"这是传级子组件的信息2" }
</script>

注意:


如果父组件是混合写法,子组件纯 Vue3 写法的话,是接收不到父组件里 data 的属性,只能接收到父组件里 setup 函数里传的属性


如果父组件是纯 Vue3 写法,子组件混合写法,可以通过 props 接收到 data 和 setup 函数里的属性,但是子组件要是在 setup 里接收,同样只能接收到父组件中 setup 函数里的属性,接收不到 data 里的属性


官方也说了,既然用了 3,就不要写 2 了,所以不推荐混合写法。下面的例子,一律只用纯 Vue3 的写法,就不写混合写法了


$emit


// Child.vue 派发
<template>
// 写法一
<button @click="emit('myClick')">按钮</buttom>
// 写法二
<button @click="handleClick">按钮</buttom>
</template>
<script setup>

// 方法一 适用于Vue3.2版本 不需要引入
// import { defineEmits } from "vue"
// 对应写法一
const emit = defineEmits(["myClick","myClick2"])
// 对应写法二
const handleClick = ()=>{
emit("myClick", "这是发送给父组件的信息")
}

// 方法二 不适用于 Vue3.2版本,该版本 useContext()已废弃
import { useContext } from "vue"
const { emit } = useContext()
const handleClick = ()=>{
emit("myClick", "这是发送给父组件的信息")
}
</script>

// Parent.vue 响应
<template>
<child @myClick="onMyClick"></child>
</template>
<script setup>
import child from "./child.vue"
const onMyClick = (msg) => {
console.log(msg) // 这是父组件收到的信息
}
</script>

expose / ref


父组件获取子组件的属性或者调用子组件方法


// Child.vue
<script setup>
// 方法一 不适用于Vue3.2版本,该版本 useContext()已废弃
import { useContext } from "vue"
const ctx = useContext()
// 对外暴露属性方法等都可以
ctx.expose({
childName: "这是子组件的属性",
someMethod(){
console.log("这是子组件的方法")
}
})

// 方法二 适用于Vue3.2版本, 不需要引入
// import { defineExpose } from "vue"
defineExpose({
childName: "这是子组件的属性",
someMethod(){
console.log("这是子组件的方法")
}
})
</script>

// Parent.vue 注意 ref="comp"
<template>
<child ref="comp"></child>
<button @click="handlerClick">按钮</button>
</template>
<script setup>
import child from "./child.vue"
import { ref } from "vue"
const comp = ref(null)
const handlerClick = () => {
console.log(comp.value.childName) // 获取子组件对外暴露的属性
comp.value.someMethod() // 调用子组件对外暴露的方法
}
</script>

attrs


attrs:包含父作用域里除 class 和 style 除外的非 props 属性集合


// Parent.vue 传送
<child :msg1="msg1" :msg2="msg2" title="3333"></child>
<script setup>
import child from "./child.vue"
import { ref, reactive } from "vue"
const msg1 = ref("1111")
const msg2 = ref("2222")
</script>

// Child.vue 接收
<script setup>
import { defineProps, useContext, useAttrs } from "vue"
// 3.2版本不需要引入 defineProps,直接用
const props = defineProps({
msg1: String
})
// 方法一 不适用于 Vue3.2版本,该版本 useContext()已废弃
const ctx = useContext()
// 如果没有用 props 接收 msg1 的话就是 { msg1: "1111", msg2:"2222", title: "3333" }
console.log(ctx.attrs) // { msg2:"2222", title: "3333" }

// 方法二 适用于 Vue3.2版本
const attrs = useAttrs()
console.log(attrs) // { msg2:"2222", title: "3333" }
</script>

v-model


可以支持多个数据双向绑定


// Parent.vue
<child v-model:key="key" v-model:value="value"></child>
<script setup>
import child from "./child.vue"
import { ref, reactive } from "vue"
const key = ref("1111")
const value = ref("2222")
</script>

// Child.vue
<template>
<button @click="handlerClick">按钮</button>
</template>
<script setup>

// 方法一 不适用于 Vue3.2版本,该版本 useContext()已废弃
import { useContext } from "vue"
const { emit } = useContext()

// 方法二 适用于 Vue3.2版本,不需要引入
// import { defineEmits } from "vue"
const emit = defineEmits(["key","value"])

// 用法
const handlerClick = () => {
emit("update:key", "新的key")
emit("update:value", "新的value")
}
</script>

provide / inject


provide / inject 为依赖注入


provide:可以让我们指定想要提供给后代组件的数据或


inject:在任何后代组件中接收想要添加在这个组件上的数据,不管组件嵌套多深都可以直接拿来用


// Parent.vue
<script setup>
import { provide } from "vue"
provide("name", "沐华")
</script>

// Child.vue
<script setup>
import { inject } from "vue"
const name = inject("name")
console.log(name) // 沐华
</script>

Vuex


// store/index.js
import { createStore } from "vuex"
export default createStore({
state:{ count: 1 },
getters:{
getCount: state => state.count
},
mutations:{
add(state){
state.count++
}
}
})

// main.js
import { createApp } from "vue"
import App from "./App.vue"
import store from "./store"
createApp(App).use(store).mount("#app")

// Page.vue
// 方法一 直接使用
<template>
<div>{{ $store.state.count }}</div>
<button @click="$store.commit('add')">按钮</button>
</template>

// 方法二 获取
<script setup>
import { useStore, computed } from "vuex"
const store = useStore()
console.log(store.state.count) // 1

const count = computed(()=>store.state.count) // 响应式,会随着vuex数据改变而改变
console.log(count) // 1
</script>

Vue2.x 组件通信方式


Vue2.x 组件通信共有12种



  1. props

  2. $emit / v-on

  3. .sync

  4. v-model

  5. ref

  6. $children / $parent

  7. $attrs / $listeners

  8. provide / inject

  9. EventBus

  10. Vuex

  11. $root

  12. slot


父子组件通信可以用:



  • props

  • $emit / v-on

  • $attrs / $listeners

  • ref

  • .sync

  • v-model

  • $children / $parent


兄弟组件通信可以用:



  • EventBus

  • Vuex

  • $parent


跨层级组件通信可以用:



  • provide/inject

  • EventBus

  • Vuex

  • $attrs / $listeners

  • $root


Vue2.x 通信使用写法


下面把每一种组件通信方式的写法一一列出


1. props


父组件向子组件传送数据,这应该是最常用的方式了


子组件接收到数据之后,不能直接修改父组件的数据。会报错,所以当父组件重新渲染时,数据会被覆盖。如果子组件内要修改的话推荐使用 computed


// Parent.vue 传送
<template>
<child :msg="msg"></child>
</template>

// Child.vue 接收
export default {
// 写法一 用数组接收
props:['msg'],
// 写法二 用对象接收,可以限定接收的数据类型、设置默认值、验证等
props:{
msg:{
type:String,
default:'这是默认数据'
}
},
mounted(){
console.log(this.msg)
},
}

2. .sync


可以帮我们实现父组件向子组件传递的数据 的双向绑定,所以子组件接收到数据后可以直接修改,并且会同时修改父组件的数据


// Parent.vue
<template>
<child :page.sync="page"></child>
</template>
<script>
export default {
data(){
return {
page:1
}
}
}

// Child.vue
export default {
props:["page"],
computed(){
// 当我们在子组件里修改 currentPage 时,父组件的 page 也会随之改变
currentPage {
get(){
return this.page
},
set(newVal){
this.$emit("update:page", newVal)
}
}
}
}
</script>

3. v-model


和 .sync 类似,可以实现将父组件传给子组件的数据为双向绑定,子组件通过 $emit 修改父组件的数据


// Parent.vue
<template>
<child v-model="value"></child>
</template>
<script>
export default {
data(){
return {
value:1
}
}
}

// Child.vue
<template>
<input :value="value" @input="handlerChange">
</template>
export default {
props:["value"],
// 可以修改事件名,默认为 input
model:{
event:"updateValue"
},
methods:{
handlerChange(e){
this.$emit("input", e.target.value)
// 如果有上面的重命名就是这样
this.$emit("updateValue", e.target.value)
}
}
}
</script>

4. ref


ref 如果在普通的DOM元素上,引用指向的就是该DOM元素;


如果在子组件上,引用的指向就是子组件实例,然后父组件就可以通过 ref 主动获取子组件的属性或者调用子组件的方法


// Child.vue
export default {
data(){
return {
name:"沐华"
}
},
methods:{
someMethod(msg){
console.log(msg)
}
}
}

// Parent.vue
<template>
<child ref="child"></child>
</template>
<script>
export default {
mounted(){
const child = this.$refs.child
console.log(child.name) // 沐华
child.someMethod("调用了子组件的方法")
}
}
</script>

5. $emit / v-on


子组件通过派发事件的方式给父组件数据,或者触发父组件更新等操作


// Child.vue 派发
export default {
data(){
return { msg: "这是发给父组件的信息" }
},
methods: {
handleClick(){
this.$emit("sendMsg",this.msg)
}
},
}
// Parent.vue 响应
<template>
<child v-on:sendMsg="getChildMsg"></child>
// 或 简写
<child @sendMsg="getChildMsg"></child>
</template>

export default {
methods:{
getChildMsg(msg){
console.log(msg) // 这是父组件接收到的消息
}
}
}

6. $attrs / $listeners


多层嵌套组件传递数据时,如果只是传递数据,而不做中间处理的话就可以用这个,比如父组件向孙子组件传递数据时


$attrs:包含父作用域里除 class 和 style 除外的非 props 属性集合。通过 this.$attrs 获取父作用域中所有符合条件的属性集合,然后还要继续传给子组件内部的其他组件,就可以通过 v-bind="$attrs"


$listeners:包含父作用域里 .native 除外的监听事件集合。如果还要继续传给子组件内部的其他组件,就可以通过 v-on="$linteners"


使用方式是相同的


// Parent.vue
<template>
<child :name="name" title="1111" ></child>
</template
export default{
data(){
return {
name:"沐华"
}
}
}

// Child.vue
<template>
// 继续传给孙子组件
<sun-child v-bind="$attrs"></sun-child>
</template>
export default{
props:["name"], // 这里可以接收,也可以不接收
mounted(){
// 如果props接收了name 就是 { title:1111 },否则就是{ name:"沐华", title:1111 }
console.log(this.$attrs)
}
}

7. $children / $parent


$children:获取到一个包含所有子组件(不包含孙子组件)的 VueComponent 对象数组,可以直接拿到子组件中所有数据和方法等


$parent:获取到一个父节点的 VueComponent 对象,同样包含父节点中所有数据和方法等


// Parent.vue
export default{
mounted(){
this.$children[0].someMethod() // 调用第一个子组件的方法
this.$children[0].name // 获取第一个子组件中的属性
}
}

// Child.vue
export default{
mounted(){
this.$parent.someMethod() // 调用父组件的方法
this.$parent.name // 获取父组件中的属性
}
}

8. provide / inject


provide / inject 为依赖注入,说是不推荐直接用于应用程序代码中,但是在一些插件或组件库里却是被常用,所以我觉得用也没啥,还挺好用的


provide:可以让我们指定想要提供给后代组件的数据或方法


inject:在任何后代组件中接收想要添加在这个组件上的数据或方法,不管组件嵌套多深都可以直接拿来用


要注意的是 provide 和 inject 传递的数据不是响应式的,也就是说用 inject 接收来数据后,provide 里的数据改变了,后代组件中的数据不会改变,除非传入的就是一个可监听的对象


所以建议还是传递一些常量或者方法


// 父组件
export default{
// 方法一 不能获取 methods 中的方法
provide:{
name:"沐华",
age: this.data中的属性
},
// 方法二 不能获取 data 中的属性
provide(){
return {
name:"沐华",
someMethod:this.someMethod // methods 中的方法
}
},
methods:{
someMethod(){
console.log("这是注入的方法")
}
}
}

// 后代组件
export default{
inject:["name","someMethod"],
mounted(){
console.log(this.name)
this.someMethod()
}
}

9. EventBus


EventBus 是中央事件总线,不管是父子组件,兄弟组件,跨层级组件等都可以使用它完成通信操作


定义方式有三种


// 方法一
// 抽离成一个单独的 js 文件 Bus.js ,然后在需要的地方引入
// Bus.js
import Vue from "vue"
export default new Vue()

// 方法二 直接挂载到全局
// main.js
import Vue from "vue"
Vue.prototype.$bus = new Vue()

// 方法三 注入到 Vue 根对象上
// main.js
import Vue from "vue"
new Vue({
el:"#app",
data:{
Bus: new Vue()
}
})

使用如下,以方法一按需引入为例


// 在需要向外部发送自定义事件的组件内
<template>
<button @click="handlerClick">按钮</button>
</template>
import Bus from "./Bus.js"
export default{
methods:{
handlerClick(){
// 自定义事件名 sendMsg
Bus.$emit("sendMsg", "这是要向外部发送的数据")
}
}
}

// 在需要接收外部事件的组件内
import Bus from "./Bus.js"
export default{
mounted(){
// 监听事件的触发
Bus.$on("sendMsg", data => {
console.log("这是接收到的数据:", data)
})
},
beforeDestroy(){
// 取消监听
Bus.$off("sendMsg")
}
}

10. Vuex


Vuex 是状态管理器,集中式存储管理所有组件的状态。这一块内容过长,如果基础不熟的话可以看这个Vuex,然后大致用法如下


比如创建这样的文件结构


微信图片_20210824003500.jpg


index.js 里内容如下


import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
import state from './state'
import user from './modules/user'

Vue.use(Vuex)

const store = new Vuex.Store({
modules: {
user
},
getters,
actions,
mutations,
state
})
export default store

然后在 main.js 引入


import Vue from "vue"
import store from "./store"
new Vue({
el:"#app",
store,
render: h => h(App)
})

然后在需要的使用组件里


import { mapGetters, mapMutations } from "vuex"
export default{
computed:{
// 方式一 然后通过 this.属性名就可以用了
...mapGetters(["引入getters.js里属性1","属性2"])
// 方式二
...mapGetters("user", ["user模块里的属性1","属性2"])
},
methods:{
// 方式一 然后通过 this.属性名就可以用了
...mapMutations(["引入mutations.js里的方法1","方法2"])
// 方式二
...mapMutations("user",["引入user模块里的方法1","方法2"])
}
}

// 或者也可以这样获取
this.$store.state.xxx
this.$store.state.user.xxx

11. $root


$root 可以拿到 App.vue 里的数据和方法


12. slot


就是把子组件的数据通过插槽的方式传给父组件使用,然后再插回来


// Child.vue
<template>
<div>
<slot :user="user"></slot>
</div>
</template>
export default{
data(){
return {
user:{ name:"沐华" }
}
}
}

// Parent.vue
<template>
<div>
<child v-slot="slotProps">
{{ slotProps.user.name }}
</child>
</div>
</template>

结语


写作不易,你的一赞一评,就是我前行的最大动力。


链接:https://juejin.cn/post/6999687348120190983

收起阅读 »

前端9种图片格式基础知识, 你应该知道的

彩色深度 彩色深度标准通常有以下几种: 8位色,每个像素所能显示的彩色数为2的8次方,即256种颜色。 16位增强色,16位彩色,每个像素所能显示的彩色数为2的16次方,即65536种颜色。 24位真彩色,每个像素所能显示的彩色数为24位,即2的24次方,约...
继续阅读 »

彩色深度


彩色深度标准通常有以下几种:



  • 8位色,每个像素所能显示的彩色数为2的8次方,即256种颜色。

  • 16位增强色,16位彩色,每个像素所能显示的彩色数为2的16次方,即65536种颜色。

  • 24位真彩色,每个像素所能显示的彩色数为24位,即2的24次方,约1680万种颜色。

  • 32位真彩色,即在24位真彩色图像的基础上再增加一个表示图像透明度信息的Alpha通道。

    32位真彩色并非是2的32次方的色数,它其实也是1677万多色,不过它增加了256阶颜色的灰度,为了方便称呼,就规定它为32位色


图的分类


光栅图和矢量图


对于图片,一般分光栅图和矢量图。



  • 光栅图:是基于 pixel像素构成的图像。JPEG、PNG,webp等都属于此类

  • 矢量图:使用点,线和多边形等几何形状来构图,具有高分辨率和缩放功能. SVG就是一种矢量图。


无压缩, 无损压缩, 有损压缩


另一种分类




  • 无压缩。无压缩的图片格式不对图片数据进行压缩处理,能准确地呈现原图片。BMP格式就是其中之一。




  • 无损压缩。压缩算法对图片的所有的数据进行编码压缩,能在保证图片的质量的同时降低图片的尺寸。png是其中的代表。




  • 有损压缩。压缩算法不会对图片所有的数据进行编码压缩,而是在压缩的时候,去除了人眼无法识别的图片细节。因此有损压缩可以在同等图片质量的情况下大幅降低图片的尺寸。其中的代表是jpg。




前端9种图片格式


诞生时间


对于超过30岁的程序员来说,她们都很年轻,真的是遇到好时光!


85年前,人们都在干嘛呢?



  1. GIF - 1987

  2. Base64- 1987

  3. JPEG - 1992

  4. PNG - 1996

  5. SVG - 1999

  6. JPEG2000 - 1997 to 2000

  7. APNG - 2004

  8. WebP - 2010


ico: 1985年??

查阅文档说ico文件格式是伴随着 Windows 1.0 发行诞生的。


GIF


GIF是一种索引色模式图片,所以GIF每帧图所表现的颜色最多为256种。GIF能够支持动画,也能支持背景透明,这点连古老的IE6都支持,所以在以前想要在项目中使用背景透明图片,其中一种方案就是生成GIF图片。


优点



  • 支持动画和透明背景

  • 兼容性好

  • 灰度图像表现佳

  • 支持交错

    部分接收到的文件可以以较低的质量显示。这在网络连接缓慢时特别有用。


缺点



  • 最多支持 8 位 256 色,色阶过渡糟糕,图片具有颗粒感

  • 支持透明,但不支持半透明,边缘有杂边


适用场景



  • 色彩简单的logo、icon、线框图适合采用gif格

  • 动画


JPG/JPEG


这里提个问题: jpg和jpeg有啥区别


平常我们大部分见到的静态图基本都是这种图片格式。这种格式的图片能比较好的表现各种色彩,主要在压缩的时候会有所失真,也正因为如此,造就了这种图片格式体积的轻量。


优点



  • 压缩率高

  • 兼容性好

  • 色彩丰富


缺点



  • JPEG不适合用来存储企业Logo、线框类的这种高清图

  • 不支持动画、背景透明


JPEG 2000 (了解即可)


JPEG 2000是基于小波变换的图像压缩标准,由Joint Photographic Experts Group组织创建和维护。JPEG 2000通常被认为是未来取代JPEG(基于离散余弦变换)的下一代图像压缩标准。JPEG 2000文件的副档名通常为.jp2,MIME类型是image/jp2。


JPEG2000的压缩比更高,而且不会产生原先的基于离散余弦变换的JPEG标准产生的块状模糊瑕疵。JPEG2000同时支持有损压缩无损压缩


目前就safari支持,can is use-png2000支持18%。


优点



  • 支持有损和无损压缩


缺点



  • 支持率太低了


ICO


ICO (Microsoft Windows 图标)文件格式是微软为 Windows 系统的桌面图标设计的。网站可以在网站的根目录中提供一个名为 favicon.ICO, 在收藏夹菜单中显示的图标,以及其他一些有用的标志性网站表示形式。

一个 ICO 文件可以包含多个图标,并以列出每个图标详细信息的目录开始。


其主要用来做网站图标,现在png也是可以用来做网站图标的。


PNG


PNG格式是有三种版本的,分别为PNG-8,PNG-24,PNG-32,所有这些版本都不支持动画的。PNG-8跟GIF类似的属性是相似的,都是索引色模式,而且都支持背景透明。相对比GIF格式好的特点在与背景透明时,图像边缘没有什么噪点,颜色表现更优秀。PNG-24其实就是无损压缩的JPEG。而PNG-32就是在PNG-24的基础上,增加了透明度的支持。


如果没有动画需求推荐使用png-8来替代gif


优点



  1. 不失真的情况下尽可能压缩图像文件的大小

  2. 像素丰富

  3. 支持透明(alpha通道)


缺点



  1. 文件大


这里额外提一下,gif和jpg有渐进,png有交错,都是在没有完全下载图片的时候,能看到图片全貌。


具体可以看在线示例: png正常,png交错,jpg渐进


APNG:Animated PNG


APNG(Animated Portable Network Graphics)顾名思义是基于 PNG 格式扩展的一种动画格式,增加了对动画图像的支持,同时加入了 24 位图像和 8 位 Alpha 透明度的支持,这意味着动画将拥有更好的质量,其诞生的目的是为了替代老旧的 GIF 格式,但它目前并没有获得 PNG 组织官方的认可。


从Can I Use上查看,除了IE系列, chrome, firefox, safari均已支持。2021-08月的时候支持达到94%。


相对GIF来说



  • 色彩丰富

  • 支持透明

  • 向下兼容 PNG

  • 支持动画


缺点



  • 生成比较繁琐

  • 未标准化


webP


有损 WebP 图像平均比视觉上类似压缩级别的 JPEG 图像小25-35% 。无损耗的 WebP 图像通常比 PNG 格式的相同图像小26% 。WebP 还支持动画: 在有损的 WebP 文件中,图像数据由 VP8位流表示,该位流可能包含多个帧。


包括体积小、色彩表现足够、支持动画。 简直了就是心中的完美女神!!


can i use - webp上看,支持率95%。 主要是Safari低版本和IE低版本不兼容。


优点



  • 同等质量更小

  • 压缩之后质量无明显变化

  • 支持无损图像

  • 支持动画


缺点



  • 兼容性吧,相对jpg,png,gif来说


SVG


SVG 是一种基于 xml 的矢量图形格式,它将图像的内容指定为一组绘图命令,这些命令创建形状、线条、应用颜色、过滤器等等。SVG 文件是理想的图表,图标和其他图像,可以准确地绘制在任何大小。因此,SVG 是现代 Web 设计中用户界面元素的流行选择。


优点



  • 可伸缩性

    你可以随心所欲地把它们做大或者做小,而不用牺牲质量



  • Svg 平均比 GIF、 JPEG、 PNG 小得多,甚至在极高的分辨率下也是如此

  • 支持动画

    更灵活,质量无与伦比

  • 与DOM无缝衔接

    Svg 可以直接使用 HTML、 CSS 和 JavaScript (例如动画)来操作


缺点



  • SVG复杂度高会减慢渲染速度

  • 不适合游戏类等高互动动画


base64


图片的 base64 编码就是可以将一副图片数据编码成一串字符串,使用该字符串代替图像地址,图片随着 HTML 的下载同时下载到本地,不再单独消耗一个http来请求图片。


优点



  • 无额外请求

  • 对于极小或者极简单图片

  • 可像单独图片一样使用,比如背景图片重复使用等

  • 没有跨域问题,无需考虑缓存、文件头或者cookies问题  


缺点



  • 相比其他格式,体积会至少大1/3

  • 编码解码有额外消耗


一些对比


PNG, GIF, JPG 比较


大小比较:通常地,PNG ≈ JPG > GIF 8位的PNG完全可以替代掉GIF

透明性:PNG > GIF > JPG

色彩丰富程度:JPG > PNG >GIF

兼容程度:GIF ≈ JPG > PNG

gif, jpg, png, web优缺点和使用场景



链接:https://juejin.cn/post/7000154907156152327

收起阅读 »

(算法入门)人人都能看懂的时间复杂度和空间复杂度

你是怎么理解算法的呢? 简单说就是,同一个功能 别人写的代码跑起来占内存 100M,耗时 100 毫秒 你写的代码跑起来占内存 500M,耗时 1000 毫秒,甚至更多 所以 衡量代码好坏有两个非常重要的标准就是:运行时间和占用空间,就是我们后面要说到的...
继续阅读 »

你是怎么理解算法的呢?


简单说就是,同一个功能



  • 别人写的代码跑起来占内存 100M,耗时 100 毫秒

  • 你写的代码跑起来占内存 500M,耗时 1000 毫秒,甚至更多


所以



  1. 衡量代码好坏有两个非常重要的标准就是:运行时间占用空间,就是我们后面要说到的时间复杂度空间复杂度也是学好算法的重要基石

  2. 这也是会算法和不会算法的攻城狮的区别、更是薪资的区别,因为待遇好的大厂面试基本都有算法


可能有人会问:别人是怎么做到的?代码没开发完 运行起来之前怎么知道占多少内存和运行时间呢?


确切的占内用存或运行时间确实算不出来,而且同一段代码在不同性能的机器上执行的时间也不一样,可是代码的基本执行次数,我们是可以算得出来的,这就要说到时间复杂度了


什么是时间复杂度


看个栗子


function foo1(){
console.log("我吃了一颗糖")
console.log("我又吃了一颗糖")
return "再吃一颗糖"
}

调用这个函数,里面总执行次数就是3次,这个没毛病,都不用算


那么下面这个栗子呢


function foo2(n){
for( let i = 0; i < n; i++){
console.log("我吃了一颗糖")
}
return "一颗糖"
}

那这个函数里面总执行次数呢?根据我们传进去的值不一样,执行次数也就不一样,但是大概次数我们总能知道


let = 0               :执行 1 次
i < n : 执行 n+1 次
i++ : 执行 n+1 次
console.log("执行了") : 执行 n 次
return 1 : 执行 1 次

这个函数的总执行次数就是 3n + 4 次,对吧


可是我们开发不可能都这样去数,所以根据代码执行时间的推导过程就有一个规律,也就是所有代码执行时间 T(n)和代码的执行次数 f(n) ,这个是成正比的,而这个规律有一个公式



T(n) = O( f(n) )



n 是输入数据的大小或者输入数据的数量  
T(n) 表示一段代码的总执行时间
f(n) 表示一段代码的总执行次数
O 表示代码的执行时间 T(n) 和 执行次数f(n) 成正比

完整的公式看着就很麻烦,别着急,这个公式只要了解一下就可以了,为的就是让你知道我们表示算法复杂度的 O() 是怎么来的,我们平时表示算法复杂度主要就是用 O(),读作大欧表示法,是字母O不是零


只用一个 O() 表示,这样看起来立马就容易理解多了


回到刚才的两个例子,就是上面的两个函数



  • 第一个函数执行了3次,用复杂度表示就是 O(3)

  • 第二个函数执行了3n + 4次,复杂度就是 O(3n+4)


这样有没有觉得还是很麻烦,因为如果函数逻辑一样的,只是执行次数差个几次,像O(3) 和 O(4),有什么差别?还要写成两种就有点多此一举了,所以复杂度里有统一的简化的表示法,这个执行时间简化的估算值就是我们最终的时间复杂度


简化的过程如下



  • 如果只是常数直接估算为1,O(3) 的时间复杂度就是 O(1),不是说只执行了1次,而是对常量级时间复杂度的一种表示法。一般情况下,只要算法里没有循环和递归,就算有上万行代码,时间复杂度也是O(1)

  • O(3n+4) 里常数4对于总执行次数的几乎没有影响,直接忽略不计,系数 3 影响也不大,因为3n和n都是一个量级的,所以作为系数的常数3也估算为1或者可以理解为去掉系数,所以 O(3n+4) 的时间复杂度为 O(n)

  • 如果是多项式,只需要保留n的最高次项O( 666n³ + 666n² + n ),这个复杂度里面的最高次项是n的3次方。因为随着n的增大,后面的项的增长远远不及n的最高次项大,所以低于这个次项的直接忽略不计,常数也忽略不计,简化后的时间复杂度为 O(n³)


这里如果没有理解的话,暂停理解一下


接下来结合栗子,看一下常见的时间复杂度


常用时间复杂度


O(1)


上面说了,一般情况下,只要算法里没有循环和递归,就算有上万行代码,时间复杂度也是 O(1),因为它的执行次数不会随着任何一个变量的增大而变长,比如下面这样


function foo(){
let n = 1
let b = n * 100
if(b === 100){
console.log("开始吃糖")
}
console.log("我吃了1颗糖")
console.log("我吃了2颗糖")
......
console.log("我吃了10000颗糖")
}

O(n)


上面也介绍了 O(n),总的来说 只有一层循环或者递归等,时间复杂度就是 O(n),比如下面这样


function foo1(n){
for( let i = 0; i < n; i++){
console.log("我吃了一颗糖")
}
}
function foo2(n){
while( --n > 0){
console.log("我吃了一颗糖")
}
}
function foo3(n){
console.log("我吃了一颗糖")
--n > 0 && foo3(n)
}

O(n²)


比如嵌套循环,如下面这样的,里层循环执行 n 次,外层循环也执行 n 次,总执行次数就是 n x n,时间复杂度就是 n 的平方,也就是 O(n²)。假设 n 是 10,那么里面的就会打印 10 x 10 = 100 次


function foo1(n){
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}

还有这样的,总执行次数为 n + n²,上面说了,如果是多项式,取最高次项,所以这个时间复杂度也是 O(n²)


function foo2(n){
for( let k = 0; k < n; k++){
console.log("我吃了一颗糖")
}
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}

//或者下面这样,以运行时间最长的,作为时间复杂度的依据,所以下面的时间复杂度就是 O(n²)
function foo3(n){
if( n > 100){
for( let k = 0; k < n; k++){
console.log("我吃了一颗糖")
}
}else{
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}
}

O(logn)


举个栗子,这里有一包糖


asdf.jpeg


这包糖里有16颗,沐华每天吃这一包糖的一半,请问多少天吃完?


意思就是16不断除以2,除几次之后等于1?用代码表示


function foo1(n){
let day = 0
while(n > 1){
n = n/2
day++
}
return day
}
console.log( foo1(16) ) // 4

循环次数的影响主要来源于 n/2 ,这个时间复杂度就是 O(logn) ,这个复杂度是怎么来的呢,别着急,继续看


再比如下面这样


function foo2(n){
for(let i = 0; i < n; i *= 2){
console.log("一天")
}
}
foo2( 16 )

里面的打印执行了 4 次,循环次数主要影响来源于 i *= 2 ,这个时间复杂度也是 O(logn)


这个 O(logn) 是怎么来的,这里补充一个小学三年级数学的知识点,对数,我们看一张图


未标题-1.jpg


没有理解的话再看一下,理解一下规律



  • 真数:就是真数,这道题里就是16

  • 底数:就是值变化的规律,比如每次循环都是i*=2,这个乘以2就是规律。比如1,2,3,4,5...这样的值的话,底就是1,每个数变化的规律是+1嘛

  • 对数:在这道题里可以理解成x2乘了多少次,这个次数


仔细观察规律就会发现这道题里底数是 2,而我们要求的天数就是这个对数4,在对数里有一个表达公式



ab = n  读作以a为底,b的对数=n,在这道题里我们知道a和n的值,也就是  2b = 16 然后求 b



把这个公式转换一下的写法如下



logan = b    在这道题里就是   log216 = ?  答案就是 4



公式是固定的,这个16不是固定的,是我们传进去的 n,所以可以理解为这道题就是求 log2n = ?


用时间复杂度表示就是 O(log2n),由于时间复杂度需要去掉常数和系数,而log的底数跟系数是一样的,所以也需要去掉,所以最后这个正确的时间复杂度就是 O(logn)


emmmmm.....


没有理解的话,可以暂停理解一下


其他还有一些时间复杂度,我由快到慢排列了一下,如下表顺序



这些时间复杂度有什么区别呢,看张图


未标题-3.jpg


随着数据量或者 n 的增大,时间复杂度也随之增加,也就是执行时间的增加,会越来越慢,越来越卡


总的来说时间复杂度就是执行时间增长的趋势,那么空间复杂度就是存储空间增长的趋势


什么是空间复杂度


空间复杂度就是算法需要多少内存,占用了多少空间


常用的空间复杂度有 O(1)O(n)O(n²)


O(1)


只要不会因为算法里的执行,导致额外的空间增长,就算是一万行,空间复杂度也是 O(1),比如下面这样,时间复杂度也是 O(1)


function foo(){
let n = 1
let b = n * 100
if(b === 100){
console.log("开始吃糖")
}
console.log("我吃了1颗糖")
console.log("我吃了2颗糖")
......
console.log("我吃了10000颗糖")
}

O(n)


比如下面这样,n 的数值越大,算法需要分配的空间就需要越多,来存储数组里的值,所以它的空间复杂度就是 O(n),时间复杂度也是 O(n)


function foo(n){
let arr = []
for( let i = 1; i < n; i++ ) {
arr[i] = i
}
}

O(n²)


O(n²) 这种空间复杂度一般出现在比如二维数组,或是矩阵的情况下


不用说,你肯定明白是啥情况啦


就是遍历生成类似这样格式的


let arr = [
[1,2,3,4,5],
[1,2,3,4,5],
[1,2,3,4,5]
]

结语


希望本文对你有一点点帮助,另外,求个赞,谢谢! ^_^


想要学好算法,就必须要理解复杂度这个重要基石


复杂度分析不难,关键还是在于多练。每次看到代码的时候,简单的一眼就能看出复杂度,难的稍微分析一下也能得出答案。推荐去 leetCode 刷题哦,App或者PC端都可以


链接:https://juejin.cn/post/6999307229752983582

收起阅读 »

什么?数学不好人都不配写CSS?

前言 大家好,这里是 CSS 兼 WebGL 魔法使——alphardex。 之前一直在玩 three.js ,接触了很多数学函数,用它们创造过很多特效。于是我思考:能否在 CSS 中也用上这些数学函数,但发现 CSS 目前还没有,据说以后的新规范会纳入,估计...
继续阅读 »

前言


大家好,这里是 CSS 兼 WebGL 魔法使——alphardex。


之前一直在玩 three.js ,接触了很多数学函数,用它们创造过很多特效。于是我思考:能否在 CSS 中也用上这些数学函数,但发现 CSS 目前还没有,据说以后的新规范会纳入,估计也要等很久。


然而,我们可以通过一些小技巧,来创作出一些属于自己的 CSS 数学函数,从而实现一些有趣的动画效果。


让我们开始吧!



CSS 数学函数


注意:以下的函数用原生 CSS 也都能实现,这里用 SCSS 函数只是为了方便封装,封装起来的话更方便调用


绝对值


绝对值就是正的还是正的,负的变为正的


可以创造 2 个数,其中一个数是另一个数的相反数,比较它们的最大值,即可获得这个数的绝对值


@function abs($v) {
@return max(#{$v}, calc(-1 * #{$v}));
}

中位数


原数减 1 并乘以一半即可


@function middle($v) {
@return calc(0.5 * (#{$v} - 1));
}

数轴上两点距离


数轴上两点距离就是两点所表示数字之差的绝对值,有了上面的绝对值公式就可以直接写出来


@function dist-1d($v1, $v2) {
$v-delta: calc(#{$v1} - #{$v2});
@return #{abs($v-delta)};
}

三角函数


其实这个笔者也不会实现~不过之前看到过好友 chokcoco 的一篇文章写到了如何在 CSS 中实现三角函数,在此表示感谢


@function fact($number) {
$value: 1;
@if $number>0 {
@for $i from 1 through $number {
$value: $value * $i;
}
}
@return $value;
}

@function pow($number, $exp) {
$value: 1;
@if $exp>0 {
@for $i from 1 through $exp {
$value: $value * $number;
}
} @else if $exp < 0 {
@for $i from 1 through -$exp {
$value: $value / $number;
}
}
@return $value;
}

@function rad($angle) {
$unit: unit($angle);
$unitless: $angle / ($angle * 0 + 1);
@if $unit==deg {
$unitless: $unitless / 180 * pi();
}
@return $unitless;
}

@function pi() {
@return 3.14159265359;
}

@function sin($angle) {
$sin: 0;
$angle: rad($angle);
// Iterate a bunch of times.
@for $i from 0 through 20 {
$sin: $sin + pow(-1, $i) * pow($angle, (2 * $i + 1)) / fact(2 * $i + 1);
}
@return $sin;
}

@function cos($angle) {
$cos: 0;
$angle: rad($angle);
// Iterate a bunch of times.
@for $i from 0 through 20 {
$cos: $cos + pow(-1, $i) * pow($angle, 2 * $i) / fact(2 * $i);
}
@return $cos;
}

@function tan($angle) {
@return sin($angle) / cos($angle);
}

例子


以下的几个动画特效演示了上面数学函数的作用


一维交错动画


初始状态


创建一排元素,用内部阴影填充,准备好我们的数学函数


<div class="list">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #222;
}

:root {
--blue-color-1: #6ee1f5;
}

(这里复制粘贴上文所有的数学公式)

.list {
--n: 16;

display: flex;
flex-wrap: wrap;
justify-content: space-evenly;

&-item {
--p: 2vw;
--gap: 1vw;
--bg: var(--blue-color-1);

@for $i from 1 through 16 {
&:nth-child(#{$i}) {
--i: #{$i};
}
}

padding: var(--p);
margin: var(--gap);
box-shadow: inset 0 0 0 var(--p) var(--bg);
}
}

fb7wZV.png


应用动画


这里用了 2 个动画:grow 负责将元素缩放出来;melt 负责“融化”元素(即消除阴影的扩散半径)


<div class="list grow-melt">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

.list {
&.grow-melt {
.list-item {
--t: 2s;

animation-name: grow, melt;
animation-duration: var(--t);
animation-iteration-count: infinite;
}
}
}

@keyframes grow {
0% {
transform: scale(0);
}

50%,
100% {
transform: scale(1);
}
}

@keyframes melt {
0%,
50% {
box-shadow: inset 0 0 0 var(--p) var(--bg);
}

100% {
box-shadow: inset 0 0 0 0 var(--bg);
}
}

fqkIkF.gif


交错动画



  1. 计算出元素下标的中位数

  2. 计算每个元素 id 到这个中位数的距离

  3. 根据距离算出比例

  4. 根据比例算出 delay


<div class="list grow-melt middle-stagger">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

.list {
&.middle-stagger {
.list-item {
--m: #{middle(var(--n))}; // 中位数,这里是7.5
--i-m-dist: #{dist-1d(var(--i), var(--m))}; // 计算每个id到中位数之间的距离
--ratio: calc(var(--i-m-dist) / var(--m)); // 根据距离算出比例
--delay: calc(var(--ratio) * var(--t)); // 根据比例算出delay
--n-delay: calc((var(--ratio) - 2) * var(--t)); // 负delay表示动画提前开始

animation-delay: var(--n-delay);
}
}
}

fqkzkD.gif


地址:Symmetric Line Animation


二维交错动画


初始状态


如何将一维的升成二维?应用网格系统即可


<div class="grid">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
$row: 8;
$col: 8;
--row: #{$row};
--col: #{$col};
--gap: 0.25vw;

display: grid;
gap: var(--gap);
grid-template-rows: repeat(var(--row), 1fr);
grid-template-columns: repeat(var(--col), 1fr);

&-item {
--p: 2vw;
--bg: var(--blue-color-1);

@for $y from 1 through $row {
@for $x from 1 through $col {
$k: $col * ($y - 1) + $x;
&:nth-child(#{$k}) {
--x: #{$x};
--y: #{$y};
}
}
}

padding: var(--p);
box-shadow: inset 0 0 0 var(--p) var(--bg);
}
}

fLsvPx.png


应用动画


跟上面的动画一模一样


<div class="grid grow-melt">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
&.grow-melt {
.grid-item {
--t: 2s;

animation-name: grow, melt;
animation-duration: var(--t);
animation-iteration-count: infinite;
}
}
}

fLsGvD.gif


交错动画



  1. 计算出网格行列的中位数

  2. 计算网格 xy 坐标到中位数的距离并求和

  3. 根据距离算出比例

  4. 根据比例算出 delay


<div class="grid grow-melt middle-stagger">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
&.middle-stagger {
.grid-item {
--m: #{middle(var(--col))}; // 中位数,这里是7.5
--x-m-dist: #{dist-1d(var(--x), var(--m))}; // 计算x坐标到中位数之间的距离
--y-m-dist: #{dist-1d(var(--y), var(--m))}; // 计算y坐标到中位数之间的距离
--dist-sum: calc(var(--x-m-dist) + var(--y-m-dist)); // 距离之和
--ratio: calc(var(--dist-sum) / var(--m)); // 根据距离和计算比例
--delay: calc(var(--ratio) * var(--t) * 0.5); // 根据比例算出delay
--n-delay: calc(
(var(--ratio) - 2) * var(--t) * 0.5
); // 负delay表示动画提前开始

animation-delay: var(--n-delay);
}
}
}

fL2Ppt.gif


地址:Symmetric Grid Animation


另一种动画


可以换一种动画 shuffle(穿梭),会产生另一种奇特的效果


<div class="grid shuffle middle-stagger">
<div class="grid-item"></div>
...(此处省略254个 grid-item )
<div class="grid-item"></div>
</div>

.grid {
$row: 16;
$col: 16;
--row: #{$row};
--col: #{$col};
--gap: 0.25vw;

&-item {
--p: 1vw;

transform-origin: bottom;
transform: scaleY(0.1);
}

&.shuffle {
.grid-item {
--t: 2s;

animation: shuffle var(--t) infinite ease-in-out alternate;
}
}
}

@keyframes shuffle {
0% {
transform: scaleY(0.1);
}

50% {
transform: scaleY(1);
transform-origin: bottom;
}

50.01% {
transform-origin: top;
}

100% {
transform-origin: top;
transform: scaleY(0.1);
}
}

fOJSZ8.gif


地址:Shuffle Grid Animation


余弦波动动画


初始状态


创建 7 个不同颜色的(这里直接选了彩虹色)列表,每个列表有 40 个子元素,每个子元素是一个小圆点


让这 7 个列表排列在一条线上,且 z 轴上距离错开,设置好基本的 delay


<div class="lists">
<div class="list">
<div class="list-item"></div>
...(此处省略39个 list-item)
</div>
...(此处省略6个 list)
</div>

.lists {
$list-count: 7;
$colors: red, orange, yellow, green, cyan, blue, purple;

position: relative;
width: 34vw;
height: 2vw;
transform-style: preserve-3d;
perspective: 800px;

.list {
position: absolute;
top: 0;
left: 0;
display: flex;
transform: translateZ(var(--z));

@for $i from 1 through $list-count {
&:nth-child(#{$i}) {
--bg: #{nth($colors, $i)};
--z: #{$i * -1vw};
--basic-delay-ratio: #{$i / $list-count};
}
}

&-item {
--w: 0.6vw;
--gap: 0.15vw;

width: var(--w);
height: var(--w);
margin: var(--gap);
background: var(--bg);
border-radius: 50%;
}
}
}

hSdtfI.png


余弦排列


运用上文的三角函数公式,让这些小圆点以余弦的一部分形状进行排列


.lists {
.list {
&-item {
$item-count: 40;
$offset: pi() * 0.5;
--wave-length: 21vw;

@for $i from 1 through $item-count {
&:nth-child(#{$i}) {
--i: #{$i};
$ratio: ($i - 1) / ($item-count - 1);
$angle-unit: pi() * $ratio;
$wave: cos($angle-unit + $offset);
--single-wave-length: calc(#{$wave} * var(--wave-length));
--n-single-wave-length: calc(var(--single-wave-length) * -1);
}
}

transform: translateY(var(--n-single-wave-length));
}
}
}

hSwuNj.png


波动动画


对每个小圆点应用上下平移动画,平移的距离就是余弦的波动距离


.lists {
.list {
&-item {
--t: 2s;

animation: wave var(--t) infinite ease-in-out alternate;
}
}
}

@keyframes wave {
from {
transform: translateY(var(--n-single-wave-length));
}

to {
transform: translateY(var(--single-wave-length));
}
}

hSwfPA.gif


交错动画


跟上面一个套路,计算从中间开始的 delay,再应用到动画上即可


.lists {
.list {
&-item {
--n: #{$item-count + 1};
--m: #{middle(var(--n))};
--i-m-dist: #{dist-1d(var(--i), var(--m))};
--ratio: calc(var(--i-m-dist) / var(--m));
--square: calc(var(--ratio) * var(--ratio));
--delay: calc(
calc(var(--square) + var(--basic-delay-ratio) + 1) * var(--t)
);
--n-delay: calc(var(--delay) * -1);

animation-delay: var(--n-delay);
}
}
}

hSwqaQ.gif


地址:Rainbow Sine


最后


CSS 数学函数能实现的特效远不止于此,希望通过本文能激起大家创作特效的灵感~


作者:alphardex
链接:https://juejin.cn/post/6999416290997698596

收起阅读 »

聊一聊移动端适配

一、引言 用户选择大屏幕有两个几个出发点,有些人想要更大的字体,更大的图片;有些人想要更多的内容,并不想要更大的图标;有些人想要个镜子…. 充分了解各种设备,我们会知道不同尺寸的屏幕本身就有各自的定位,像ipad类的大屏设备本身相比较iphone5就应该具...
继续阅读 »

一、引言



用户选择大屏幕有两个几个出发点,有些人想要更大的字体,更大的图片;有些人想要更多的内容,并不想要更大的图标;有些人想要个镜子….



充分了解各种设备,我们会知道不同尺寸的屏幕本身就有各自的定位,像ipad类的大屏设备本身相比较iphone5就应该具有更大的视野,而不是粗暴的让用户去感受老人机的体验。


但由于设计及开发资源的紧张,现阶段只能将一套设计稿应用在多尺寸设备上,因此我们需要考虑在保持一套设计稿的方案下如何使展示更加合理。


二、基本单位


对于移动端开发而言,为了做到页面高清的效果,视觉稿的规范往往会遵循以下两点:



  1. 首先,选取一款手机的屏幕宽高作为基准(以前是iphone4的320×480,现在更多的是iphone6的375×667)。

  2. 对于retina屏幕(如: dpr=2),为了达到高清效果,视觉稿的画布大小会是基准的2倍,也就是说像素点个数是原来的4倍(对iphone6而言:原先的375×667,就会变成750×1334)。


物理像素(physical pixel)


一个物理像素是显示器(手机屏幕)上最小的物理显示单元,在操作系统的调度下,每一个设备像素都有自己的颜色值和亮度值。


设备独立像素(density-independent pixel)


设备独立像素(也叫密度无关像素),可以认为是计算机坐标系统中得一个点,这个点代表一个可以由程序使用的虚拟像素(比如: css像素),然后由相关系统转换为物理像素。


所以说,物理像素和设备独立像素之间存在着一定的对应关系,这就是接下来要说的设备像素比。


DPR 设备像素比(device pixel ratio )


设备像素比 = 物理像素 / 设备独立像素; // 在某一方向上,x方向或者y方向
可以在JS中 window.devicePixelRatio获取到当前设备的dpr


三、常见的布局类型


rem 布局


原理: 根据手机的屏幕尺寸 和dpr,动态修改html的基准值(font-size)


公式: rem = document.documentElement.clientWidth * dpr / 100


注释: 乘以dpr,是因为页面有可能为了实现1px border页面会缩放(scale) 1/dpr 倍(如果没有,dpr=1)


假设我们将屏幕宽度平均分成100份,每一份的宽度用per表示,per = 屏幕宽度 / 100,如果将per作为单位,per前面的数值就代表屏幕宽度的百分比


p {width: 50per;} /* 屏幕宽度的50% */

如果想要页面元素随着屏幕宽度等比变化,我们需要上面的per单位,如果子元素设置rem单位的属性,通过更改html元素的字体大小,就可以让子元素实际大小发生变化


html {font-size: 16px}
p {width: 2rem} /* 32px*/

html {font-size: 32px}
p {width: 2rem} /*64px*/

如果让html元素字体的大小,恒等于屏幕宽度的1/100,那1rem和1per就等价了


html {fons-size: 元素宽度 / 100}
p {width: 50rem} /* 50rem = 50per = 屏幕宽度的50% */

实际应用



rem作用于非根元素时,相对于根元素字体大小;rem作用于根元素字体大小时,相对于其初始字体大小



可以看出 rem 取值分为两种情况,设置在根元素时和非根元素时,举个例子:


/* 作用于根元素,相对于原始大小(16px),所以html的font-size为32px*/
html {font-size: 2rem}

/* 作用于非根元素,相对于根元素字体大小,所以为64px */
p {font-size: 2rem}

举个例子:
























vw


vw/vh是基于 Viewport 视窗的长度单位window.innerWidth/window.innerHeight
在CSS Values and Units Module Level 3中和Viewport相关的单位有四个,分别为vwvhvminvmax



  • vw:是Viewport’s width的简写, 1vw等于window.innerWidth的1%

  • vh:和vw类似,是Viewport’s height的简写,1vh等于window.innerHeihgt的1%\

  • vmin:vmin的值是当前vw和vh中较小的值

  • vmax:vmax的值是当前vw和vh中较大的值


image.png
可以看到vw其实是实现了1vw = 1per,比起rem需要计算html的基准值,vw无疑更加方便。


/* rem方案 */
html {fons-size: width / 100}
p {width: 15.625rem}

/* vw方案 */
p {width: 15.625vw}

Q:vw如此方便,是不是就比rem更好,可以完全取代rem了呢?


A:当然不是。


vw也有缺点。



  • vw换算有时并不精确,较小的像素不好适配,就像我们可以用较小值精确地表示较大值,用较大值表示较小值就可能存在数位换算等问题而无法精确表示。

  • vw的兼容性不如rem

  • 使用弹性布局时,vw无法限制最大宽度。rem可以通过控制HTML基准值,来实现最大宽度的限制。


Q:rem就如此完美吗?


A:rem也并不是万能的



  • rem的制作成本更大,需要使用额外的插件去实现。

  • 字体不能用rem,字体大小和字体宽度不成线性关系,所有字体大小不能使用rem,由于设置了根元素字体的大小,会影响所有没有设置字体的元素,因此需要设置所有需要字体控制的元素。

  • 从用户体验上来看,文字阅读的舒适度跟媒体介质大小是没关系的。


四、适配方案


方案一: rem/vw


适用场景:



  • 对视觉组件种类较多,视觉设计对元素位置的相对关系依赖较强的移动端页面:vw/rem


示例:



  • 饿了么(h5.ele.me/msite/)

  • 对viewport进行了缩放

  • html元素的font-size依然由px指定

  • 具体元素的布局上使用vw + rem fallbak的形式

  • 没有限制布局宽度

  • css构建过程需要插件支持


方案二: flex + px + 百分比


适用场景:



  • 追求阅读体验的场景,如列表页。


示例:





作者:_Battle
链接:https://juejin.cn/post/6999438892441026591

收起阅读 »

8个工程必备的JavaScript代码片段(建议添加到项目中)

1. 获取文件后缀名 使用场景:上传文件判断后缀名 /** * 获取文件后缀名 * @param {String} filename */ export function getExt(filename) { if (typeof filena...
继续阅读 »

1. 获取文件后缀名


使用场景:上传文件判断后缀名


/**
* 获取文件后缀名
* @param {String} filename
*/
export function getExt(filename) {
if (typeof filename == 'string') {
return filename
.split('.')
.pop()
.toLowerCase()
} else {
throw new Error('filename must be a string type')
}
}

使用方式


getExt("1.mp4") //->mp4

2. 复制内容到剪贴板


export function copyToBoard(value) {
const element = document.createElement('textarea')
document.body.appendChild(element)
element.value = value
element.select()
if (document.execCommand('copy')) {
document.execCommand('copy')
document.body.removeChild(element)
return true
}
document.body.removeChild(element)
return false
}


使用方式:


//如果复制成功返回true
copyToBoard('lalallala')

原理:



  1. 创建一个textare元素并调用select()方法选中

  2. document.execCommand('copy')方法,拷贝当前选中内容到剪贴板。


3. 休眠多少毫秒


/**
* 休眠xxxms
* @param {Number} milliseconds
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

//使用方式
const fetchData=async()=>{
await sleep(1000)
}

4. 生成随机字符串


/**
* 生成随机id
* @param {*} length
* @param {*} chars
*/
export function uuid(length, chars) {
chars =
chars ||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
length = length || 8
var result = ''
for (var i = length; i > 0; --i)
result += chars[Math.floor(Math.random() * chars.length)]
return result
}

使用方式


//第一个参数指定位数,第二个字符串指定字符,都是可选参数,如果都不传,默认生成8位
uuid()

使用场景:用于前端生成随机的ID,毕竟现在的Vue和React都需要绑定key


5. 简单的深拷贝


/**
*深拷贝
* @export
* @param {*} obj
* @returns
*/
export function deepCopy(obj) {
if (typeof obj != 'object') {
return obj
}
if (obj == null) {
return obj
}
return JSON.parse(JSON.stringify(obj))
}

缺陷:只拷贝对象、数组以及对象数组,对于大部分场景已经足够


const person={name:'xiaoming',child:{name:'Jack'}}
deepCopy(person) //new person

6. 数组去重


/**
* 数组去重
* @param {*} arr
*/
export function uniqueArray(arr) {
if (!Array.isArray(arr)) {
throw new Error('The first parameter must be an array')
}
if (arr.length == 1) {
return arr
}
return [...new Set(arr)]
}

原理是利用Set中不能出现重复元素的特性


uniqueArray([1,1,1,1,1])//[1]

7. 对象转化为FormData对象


/**
* 对象转化为formdata
* @param {Object} object
*/

export function getFormData(object) {
const formData = new FormData()
Object.keys(object).forEach(key => {
const value = object[key]
if (Array.isArray(value)) {
value.forEach((subValue, i) =>
formData.append(key + `[${i}]`, subValue)
)
} else {
formData.append(key, object[key])
}
})
return formData
}

使用场景:上传文件时我们要新建一个FormData对象,然后有多少个参数就append多少次,使用该函数可以简化逻辑


使用方式:


let req={
file:xxx,
userId:1,
phone:'15198763636',
//...
}
fetch(getFormData(req))

8.保留到小数点以后n位


// 保留小数点以后几位,默认2位
export function cutNumber(number, no = 2) {
if (typeof number != 'number') {
number = Number(number)
}
return Number(number.toFixed(no))
}

使用场景:JS的浮点数超长,有时候页面显示时需要保留2位小数


作者:_红领巾
链接:https://juejin.cn/post/6999391770672889893

收起阅读 »

前端工程化实战 - 可配置的模板管理

功能设计 如果每一次新脚手架的开发或者模板的更新都需要重新更新一次 CLI 的话,虽然成本不高,但是开发模板的同学需要通知 CLI 开发的同学去升级,使用模板的同学又需要在等 CLI 开发完毕才能使用,中间交流沟通的成本就增加了。 其次,对于业务开发同学来说,...
继续阅读 »

功能设计


如果每一次新脚手架的开发或者模板的更新都需要重新更新一次 CLI 的话,虽然成本不高,但是开发模板的同学需要通知 CLI 开发的同学去升级,使用模板的同学又需要在等 CLI 开发完毕才能使用,中间交流沟通的成本就增加了。


其次,对于业务开发同学来说,可能只需要一类或者几类的模板,那么如果 CLI 是一个大而全的模板集合,对这些同学来说,快速选择模板创建项目反而也是一个负担,因为要在很多模板中选择自己想要的也是很花费时间。


所以我们的目的是设计一款拥有自定义配置与可升级模板功能的 CLI 工具。


未命名文件.png


既然是自定义配置,那么就需要用户可以在本地手动添加、删除、更新自己常用的模板信息,同时需要可以动态的拉取这些模板而不是一直下载下来就是本地的旧版本。


根据需求,可简单设计一下我们 CLI 的模板功能概要:



  1. 需要保存模板来源的地址

  2. 根据用户的选择拉取不同的模板代码

  3. 将模板保存在本地


实战开发


那么根据上面的设计思路,我们可以一步步开发所需要的功能


本地保存模板地址功能


第一步,如果需要将模板的一些信息保存在本地的话,我们需要一个对话型的交互,引导用户输入我们需要的信息,所以可以选择 inquirer 这个工具库。



Inquirerjs 是一个用来实现命令行交互式界面的工具集合。它帮助我们实现与用户的交互式交流,比如给用户提一个问题,用户给我们一个答案,我们根据用户的答案来做一些事情,典型应用如 plop等生成器工具。



一般拉取代码的话,我们需要知道用户输入的模板地址(通过 URL 拉取对应模板的必须条件)、模板别名(方便用户做搜索)、模板描述(方便用户了解模板信息)


这样需要保存的模板信息有地址、别名与描述,后续可以方便我们去管理对应的模板。示例代码如下:


import inquirer from 'inquirer';
import { addTpl } from '@/tpl'

const promptList = [
{
type: 'input',
message: '请输入仓库地址:',
name: 'tplUrl',
default: 'https://github.com/boty-design/react-tpl'
},
{
type: 'input',
message: '模板标题(默认为 Git 名作为标题):',
name: 'name',
default({ tplUrl }: { tplUrl: string }) {
return tplUrl.substring(tplUrl.lastIndexOf('/') + 1)
}
},
{
type: 'input',
message: '描述:',
name: 'desc',
}
];

export default () => {
inquirer.prompt(promptList).then((answers: any) => {
const { tplUrl, name, desc } = answers
addTpl(tplUrl, name, desc)
})
}
复制代码

通过 inquirer 已经拿到了对应的信息,但由于会有电脑重启等各种情况发生,所以数据存在缓存中是不方便的,这种 CLI 工具如果使用数据库来存储也是大材小用,所以可以将信息直接已经以 json 文件的方式存储在本地。


示例代码如下:


import { loggerError, loggerSuccess, getDirPath } from '@/util'
import { loadFile, writeFile } from '@/util/file'

interface ITpl {
tplUrl: string
name: string
desc: string
}

const addTpl = async (tplUrl: string, name: string, desc: string) => {
const cacheTpl = getDirPath('../cacheTpl')
try {
const tplConfig = loadFile<ITpl[]>(`${cacheTpl}/.tpl.json`)
let file = [{
tplUrl,
name,
desc
}]
if (tplConfig) {
const isExist = tplConfig.some(tpl => tpl.name === name)
if (isExist) {
file = tplConfig.map(tpl => {
if (tpl.name === name) {
return {
tplUrl,
name,
desc
}
}
return tpl
})
} else {
file = [
...tplConfig,
...file
]
}
}
writeFile(cacheTpl, '.tpl.json', JSON.stringify(file, null, "\t"))
loggerSuccess('Add Template Successful!')
} catch (error) {
loggerError(error)
}
}

export {
addTpl,
}

这里我们需要对是否保存还是更新模板做一个简单的流程判断:



  1. 判断当前是否存在 tpl 的缓存文件,如果已存在缓存文件,那么需要跟当前的模板信息合并,如果不存在的话则需要创建文件,将获取的信息保存进去。

  2. 如果当前已存在缓存文件,需要根据 name 判断是已经被缓存了,如果被缓存了的话,则根据 name 来更新对应的模板信息。


接下来,我们来演示一下,使用的效果。


根据之前的操作,构建完 CLI 之后,运行 fe-cil add tpl 可以得到如下的结果:


image.png


那么在对应的路径可以看到已经将这条模板信息缓存下来了。


image.png


如上,我们已经完成一个简单的本地对模板信息添加与修改功能,同样删除也是类似的操作,根据自己的实际需求开发即可。


下载模板


在保存了模板之后,我们需要选择对应的模板下载了。


下载可以使用 download-git-repo 作为 CLI 下载的插件,这是一款非常好用的插件,支持无 clone 去下载对应的模板,非常适合我们的项目。



download-git-repo 是一款下载 git repository 的工具库,它提供了简写与 direct:url 直接下载两种方式,同时也提供直接下载代码与 git clone 的功能,非常使用与方便。



同样在下载模板的时候,我们需要给用户展示当前的保存好的模板列表,这里同样需要使用到 inquirer 工具。



  1. 使用 inquirer 创建 list 选择交互模式,读取本地模板列表,让用户选择需要的模板


export const selectTpl = () => {
const tplList = getTplList()
const promptList = [
{
type: 'list',
message: '请选择模板下载:',
name: 'name',
choices: tplList && tplList.map((tpl: ITpl) => tpl.name)
},
{
type: 'input',
message: '下载路径:',
name: 'path',
default({ name }: { name: string }) {
return name.substring(name.lastIndexOf('/') + 1)
}
}
];

inquirer.prompt(promptList).then((answers: any) => {
const { name, path } = answers
const select = tplList && tplList.filter((tpl: ITpl) => tpl.name)
const tplUrl = select && select[0].tplUrl || ''
loadTpl(name, tplUrl, path)
})
}


  1. 使用 download-git-repo 下载对应的模板


export const loadTpl = (name: string, tplUrl: string, path: string) => {
download(`direct:${tplUrl}`, getCwdPath(`./${path}`), (err: string) => {
if (err) {
loggerError(err)
} else {
loggerSuccess(`Download ${name} Template Successful!`)
}
})
}

但是问题来了,如果选择 direct 的模式,那么下载的是一个 zip 的地址,而不是正常的 git 地址,那么我们上述的地址就无效了,所以在正式下载代码之前需要对地址做一层转换。


首先看拉取规则,正常的 git 地址是 https://github.com/boty-design/react-tpl,而实际在 github 中下载的地址则是 https://codeload.github.com/boty-design/react-tpl/zip/refs/heads/main,可以看到对比正常的 github 链接的话,域名跟链接都有所改变,但是一定有项目名跟团队名,所以我们在存储的时候可以将 boty-design/react-tpl 拆出来,后期方便我们组装。


const { pathname } = new URL(tplUrl)
if (tplUrl.includes('github.com')) {
reTpl.org = pathname.substring(1)
reTpl.downLoadUrl = 'https://codeload.github.com'
}

如上述代码,解析 tplUrl 拿到的 pathname 就是我们需要的信息,再 dowload 模板的时候,重新组装下载链接即可。


image.png


image.png


如上图所示,我们可以将公共的模板下载到本地,方便同学正常开发了,但是此时还有一个问题,那就是上面的分支是 main 分支,不是每一个模板都有这个分支,可控性太差,那么我们怎么拿到项目所有的分支来选择性下载呢。


Github Api


在 Github 中对于开源、不是私有的项目,可以省去授权 token 的步骤,直接使用 Github Api 获取到对应的信息。


所以针对上面提到问题,我们可以借助 Github Api 提供的能力来解决。


获取分支的链接是 https://api.github.com/repos/boty-design/react-tpl/branches,在开发之前我们可以使用 PostMan 来测试一下是否正常返回我们需要的结果。


image.png


如上可以看到,已经能通过 Github Api 拿到我们想要的分支信息了。



如果出现了下述错误的话,没关系,只是 github 限制访问的频率罢了



image.png


针对上述的问题,我们需要的是控制频率、使用带条件的请求或者使用 token 请求 Github Api 的方式来规避,但是鉴于模板来说,一般请求频率也不会很高,只是我在开发的时候需要不断的请求来测试,才会出现这种问题,各位同学有兴趣的话可以自己试试其他的解决方案。


分支代码优化


未命名文件 (1).png


在预研完 Github Api 之后,接下来就需要对拿到的信息做一层封装,例如只有一条分支的时候用户可以直接下载模板,如果请求到多条分支的时候,则需要显示分支让用户自由选择对应的分支下载模板,整体的业务流程图如上所示。


主要逻辑代码如下:


export const selectTpl = async () => {
const prompts: any = new Subject();
let select: ITpl
let githubName: string
let path: string
let loadUrl: string

try {
const onEachAnswer = async (result: any) => {
const { name, answer } = result
if (name === 'name') {
githubName = answer
select = tplList.filter((tpl: ITpl) => tpl.name === answer)[0]
const { downloadUrl, org } = select
const branches = await getGithubBranch(select) as IBranch[]
loadUrl = `${downloadUrl}/${org}/zip/refs/heads`
if (branches.length === 1) {
loadUrl = `${loadUrl}/${branches[0].name}`
prompts.next({
type: 'input',
message: '下载路径:',
name: 'path',
default: githubName
});
} else {
prompts.next({
type: 'list',
message: '请选择分支:',
name: 'branch',
choices: branches.map((branch: IBranch) => branch.name)
});
}
}
if (name === 'branch') {
loadUrl = `${loadUrl}/${answer}`
prompts.next({
type: 'input',
message: '下载路径:',
name: 'path',
default: githubName
});
}
if (name === 'path') {
path = answer
prompts.complete();
}
}

const onError = (error: string) => {
loggerError(error)
}

const onCompleted = () => {
loadTpl(githubName, loadUrl, path)
}

inquirer.prompt(prompts).ui.process.subscribe(onEachAnswer, onError, onCompleted);

const tplList = getTplList() as ITpl[]

prompts.next({
type: 'list',
message: '请选择模板:',
name: 'name',
choices: tplList.map((tpl: ITpl) => tpl.name)
});
} catch (error) {
loggerError(error)
}
}

上述代码,我们可以看到使用了 RXJS 来动态的渲染交互问题,因为存在一些模板的项目分支只有一个的情况。如果我们每次都需要用户都去选择分支是有多余累赘的,所以固定的问题式交互已经不适用了,我们需要借助 RXJS 动态添加 inquirer 问题,通过获取的分支数量来判断是否出现选择分支这个选项,提高用户体验。



链接:https://juejin.cn/post/6999397309180182564

收起阅读 »

CSS为什么这么难学?方法很重要!

大家好,我是零一。前段时间我在知乎刷到这样一个提问:为什么CSS这么难学? 看到这个问题以后,我仔细一想,CSS学习起来好像是挺困难的,它似乎没有像JavaScript那样非常系统的学习大纲,大家平时也不会用到所有的CSS,基本上用来用去就是那么几个常用的属...
继续阅读 »

大家好,我是零一。前段时间我在知乎刷到这样一个提问:为什么CSS这么难学?


知乎某用户提问


看到这个问题以后,我仔细一想,CSS学习起来好像是挺困难的,它似乎没有像JavaScript那样非常系统的学习大纲,大家平时也不会用到所有的CSS,基本上用来用去就是那么几个常用的属性,甚至就连很多培训机构的入门教学视频都也只会教你一些常用的CSS(不然你以为一个几小时的教学视频怎么能让你快速入门CSS的呢?)


一般别人回答你CSS很好学也是因为它只用那些常用的属性,他很有可能并没有深入去了解。要夸张一点说,CSS应该也能算作一门小小的语言了吧,深入研究进去,知识点也不少。我们如果不是专门研究CSS的,也没必要做到了解CSS的所有属性的使用以及所有后续新特性的语法,可以根据工作场景按需学习,但要保证你学习的属性足够深入~


那么我们到底该如何学习CSS呢? 为此我列了一个简单的大纲,想围绕这几点大概讲一讲


CSS学习大纲


一、书籍、社区文章


这应该是大家学习CSS最常见的方式了(我亦如此)。有以下几个场景:


场景一:开发中遇到「文本字数超出后以省略号(...)展示」的需求,打开百度搜索:css字数过多用省略号展示,诶~搜到了!ctrl+c、ctrl+v,学废了,完工!


搜索引擎学习法


场景二:某天早晨逛技术社区,看到一篇关于CSS的文章,看到标题中有个CSS属性叫resizeresize属性是啥,我咋没用过?点进去阅读得津津有味~ two minutes later ~ 奥,原来还有这个属性,是这么用的呀,涨姿势了!


社区博客学习法


场景三:我决定了,我要好好学CSS,打开购物网站搜索:CSS书籍,迅速下单!等书到了,开始每天翻阅学习。当然了此时又有好几种情况了,分别是:



  • 就只有刚拿到书的第一天翻阅了一下,往后一直落灰

  • 看了一部分,但又懒得动手敲代码,最终感到无趣放弃了阅读

  • 认认真真看完了书,也跟着书上的代码敲了,做了很多笔记,最终学到了很多



无论是上面哪几种方式,我觉得都是挺不错的,顺便再给大家推荐几个不错的学习资源



毕竟站在巨人的肩膀上,才是最高效的,你们可以花1个小时学习到大佬们花1天才总结出来的知识


二、记住CSS的数据类型


CSS比较难学的另一个点,可能多半是因为CSS的属性太多了,而且每个属性的值又支持很多种写法,所以想要轻易记住每个属性的所有写法几乎是不太可能的。最近在逛博客时发现原来CSS也有自己的数据类型,这里引用一下张鑫旭大佬的CSS值类型文档大全,方便大家后续查阅


简单介绍一下CSS的数据类型就是这样的:


CSS数据类型


图中用<>括起来的表示一种CSS数据类型,介绍一下图中几个类型:



  • :表示值可以是数字

  • :表示元素的尺寸长度,例如3px33em34rem

  • :表示基于父元素的百分比,例如33%

  • :表示值既可以是 ,也可以是

  • :表示元素的位置。值可以是 left/right/top/bottom


来看两个CSS属性:



  • 第一个是width,文档会告诉你该属性支持的数据类型有 ,那么我们就知道该属性有以下几种写法:width: 1pxwidth: 3remwidth: 33emwidth: 33%

  • 第二个属性是background-position,文档会告诉你该属性支持的数据类型有 ,那么我们就知道该属性有以下几种写法:background-position: leftbackground-position: right background-position: topbackground-position: bottombackground-position: 30%background-position: 3rem


从这个例子中我们可以看出,想要尽可能得记住更多的CSS属性的使用,可以从记住CSS数据类型(现在差不多有40+种数据类型)开始,这样你每次学习新的CSS属性时,思路就会有所转变,如下图


没记住CSS数据类型的我:


之前的思想


记住CSS数据类型的我:


现在的思想


不知道你有没有发现,如果文档只告诉你background-position支持 数据类型,你确定你能知道该属性的全部用法吗?你确实知道该属性支持background-position: 3rem这样的写法,因为你知道 数据类型包含了 数据类型,但你知道它还支持background-position: bottom 50px right 100px;这样的写法吗?为什么可以写四个值并且用空格隔开?这是谁告诉你的?


这就需要我们了解CSS的语法了,请认真看下一节


三、读懂CSS的语法


我之前某个样式中需要用到裁剪的效果,所以准备了解一下CSS中的clip-path属性怎么使用,于是就查询了比较权威的clip-path MDN,看着看着,我就发现了这个


clip-path 语法


我这才意识到我竟然连CSS的语法都看不懂。说实话,以前无论是初学CSS还是临时找一下某个CSS属性的用法,都是直接百度,瞬间就能找到自己想要的答案(例如菜鸟教程),而这次,我是真的傻了! 因为本身clip-path这个属性就比较复杂,支持的语法也比较多,光看MDN给你的示例代码根本无法Get到这个属性所有的用法和含义(菜鸟教程就更没法全面地教你了)


于是我就顺着网线去了解了一下CSS的语法中的一些符号的含义,帮助我更好得理解语法


因为关于CSS语法符号相关的知识在CSS属性值定义语法 MDN上都有一篇超级详细的介绍了(建议大家一定要先看看MDN这篇文章!!非常通俗易懂),所以我就不多做解释了,这里只放几个汇总表格


属性组合符号


解读CSS语法


以本节clip-path的语法为例,我们来简单对其中某一个属性来进行解读(只会解读部分哦,因为解读全部的话篇幅会很长很长)


先看看整体的结构


clip-path的语法


一共分为四部分,顺序是从上到下的,每两个部分之间都以where来连接,表示的是where下面的部分是对上面那个部分的补充解释


:表示的是clip-path这个属性支持的写法为:要不只写 数据类型的值,要不就最起码从 这两者之间选一种类型的值来写,要不就为none


:我们得知①中的 数据类型支持的写法为:inset()circle()ellipse()polygon()path()这5个函数


:因为我们想了解circle()这个函数的具体使用,所以就先只看这个了。我们得知circle()函数的参数支持 两种数据结构,且两者都是可写可不写,但如果要写 ,那前面必须加一个at


:首先看到 支持的属性是 (这个顾名思义就是)、closest-sidefarthest-side。而 数据类型的语法看起来就比较复杂了,我们单独来分析,因为真的非常非常长,我将 格式化并美化好给你展现出来,便于你们阅读(我也建议你们如果在学习某个属性的语法时遇到这么长的语法介绍,也像我一下把它格式化一下,这样方便你们阅读和理解)


<position>数据类型的语法


如图可得,整体分为三大部分,且这三部分是互斥关系,即这三部分只能出现一个,再根据我们前面学习的CSS语法的符号,就可以知道怎么使用了,因为这里支持的写法太多了,我直接列个表格吧(其实就是排列组合)!如果还有不懂的,你们可以仔细阅读一下MDN的语法介绍或者也可以评论区留言问我,我看到会第一时间回复!


类型支持的写法


嚯!累死我了,这支持的写法也太多太多了吧!


四、多动手尝试


上一节,我们在学习clip-path属性的语法以后,知道了我们想要的圆圈裁剪(circle())的语法怎么写,那么你就真的会了吗?可能你看了MDN给你举的例子,知道了circle(40%)大致实现的效果是咋样的,如下图


MDN clip-path的简单案例


如我前文说的一样,MDN只给你列举了circle()这个函数最简单的写法,但我们刚刚学习了其语法,得知还有别的写法(例如circle(40% at left)),而且MDN文档也只是告诉你支持哪些语法,它也并没有明确告诉你,哪个语法的作用是怎么样的,能实现什么样的效果。


此时就需要我们自己上手尝试了






<span class="scss">尝试<span class="hljs-attribute">clip-path</span>的circle()的使用</span>







看一下效果,嗯,跟MDN展示的是一样的


clip-path: circle(40%)


再修改一下值clip-path: circle(60%),看看效果


clip-path: circle(60%)


我似乎摸出了规律,看样子是以元素的中心为基准点,60%的意思就是从中心到边缘长度的60%为半径画一个圆,裁剪掉该圆之外的内容。这些都是MDN文档里没有讲到的,靠我亲手实践验证出来的。


接下来我们来试试其它的语法~


试试将值改成clip-path: circle(40% at top)


clip-path: circle(40% at top)


诶?很神奇!为什么会变成这个样子,我似乎还没找到什么规律,再把值改一下试试clip-path: circle(80% at top)


clip-path: circle(80% at top)


看样子圆心挪到了元素最上方的中间,然后以圆心到最下面边缘长度的80%为半径画了个圆进行了裁剪。至此我们似乎明白了circle()语法中at 后面的数据类型是干什么的了,大概就是用来控制裁剪时画的圆的圆心位置


剩下的时间就交给你自己来一个一个试验所有的语法了,再举个简单的例子,比如你再试一下clip-path: circle(40% at 30px),你一定好奇这是啥意思,来看看效果


clip-path: circle(40% at 30px)


直观上看,整个圆向左移动了一些距离,在我们没设置at 30px时,圆心是在元素的中心的,而现在似乎向右偏移了,大胆猜测at 30px的意思是圆心的横坐标距离元素的最左侧30px


接下来验证一下我们的猜测,继续修改其值clip-path: circle(40% at 0)


clip-path: circle(40% at 0)


很明显此时的圆心是在最左侧的中间部分,应该可以说是证明了我们刚才的猜测了,那么不妨再来验证一下纵坐标的?继续修改值clip-path: circle(40% at 0 0)


clip-path: circle(40% at 0 0)


不错,非常顺利,at 0 0中第二个0的意思就是圆心纵坐标离最上方的距离为0的意思。那么我们此时就可以放心得得出一个结论了,对于像30px33em这样的 数据类型的值,其对应的坐标是如图所示的


坐标情况


好了,本文篇幅也已经很长了,我就不继续介绍其它语法的使用了,刚才纯粹是用来举个例子,因为本文我们本来就不是在介绍circle()的使用教程,感兴趣的读者可以下去自己动手实践哦~


所以实践真的很重要很重要!! MDN文档没有给你列举每种语法对应的效果,因为每种都列出来,文档看着就很杂乱了,所以这只能靠你自己。记得张鑫旭大佬在一次直播中讲到,他所掌握的CSS的特性,也都是用大量的时间去动手试出来的,也不是看看啥文档就能理解的,所以你在大佬们的一篇文章中了解到的某个CSS属性的使用,可能是他们花费几小时甚至十几个小时研究出来的。


CSS很多特性会有兼容性问题,因为市面上有很多家浏览器厂商,它们支持的程度各不相同,而我们平常了解CSS某个属性的兼容性,是这样的


查看MDN的某个属性的浏览器兼容性


clip-path的浏览器兼容性


通过Can I Use来查找某个属性的浏览器兼容性


can i use


这些都是正确的,但有时候可能某些CSS属性的浏览器兼容性都无法通过这两个渠道获取到,那么该怎么办呢?手动试试每个浏览器上该属性的效果是否支持呗(鑫旭大佬说他以前也会这么干),这点我就不举例子了,大家应该能体会到


☀️ 最后


其实每个CSS大佬都不是因为某些快捷的学习路径而成功的,他们都是靠着不断地动手尝试、记录、总结各种CSS的知识,也会经常用学到的CSS知识去做一个小demo用于巩固,前几个月加了大漠老师的好友,我就经常看到他朋友圈有一些CSS新特性的demo演示代码和文章(真心佩服),coco大佬也是,也经常会发一些单纯用CSS实现的炫酷特效(据说没有他实现不了的特效哦~)


另外,如果想要更加深入,你们还可以关注一下CSS的规范,这个比较权威的就是W3C的CSS Working Group了,里面有很多CSS的规范文档


w3c css规范


好了,再推荐几本业界公认的还算不错的书籍吧~例如《CSS权威指南》、《CSS揭秘》、《CSS世界》、《CSS新世界》等等...


最后对于「如何学习CSS?」这个话题,你还有什么问题或者你觉得还不错的学习方法吗?欢迎在评论区留言讨论~



链接:https://juejin.cn/post/6999418363239727111

收起阅读 »

前端面试知识点(四)

9、ES6 Module 相对于 CommonJS 的优势是什么?温馨提示:如果你只是想知道本题的答案,那么直接进入传送门 16.8.2 Static module structure 。除此之外,以下 ES Module 的代码只在 No...
继续阅读 »

9、ES6 Module 相对于 CommonJS 的优势是什么?

温馨提示:如果你只是想知道本题的答案,那么直接进入传送门 16.8.2 Static module structure 。除此之外,以下 ES Module 的代码只在 Node.js 环境中进行了测试,感兴趣的同学可以使用浏览器进行再测试。对不同规范模块的代码编译选择了 Webpack,感兴趣的同学也可以采用 Rollup 进行编译测试。

关于 ES Module 和 CommonJS 的规范以及语法,这里不再详细叙述,如果你还不了解这两者的语法糖,可以查看 ECMAScript 6 入门 / Module 语法ES Module 标准以及 Node.js 的 CommonJS 模块,两者的主要区别如下所示:

类型ES ModuleCommonJS
加载方式编译时运行时
引入性质引用 / 只读浅拷贝 / 可读写
模块作用域thisthis / __filename / __dirname...

9.1 加载方式

加载方式是 ES Module 和 CommonJS 的最主要区别,这使得两者在编译时运行时上各有优劣。首先来看一下 ES Module 在加载方式上的特性,如下所示:

// 编译时:VS Code 鼠标 hover 到 b 时可以显示出 b 的类型信息
import { b } from './b';

const a = 1;
// WARNING: 具有逻辑
if(a === 1) {
// 编译时:ESLint: Parsing error: 'import' and 'export' may only appear at the top level
// 运行时:SyntaxError: Unexpected token '{'
// TIPS: 这里可以使用 import() 进行动态导入
import { b } from './b';
}

const c = 'b';
// WARNING: 含有变量
// 编译时:ESLint:Parsing error: Unexpected token `
// 运行时:SyntaxError: Unexpected template string
import { d } from `./${c}`;

CommonJS 相对于 ES Module 在加载方式上的特性如下所示:

const a = 1;

if(a === 1) {
// VS Code 鼠标 hover 到 b 时,无法显示出 b 的类型信息
const b = require('./b');
}

const c = 'b';
const d = require(`./${c}`);

大家可能知道上述语法的差异性,接下来通过理论知识重点讲解一下两者产生差异的主要原因。在前端知识点扫盲(一)/ 编译器原理中重点讲解了整个编译器的执行阶段,如下图所示: image.png ES Module 是采用静态的加载方式,也就是模块中导入导出的依赖关系可以在代码编译时就确定下来。如上图所示,代码在编译的过程中可以做的事情包含词法和语法分析、类型检查以及代码优化等等。因此采用 ES Module 进行代码设计时可以在编译时通过 ESLint 快速定位出模块的词法语法错误以及类型信息等。ES Module 中会产生一些错误的加载方式,是因为这些加载方式含有逻辑和变量的运行时判断,只有在代码的运行时阶段才能确定导入导出的依赖关系,这明显和 ES Module 的加载机制不相符。

CommonJS 相对于 ES Module 在加载模块的方式上存在明显差异,是因为 CommonJS 在运行时进行加载方式的动态解析,在运行时阶段才能确定的导入导出关系,因此无法进行静态编译优化和类型检查。

温馨提示:注意 import 语法和 import() 的区别,import() 是 tc39 中的一种提案,该提案允许你可以使用类似于 import(`${path}/foo.js`) 的导入语句(估计是借鉴了 CommonJS 可以动态加载模块的特性),因此也允许你在运行时进行条件加载,也就是所谓的懒加载。除此之外,import 和 import() 还存在其他一些重要的区别,大家还是自行谷歌一下。

9.2 编译优化

由于 ES Module 是在编译时就能确定模块之间的依赖关系,因此可以在编译的过程中进行代码优化。例如:

// hello.js 
export function a() {
console.log('a');
}

export function b() {
console.log('b');
}

// index.js
// TIPS: Webpack 编译入口文件
// 这里不引入 function b
import { a } from './hello';
console.log(a);

使用 Webpack 5.47.1 (Webpack Cli 4.7.2)进行代码编译,生成的编译产物如下所示:

(()=>{"use strict";console.log((function(){console.log("a")}))})();

可以发现编译生成的产物没有 function b 的代码,这是在编译阶段对代码进行了优化,移除了未使用的代码(Dead Code),这种优化的术语被叫做 Tree Shaking

温馨提示:你可以将应用程序想象成一棵树。绿色表示实际用到的 Source Code(源码)和 Library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。

温馨提示:在 ES Module 中可能会因为代码具有副作用(例如操作原型方法以及添加全局对象的属性等)导致优化失败,如果想深入了解 Tree Shaking 的更多优化注意事项,可以深入阅读你的 Tree-Shaking 并没什么卵用

为了对比 ES Module 的编译优化能力,同样采用 CommonJS 规范进行模块导入:

// hello.js
exports.a = function () {
console.log('a');
};

exports.b = function () {
console.log('b');
};

// index.js
// TIPS: Webpack 编译入口文件
const { a } = require('./hello');
console.log(a);

使用 Webpack 进行代码编译,生成的编译产物如下所示:

(() => {
var o = {
418: (o, n) => {
(n.a = function () {
console.log('a');
}),
// function b 的代码并没有被去除
(n.b = function () {
console.log('b');
});
},
},
n = {};
function r(t) {
var e = n[t];
if (void 0 !== e) return e.exports;
var s = (n[t] = { exports: {} });
return o[t](s, s.exports, r), s.exports;
}
(() => {
const { a: o } = r(418);
console.log(o);
})();
})();

可以发现在 CommonJS 模块中,尽管没有使用 function b,但是代码仍然会被打包编译,正是因为 CommonJS 模块只有在运行时才能进行同步导入,因此无法在编译时确定是否 function b 是一个 Dead Code。

温馨提示:在 Node.js 环境中一般不需要编译 CommonJS 模块代码,除非你使用了当前 Node 版本所不能兼容的一些新语法特性。

大家可能会注意到一个新的问题,当我们在制作工具库或者组件库的时候,通常会将库包编译成 ES5 语法,这样尽管 Babel 以及 Webpack 默认会忽略 node_modules 里的模块,我们的项目在编译时引入的这些模块仍然能够做到兼容。在这个过程中,如果你制作的库包体积非常大,你又不提供非常细粒度的按需引入的加载方式,那么你可以编译你的源码使得编译产物可以支持 ES Module 的导入导出模式(注意只支持 ES6 中模块的语法,其他的语法仍然需要被编译成 ES5),当项目真正引入这些库包时可以通过 Tree Shaking 的特性在编译时去除未引入的代码(Dead Code)。

温馨提示:如果你想了解如何使发布的 Npm 库包支持 Tree Shaking 特性,可以查看 defense-of-dot-js / Typical Usage、 Webpack / Final Stepspgk.module 以及 rollup.js / Tree Shaki…

Webpack 对于 module 字段的支持的描述提示:The module property should point to a script that utilizes ES2015 module syntax but no other syntax features that aren't yet supported by browsers or node. This enables webpack to parse the module syntax itself, allowing for lighter bundles via tree shaking if users are only consuming certain parts of the library.

9.3 加载原理 & 引入性质

温馨提示:下述理论部分以及图片内容均出自于 2018 年的文章 ES modules: A cartoon deep-dive,如果想要了解更多原理信息可以查看 TC39 的 16.2 Modules

在 ES Module 中使用模块进行开发,其实是在编译时构建模块之间的依赖关系图。在浏览器或者服务的文件系统中运行 ES6 代码时,需要解析所有的模块文件,然后将模块转换成 Module Record 数据结构,具体如下图所示: 05_module_record-768x441.png

事实上, ES Module 的加载过程主要分为如下三个阶段:

  • 构建(Construction):主要分为查找、加载(在浏览器中是下载文件,在本地文件系统中是加载文件)、然后把文件解析成 Module Record。
  • 实例化(Instantiation):给所有的 Module Record 分配内存空间(此刻还没有填充值),并根据导入导出关系确定各自之间的引用关系,确定引用关系的过程称为链接(Linking)。
  • 运行(Evaluation):运行代码,给内存地址填充运行时的模块数据。

温馨提示:import 的上述三个阶段其实在 import() 中体现的更加直观(尽管 import 已经被多数浏览器支持,但是我们在真正开发和运行的过程中仍然会使用编译后的代码运行,而不是采用浏览器 script 标签的远程地址的动态异步加载方式),而 import() 事实上如果要实现懒加载优化(例如 Vue 里的路由懒加载,更多的是在浏览器的宿主环境而不是 Node.js 环境,这里不展开更多编译后实现方式的细节问题),大概率要完整经历上述三个阶段的异步加载过程,具体再次查看 tc39 动态提案:This proposal adds an import(specifier) syntactic form, which acts in many ways like a function (but see below). It returns a promise for the module namespace object of the requested module, which is created after fetching, instantiating, and evaluating all of the module's dependencies, as well as the module itself.

07_3_phases.png ES Module 模块加载的三个阶段分别需要在编译时和运行时进行(可能有的同学会像我一样好奇实例化阶段到底是在编译时还是运行时进行,根据 tc39 动态加载提案里的描述可以得出你想要的答案:The existing syntactic forms for importing modules are static declarations. They accept a string literal as the module specifier, and introduce bindings into the local scope via a pre-runtime "linking" process.),而 CommonJS 规范中的模块是在运行时同步顺序执行,模块在加载的过程中不会被中断,具体如下图所示: 43_cjs_cycle.png 上图中 main.js 在运行加载 counter.js 时,会先等待 counter.js 运行完成后才能继续运行代码,因此在 CommonJS 中模块的加载是阻塞式的。CommonJS 采用同步阻塞式加载模块是因为它只需要从本地的文件系统中加载文件,耗费的性能和时间很少,而 ES Module 在浏览器(注意这里说的是浏览器)中运行的时候需要下载文件然后才能进行实例化和运行,如果这个过程是同步进行,那么会影响页面的加载性能。

从 ES Module 链接的过程可以发现模块之间的引用关系是内存的地址引用,如下所示:

// hello.js
export let a = 1;

setTimeout(() => {
a++;
}, 1000);


// index.js
import { a } from './hello.js';

setTimeout(() => {
console.log(a); // 2
}, 2000);

在 Node (v14.15.4)环境中运行上述代码得到的执行结果是 2,对比一下 CommonJS 规范的执行:

// hello.js
exports.a = 1;

setTimeout(() => {
exports.a++;
}, 1000);


// index.js
let { a } = require('./hello');

setTimeout(() => {
console.log(a); // 1
}, 2000);

可以发现打印的结果信息和 ES Module 的结果不一样,这里的执行结果为 1。产生上述差异的根本原因是实例化的方式不同,如下图所示:1665647773-5acd908e6e76f_fix732.png

在 ES Module 的导出中 Module Record 会实时跟踪(wire up 在这里理解为链接或者引用的意思)和绑定每一个导出变量对应的内存地址(从上图可以发现值还没有被填充,而 function 则可以在链接阶段进行初始化),导入同样对应的是导出所对应的同一个内存地址,因此对导入变量进行处理其实处理的是同一个引用地址的数据,如下图所示:

1181374600-5acd91c0798bf_fix732.png

CommonJS 规范在导出时事实上导出的是值拷贝,如下图所示:

516296747-5acd92fbbb9e6_fix732.png

在上述代码执行的过程中先对变量 a 进行值拷贝,因此尽管设置了定时器,变量 a 被引入后打印的信息仍然是 1。需要注意的是这种拷贝是浅拷贝,如下所示:

// hello.js
exports.a = {
value: 1,
};

setTimeout(() => {
exports.a.value++;
}, 1000);

// index.js
let { a } = require('./hello');

setTimeout(() => {
console.log(a.value); // 2
}, 2000);

接下来对比编译后的差异,将 ES Module 的源码进行编译(仍然使用 Webpack),编译之后的代码如下所示:

(() => {
'use strict';
let e = 1;
setTimeout(() => {
e++;
}, 1e3),
setTimeout(() => {
console.log(e);
}, 2e3);
})();

可以看出,将 ES Module 的代码进行编译后,使用的是同一个变量值,此时将 CommonJS 的代码进行编译:

(() => {
var e = {
418: (e, t) => {
// hello.js 中的模块代码
(t.a = 1),
setTimeout(() => {
t.a++;
}, 1e3);
},
},
t = {};
function o(r) {
// 开辟模块的缓存空间
var s = t[r];
// 获取缓存信息,每次返回相同的模块对象信息
if (void 0 !== s) return s.exports;
// 开辟模块对象的内存空间
var a = (t[r] = { exports: {} });
// 逗号运算符,先运行模块代码,赋值模块对象的值,然后返回模块信息
// 由于缓存,模块代码只会被执行一次
return e[r](a, a.exports, o), a.exports;
}
(() => {
// 浅拷贝
let { a: e } = o(418);
setTimeout(() => {
// 尽管 t.a ++,这里输出的仍然是 1
console.log(e);
}, 2e3);
})();
})();

可以发现 CommonJS 规范在编译后会缓存模块的信息,从而使得下一次将从缓存中直接获取模块数据。除此之外,缓存会使得模块代码只会被执行一次。查看 Node.js 官方文档对于 CommonJS 规范的缓存描述,发现 Webpack 的编译完全符合 CommonJS 规范的缓存机制。了解了这个机制以后,你会发现多次使用 require 进行模块加载不会导致代码被执行多次,这是解决无限循环依赖的一个重要特征。

除了引入的方式可能会有区别之外,引入的代码可能还存在一些区别,比如在 ES Module 中:

// hello.js
export function a() {
console.log('a this: ', this);
}


// index.js
import { a } from './hello.js';

// a = 1;
^
// TypeError: Assignment to constant variable.
// ...
// at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
// at async Loader.import (internal/modules/esm/loader.js:166:24)
// at async Object.loadESM (internal/process/esm_loader.js:68:5)
a = 1;

使用 Node.js 直接运行上述 ES Module 代码,是会产生报错的,因为导入的变量根据提示可以看出是只读变量,而如果采用 Webpack 进行编译后运行,则没有上述问题,除此之外 CommonJS 中导入的变量则可读可写。当然除此之外,你也可以尝试更多的其他方面,比如:

// hello.js

// 非严格模式
b = 1;

export function a() {
console.log('a this: ', this);
}

// index.js
import { a } from './hello.js';

console.log('a: ', a);

你会发现使用 Node.js 环境执行上述 ES Module 代码,会直接抛出下述错误信息:

ReferenceError: b is not defined
at file:///Users/ziyi/Desktop/Gitlab/Explore/module-example/esmodule/hello.js:1:3
at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
at async Loader.import (internal/modules/esm/loader.js:166:24)
at async Object.loadESM (internal/process/esm_loader.js:68:5)

是因为 ES Module 的模块需要运行在严格模式下, 而 CommonJS 规范则没有这样的要求,如果你在仔细一点观察的话,会发现使用 Webpack 进行编译的时候,ES Module 编译的代码会在前面加上 "use strict",而 CommonJS 编译的代码没有。

9.4 模块作用域

大家会发现在 Node.js 的模块中设计代码时可以使用诸如 __dirname、__filename 之类的变量(需要注意在 Webpack 编译出的 CommonJS 前端产物中,并没有 __filename、__dirname 等变量信息,浏览器中并不需要这些文件系统的变量信息),是因为 Node.js 在加载模块时会对其进行如下包装:

// https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js#L206
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];

索性看到这个模块作用域的代码,我们就继续查看一下 require 的源码:

// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L997
Module.prototype.require = function(id) {
validateString(id, 'id');
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id,
'must be a non-empty string');
}
requireDepth++;
try {
return Module._load(id, this, /* isMain */ false);
} finally {
requireDepth--;
}
};

// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L757

// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call
// `NativeModule.prototype.compileForPublicLoader()` and return the exports.
// 3. Otherwise, create a new module for the file and save it to the cache.
// Then have it load the file contents before returning its exports
// object.
Module._load = function(request, parent, isMain) {
let relResolveCacheIdentifier;
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
// Fast path for (lazy loaded) modules in the same directory. The indirect
// caching is required to allow cache invalidation without changing the old
// cache key names.
relResolveCacheIdentifier = `${parent.path}\x00${request}`;
const filename = relativeResolveCache[relResolveCacheIdentifier];
// 有缓存,则走缓存
if (filename !== undefined) {
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
return cachedModule.exports;
}
delete relativeResolveCache[relResolveCacheIdentifier];
}
}

// `node:` 用于检测核心模块,例如 fs、path 等
// Node.js 文档:http://nodejs.cn/api/modules.html#modules_core_modules
// 这里主要用于绕过 require 缓存
const filename = Module._resolveFilename(request, parent, isMain);
if (StringPrototypeStartsWith(filename, 'node:')) {
// Slice 'node:' prefix
const id = StringPrototypeSlice(filename, 5);

const module = loadNativeModule(id, request);
if (!module?.canBeRequiredByUsers) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(filename);
}

return module.exports;
}

// 缓存处理
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded) {
const parseCachedModule = cjsParseCache.get(cachedModule);
if (!parseCachedModule || parseCachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
parseCachedModule.loaded = true;
} else {
return cachedModule.exports;
}
}

const mod = loadNativeModule(filename, request);
if (mod?.canBeRequiredByUsers) return mod.exports;

// Don't call updateChildren(), Module constructor already does.
const module = cachedModule || new Module(filename, parent);

if (isMain) {
process.mainModule = module;
module.id = '.';
}

Module._cache[filename] = module;
if (parent !== undefined) {
relativeResolveCache[relResolveCacheIdentifier] = filename;
}

let threw = true;
try {
module.load(filename);
threw = false;
} finally {
if (threw) {
delete Module._cache[filename];
if (parent !== undefined) {
delete relativeResolveCache[relResolveCacheIdentifier];
const children = parent?.children;
if (ArrayIsArray(children)) {
const index = ArrayPrototypeIndexOf(children, module);
if (index !== -1) {
ArrayPrototypeSplice(children, index, 1);
}
}
}
} else if (module.exports &&
!isProxy(module.exports) &&
ObjectGetPrototypeOf(module.exports) ===
CircularRequirePrototypeWarningProxy) {
ObjectSetPrototypeOf(module.exports, ObjectPrototype);
}
}

return module.exports;
};

温馨提示:这里没有将 wrapper 和 _load 的联系说清楚(最后如何在 _load 中执行 wrapper),大家可以在 Node.js 源码中跟踪一下看一下上述代码是怎么被执行的,是否是 eval 呢?不说了,脑壳疼,想要了解更多信息,可以查看 Node.js / vm。除此之外,感兴趣的同学也了解一下 import 语法在 Node.js 中的底层实现,这里脑壳疼,就没有深入研究了。

温馨提示的温馨提示:比如你在源码中找不到上述代码的执行链路,那最简单的方式就是引入一个错误的模块,让错误信息将错误栈抛出来,比如如下所示,你会发现最底下执行了 wrapSafe,好了你又可以开始探索了,因为你对 safe 这样的字眼一定感到好奇,底下是不是执行的时候用了沙箱隔离呢?

SyntaxError: Cannot use import statement outside a module
at wrapSafe (internal/modules/cjs/loader.js:979:16)
at Module._compile (internal/modules/cjs/loader.js:1027:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
at Module.load (internal/modules/cjs/loader.js:928:32)
at Function.Module._load (internal/modules/cjs/loader.js:769:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
at internal/main/run_main_module.js:17:47

温馨提示:是不是以前经常有面试官询问 exports 和 module.exports 有什么关联,其实根本不用纠结这个问题,因为两者指向的是同一个引用地址,你如果对 exports 进行重新赋值,那么引用发生了改变,你新引用的部分当然就不会导出了,因为从源码里可以看出,我们这里导出的是 module.exports。

接下来主要是重点看下 this 执行上下文的差异(注意这里只测试 Node.js 环境,编译后的代码可能会有差异),首先执行 ES Module 模块的代码:

// hello.js
export function a() {
console.log('this: ', this); // undefined
}

// index.js
import { a } from './hello.js';
a();

我们接着执行 CommonJS 的代码:

// hello.js
exports.a = function () {
console.log('this: ', this);
};

// index.js
let { a } = require('./hello');
a();

你会发现 this 的上下文环境是有信息的,可能是当前模块的信息,具体没有深究:

image.png

温馨提示:Node.js 的调试还能在浏览器进行?可以查看一下 Node.js 调试,当然你也可以使用 VS Code 进行调试,需要进行一些额外的 launch 配置,当然如果你觉得 Node.js 自带的浏览器调试方式太难受了,也可以想想办法,如何通过 IP 端口在浏览器中进行调试,并且可以做到代码变动监听调试。

大家可以不用太纠结代码的细致实现,只需要大致可以了解到 CommonJS 中模块的导入过程即可,事实上 Webpack 编译的结果大致可以理解为该代码的浏览器简易版。那还记得我之前在面试分享中的题目:两年工作经验成功面试阿里P6总结 / 如何在Node端配置路径别名(类似于Webpack中的alias配置),如果你阅读了上述源码,基本上思路就是 HACK 原型链上的 require 方法:

const Module = require('module');
const originalRequire = Module.prototype.require;

Module.prototype.require = function(id){
// 这里加入 path 的逻辑
return originalRequire.apply(this, id);
};

小结

目前的面试题答案系列稍微有些混乱,后续可能会根据类目对面试题进行简单分类,从而整理出更加体系化的答案。本篇旨在希望大家可以对面试题进行举一反三,从而加深理解(当我们问出一个问题的时候,可以衍生出 N 个问题)。


链接:https://juejin.cn/post/6996815121855021087

收起阅读 »

前端面试知识点(三)

6、简单描述一下 Babel 的编译过程? Babel 是一个源到源的转换编译器(Transpiler),它的主要作用是将 JavaScript 的高版本语法(例如 ES6)转换成低版本语法(例如 ES5),从而可以适配浏览器的兼容性。 温馨提示:如果某种高...
继续阅读 »

6、简单描述一下 Babel 的编译过程?


Babel 是一个源到源的转换编译器(Transpiler),它的主要作用是将 JavaScript 的高版本语法(例如 ES6)转换成低版本语法(例如 ES5),从而可以适配浏览器的兼容性。



温馨提示:如果某种高级语言或者应用语言(例如用于人工智能的计算机设计语言)转换的目标语言不是特定计算机的汇编语言,而是面向另一种高级程序语言(很多研究性的编译器将 C 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。



image.png


从上图可知,Babel 的编译过程主要可以分为三个阶段:



  • 解析(Parse):包括词法分析和语法分析。词法分析主要把字符流源代码(Char Stream)转换成令牌流( Token Stream),语法分析主要是将令牌流转换成抽象语法树(Abstract Syntax Tree,AST)。

  • 转换(Transform):通过 Babel 的插件能力,将高版本语法的 AST 转换成支持低版本语法的 AST。当然在此过程中也可以对 AST 的 Node 节点进行优化操作,比如添加、更新以及移除节点等。

  • 生成(Generate):将 AST 转换成字符串形式的低版本代码,同时也能创建 Source Map 映射。


具体的流程如下所示:
image.png


举个栗子,如果要将 TypeScript 语法转换成 ES5 语法:


// 源代码
let a: string = 1;
// 目标代码
var a = 1;

6.1 解析(Parser)


Babel 的解析过程(源码到 AST 的转换)可以使用 @babel/parser,它的主要特点如下:



  • 支持解析最新的 ES2020

  • 支持解析 JSX、Flow & TypeScript

  • 支持解析实验性的语法提案(支持任何 Stage 0 的 PRS)


@babel/parser 主要是基于输入的字符串流(源代码)进行解析,最后转换成规范(基于 ESTree 进行调整)的 AST,如下所示:


import { parse } from '@babel/parser';
const source = `let a: string = 1;`;

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是支持解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

需要注意,在 Parser 阶段主要是进行词法和语法分析,如果词法或者语法分析错误,那么会在该阶段被检测出来。如果检测正确,则可以进入语法的转换阶段。


6.2 转换(Transform)


Babel 的转换过程(AST 到 AST 的转换)主要使用 @babel/traverse,该库包可以通过访问者模式自动遍历并访问 AST 树的每一个 Node 节点信息,从而实现节点的替换、移除和添加操作,如下所示:


import { parse } from '@babel/parser';
import traverse from '@babel/traverse';

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}

const source = `let a: string = 1;`;

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是可以解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

// 转换(Transform) 阶段
traverse(ast, {
// 访问变量声明标识符
VariableDeclaration(path) {
// 将 const 和 let 转换为 var
path.node.kind = 'var';
},
// 访问 TypeScript 类型声明标识符
TSTypeAnnotation(path) {
// 移除 TypeScript 的声明类型
path.remove();
},
});

关于 Babel 中的访问器 API,这里不再过多说明,如果想了解更多信息,可以查看 Babel 插件手册。除此之外,你可能已经注意到这里的转换逻辑其实可以理解为实现一个简单的 Babel 插件,只是没有封装成 Npm 包。当然,在真正的插件开发开发中,还可以配合 @babel/types 工具包进行节点信息的判断处理。



温馨提示:这里只是简单的一个 Demo 示例,在真正转换 let、const 等变量声明的过程中,还会遇到处理暂时性死区(Temporal Dead Zone, TDZ)的情况,更多详细信息可以查看官方的插件 babel-plugin-transform-block-scoping



6.3 生成(Generate)


Babel 的代码生成过程(AST 到目标代码的转换)主要使用 @babel/generator,如下所示:


import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}
const source = `let a: string = 1;`;

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是可以解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

// 转换(Transform) 阶段
traverse(ast, {
// 访问词法规则
VariableDeclaration(path) {
path.node.kind = 'var';
},

// 访问词法规则
TSTypeAnnotation(path) {
// 移除 TypeScript 的声明类型
path.remove();
},
});

// 生成(Generate)阶段
const { code } = generate(ast);
// code: var a = 1;
console.log('code: ', code);

如果你想了解上述输入源对应的 AST 数据或者尝试自己编译,可以使用工具 AST Explorer (也可以使用 Babel 官网自带的 Try It Out ),具体如下所示:


image.png



温馨提示:上述第三个框是以插件的 API 形式进行调用,如果想了解 Babel 的插件开发,可以查看 Babel 插件手册 / 编写你的第一个 Babel 插件



如果你觉得 Babel 的编译过程太过于简单,你可以尝试更高阶的玩法,比如自己设计词法和语法规则从而实现一个简单的编译器(Babel 内置了这些规则),你完全可以不只是做出一个源到源的转换编译器,而是实现一个真正的从 JavaScript (TypeScript) 到机器代码的完整编译器,包括实现中间代码 IR 以及提供机器的运行环境等,这里给出一个可以尝试这种高阶玩法的库包 antlr4ts(可以配合交叉编译工具链 riscv-gnu-toolchain,gcc编译工具的制作还是非常耗时的)。



阅读链接: Babel 用户手册Babel 插件手册






收起阅读 »

前端面试知识点(二)

语法 22、如何实现一个上中下三行布局,顶部和底部最小高度是 100px,中间自适应? 23、如何判断一个元素 CSS 样式溢出,从而可以选择性的加 title 或者 Tooltip? 24、如何让 CSS 元素左侧自动溢出(... 溢出在左侧)? The&n...
继续阅读 »

语法


22、如何实现一个上中下三行布局,顶部和底部最小高度是 100px,中间自适应?


23、如何判断一个元素 CSS 样式溢出,从而可以选择性的加 title 或者 Tooltip?


24、如何让 CSS 元素左侧自动溢出(... 溢出在左侧)?


The direction CSS property sets the direction of text, table columns, and horizontal overflow. Use rtl for languages written from right to left (like Hebrew or Arabic), and ltr for those written from left to right (like English and most other languages).


具体查看:developer.mozilla.org/en-US/docs/…


25、什么是沙箱?浏览器的沙箱有什么作用?


26、如何处理浏览器中表单项的密码自动填充问题?


27、Hash 和 History 路由的区别和优缺点?


28、JavaScript 中对象的属性描述符有哪些?分别有什么作用?


29、JavaScript 中 console 有哪些 api ?


The console object provides access to the browser's debugging console (e.g. the Web console in Firefox). The specifics of how it works varies from browser to browser, but there is a de facto set of features that are typically provided.


这里列出一些我常用的 API:



  • console.log

  • console.error

  • console.time

  • console.timeEnd

  • console.group


具体查看:developer.mozilla.org/en-US/docs/…


30、 简单对比一下 Callback、Promise、Generator、Async 几个异步 API 的优劣?


在 JavaScript 中利用事件循环机制(Event Loop)可以在单线程中实现非阻塞式、异步的操作。例如



我们重点来看一下常用的几种编程方式(Callback、Promise、Generator、Async)在语法糖上带来的优劣对比。


Callback


Callback(回调函数)是在 Web 前端开发中经常会使用的编程方式。这里举一个常用的定时器示例:


export interface IObj {
value: string;
deferExec(): void;
deferExecAnonymous(): void;
console(): void;
}

export const obj: IObj = {
value: 'hello',

deferExecBind() {
// 使用箭头函数可达到一样的效果
setTimeout(this.console.bind(this), 1000);
},

deferExec() {
setTimeout(this.console, 1000);
},

console() {
console.log(this.value);
},
};

obj.deferExecBind(); // hello
obj.deferExec(); // undefined

回调函数经常会因为调用环境的变化而导致 this 的指向性变化。除此之外,使用回调函数来处理多个继发的异步任务时容易导致回调地狱(Callback Hell):


fs.readFile(fileA, 'utf-8', function (err, data) {
fs.readFile(fileB, 'utf-8', function (err, data) {
fs.readFile(fileC, 'utf-8', function (err, data) {
fs.readFile(fileD, 'utf-8', function (err, data) {
// 假设在业务中 fileD 的读写依次依赖 fileA、fileB 和 fileC
// 或者经常也可以在业务中看到多个 HTTP 请求的操作有前后依赖(继发 HTTP 请求)
// 这些异步任务之间纵向嵌套强耦合,无法进行横向复用
// 如果某个异步发生变化,那它的所有上层或下层回调可能都需要跟着变化(比如 fileA 和 fileB 的依赖关系倒置)
// 因此称这种现象为 回调地狱
// ....
});
});
});
});


回调函数不能通过 return 返回数据,比如我们希望调用带有回调参数的函数并返回异步执行的结果时,只能通过再次回调的方式进行参数传递:


// 希望延迟 3s 后执行并拿到结果
function getAsyncResult(result: number) {
setTimeout(() => {
return result * 3;
}, 1000);
}

// 尽管这是常规的编程思维方式
const result = getAsyncResult(3000);
// 但是打印 undefined
console.log('result: ', result);

function getAsyncResultWithCb(result: number, cb: (result: number) => void) {
setTimeout(() => {
cb(result * 3);
}, 1000);
}

// 通过回调的形式获取结果
getAsyncResultWithCb(3000, (result) => {
console.log('result: ', result); // 9000
});


对于 JavaScript 中标准的异步 API 可能无法通过在外部进行 try...catch... 的方式进行错误捕获: 


try {
setTimeout(() => {
// 下述是异常代码
// 你可以在回调函数的内部进行 try...catch...
console.log(a.b.c)
}, 1000)

} catch(err) {
// 这里不会执行
// 进程会被终止
console.error(err)
}

上述示例讲述的都是 JavaScript 中标准的异步 API ,如果使用一些三方的异步 API 并且提供了回调能力时,这些 API 可能是非受信的,在真正使用的时候会因为执行反转(回调函数的执行权在三方库中)导致以下一些问题:



  • 使用者的回调函数设计没有进行错误捕获,而恰恰三方库进行了错误捕获却没有抛出错误处理信息,此时使用者很难感知到自己设计的回调函数是否有错误

  • 使用者难以感知到三方库的回调时机和回调次数,这个回调函数执行的权利控制在三方库手中

  • 使用者无法更改三方库提供的回调参数,回调参数可能无法满足使用者的诉求

  • ...


举个简单的例子:


interface ILib<T> {
params: T;
emit(params: T): void;
on(callback: (params: T) => void): void;
}

// 假设以下是一个三方库,并发布成了npm 包
export const lib: ILib<string> = {
params: '',

emit(params) {
this.params = params;
},

on(callback) {
try {
// callback 回调执行权在 lib 上
// lib 库可以决定回调执行多次
callback(this.params);
callback(this.params);
callback(this.params);
// lib 库甚至可以决定回调延迟执行
// 异步执行回调函数
setTimeout(() => {
callback(this.params);
}, 3000);
} catch (err) {
// 假设 lib 库的捕获没有抛出任何异常信息
}
},
};

// 开发者引入 lib 库开始使用
lib.emit('hello');

lib.on((value) => {
// 使用者希望 on 里的回调只执行一次
// 这里的回调函数的执行时机是由三方库 lib 决定
// 实际上打印四次,并且其中一次是异步执行
console.log(value);
});

lib.on((value) => {
// 下述是异常代码
// 但是执行下述代码不会抛出任何异常信息
// 开发者无法感知自己的代码设计错误
console.log(value.a.b.c)
});

Promise


Callback 的异步操作形式除了会造成回调地狱,还会造成难以测试的问题。ES6 中的 Promise (基于 Promise A + 规范的异步编程解决方案)利用有限状态机的原理来解决异步的处理问题,Promise 对象提供了统一的异步编程 API,它的特点如下:



  • Promise 对象的执行状态不受外界影响。Promise 对象的异步操作有三种状态: pending(进行中)、 fulfilled(已成功)和 rejected(已失败) ,只有 Promise 对象本身的异步操作结果可以决定当前的执行状态,任何其他的操作无法改变状态的结果

  • Promise 对象的执行状态不可变。Promise 的状态只有两种变化可能:从 pending(进行中)变为 fulfilled(已成功)或从 pending(进行中)变为 rejected(已失败)



温馨提示:有限状态机提供了一种优雅的解决方式,异步的处理本身可以通过异步状态的变化来触发相应的操作,这会比回调函数在逻辑上的处理更加合理,也可以降低代码的复杂度。



Promise 对象的执行状态不可变示例如下:


const promise = new Promise<number>((resolve, reject) => {
// 状态变更为 fulfilled 并返回结果 1 后不会再变更状态
resolve(1);
// 不会变更状态
reject(4);
});

promise
.then((result) => {
// 在 ES 6 中 Promise 的 then 回调执行是异步执行(微任务)
// 在当前 then 被调用的那轮事件循环(Event Loop)的末尾执行
console.log('result: ', result);
})
.catch((error) => {
// 不执行
console.error('error: ', error);
});

假设要实现两个继发的 HTTP 请求,第一个请求接口返回的数据是第二个请求接口的参数,使用回调函数的实现方式如下所示(这里使用 setTimeout 来指代异步请求):


// 回调地狱
const doubble = (result: number, callback: (finallResult: number) => void) => {
// Mock 第一个异步请求
setTimeout(() => {
// Mock 第二个异步请求(假设第二个请求的参数依赖第一个请求的返回结果)
setTimeout(() => {
callback(result * 2);
}, 2000);
}, 1000);
};

doubble(1000, (result) => {
console.log('result: ', result);
});


温馨提示:继发请求的依赖关系非常常见,例如人员基本信息管理系统的开发中,经常需要先展示组织树结构,并默认加载第一个组织下的人员列表信息。



如果采用 Promise 的处理方式则可以规避上述常见的回调地狱问题:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
// 将 resolve 改成 reject 会被 catch 捕获
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
// 将 resolve 改成 reject 会被 catch 捕获
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
return nextPromise(result);
})
.then((result) => {
// 2s 后打印 2000
console.log('result: ', result);
})
// 任何一个 Promise 到达 rejected 状态都能被 catch 捕获
.catch((err) => {
console.error('err: ', err);
});

Promise 的错误回调可以同时捕获 firstPromisenextPromise 两个函数的 rejected 状态。接下来考虑以下调用场景:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
nextPromise(result).then((result) => {
// 后打印
console.log('nextPromise result: ', result);
});
})
.then((result) => {
// 先打印
// 由于上一个 then 没有返回值,这里打印 undefined
console.log('firstPromise result: ', result);
})
.catch((err) => {
console.error('err: ', err);
});

首先 Promise 可以注册多个 then(放在一个执行队列里),并且这些 then 会根据上一次返回值的结果依次执行。除此之外,各个 Promise 的 then 执行互不干扰。 我们将示例进行简单的变换:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
// 返回了 nextPromise 的 then 执行后的结果
return nextPromise(result).then((result) => {
return result;
});
})
// 接着 nextPromise 的 then 执行的返回结果继续执行
.then((result) => {
// 2s 后打印 2000
console.log('nextPromise result: ', result);
})
.catch((err) => {
console.error('err: ', err);
});


上述例子中的执行结果是因为 then 的执行会返回一个新的 Promise 对象,并且如果 then 执行后返回的仍然是 Promise 对象,那么下一个 then 的链式调用会等待该 Promise 对象的状态发生变化后才会调用(能得到这个 Promise 处理的结果)。接下来重点看下 Promise 的错误处理:


const promise = new Promise<string>((resolve, reject) => {
// 下述是异常代码
console.log(a.b.c);
resolve('hello');
});

promise
.then((result) => {
console.log('result: ', result);
})
// 去掉 catch 仍然会抛出错误,但不会退出进程终止脚本执行
.catch((err) => {
// 执行
// ReferenceError: a is not defined
console.error(err);
});

setTimeout(() => {
// 继续执行
console.log('hello world!');
}, 2000);

从上述示例可以看出 Promise 的错误不会影响其他代码的执行,只会影响 Promise 内部的代码本身,因为Promise 会在内部对错误进行异常捕获,从而保证整体代码执行的稳定性。Promise 还提供了其他的一些 API 方便多任务的执行,包括



  • Promise.all:适合多个异步任务并发执行但不允许其中任何一个任务失败

  • Promise.race :适合多个异步任务抢占式执行

  • Promise.allSettled :适合多个异步任务并发执行但允许某些任务失败


Promise 相对于 Callback 对于异步的处理更加优雅,并且能力也更加强大, 但是也存在一些自身的缺点:



  • 无法取消 Promise 的执行

  • 无法在 Promise 外部通过 try...catch... 的形式进行错误捕获(Promise 内部捕获了错误)

  • 状态单一,每次决断只能产生一种状态结果,需要不停的进行链式调用



温馨提示:手写 Promise 是面试官非常喜欢的一道笔试题,本质是希望面试者能够通过底层的设计正确了解 Promise 的使用方式,如果你对 Promise 的设计原理不熟悉,可以深入了解一下或者手动设计一个。



Generator


Promise 解决了 Callback 的回调地狱问题,但也造成了代码冗余,如果一些异步任务不支持 Promise 语法,就需要进行一层 Promise 封装。Generator 将 JavaScript 的异步编程带入了一个全新的阶段,它使得异步代码的设计和执行看起来和同步代码一致。Generator 使用的简单示例如下:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

// 在 Generator 函数里执行的异步代码看起来和同步代码一致
function* gen(result: number): Generator<Promise<number>, Promise<number>, number> {
// 异步代码
const firstResult = yield firstPromise(result)
console.log('firstResult: ', firstResult) // 2
// 异步代码
const nextResult = yield nextPromise(firstResult)
console.log('nextResult: ', nextResult) // 6
return nextPromise(firstResult)
}

const g = gen(1)

// 手动执行 Generator 函数
g.next().value.then((res: number) => {
// 将 firstPromise 的返回值传递给第一个 yield 表单式对应的 firstResult
return g.next(res).value
}).then((res: number) => {
// 将 nextPromise 的返回值传递给第二个 yield 表单式对应的 nextResult
return g.next(res).value
})

通过上述代码,可以看出 Generator 相对于 Promise 具有以下优势:



  • 丰富了状态类型,Generator 通过 next 可以产生不同的状态信息,也可以通过 return 结束函数的执行状态,相对于 Promise 的 resolve 不可变状态更加丰富 

  • Generator 函数内部的异步代码执行看起来和同步代码执行一致,非常利于代码的维护

  • Generator 函数内部的执行逻辑和相应的状态变化逻辑解耦,降低了代码的复杂度


next 可以不停的改变状态使得 yield 得以继续执行的代码可以变得非常有规律,例如从上述的手动执行 Generator 函数可以看出,完全可以将其封装成一个自动执行的执行器,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>, Promise<number>, number>

function* gen(): Gen {
const firstResult = yield firstPromise(1)
console.log('firstResult: ', firstResult) // 2
const nextResult = yield nextPromise(firstResult)
console.log('nextResult: ', nextResult) // 6
return nextPromise(firstResult)
}

// Generator 自动执行器
function co(gen: () => Gen) {
const g = gen()
function next(data: number) {
const result = g.next(data)
if(result.done) {
return result.value
}
result.value.then(data => {
// 通过递归的方式处理相同的逻辑
next(data)
})
}
// 第一次调用 next 主要用于启动 Generator 函数
// 内部指针会从函数头部开始执行,直到遇到第一个 yield 表达式
// 因此第一次 next 传递的参数没有任何含义(这里传递只是为了防止 TS 报错)
next(0)
}

co(gen)



温馨提示:TJ Holowaychuk 设计了一个 Generator 自动执行器 Co,使用 Co 的前提是 yield  命令后必须是 Promise 对象或者 Thunk 函数。Co 还可以支持并发的异步处理,具体可查看官方的 API 文档



需要注意的是 Generator 函数的返回值是一个 Iterator 遍历器对象,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>>;

function* gen(): Gen {
yield firstPromise(1);
yield nextPromise(2);
}

// 注意使用 next 是继发执行,而这里是并发执行
Promise.all([...gen()]).then((res) => {
console.log('res: ', res);
});

for (const promise of gen()) {
promise.then((res) => {
console.log('res: ', res);
});
}

Generator 函数的错误处理相对复杂一些,极端情况下需要对执行和 Generator 函数进行双重错误捕获,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// 需要注意这里的reject 没有被捕获
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>>;

function* gen(): Gen {
try {
yield firstPromise(1);
yield nextPromise(2);
} catch (err) {
console.error('Generator 函数错误捕获: ', err);
}
}

try {
const g = gen();
g.next();
// 返回 Promise 后还需要通过 Promise.prototype.catch 进行错误捕获
g.next();
// Generator 函数错误捕获
g.throw('err');
// 执行器错误捕获
g.throw('err');
} catch (err) {
console.error('执行错误捕获: ', err);
}

在使用 g.throw 的时候还需要注意以下一些事项:



  • 如果 Generator 函数本身没有捕获错误,那么 Generator 函数内部抛出的错误可以在执行处进行错误捕获

  • 如果 Generator 函数内部和执行处都没有进行错误捕获,则终止进程并抛出错误信息

  • 如果没有执行过 g.next,则 g.throw 不会在 Gererator 函数中被捕获(因为执行指针没有启动 Generator 函数的执行),此时可以在执行处进行执行错误捕获


Async


Async 是 Generator 函数的语法糖,相对于 Generator 而言 Async 的特性如下:



  • 内置执行器:Generator 函数需要设计手动执行器或者通用执行器(例如 Co 执行器)进行执行,Async 语法则内置了自动执行器,设计代码时无须关心执行步骤

  • yield 命令无约束:在 Generator 中使用 Co 执行器时 yield 后必须是 Promise 对象或者 Thunk 函数,而 Async 语法中的 await 后可以是 Promise 对象或者原始数据类型对象、数字、字符串、布尔值等(此时会对其进行 Promise.resolve() 包装处理) 

  • 返回 Promise: async 函数的返回值是 Promise 对象(返回原始数据类型会被 Promise 进行封装), 因此还可以作为 await  的命令参数,相对于 Generator 返回 Iterator 遍历器更加简洁实用


举个简单的示例:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
const firstResult = await firstPromise(1);
// 1s 后打印 2
console.log('firstResult: ', firstResult);
// 等待 firstPromise 的状态发生变化后执行
const nextResult = await nextPromise(firstResult);
// 2s 后打印 6
console.log('nextResult: ', nextResult);
return nextResult;
}

co();

co().then((res) => {
console.log('res: ', res); // 6
});

通过上述示例可以看出,async 函数的特性如下:



  • 调用 async 函数后返回的是一个 Promise 对象,通过 then 回调可以拿到 async 函数内部 return 语句的返回值  

  • 调用 async 函数后返回的 Promise 对象必须等待内部所有 await 对应的 Promise 执行完(这使得 async 函数可能是阻塞式执行)后才会发生状态变化,除非中途遇到了 return 语句

  • await 命令后如果是 Promise 对象,则返回 Promise 对象处理后的结果,如果是原始数据类型,则直接返回原始数据类型


上述代码是阻塞式执行,nextPromise 需要等待 firstPromise 执行完成后才能继续执行,如果希望两者能够并发执行,则可以进行下述设计:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
return await Promise.all([firstPromise(1), nextPromise(1)]);
}

co().then((res) => {
console.log('res: ', res); // [2,3]
});

除了使用 Promise 自带的并发执行 API,也可以通过让所有的 Promise 提前并发执行来处理:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
console.log('firstPromise');
setTimeout(() => resolve(result * 2), 10000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
console.log('nextPromise');
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
// 执行 firstPromise
const first = firstPromise(1);
// 和 firstPromise 同时执行 nextPromise
const next = nextPromise(1);
// 等待 firstPromise 结果回来
const firstResult = await first;
console.log('firstResult: ', firstResult);
// 等待 nextPromise 结果回来
const nextResult = await next;
console.log('nextResult: ', nextResult);
return nextResult;
}

co().then((res) => {
console.log('res: ', res); // 3
});

Async 的错误处理相对于 Generator 会更加简单,具体示例如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Promise 决断错误
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
const firstResult = await firstPromise(1);
console.log('firstResult: ', firstResult);
const nextResult = await nextPromise(1);
console.log('nextResult: ', nextResult);
return nextResult;
}

co()
.then((res) => {
console.log('res: ', res);
})
.catch((err) => {
console.error('err: ', err); // err: 2
});

async 函数内部抛出的错误,会导致函数返回的 Promise 对象变为 rejected 状态,从而可以通过 catch 捕获, 上述代码只是一个粗粒度的容错处理,如果希望 firstPromise 错误后可以继续执行 nextPromise,则可以通过 try...catch...async 函数里进行局部错误捕获:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Promise 决断错误
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
try {
await firstPromise(1);
} catch (err) {
console.error('err: ', err); // err: 2
}

// nextPromise 继续执行
const nextResult = await nextPromise(1);
return nextResult;
}

co()
.then((res) => {
console.log('res: ', res); // res: 3
})
.catch((err) => {
console.error('err: ', err);
});


温馨提示:Callback 是 Node.js 中经常使用的编程方式,Node.js 中很多原生的 API 都是采用 Callback 的形式进行异步设计,早期的 Node.js 经常会有 Callback 和 Promise 混用的情况,并且在很长一段时间里都没有很好的支持 Async 语法。如果你对 Node.js 和它的替代品 Deno 感兴趣,可以观看 Ryan Dahl 在 TS Conf 2019 中的经典演讲 Deno is a New Way to JavaScript



31、 Object.defineProperty 有哪几个参数?各自都有什么作用?


32、 Object.defineProperty 和 ES6 的 Proxy 有什么区别?



阅读链接:基于 Vue 实现一个 MVVM - 数据劫持的实现。



33、 ES6 中 Symbol、Map、Decorator 的使用场景有哪些?或者你在哪些库的源码里见过这些 API 的使用?


34、 为什么要使用 TypeScript ? TypeScript 相对于 JavaScript 的优势是什么?


35、 TypeScript 中 const 和 readonly 的区别?枚举和常量枚举的区别?接口和类型别名的区别?


36、 TypeScript 中 any 类型的作用是什么?


37、 TypeScript 中 any、never、unknown 和 void 有什么区别?


38、 TypeScript 中 interface 可以给 Function / Array / Class(Indexable)做声明吗?


39、 TypeScript 中可以使用 String、Number、Boolean、Symbol、Object 等给类型做声明吗?


40、 TypeScript 中的 this 和 JavaScript 中的 this 有什么差异?


41、 TypeScript 中使用 Unions 时有哪些注意事项?


42、 TypeScript 如何设计 Class 的声明?


43、 TypeScript 中如何联合枚举类型的 Key?


44、 TypeScript 中 ?.、??、!.、_、** 等符号的含义?


45、 TypeScript 中预定义的有条件类型有哪些?


46、 简单介绍一下 TypeScript 模块的加载机制?


47、 简单聊聊你对 TypeScript 类型兼容性的理解?抗变、双变、协变和逆变的简单理解?


48、 TypeScript 中对象展开会有什么副作用吗?


49、 TypeScript 中 interface、type、enum 声明有作用域的功能吗?


50、 TypeScript 中同名的 interface 或者同名的 interface 和 class 可以合并吗?


51、 如何使 TypeScript 项目引入并识别编译为 JavaScript 的 npm 库包?


52、 TypeScript 的 tsconfig.json 中有哪些配置项信息?


53、 TypeScript 中如何设置模块导入的路径别名?



链接:https://juejin.cn/post/6987549240436195364

收起阅读 »